Skip to content

Instantly share code, notes, and snippets.

@cheeaun
Created July 30, 2025 08:58
Show Gist options
  • Save cheeaun/0004cc68cab1d92af132eb2752a2f909 to your computer and use it in GitHub Desktop.
Save cheeaun/0004cc68cab1d92af132eb2752a2f909 to your computer and use it in GitHub Desktop.
img-gif.js — web component to render GIF as static image (`manualplay` attribute)
import { decodeFrames } from 'modern-gif';
import workerUrl from 'modern-gif/worker?url';
class ImgGif extends HTMLElement {
constructor() {
super();
this.frame = null;
this.canvas = null;
this.ctx = null;
this.isPlaying = false;
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return [
'src',
'alt',
'crossorigin',
'width',
'height',
'referrerpolicy',
'loading',
'fetchpriority',
'manualplay',
'staticsrc',
];
}
async connectedCallback() {
this.render();
// Default behavior is auto-play unless manualplay is specified
if (!this.hasAttribute('manualplay')) {
await this.loadGif();
this.play();
} else {
// manualplay: load GIF and show first frame in canvas
await this.loadGif();
}
}
disconnectedCallback() {
this.frame = null;
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
if (name === 'src') {
if (this.frame) {
this.frame = null;
}
}
this.render();
if (name === 'src') {
this.loadGif().then(() => {
if (!this.hasAttribute('manualplay')) {
this.play();
}
// If manualplay, loadGif already shows first frame in canvas
});
}
if (name === 'manualplay') {
if (this.hasAttribute('manualplay')) {
// Switching to manual mode - reset playing state and show canvas
this.isPlaying = false;
if (!this.frame) {
this.loadGif();
} else {
this.stop();
}
} else {
// Switching to auto-play mode
this.play();
}
}
if (name === 'staticsrc' && this.hasAttribute('manualplay')) {
if (this.frame) {
this.stop();
}
}
}
}
async loadGif() {
const src = this.getAttribute('src');
if (!src) return;
try {
const response = await fetch(src);
if (!response.ok) {
throw new Error(`Failed to fetch GIF: ${response.status}`);
}
const buffer = await response.arrayBuffer();
if (this.hasAttribute('manualplay')) {
// Only decode first frame for efficiency
const options = { workerUrl, range: [0, 0] };
const frames = await decodeFrames(buffer, options);
console.log('GIF frame', frames);
if (!frames || frames.length === 0) {
throw new Error('No frames decoded from GIF');
}
this.frame = frames[0];
// Try static image first, fallback to canvas first-frame
// Only render if not currently playing
if (!this.isPlaying) {
await this.renderStaticContent();
}
} else {
// Default: auto-play using native img element (no decoding needed)
this.frame = null;
this.renderAsImage();
}
} catch (error) {
console.error('Failed to load GIF:', error);
// If loading fails, fallback to regular img
this.renderAsImage();
}
}
renderFirstFrameCanvas() {
if (!this.frame) return;
console.log('DEBUG: Rendering first frame canvas');
console.log('Frame dimensions:', this.frame.width, 'x', this.frame.height);
console.log('Frame data length:', this.frame.data?.length);
console.log(
'Expected data length:',
this.frame.width * this.frame.height * 4,
);
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.canvas.width = this.frame.width;
this.canvas.height = this.frame.height;
// Apply any width/height attributes (as display size, not canvas size)
this.applyDimensionAttributes(this.canvas);
this.shadowRoot.innerHTML = '';
this.shadowRoot.appendChild(this.canvas);
this.drawFirstFrame();
}
applyDimensionAttributes(element) {
if (this.hasAttribute('width')) {
const width = parseInt(this.getAttribute('width'), 10);
if (!isNaN(width)) {
element.style.width = width + 'px';
}
}
if (this.hasAttribute('height')) {
const height = parseInt(this.getAttribute('height'), 10);
if (!isNaN(height)) {
element.style.height = height + 'px';
}
}
}
async tryLoadStaticImage() {
const staticSrc = this.getAttribute('staticsrc');
if (!staticSrc) {
return false;
}
return new Promise((resolve) => {
const img = document.createElement('img');
for (let attr of this.attributes) {
if (
attr.name !== 'manualplay' &&
attr.name !== 'staticsrc' &&
attr.name !== 'src'
) {
img.setAttribute(attr.name, attr.value);
}
}
img.src = staticSrc;
this.applyDimensionAttributes(img);
img.onload = () => {
this.shadowRoot.innerHTML = '';
this.shadowRoot.appendChild(img);
resolve(true);
};
img.onerror = () => {
// Failed to load static image, fallback needed
console.warn('Failed to load static image:', staticSrc);
resolve(false);
};
});
}
async renderStaticContent() {
// Try to load static image first, fallback to canvas first-frame if it fails
console.log('DEBUG: renderStaticContent() called');
const staticImageLoaded = await this.tryLoadStaticImage();
if (!staticImageLoaded) {
console.log(
'DEBUG: static image failed, calling renderFirstFrameCanvas()',
);
this.renderFirstFrameCanvas();
} else {
console.log('DEBUG: static image loaded successfully');
}
}
drawFirstFrame() {
if (!this.frame || !this.ctx) return;
console.log('DEBUG: Drawing first frame');
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
try {
// Check if frame data is valid
if (!this.frame.data || this.frame.data.length === 0) {
console.error('Frame data is empty or missing');
return;
}
// Sample some pixel values to check data
console.log(
'First 16 bytes of frame data:',
Array.from(this.frame.data.slice(0, 16)),
);
const imageData = new ImageData(
this.frame.data,
this.frame.width,
this.frame.height,
);
this.ctx.putImageData(imageData, 0, 0);
console.log('DEBUG: Successfully drew frame to canvas');
} catch (error) {
console.error('Error drawing first frame:', error);
// Fallback: fill canvas with a solid color to indicate error
this.ctx.fillStyle = '#ff0000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
}
play() {
// Switch from canvas to img element for natural GIF animation
this.isPlaying = true;
this.renderAsImage();
}
async stop() {
// Switch from img back to static content (staticSrc or first-frame canvas)
this.isPlaying = false;
if (this.frame) {
await this.renderStaticContent();
}
}
renderAsImage() {
console.log('DEBUG: renderAsImage() called, isPlaying=', this.isPlaying);
const img = document.createElement('img');
for (let attr of this.attributes) {
if (attr.name !== 'manualplay') {
img.setAttribute(attr.name, attr.value);
}
}
// Apply dimension attributes as styles for proper scaling
this.applyDimensionAttributes(img);
this.shadowRoot.innerHTML = '';
this.shadowRoot.appendChild(img);
console.log(
'DEBUG: renderAsImage() finished, shadowRoot now has:',
this.shadowRoot.children[0]?.tagName,
);
}
render() {
const src = this.getAttribute('src');
if (!src) {
this.shadowRoot.innerHTML = '';
return;
}
let isGif = false;
try {
const url = new URL(src, window.location.href);
const pathname = url.pathname;
isGif = pathname.toLowerCase().endsWith('.gif');
} catch (e) {
// If URL parsing fails, fall back to simple check
isGif = src.toLowerCase().endsWith('.gif');
}
if (isGif) {
// If we already have frame loaded, keep using it
if (this.frame) {
return;
}
this.loadGif().catch(() => {
// If loading fails, fallback to regular img
this.renderAsImage();
});
} else {
// Clean up any existing frame
if (this.frame) {
this.frame = null;
}
this.renderAsImage();
}
}
}
if (!customElements.get('img-gif')) {
customElements.define('img-gif', ImgGif);
}
export default ImgGif;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment