Use this when building an app that needs to add/import animated GIFs and control playback with a movable playhead, stop/start, and speed changes.
Do not use a normal <img src="file.gif"> if the user needs playback controls. Browser GIF playback is opaque: you cannot reliably pause on an exact frame, scrub the timeline, reset to frame zero, or change speed.
Decode the GIF into frames and render onto a <canvas> yourself.
For animated SVGs, do not convert them to GIF just to make them playable. Host the SVG as a same-origin file and render it in an <iframe> so the parent app can access iframe.contentDocument.getAnimations({ subtree: true }). Use the Web Animations API to pause/play, set currentTime, and set playbackRate for CSS/SVG animations.
gifuct-js for GIF parsing/decodingmulter for GIF file uploadInstall:
npm install gifuct-js express multer
npm install -D vitest jsdom
Create pure helpers first and test them before the UI:
export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
export function buildTimeline(frames) {
let cursor = 0;
return frames.map((frame, index) => {
// Important: use ??, not ||, so explicit 0 can be clamped to min delay
const rawDelay = frame.delay ?? 100;
const delay = Math.max(20, Number(rawDelay));
const start = cursor;
cursor += delay;
return { index, start, end: cursor, delay };
});
}
export function frameIndexAtTime(timeline, timeMs) {
if (!timeline.length) return 0;
const duration = timeline[timeline.length - 1].end;
const wrapped = ((timeMs % duration) + duration) % duration;
let lo = 0;
let hi = timeline.length - 1;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const item = timeline[mid];
if (wrapped < item.start) hi = mid - 1;
else if (wrapped >= item.end) lo = mid + 1;
else return item.index;
}
return timeline.length - 1;
}
export function timeForProgress(progress, durationMs) {
return clamp(Number(progress || 0), 0, 1) * Math.max(0, Number(durationMs || 0));
}
Regression test to include:
it('builds cumulative frame timing with a minimum safe delay', () => {
const timeline = buildTimeline([{ delay: 100 }, { delay: 0 }, { delay: 250 }]);
expect(timeline).toEqual([
{ index: 0, start: 0, end: 100, delay: 100 },
{ index: 1, start: 100, end: 120, delay: 20 },
{ index: 2, start: 120, end: 370, delay: 250 },
]);
});
This catches the common bug where frame.delay || 100 turns an explicit 0 delay into 100 instead of applying the minimum delay.
import { decompressFrames, parseGIF } from 'gifuct-js';
const buffer = await fetch(gif.url).then(r => r.arrayBuffer());
const gif = parseGIF(buffer);
const frames = decompressFrames(gif, true); // true gives RGBA patches
const size = { width: gif.lsd.width, height: gif.lsd.height };
const timeline = buildTimeline(frames);
Each decompressed frame is a patch, not always a full canvas. Draw patches onto a canvas in order up to the current frame.
function patchFrameToCanvas(frame, patchCanvas) {
const { dims, patch } = frame;
patchCanvas.width = dims.width;
patchCanvas.height = dims.height;
const ctx = patchCanvas.getContext('2d');
const imageData = ctx.createImageData(dims.width, dims.height);
imageData.data.set(patch);
ctx.putImageData(imageData, 0, 0);
return patchCanvas;
}
function drawFrame(canvas, frames, gifSize, index, patchCanvas) {
canvas.width = gifSize.width;
canvas.height = gifSize.height;
const ctx = canvas.getContext('2d');
if (index === 0) ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i <= index; i += 1) {
const f = frames[i];
const source = patchFrameToCanvas(f, patchCanvas);
ctx.drawImage(source, f.dims.left, f.dims.top);
if (f.disposalType === 2 && i < index) {
ctx.clearRect(f.dims.left, f.dims.top, f.dims.width, f.dims.height);
}
}
}
For small/medium GIFs this is simple and robust. For very large GIFs, optimize by caching composited frame canvases or maintaining forward playback state.
Use requestAnimationFrame; multiply delta by speed; wrap by duration.
let lastTick = null;
function tick(now) {
if (lastTick == null) lastTick = now;
const delta = (now - lastTick) * speed;
lastTick = now;
setPlayhead(t => (t + delta) % duration);
requestAnimationFrame(tick);
}
Controls:
0playhead = progress * duration0.25x, 0.5x, 1x, 1.5x, 2x, etc.)frameIndexAtTime(timeline, playhead) + 1 / frames.lengthSupport SVGs as a separate media kind alongside GIFs:
export function detectMediaKind({ filename = '', mimeType = '', url = '' } = {}) {
const value = `${filename} ${mimeType} ${url}`.toLowerCase();
if (value.includes('image/svg') || value.includes('.svg')) return 'svg';
return 'gif';
}
function getSvgAnimations(iframe) {
const doc = iframe?.contentDocument; // must be same-origin
return doc?.getAnimations ? doc.getAnimations({ subtree: true }) : [];
}
Render SVGs in an iframe rather than <img>/object when you need direct animation control:
<iframe ref={iframeRef} onLoad={onSvgLoad} title={active.title} src={active.url} />
On load and whenever playhead/speed/play state changes:
const animations = getSvgAnimations(iframeRef.current);
animations.forEach(animation => {
animation.playbackRate = speed;
const timing = animation.effect?.getTiming?.() || {};
const ownDuration = Number.isFinite(timing.duration) && timing.duration > 0 ? timing.duration : 10000;
animation.currentTime = playhead % ownDuration;
isPlaying ? animation.play() : animation.pause();
});
Implementation notes:
- Same-origin is required for contentDocument; download/upload SVGs to the app’s own /media/... directory first.
- Infer duration from animation.effect.getTiming().duration when available; use a default timeline like 10s for SVG CSS animations when needed.
- Use the same app-level RAF playhead as GIFs: speed multiplies delta, stop sets playhead to 0, scrub pauses and sets currentTime.
- Store kind: 'svg' | 'gif' in library metadata; normalize older records that lack kind.
- For uploads, accept image/gif,image/svg+xml,.gif,.svg and validate remote SVGs by content-type or the presence of <svg near the start of the file.
Animated SVGs have a major advantage over GIFs: they should fit any device cleanly. Do not treat zoom as just making the iframe wider. First normalize the SVG document inside the same-origin iframe so the SVG's own viewport fits the available frame; then layer zoom/pan on top.
If an SVG has fixed attributes like width="1200" height="800", mobile iframe rendering can show a clipped top-left portion instead of the full viewBox. The parent <iframe width="100%"> is not enough — the SVG inside the iframe still uses its intrinsic size.
On iframe load, reach into iframe.contentDocument (same-origin only) and force the inner SVG to fit:
export function normalizeSvgDocument(doc) {
if (!doc) return false;
const svg = doc.querySelector?.('svg');
if (!svg) return false;
doc.documentElement.style.margin = '0';
doc.documentElement.style.width = '100%';
doc.documentElement.style.height = '100%';
doc.documentElement.style.overflow = 'hidden';
if (doc.body) {
doc.body.style.margin = '0';
doc.body.style.width = '100%';
doc.body.style.height = '100%';
doc.body.style.overflow = 'hidden';
doc.body.style.display = 'grid';
doc.body.style.placeItems = 'center';
doc.body.style.background = 'transparent';
}
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.maxWidth = '100%';
svg.style.maxHeight = '100%';
svg.style.display = 'block';
return true;
}
Call this before controlling animations:
function onSvgLoad() {
normalizeSvgDocument(iframeRef.current?.contentDocument);
const animations = getSvgAnimations(iframeRef.current);
animations.forEach(animation => animation.pause());
syncSvgAnimations(isPlaying ? 'play' : 'pause', playhead);
}
Use tested pure helpers:
export const MIN_ZOOM = 0.35;
export const MAX_ZOOM = 4;
export const ZOOM_STEP = 0.15;
export function clampZoom(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 1;
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, numeric));
}
export function stepZoom(current, direction, step = ZOOM_STEP) {
return clampZoom(Number(current || 1) + direction * step);
}
export function zoomPercent(value) {
return `${Math.round(clampZoom(value) * 100)}%`;
}
UI pattern:
- Reset zoom to 1 when switching media.
- At 100%, show the full SVG fitted to the viewer on phone (preserveAspectRatio="xMidYMid meet").
- Show zoom controls only for SVGs: minus button, range slider (0.35 → 4), plus button, and a percentage/reset button.
- Make the viewer overflow: auto with overscroll-behavior: contain and -webkit-overflow-scrolling: touch so the user can pan around when zoomed in.
- Wrap the iframe in a zoom surface whose width is zoom * 100%; keep an aspect ratio matching the SVG/viewBox (e.g. 3 / 2 for 1200×800 generated scenes).
<div className="viewer">
<div className="zoomSurface" style={{ width: `${zoom * 100}%` }}>
<iframe className="svgFrame" ref={iframeRef} onLoad={onSvgLoad} src={active.url} />
</div>
</div>
.viewer {
max-height: 68vh;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
display: grid;
place-items: center;
}
.zoomSurface { width: 100%; min-width: 220px; aspect-ratio: 3 / 2; display: grid; place-items: center; }
.svgFrame { width: 100%; height: 100%; min-height: 220px; border: 0; background: transparent; }
Regression test to include with jsdom:
// @vitest-environment jsdom
it('forces fixed-size SVGs to scale into the viewer with preserveAspectRatio meet', () => {
document.body.innerHTML = '<svg width="1200" height="800" viewBox="0 0 1200 800"></svg>';
expect(normalizeSvgDocument(document)).toBe(true);
const svg = document.querySelector('svg');
expect(svg.getAttribute('width')).toBe('100%');
expect(svg.getAttribute('height')).toBe('100%');
expect(svg.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet');
});
/media statically from a persistent data directory (keep /gifs as a backwards-compatible alias if the app already exists)POST /api/gifs/upload with multer.single('gif') can still be used, but accept both GIF and SVG filesPOST /api/gifs/url downloads remote GIF/SVG server-side, validates header/content-type, saves it locallyGET /api/gifs returns library metadata including kindDELETE /api/gifs/:id removes metadata and file only after explicit confirmation; do not expose one-tap destructive delete in the main playback controlsValidation details:
fileFilter: (_req, file, cb) => cb(null, file.mimetype === 'image/gif' || /\.gif$/i.test(file.originalname))
For URL import, verify either content-type includes gif or the first three bytes are GIF.
Destructive delete pattern:
export function canDeleteMedia(title, confirmation) {
return String(confirmation || '').trim() === String(title || '').trim();
}
app.delete('/api/gifs/:id', (req, res) => {
const item = library.find(g => g.id === req.params.id);
if (!item) return res.status(404).json({ error: 'Not found' });
if (String(req.body?.confirmTitle || '').trim() !== String(item.title || '').trim()) {
return res.status(400).json({ error: 'Type the exact animation title to confirm deletion' });
}
// remove metadata + file only after confirmation
});
UI guidance:
- Do not put a red Delete button beside Play/Stop/Speed; it is too easy to tap accidentally on mobile.
- Put delete in a collapsed <details> “Danger zone”.
- Require typing the exact animation title before enabling the final delete button.
- Enforce the same confirmation on the backend so API calls cannot bypass the guard.
Use the vps-app-deployment skill. For a Vite + Express single-port app:
npm test
npm run build
PORT=4018 pm2 start /home/avalon/apps/APP/server.cjs --name APP --cwd /home/avalon/apps/APP --time
pm2 save
Pitfall: do not run pm2 start server.cjs -- PORT=4018; that passes the port as a script argument rather than an environment variable.
For nginx, set client_max_body_size above the upload limit (for example 60M for 50MB GIF uploads).
GET /api/health returns OKGET /api/gifskind: 'svg' appears in GET /api/gifsimage/svg+xmltext/javascript, not text/html