Fabric.js Canvas 上的动画 GIF

Animated GIF on Fabric.js Canvas

我正在做一个项目,有人要求我在 fabric.js canvas 上支持 GIF 动画。

根据 https://github.com/kangax/fabric.js/issues/560,我遵循了使用 fabric.util.requestAnimFrame 定期渲染的建议。使用此方法可以很好地呈现视频,但 GIF 似乎没有更新。

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 = 'http://i.stack.imgur.com/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 在这里:http://jsfiddle.net/phoenixrizin/o359o11f/

任何建议将不胜感激!我一直在到处搜索,但没有找到可行的解决方案。

根据 to specs 关于 Canvas 2DRenderingContext drawImage 方法,

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.

这意味着我们的动画 canvas 只会在 canvas 上绘制第一帧。
这是因为我们无法控制 img 标签内的动画。

而 fabricjs 基于 canvas API,因此受相同的规则约束。

然后解决方案是解析动画 gif 中的所有静止图像并将其导出为 sprite-sheet。 由于 sprite class.

,您可以轻松地在 fabricjs 中对其进行动画处理

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>

这是我的实现,对小 Gif 非常有效,对大 Gif 不太有效(内存限制)。

现场演示:https://codesandbox.io/s/red-flower-27i85

使用两个 files/methods

1。 gifToSprite.js:使用gifuct-js 库将gif 导入、解析和解压到帧,创建精灵sheet return 其dataURL。您可以设置 maxWidthmaxHeight 以毫秒为单位缩放 gif,并设置 maxDuration 以减少帧数。

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: 主要是gifToSprite的包装器,取相同参数return fabric.Image的一个实例,覆盖_render方法重绘canvas每次延迟后,在playpausestop.

中添加三个方法
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。实施:

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

我们在自己的项目中使用了 答案中的示例,但发现它缺少一些功能并且存在局限性。以下是改进:

  • 每帧延迟而不只是第一帧
  • 更大的 gif 文件性能更高,而且巨大的 gif 文件不会再因 max canvas size 溢出而崩溃。它现在可以使用相应交换位置的多个精灵
  • 移植到 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. 实现还是一样

感谢@Fennec 提供原始代码,希望这些对您也有用。