欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

如何在HTML5 Canvas中画出GIF动画

最编程 2024-07-26 13:13:07
...

效果大概这样

                 (图中用来播放的gif图来源于贴吧。如果你觉得侵权请私信我,我立刻下架)

     canvas这个东西只能渲染静态图片,不能渲染动图。即便我们用常规的加载图片的办法去绘制gif。我们也只能绘制他的第一帧。不能动。所以想在 canvas 上渲染 gif,我们必须拿到 gif 的每一帧和每一帧播放的时间。于是我找到一个 gif 的控制的库 --  libgif.js (点击跳转)

      观察他的element后发现他就是用 canvas 来进行播放 gif 的。不过他的用法是先得有一个 img元素 然后创建 SuperGif对象 来进行控制。这不是很符合我的需求。我是想要直接读取路径直接把 gif 在 canvas渲染出来。于是花了半天把源码看了下。然后把核心代码整了出来稍加修改。改为就读路径就渲染gif

      大概思路就是 xhr 请求文件 ---- 解析gif ---- 把每一帧的图像和播放时间存在 FRAME_LIST 里面。最后用 setInterval 来进行播放。 因为我们每一帧和播放时间都有了。所以 无论是播放、倍速、暂停、切换 上/下一帧都能轻松实现。我这里只给到播放,暂停之类的大家可以自己扩展。

直接用 loadGIF 方法就会自己加载且自动播放。

 loadGIF("./example_gifs/fff.gif");

        播放方法是 playGif。调用  playGif 方法地方就是加载结束的地方。FRAME_LIST 这个全局变量就是存放当前gif所有帧的数组。扩展请在这些地方扩展。其他的地方你要动的话,请三思。毕竟我把源码拿过来后我自己也改了(欸嘿)。

