Animated GIF on Fabric.js Canvas
Asked Answered
F

4

10

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.

Findlay answered 20/1, 2015 at 22:51 Comment(4)
Have you found a good solution?Brocatel
@Brocatel I ended up going a different route and using a non-canvas solution, since I couldn't find an answer to this.Findlay
Me too had this same problem. Please @PhoenixRizin what approach did you finally choose?Commentary
@fongoh-martin see my previous reply. Did not pursue this further.Findlay
G
5

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.

Gilded answered 10/1, 2017 at 0:44 Comment(0)
A
4

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();
Agincourt answered 3/9, 2020 at 2:56 Comment(1)
When i am export it as svg, then image is not animated.Raff
G
2

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>
Gutsy answered 16/10, 2019 at 3:10 Comment(0)
P
2

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:

  • Per frame delay instead of just the first frame
  • Bigger gifs are more performant and huge gifs no longer crash due to overflowing the max canvas size. It now works using multiple sprites that are swapped in place accordingly
  • Ported to TypeScript
  1. 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);
            });
        }
    }
    
  2. 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
                });
            });
        });
    }
    
  3. Implementation is still the same

Thanks for @Fennec for the original code and hopefully these are useful for you too.

Pothead answered 29/3, 2022 at 15:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.