소스 검색

Fix GIF rendering artifacts related to patching

JC Brand 2 년 전
부모
커밋
12a7933e39
1개의 변경된 파일92개의 추가작업 그리고 42개의 파일을 삭제
  1. 92 42
      src/shared/gif/index.js

+ 92 - 42
src/shared/gif/index.js

@@ -1,3 +1,4 @@
+import { log } from '@converse/headless';
 import { getOpenPromise } from '@converse/openpromise';
 import { parseGIF, decompressFrames } from 'gifuct-js';
 
@@ -36,8 +37,11 @@ export default class ConverseGif {
         this.gif_el = el.querySelector('img');
         this.canvas = el.querySelector('canvas');
         this.ctx = this.canvas.getContext('2d');
-        // It's good practice to pre-render to an offscreen canvas
+
+        // Offscreen canvas with full gif
         this.offscreenCanvas = document.createElement('canvas');
+        // Offscreen canvas for patches
+        this.patchCanvas = document.createElement('canvas');
 
         this.ctx_scaled = false;
         this.frames = [];
@@ -49,6 +53,7 @@ export default class ConverseGif {
         this.start = null;
         this.hovering = null;
         this.frameImageData = null;
+        this.disposal_restore_from_idx = null;
 
         this.initialize();
     }
@@ -70,10 +75,10 @@ export default class ConverseGif {
 
         // Show the first frame
         this.frame_idx = 0;
-        this.putFrame(this.frame_idx);
+        this.renderImage();
 
         if (this.options.autoplay) {
-            const delay = (this.frames[this.frame_idx]?.delay ?? 0);
+            const delay = this.frames[this.frame_idx]?.delay ?? 0;
             setTimeout(() => this.play(), delay);
         }
     }
@@ -110,14 +115,14 @@ export default class ConverseGif {
      * `frame_delay` parameters can also be passed in. The `timestamp`
      * parameter comes from `requestAnimationFrame`.
      *
-     * The purpose of this method is to call `putFrame` with the right delay
+     * The purpose of this method is to call `renderImage` with the right delay
      * in order to render the GIF animation.
      *
      * Note, this method will cause the *next* upcoming frame to be rendered,
      * not the current one.
      *
-     * This means `this.frame_idx` will be incremented before calling `this.putFrame`, so
-     * `putFrame(0)` needs to be called *before* this method, otherwise the
+     * This means `this.frame_idx` will be incremented before calling `this.renderImage`, so
+     * `renderImage(0)` needs to be called *before* this method, otherwise the
      * animation will incorrectly start from frame #1 (this is done in `initPlayer`).
      *
      * @param { DOMHighResTimeStamp } timestamp - The timestamp as returned by `requestAnimationFrame`
@@ -132,7 +137,7 @@ export default class ConverseGif {
             return;
         }
         if (timestamp - previous_timestamp < frame_delay) {
-            this.hovering ? this.drawPauseIcon() : this.putFrame(this.frame_idx);
+            this.hovering ? this.drawPauseIcon() : this.renderImage();
             // We need to wait longer
             requestAnimationFrame((ts) => this.onAnimationFrame(ts, previous_timestamp, frame_delay));
             return;
@@ -142,8 +147,8 @@ export default class ConverseGif {
             return;
         }
         this.frame_idx = next_frame;
-        this.putFrame(this.frame_idx);
-        const delay = (this.frames[this.frame_idx]?.delay || 8);
+        this.renderImage();
+        const delay = this.frames[this.frame_idx]?.delay || 8;
         requestAnimationFrame((ts) => this.onAnimationFrame(ts, timestamp, delay));
     }
 
@@ -195,21 +200,13 @@ export default class ConverseGif {
 
     drawError () {
         this.ctx.fillStyle = 'black';
-        this.ctx.fillRect(
-            0,
-            0,
-            this.options.width ? this.options.width : this.lsd.width,
-            this.options.height ? this.options.height : this.lsd.height
-        );
+        this.ctx.fillRect(0, 0, this.options.width, this.options.height);
         this.ctx.strokeStyle = 'red';
         this.ctx.lineWidth = 3;
         this.ctx.moveTo(0, 0);
-        this.ctx.lineTo(
-            this.options.width ? this.options.width : this.lsd.width,
-            this.options.height ? this.options.height : this.lsd.height
-        );
-        this.ctx.moveTo(0, this.options.height ? this.options.height : this.lsd.height);
-        this.ctx.lineTo(this.options.width ? this.options.width : this.lsd.width, 0);
+        this.ctx.lineTo(this.options.width, this.options.height);
+        this.ctx.moveTo(0, this.options.height);
+        this.ctx.lineTo(this.options.width, 0);
         this.ctx.stroke();
     }
 
@@ -224,41 +221,97 @@ export default class ConverseGif {
         this.el.requestUpdate();
     }
 
+    manageDisposal (i) {
+        if (i <= 0) return;
+
+        const offscreenContext = this.offscreenCanvas.getContext('2d');
+        const disposal = this.frames[i - 1].disposalType;
+        /*
+         *  Disposal 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 (i > 1) {
+            if (disposal === 3) {
+                // eslint-disable-next-line no-eq-null
+                if (this.disposal_restore_from_idx != null) {
+                    offscreenContext.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0);
+                }
+            } else {
+                this.disposal_restore_from_idx = i - 1;
+            }
+        }
+
+        if (disposal === 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
+            offscreenContext.clearRect(
+                this.last_frame.dims.left,
+                this.last_frame.dims.top,
+                this.last_frame.dims.width,
+                this.last_frame.dims.height
+            );
+        }
+    }
+
     /**
      * Draws a gif frame at a specific index inside the canvas.
-     * @param {number|string} i - The frame index
+     * @param {boolean} show_pause_on_hover - The frame index
      */
-    putFrame (i, show_pause_on_hover = true) {
+    renderImage (show_pause_on_hover = true) {
         if (!this.frames.length) return;
 
+        let i = this.frame_idx;
         i = parseInt(i.toString(), 10);
         if (i > this.frames.length - 1 || i < 0) {
             i = 0;
         }
 
+        this.manageDisposal(i);
+
         const frame = this.frames[i];
+        const patchContext = this.patchCanvas.getContext('2d');
+        const offscreenContext = this.offscreenCanvas.getContext('2d');
         const dims = frame.dims;
-
         if (
             !this.frameImageData ||
             dims.width != this.frameImageData.width ||
             dims.height != this.frameImageData.height
         ) {
-            this.offscreenCanvas.width = dims.width;
-            this.offscreenCanvas.height = dims.height;
-            this.frameImageData = this.offscreenCanvas.getContext('2d').createImageData(dims.width, dims.height);
+            this.patchCanvas.width = dims.width;
+            this.patchCanvas.height = dims.height;
+            this.frameImageData = patchContext.createImageData(dims.width, dims.height);
         }
 
         // set the patch data as an override
         this.frameImageData.data.set(frame.patch);
+        // draw the patch back over the canvas
+        patchContext.putImageData(this.frameImageData, 0, 0);
+
+        offscreenContext.drawImage(this.patchCanvas, dims.left, dims.top);
 
-        this.offscreenCanvas.getContext('2d').putImageData(this.frameImageData, 0, 0);
-        this.ctx.globalCompositeOperation = 'copy';
-        this.ctx.drawImage(this.offscreenCanvas, dims.left, dims.top);
+        const imageData = offscreenContext.getImageData(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
+        this.ctx.putImageData(imageData, 0, 0);
+        this.ctx.drawImage(this.canvas, 0, 0, this.canvas.width, this.canvas.height);
 
         if (show_pause_on_hover && this.hovering) {
             this.drawPauseIcon();
         }
+
+        this.last_frame = frame;
     }
 
     /**
@@ -278,20 +331,16 @@ export default class ConverseGif {
     }
 
     drawPauseIcon () {
-        if (!this.playing) {
-            return;
-        }
-        // Clear the potential play button by re-rendering the current frame
-        this.putFrame(this.frame_idx, false);
+        if (!this.playing) return;
 
-        this.ctx.globalCompositeOperation = 'source-over';
+        // Clear the potential play button by re-rendering the current frame
+        this.renderImage(false);
 
         // Draw dark overlay
         this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
         this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
 
         const icon_size = this.canvas.height * 0.1;
-
         // Draw bars
         this.ctx.lineWidth = this.canvas.height * 0.04;
         this.ctx.beginPath();
@@ -318,10 +367,7 @@ export default class ConverseGif {
         if (this.playing) return;
 
         // Clear the potential pause button by re-rendering the current frame
-        this.putFrame(this.frame_idx, false);
-
-        this.ctx.globalCompositeOperation = 'source-over';
-
+        this.renderImage(false);
         // Draw dark overlay
         this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
         this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
@@ -375,7 +421,11 @@ export default class ConverseGif {
             promise.resolve(h.response);
         };
         h.onprogress = (e) => e.lengthComputable && this.doShowProgress(e.loaded, e.total, true);
-        h.onerror = () => this.showError();
+        h.onerror = (e) => {
+            log.error(e);
+            this.showError();
+        };
+
         h.send();
         return promise;
     }