下面是源码:(运行的时候注意,因为读本地文件肯定会存在跨域问题,直接跑铁不行。如要运行,请整为同源 ps:可以看上面效果图的url地址。例如你用vscode 跑的话,可以装一个 Live Server 这样的插件来运行总之方法很多。)

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <canvas id="canvas" width="800" height="600" style="background-color: antiquewhite;"></canvas>
    <script>
        var CANVAS = document.getElementById("canvas");
        var CTX = CANVAS.getContext('2d');
        var FRAME_LIST = []; // 存放每一帧以及对应的延时
        var TEMP_CANVAS = document.createElement("canvas");//用来拿 imagedata 的工具人
        var TEMP_CANVAS_CTX = null// 工具人的 getContext('2d')
        var GIF_INFO = {}; // GIF 的一些信息
        var STREAM = null;
        var LAST_DISPOSA_METHOD = null;
        var CURRENT_FRAME_INDEX = -1; //当前帧的下标
        var TRANSPARENCY = null;
        var DELAY = 0; // 当前帧的时间
 
        class Stream {
            constructor(data) {
                this.data = data;
                this.len = data.length;
                this.pos = 0;
            }
 
            readByte() {
                if (this.pos >= this.data.length) {
                    throw new Error('Attempted to read past end of stream.');
                }
                if (this.data instanceof Uint8Array)
                    return this.data[this.pos++];
                else
                    return this.data.charCodeAt(this.pos++) & 0xFF;
            };
 
            readBytes(n) {
                let bytes = [];
                for (let i = 0; i < n; i++) {
                    bytes.push(this.readByte());
                }
                return bytes;
            };
 
            read(n) {
                let chars = '';
                for (let i = 0; i < n; i++) {
                    chars += String.fromCharCode(this.readByte());
                }
                return chars;
            };
 
            readUnsigned() { // Little-endian.
                let unsigned = this.readBytes(2);
                return (unsigned[1] << 8) + unsigned[0];
            };
        };
 
        // 转流数组
        function byteToBitArr(bite) {
            let byteArr = [];
            for (let i = 7; i >= 0; i--) {
                byteArr.push(!!(bite & (1 << i)));
            }
            return byteArr;
        };
 
        // Generic functions
        function bitsToNum(ba) {
            return ba.reduce(function (s, n) {
                return s * 2 + n;
            }, 0);
        };
 
        function lzwDecode(minCodeSize, data) {
            // TODO: Now that the GIF parser is a bit different, maybe this should get an array of bytes instead of a String?
            let pos = 0; // Maybe this streaming thing should be merged with the Stream?
            function readCode(size) {
                let code = 0;
                for (let i = 0; i < size; i++) {
                    if (data.charCodeAt(pos >> 3) & (1 << (pos & 7))) {
                        code |= 1 << i;
                    }
                    pos++;
                }
                return code;
            };
 
            let output = [],
                clearCode = 1 << minCodeSize,
                eoiCode = clearCode + 1,
                codeSize = minCodeSize + 1,
                dict = [];
 
            function clear() {
                dict = [];
                codeSize = minCodeSize + 1;
                for (let i = 0; i < clearCode; i++) {
                    dict[i] = [i];
                }
                dict[clearCode] = [];
                dict[eoiCode] = null;
 
            };
 
            let code = null, last = null;
            while (true) {
                last = code;
                code = readCode(codeSize);
 
                if (code === clearCode) {
                    clear();
                    continue;
                }
                if (code === eoiCode) {
                    break
                };
                if (code < dict.length) {
                    if (last !== clearCode) {
                        dict.push(dict[last].concat(dict[code][0]));
                    }
                }
                else {
                    if (code !== dict.length) {
                        throw new Error('Invalid LZW code.');
                    }
                    dict.push(dict[last].concat(dict[last][0]));
                }
                output.push.apply(output, dict[code]);
 
                if (dict.length === (1 << codeSize) && codeSize < 12) {
                    // If we're at the last code and codeSize is 12, the next code will be a clearCode, and it'll be 12 bits long.
                    codeSize++;
                }
            }
            return output;
        };
 
        function readSubBlocks() {
            let size = null, data = '';
            do {
                size = STREAM.readByte();
                data += STREAM.read(size);
            } while (size !== 0);
            return data;
        };
 
        function doImg(img) {
            if (!TEMP_CANVAS_CTX) {
                // 没有就创建
                TEMP_CANVAS_CTX = TEMP_CANVAS.getContext('2d');
            }
          
            const currIdx = FRAME_LIST.length,
                  ct = img.lctFlag ? img.lct : GIF_INFO.gct;
            /*
            LAST_DISPOSA_METHOD indicates the way in which the graphic is to
            be treated after being displayed.

            Values :    0 - No disposal specified. The decoder is
                            not required to take any action.
                        1 - Do not dispose. The graphic is to be left
                            in place.
                        2 - Restore to background color. The area used by the
                            graphic must be restored to the background color.
                        3 - Restore to previous. The decoder is required to
                            restore the area overwritten by the graphic with
                            what was there prior to rendering the graphic.

                            Importantly, "previous" means the frame state
                            after the last disposal of method 0, 1, or 2.
            */
            if (currIdx > 0) {
                // 这块不要动
                if (LAST_DISPOSA_METHOD === 3) {
                    // Restore to previous
                    // If we disposed every TEMP_CANVAS_CTX including first TEMP_CANVAS_CTX up to this point, then we have
                    // no composited TEMP_CANVAS_CTX to restore to. In this case, restore to background instead.
                    if (CURRENT_FRAME_INDEX !== null && CURRENT_FRAME_INDEX > -1) {
                    	TEMP_CANVAS_CTX.putImageData(FRAME_LIST[CURRENT_FRAME_INDEX].data, 0, 0);
                    } else {
                        TEMP_CANVAS_CTX.clearRect(0, 0, TEMP_CANVAS.width, TEMP_CANVAS.height);
                    }
                } else {
                    CURRENT_FRAME_INDEX = currIdx - 1;
                }
                
                if (LAST_DISPOSA_METHOD === 2) {
                    // Restore to background color
                    // Browser implementations historically restore to transparent; we do the same.
                    // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079
                    TEMP_CANVAS_CTX.clearRect(0, 0, TEMP_CANVAS.width, TEMP_CANVAS.height);
                }
            }
            let imgData = TEMP_CANVAS_CTX.getImageData(img.leftPos, img.topPos, img.width, img.height);
            img.pixels.forEach(function (pixel, i) {
                if (pixel !== TRANSPARENCY) {
                    imgData.data[i * 4 + 0] = ct[pixel][0];
                    imgData.data[i * 4 + 1] = ct[pixel][1];
                    imgData.data[i * 4 + 2] = ct[pixel][2];
                    imgData.data[i * 4 + 3] = 255; // Opaque.
                }
            });
            TEMP_CANVAS_CTX.putImageData(imgData, img.leftPos, img.topPos);
            // 补充第1帧
            // if(currIdx == 0){
                // pushFrame(DELAY);
            // }
        };
 
        function pushFrame(delay) {
            if (!TEMP_CANVAS_CTX) {
                return
            };
            FRAME_LIST.push({ delay, data: TEMP_CANVAS_CTX.getImageData(0, 0, GIF_INFO.width, GIF_INFO.height) });
        };
 
        // 解析
        function parseExt(block) {
            
            function parseGCExt(block) {

                pushFrame(DELAY);

                STREAM.readByte(); // Always 4 
                var bits = byteToBitArr(STREAM.readByte());
                block.reserved = bits.splice(0, 3); // Reserved; should be 000.
 
                block.disposalMethod = bitsToNum(bits.splice(0, 3));

                LAST_DISPOSA_METHOD = block.disposalMethod
 
                block.userInput = bits.shift();
                block.transparencyGiven = bits.shift();
                block.delayTime = STREAM.readUnsigned();
                DELAY = block.delayTime;
                block.transparencyIndex = STREAM.readByte();
                block.terminator = STREAM.readByte();
                
                TRANSPARENCY = block.transparencyGiven ? block.transparencyIndex : null;
            };
 
            function parseComExt(block) {
                block.comment = readSubBlocks();
            };
 
            function parsePTExt(block) {
                // No one *ever* uses this. If you use it, deal with parsing it yourself.
                STREAM.readByte(); // Always 12 这个必须得这样执行一次
                block.ptHeader = STREAM.readBytes(12);
                block.ptData = readSubBlocks();
            };
 
            function parseAppExt(block) {
                var parseNetscapeExt = function (block) {
                    STREAM.readByte(); // Always 3 这个必须得这样执行一次
                    block.unknown = STREAM.readByte(); // ??? Always 1? What is this?
                    block.iterations = STREAM.readUnsigned();
                    block.terminator = STREAM.readByte();
                };
 
                function parseUnknownAppExt(block) {
                    block.appData = readSubBlocks();
                    // FIXME: This won't work if a handler wants to match on any identifier.
                    // handler.app && handler.app[block.identifier] && handler.app[block.identifier](block);
                };
 
                STREAM.readByte(); // Always 11 这个必须得这样执行一次
                block.identifier = STREAM.read(8);
                block.authCode = STREAM.read(3);
                switch (block.identifier) {
                    case 'NETSCAPE':
                        parseNetscapeExt(block);
                        break;
                    default:
                        parseUnknownAppExt(block);
                        break;
                }
            };
 
            function parseUnknownExt(block) {
                block.data = readSubBlocks();
            };
 
            block.label = STREAM.readByte();
            switch (block.label) {
                case 0xF9: 
                    block.extType = 'gce';
                    parseGCExt(block);
                    break;
                case 0xFE:
                    block.extType = 'com';
                    parseComExt(block);
                    break;
                case 0x01:
                    block.extType = 'pte';
                    parsePTExt(block);
                    break;
                case 0xFF:
                    block.extType = 'app';
                    parseAppExt(block);
                    break;
                default:
                    block.extType = 'unknown';
                    parseUnknownExt(block);
                    break;
            }
        };
 
        function parseImg(img) {
            function deinterlace(pixels, width) {
                // Of course this defeats the purpose of interlacing. And it's *probably*
                // the least efficient way it's ever been implemented. But nevertheless...
                let newPixels = new Array(pixels.length);
                const rows = pixels.length / width;
 
                function cpRow(toRow, fromRow) {
                    const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width);
                    newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels));
                };
 
                // See appendix E.
                const offsets = [0, 4, 2, 1],
                    steps = [8, 8, 4, 2];
 
                let fromRow = 0;
                for (var pass = 0; pass < 4; pass++) {
                    for (var toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) {
                        cpRow(toRow, fromRow)
                        fromRow++;
                    }
                }
 
                return newPixels;
            };
 
            img.leftPos = STREAM.readUnsigned();
            img.topPos = STREAM.readUnsigned();
            img.width = STREAM.readUnsigned();
            img.height = STREAM.readUnsigned();
 
            let bits = byteToBitArr(STREAM.readByte());
            img.lctFlag = bits.shift();
            img.interlaced = bits.shift();
            img.sorted = bits.shift();
            img.reserved = bits.splice(0, 2);
            img.lctSize = bitsToNum(bits.splice(0, 3));
 
            if (img.lctFlag) {
                img.lct = parseCT(1 << (img.lctSize + 1));
            }
 
            img.lzwMinCodeSize = STREAM.readByte();
 
            const lzwData = readSubBlocks();
 
            img.pixels = lzwDecode(img.lzwMinCodeSize, lzwData);

            if (img.interlaced) { // Move
                img.pixels = deinterlace(img.pixels, img.width);
            }
            doImg(img);
        };
 
        // LZW (GIF-specific)
        function parseCT(entries) { // Each entry is 3 bytes, for RGB.
            let ct = [];
            for (let i = 0; i < entries; i++) {
                ct.push(STREAM.readBytes(3));
            }
            return ct;
        };
 
        function parseHeader() {
            GIF_INFO.sig = STREAM.read(3);
            GIF_INFO.ver = STREAM.read(3);
            if (GIF_INFO.sig !== 'GIF') throw new Error('Not a GIF file.'); // XXX: This should probably be handled more nicely.
            GIF_INFO.width = STREAM.readUnsigned();
            GIF_INFO.height = STREAM.readUnsigned();
 
            let bits = byteToBitArr(STREAM.readByte());
            GIF_INFO.gctFlag = bits.shift();
            GIF_INFO.colorRes = bitsToNum(bits.splice(0, 3));
            GIF_INFO.sorted = bits.shift();
            GIF_INFO.gctSize = bitsToNum(bits.splice(0, 3));
 
            GIF_INFO.bgColor = STREAM.readByte();
            GIF_INFO.pixelAspectRatio = STREAM.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64
            if (GIF_INFO.gctFlag) {
                GIF_INFO.gct = parseCT(1 << (GIF_INFO.gctSize + 1));
            }
            // 给 TEMP_CANVAS 设置大小
            TEMP_CANVAS.width = GIF_INFO.width;
            TEMP_CANVAS.height = GIF_INFO.height;
            TEMP_CANVAS.style.width = GIF_INFO.width + 'px';
            TEMP_CANVAS.style.height = GIF_INFO.height + 'px';
            TEMP_CANVAS.getContext('2d').setTransform(1, 0, 0, 1, 0, 0);
        };
 
        function parseBlock() {
            let block = {};
            block.sentinel = STREAM.readByte();
            switch (String.fromCharCode(block.sentinel)) { // For ease of matching
                case '!':
                    block.type = 'ext';
                    parseExt(block);
                    break;
                case ',':
                    block.type = 'img';
                    parseImg(block);
                    break;
                case ';':
                    block.type = 'eof';
                    pushFrame(DELAY);
                    // 已经结束啦。结束就跑这
                    playGif();
                    console.log(FRAME_LIST);
                    break;
                default:
                    throw new Error('Unknown block: 0x' + block.sentinel.toString(16)); // TODO: Pad this with a 0.
            }
 
            // 递归
            if (block.type !== 'eof') {
                setTimeout(parseBlock, 0);
            }
        };
 
        // 播放gif
        function playGif() {
            let len = FRAME_LIST.length;
            let index = 0;
            function play() {
                TEMP_CANVAS.getContext("2d").putImageData(FRAME_LIST[index].data, 0, 0);
                CTX.globalCompositeOperation = "copy";
                CTX.drawImage(TEMP_CANVAS, 100, 200);
                setTimeout(play,  FRAME_LIST[index].delay * 100);
                index++;
                if (index >= len) {
                    index = 0;
                }
            }
            play();
        }
 
        // 用xhr请求本地文件
        function loadGIF(url) {
            const h = new XMLHttpRequest();
            h.open('GET', url, true);
            // 浏览器兼容处理
            if ('overrideMimeType' in h) {
                h.overrideMimeType('text/plain; charset=x-user-defined');
            }
            // old browsers (XMLHttpRequest-compliant)
            else if ('responseType' in h) {
                h.responseType = 'arraybuffer';
            }
            // IE9 (Microsoft.XMLHTTP-compliant)
            else {
                h.setRequestHeader('Accept-Charset', 'x-user-defined');
            }
 
            h.onload = function (e) {
                if (this.status != 200) {
                    doLoadError('xhr - response');
                }
                // emulating response field for IE9
                if (!('response' in this)) {
                    this.response = new VBArray(this.responseText).toArray().map(String.fromCharCode).join('');
                }
                let data = this.response;
                if (data.toString().indexOf("ArrayBuffer") > 0) {
                    data = new Uint8Array(data);
                }
 
                STREAM = new Stream(data);
                parseHeader();
                parseBlock();
            };
 
            h.onerror = function (e) {
                console.log("摆烂 error", e)
            };
 
            h.send();
        }
 
        // 测试
        loadGIF("./example_gifs/333.gif");
    </script>
</body>
</html>







后记:

看见评论说多gif放一个canvas上播放有问题,我做了个例子放下面

 思路是记住多个 FRAME_LIST 然后统一播放