animated-gif-playback-controls

/home/avalon/.hermes/skills/software-development/animated-gif-playback-controls/SKILL.md · raw

Animated GIF Playback Controls

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.

Core Lesson

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.

Install:

npm install gifuct-js express multer
npm install -D vitest jsdom

Test-First Timeline Helpers

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.

Decode GIFs in the Browser

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

Canvas Rendering Pattern

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.

Playback Loop

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:

Animated SVG Playback Pattern

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

SVG Mobile Fit, Zoom, and Pan Pattern

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.

Root cause to avoid

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);
}

Zoom controls after fit-to-screen baseline

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.354), 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');
});

Express Upload + URL Import Pattern

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

Deployment Notes for Alex's VPS

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

Verification Checklist