Reputation: 178
I'm working on a project where I've been asked to support animated GIF on a fabric.js canvas.
As per https://github.com/kangax/fabric.js/issues/560, I've followed the advice to render on regular intervals, using fabric.util.requestAnimFrame. Video renders just fine with this method, but GIFs don't seem to update.
var canvas = new fabric.StaticCanvas(document.getElementById('stage'));
fabric.util.requestAnimFrame(function render() {
canvas.renderAll();
fabric.util.requestAnimFrame(render);
});
var myGif = document.createElement('img');
myGif.src = 'https://i.sstatic.net/e8nZC.gif';
if(myGif.height > 0){
addImgToCanvas(myGif);
} else {
myGif.onload = function(){
addImgToCanvas(myGif);
}
}
function addImgToCanvas(imgToAdd){
var obj = new fabric.Image(imgToAdd, {
left: 105,
top: 30,
crossOrigin: 'anonymous',
height: 100,
width:100
});
canvas.add(obj);
}
JSFiddle here: http://jsfiddle.net/phoenixrizin/o359o11f/
Any advice will be greatly appreciated! I've been searching everywhere, but haven't found a working solution.
Upvotes: 10
Views: 15268
Reputation: 31
We used the example from this answer in our own project, but discovered it was lacking a few features and had limitations. The following are the improvements:
gif.utils.ts
import {parseGIF, decompressFrames, ParsedFrame} from 'gifuct-js';
import fetch from 'node-fetch';
export async function gifToSprites(gif: string | File, maxWidth?: number, maxHeight?: number) {
const arrayBuffer = await getGifArrayBuffer(gif);
const frames = decompressFrames(parseGIF(arrayBuffer), true);
if (!frames[0]) {
throw new Error('No frames found in gif');
}
const totalFrames = frames.length;
// get the frames dimensions and delay
let width = frames[0].dims.width;
let height = frames[0].dims.height;
// set the scale ratio if any
maxWidth = maxWidth || width;
maxHeight = maxHeight || height;
const scale = Math.min(maxWidth / width, maxHeight / height);
width = width * scale;
height = height * scale;
const dataCanvas = document.createElement('canvas');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dataCtx = dataCanvas.getContext('2d')!;
const frameCanvas = document.createElement('canvas');
frameCanvas.width = width;
frameCanvas.height = height;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const frameCtx = frameCanvas.getContext('2d')!;
// 4096 is the max canvas width in IE
const framesPerSprite = Math.floor(4096 / width);
const totalSprites = Math.ceil(totalFrames / framesPerSprite);
let previousFrame: ParsedFrame | undefined;
const sprites: Array<HTMLCanvasElement> = [];
for (let spriteIndex = 0; spriteIndex < totalSprites; spriteIndex++) {
const framesOffset = framesPerSprite * spriteIndex;
const remainingFrames = totalFrames - framesOffset;
const currentSpriteTotalFrames = Math.min(framesPerSprite, remainingFrames);
const spriteCanvas = document.createElement('canvas');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const spriteCtx = spriteCanvas.getContext('2d')!;
spriteCanvas.width = width * currentSpriteTotalFrames;
spriteCanvas.height = height;
frames.slice(framesOffset, framesOffset + currentSpriteTotalFrames).forEach((frame, i) => {
const frameImageData = dataCtx.createImageData(frame.dims.width, frame.dims.height);
frameImageData.data.set(frame.patch);
dataCanvas.width = frame.dims.width;
dataCanvas.height = frame.dims.height;
dataCtx.putImageData(frameImageData, 0, 0);
if (previousFrame?.disposalType === 2) {
const {width, height, left, top} = previousFrame.dims;
frameCtx.clearRect(left, top, width, height);
}
// draw a frame from the imageData
frameCtx.drawImage(
dataCanvas,
frame.dims.left * scale,
frame.dims.top * scale,
frame.dims.width * scale,
frame.dims.height * scale
);
// add the frame to the sprite sheet
spriteCtx.drawImage(frameCanvas, width * i, 0);
previousFrame = frame;
});
sprites.push(spriteCanvas);
spriteCanvas.remove();
}
// clean the dom, dispose of the unused canvass
dataCanvas.remove();
frameCanvas.remove();
return {
framesPerSprite,
sprites,
frames,
frameWidth: width,
frameHeight: height,
totalFrames
};
}
async function getGifArrayBuffer(gif: string | File): Promise<ArrayBuffer> {
if (typeof gif === 'string') {
return fetch(gif).then((resp) => resp.arrayBuffer());
} else {
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(gif);
});
}
}
image.fabric.ts
:
import {gifToSprites} from '../utils/gif.utils';
const [PLAY, PAUSE, STOP] = [0, 1, 2];
export async function fabricGif(
gif: string | File,
maxWidth?: number,
maxHeight?: number
): Promise<{image: fabric.Image}> {
const {framesPerSprite, sprites, frames, frameWidth, frameHeight, totalFrames} =
await gifToSprites(gif, maxWidth, maxHeight);
const frameCanvas = document.createElement('canvas');
frameCanvas.width = frameWidth;
frameCanvas.height = frameHeight;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const frameCtx = frameCanvas.getContext('2d')!;
frameCtx.drawImage(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sprites[0]!,
0,
0,
frameWidth,
frameHeight
);
return new Promise((resolve) => {
window.fabric.Image.fromURL(frameCanvas.toDataURL(), (image) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstFrame = frames[0]!;
let framesIndex = 0;
let start = performance.now();
let status: number;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let accumulatedDelay = firstFrame.delay;
image.width = frameWidth;
image.height = frameHeight;
image._render = function (ctx) {
if (status === PAUSE || (status === STOP && framesIndex === 0)) return;
const now = performance.now();
const delta = now - start;
if (delta > accumulatedDelay) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
accumulatedDelay += frames[framesIndex]!.delay;
framesIndex++;
}
if (framesIndex === totalFrames || status === STOP) {
framesIndex = 0;
start = now;
accumulatedDelay = firstFrame.delay;
}
const spriteIndex = Math.floor(framesIndex / framesPerSprite);
ctx.drawImage(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sprites[spriteIndex]!,
frameWidth * (framesIndex % framesPerSprite),
0,
frameWidth,
frameHeight,
-frameWidth / 2,
-frameHeight / 2,
frameWidth,
frameHeight
);
};
const methods = {
play: () => {
status = PLAY;
image.dirty = true;
},
pause: () => {
status = PAUSE;
image.dirty = false;
},
stop: () => {
status = STOP;
image.dirty = false;
},
getStatus: () => ['Playing', 'Paused', 'Stopped'][status]
};
methods.play();
resolve({
...methods,
image
});
});
});
}
Implementation is still the same
Thanks for @Fennec for the original code and hopefully these are useful for you too.
Upvotes: 2
Reputation: 1882
Here is my implementation, very efficient with small Gifs, not so well with larger ones (memory limits).
live demo : https://codesandbox.io/s/red-flower-27i85
Using two files/methods
1 . gifToSprite.js
: Import, parse and decompress the gif with gifuct-js library to frames, create the sprite sheet return its dataURL. You can set a maxWidth
, maxHeight
to scale the gif and a maxDuration
in millisecond to reduce the number of frames.
import { parseGIF, decompressFrames } from "gifuct-js";
/**
* gifToSprite "async"
* @param {string|input File} gif can be a URL, dataURL or an "input File"
* @param {number} maxWidth Optional, scale to maximum width
* @param {number} maxHeight Optional, scale to maximum height
* @param {number} maxDuration Optional, in milliseconds reduce the gif frames to a maximum duration, ex: 2000 for 2 seconds
* @returns {*} {error} object if any or a sprite sheet of the converted gif as dataURL
*/
export const gifToSprite = async (gif, maxWidth, maxHeight, maxDuration) => {
let arrayBuffer;
let error;
let frames;
// if the gif is an input file, get the arrayBuffer with FileReader
if (gif.type) {
const reader = new FileReader();
try {
arrayBuffer = await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(gif);
});
} catch (err) {
error = err;
}
}
// else the gif is a URL or a dataUrl, fetch the arrayBuffer
else {
try {
arrayBuffer = await fetch(gif).then((resp) => resp.arrayBuffer());
} catch (err) {
error = err;
}
}
// Parse and decompress the gif arrayBuffer to frames with the "gifuct-js" library
if (!error) frames = decompressFrames(parseGIF(arrayBuffer), true);
if (!error && (!frames || !frames.length)) error = "No_frame_error";
if (error) {
console.error(error);
return { error };
}
// Create the needed canvass
const dataCanvas = document.createElement("canvas");
const dataCtx = dataCanvas.getContext("2d");
const frameCanvas = document.createElement("canvas");
const frameCtx = frameCanvas.getContext("2d");
const spriteCanvas = document.createElement("canvas");
const spriteCtx = spriteCanvas.getContext("2d");
// Get the frames dimensions and delay
let [width, height, delay] = [
frames[0].dims.width,
frames[0].dims.height,
frames.reduce((acc, cur) => (acc = !acc ? cur.delay : acc), null)
];
// Set the Max duration of the gif if any
// FIXME handle delay for each frame
const duration = frames.length * delay;
maxDuration = maxDuration || duration;
if (duration > maxDuration) frames.splice(Math.ceil(maxDuration / delay));
// Set the scale ratio if any
maxWidth = maxWidth || width;
maxHeight = maxHeight || height;
const scale = Math.min(maxWidth / width, maxHeight / height);
width = width * scale;
height = height * scale;
//Set the frame and sprite canvass dimensions
frameCanvas.width = width;
frameCanvas.height = height;
spriteCanvas.width = width * frames.length;
spriteCanvas.height = height;
frames.forEach((frame, i) => {
// Get the frame imageData from the "frame.patch"
const frameImageData = dataCtx.createImageData(
frame.dims.width,
frame.dims.height
);
frameImageData.data.set(frame.patch);
dataCanvas.width = frame.dims.width;
dataCanvas.height = frame.dims.height;
dataCtx.putImageData(frameImageData, 0, 0);
// Draw a frame from the imageData
if (frame.disposalType === 2) frameCtx.clearRect(0, 0, width, height);
frameCtx.drawImage(
dataCanvas,
frame.dims.left * scale,
frame.dims.top * scale,
frame.dims.width * scale,
frame.dims.height * scale
);
// Add the frame to the sprite sheet
spriteCtx.drawImage(frameCanvas, width * i, 0);
});
// Get the sprite sheet dataUrl
const dataUrl = spriteCanvas.toDataURL();
// Clean the dom, dispose of the unused canvass
dataCanvas.remove();
frameCanvas.remove();
spriteCanvas.remove();
return {
dataUrl,
frameWidth: width,
framesLength: frames.length,
delay
};
};
2 . fabricGif.js
: Mainly a wrapper for gifToSprite
, take the same parameters return an instance of fabric.Image
, override the _render
method to redraw the canvas after each delay, add three methods to play
, pause
, and stop
.
import { fabric } from "fabric";
import { gifToSprite } from "./gifToSprite";
const [PLAY, PAUSE, STOP] = [0, 1, 2];
/**
* fabricGif "async"
* Mainly a wrapper for gifToSprite
* @param {string|File} gif can be a URL, dataURL or an "input File"
* @param {number} maxWidth Optional, scale to maximum width
* @param {number} maxHeight Optional, scale to maximum height
* @param {number} maxDuration Optional, in milliseconds reduce the gif frames to a maximum duration, ex: 2000 for 2 seconds
* @returns {*} {error} object if any or a 'fabric.image' instance of the gif with new 'play', 'pause', 'stop' methods
*/
export const fabricGif = async (gif, maxWidth, maxHeight, maxDuration) => {
const { error, dataUrl, delay, frameWidth, framesLength } = await gifToSprite(
gif,
maxWidth,
maxHeight,
maxDuration
);
if (error) return { error };
return new Promise((resolve) => {
fabric.Image.fromURL(dataUrl, (img) => {
const sprite = img.getElement();
let framesIndex = 0;
let start = performance.now();
let status;
img.width = frameWidth;
img.height = sprite.naturalHeight;
img.mode = "image";
img.top = 200;
img.left = 200;
img._render = function (ctx) {
if (status === PAUSE || (status === STOP && framesIndex === 0)) return;
const now = performance.now();
const delta = now - start;
if (delta > delay) {
start = now;
framesIndex++;
}
if (framesIndex === framesLength || status === STOP) framesIndex = 0;
ctx.drawImage(
sprite,
frameWidth * framesIndex,
0,
frameWidth,
sprite.height,
-this.width / 2,
-this.height / 2,
frameWidth,
sprite.height
);
};
img.play = function () {
status = PLAY;
this.dirty = true;
};
img.pause = function () {
status = PAUSE;
this.dirty = false;
};
img.stop = function () {
status = STOP;
this.dirty = false;
};
img.getStatus = () => ["Playing", "Paused", "Stopped"][status];
img.play();
resolve(img);
});
});
};
3 . Implementation:
import { fabric } from "fabric";
import { fabricGif } from "./fabricGif";
async function init() {
const c = document.createElement("canvas");
document.querySelector("body").append(c)
const canvas = new fabric.Canvas(c);
canvas.setDimensions({
width: window.innerWidth,
height: window.innerHeight
});
const gif = await fabricGif(
"https://media.giphy.com/media/11RwocOdukxqN2/giphy.gif",
200,
200
);
gif.set({ top: 50, left: 50 });
canvas.add(gif);
fabric.util.requestAnimFrame(function render() {
canvas.renderAll();
fabric.util.requestAnimFrame(render);
});
}
init();
Upvotes: 4
Reputation: 29
var canvas = new fabric.Canvas(document.getElementById('stage'));
var url = 'https://themadcreator.github.io/gifler/assets/gif/run.gif';
fabric.Image.fromURL(url, function(img) {
img.scaleToWidth(80);
img.scaleToHeight(80);
img.left = 105;
img.top = 30;
gif(url, function(frames, delay) {
var framesIndex = 0,
animInterval;
img.dirty = true;
img._render = function(ctx) {
ctx.drawImage(frames[framesIndex], -this.width / 2, -this.height / 2, this.width, this.height);
}
img.play = function() {
if (typeof(animInterval) === 'undefined') {
animInterval = setInterval(function() {
framesIndex++;
if (framesIndex === frames.length) {
framesIndex = 0;
}
}, delay);
}
}
img.stop = function() {
clearInterval(animInterval);
animInterval = undefined;
}
img.play();
canvas.add(img);
})
})
function gif(url, callback) {
var tempCanvas = document.createElement('canvas');
var tempCtx = tempCanvas.getContext('2d');
var gifCanvas = document.createElement('canvas');
var gifCtx = gifCanvas.getContext('2d');
var imgs = [];
var xhr = new XMLHttpRequest();
xhr.open('get', url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
var tempBitmap = {};
tempBitmap.url = url;
var arrayBuffer = xhr.response;
if (arrayBuffer) {
var gif = new GIF(arrayBuffer);
var frames = gif.decompressFrames(true);
gifCanvas.width = frames[0].dims.width;
gifCanvas.height = frames[0].dims.height;
for (var i = 0; i < frames.length; i++) {
createFrame(frames[i]);
}
callback(imgs, frames[0].delay);
}
}
xhr.send(null);
var disposalType;
function createFrame(frame) {
if (!disposalType) {
disposalType = frame.disposalType;
}
var dims = frame.dims;
tempCanvas.width = dims.width;
tempCanvas.height = dims.height;
var frameImageData = tempCtx.createImageData(dims.width, dims.height);
frameImageData.data.set(frame.patch);
if (disposalType !== 1) {
gifCtx.clearRect(0, 0, gifCanvas.width, gifCanvas.height);
}
tempCtx.putImageData(frameImageData, 0, 0);
gifCtx.drawImage(tempCanvas, dims.left, dims.top);
var dataURL = gifCanvas.toDataURL('image/png');
var tempImg = fabric.util.createImage();
tempImg.src = dataURL;
imgs.push(tempImg);
}
}
render()
function render() {
if (canvas) {
canvas.renderAll();
}
fabric.util.requestAnimFrame(render);
}
#stage {
border: solid 1px #CCCCCC;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/1.4.13/fabric.min.js"></script>
<script src="http://matt-way.github.io/gifuct-js/bower_components/gifuct-js/dist/gifuct-js.js"></script>
<canvas id="stage" height="160" width="320"></canvas>
Upvotes: 2
Reputation: 136678
According to specs about the Canvas 2DRenderingContext drawImage
method,
Specifically, when a CanvasImageSource object represents an animated image in an HTMLImageElement, the user agent must use the default image of the animation (the one that the format defines is to be used when animation is not supported or is disabled), or, if there is no such image, the first frame of the animation, when rendering the image for CanvasRenderingContext2D APIs.
This means that only the first frame of our animated canvas will be drawn on the canvas.
This is because we don't have any control on animations inside an img tag.
And fabricjs is based on canvas API and thus regulated by the same rules.
The solution is then to parse all the still-images from your animated gif and to export it as a sprite-sheet. You can then easily animate it in fabricjs thanks to the sprite class.
Upvotes: 5