Search code examples
javascriptreactjstypescriptweb-componentgif

Convert GIF Player Web Component into React Component?


I want to convert a GIF Player Web Component into React Component.

I tried finding a React GIF Player library but there aren't any that are working fine.

Currently, GIF Player Web Component looks promising but it's not available in React. It looks like:

import { LitElement, html, css } from "lit";
import { GifReader } from "omggif";

class GifPlayer extends LitElement {
  static get styles() {
    return css`
      :host {
        display: inline-block;
      }
      canvas {
        display: block;
        width: 100%;
        height: 100%;
      }
    `;
  }
  static get properties() {
    return {
      src: { type: String },
      alt: { type: String },
      autoplay: { type: Boolean },
      play: { type: Function },
      pause: { type: Function },
      restart: { type: Function },
      currentFrame: { type: Number },
      frames: { attribute: false, type: Array },
      playing: { attribute: false, type: Boolean },
      width: { attribute: false, type: Number },
      height: { attribute: false, type: Number },
    };
  }

  constructor() {
    super();
    this.currentFrame = 1;
    this.frames = [];
    this.step = this.step();
    this.play = this.play.bind(this);
    this.pause = this.pause.bind(this);
    this.renderFrame = this.renderFrame.bind(this);
    this.loadSource = this.loadSource.bind(this);
  }

  firstUpdated() {
    this.canvas = this.renderRoot.querySelector("canvas");
    this.context = this.canvas.getContext("2d");
    this.loadSource(this.src).then(() => {
      if (this.autoplay) this.play();
    });
  }

  updated(changedProperties) {
    if (changedProperties.has("width")) {
      this.canvas.width = this.width;
      this.renderFrame(false);
    }
    if (changedProperties.has("height")) {
      this.canvas.height = this.height;
      this.renderFrame(false);
    }
  }

  render() {
    return html`<canvas role="img" aria-label=${this.alt}></canvas>`;
  }

  play() {
    if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
    this.animationFrame = requestAnimationFrame(this.step);
    this.playing = true;
  }

  pause() {
    this.playing = false;
    if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
  }

  restart() {
    this.currentFrame = 1;
    if (this.playing) {
      this.play();
    } else {
      this.pause();
      this.renderFrame(false);
    }
  }

  step() {
    let previousTimestamp;
    return (timestamp) => {
      if (!previousTimestamp) previousTimestamp = timestamp;
      const delta = timestamp - previousTimestamp;
      const delay = this.frames[this.currentFrame]?.delay;
      if (this.playing && delay && delta > delay) {
        previousTimestamp = timestamp;
        this.renderFrame();
      }
      this.animationFrame = requestAnimationFrame(this.step);
    };
  }

  renderFrame(progress = true) {
    if (!this.frames.length) return;
    if (this.currentFrame === this.frames.length - 1) {
      this.currentFrame = 0;
    }

    this.context.putImageData(this.frames[this.currentFrame].data, 0, 0);
    if (progress) {
      this.currentFrame = this.currentFrame + 1;
    }
  }

  async loadSource(url) {
    const response = await fetch(url);
    const buffer = await response.arrayBuffer();
    const uInt8Array = new Uint8Array(buffer);
    const gifReader = new GifReader(uInt8Array);
    const gif = gifData(gifReader);
    const { width, height, frames } = gif;
    this.width = width;
    this.height = height;
    this.frames = frames;
    if (!this.alt) {
      this.alt = url;
    }
    this.renderFrame(false);
  }
}

function gifData(gif) {
  const frames = Array.from(frameDetails(gif));
  return { width: gif.width, height: gif.height, frames };
}

function* frameDetails(gifReader) {
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  const frameCount = gifReader.numFrames();
  let previousFrame;

  for (let i = 0; i < frameCount; i++) {
    const frameInfo = gifReader.frameInfo(i);
    const imageData = context.createImageData(
      gifReader.width,
      gifReader.height
    );
    if (i > 0 && frameInfo.disposal < 2) {
      imageData.data.set(new Uint8ClampedArray(previousFrame.data.data));
    }
    gifReader.decodeAndBlitFrameRGBA(i, imageData.data);
    previousFrame = {
      data: imageData,
      delay: gifReader.frameInfo(i).delay * 10,
    };
    yield previousFrame;
  }
}

customElements.define("gif-player", GifPlayer);

However, I don't know how to convert it to a React Component.

I want to convert it in TypeScript. I've managed to convert it a little bit like:

// inspired by https://github.com/WillsonSmith/gif-player-component/blob/main/gif-player.js

import React from 'react'
import { GifReader } from 'omggif'

export class GifPlayer extends React.Component {
    static get styles() {
        return `
            :host {
                display: inline-block;
            }
            canvas {
                display: block;
                width: 100%;
                height: 100%;
            }
        `
    }

    static get properties() {
        return {
            src: { type: String },
            alt: { type: String },
            autoplay: { type: Boolean },
            play: { type: Function },
            pause: { type: Function },
            restart: { type: Function },
            currentFrame: { type: Number },
            frames: { attribute: false, type: Array },
            playing: { attribute: false, type: Boolean },
            width: { attribute: false, type: Number },
            height: { attribute: false, type: Number },
        }
    }

    constructor(props) {
        super(props)
        this.currentFrame = 1
        this.frames = []
        this.step = this.step()
        this.play = this.play.bind(this)
        this.pause = this.pause.bind(this)
        this.renderFrame = this.renderFrame.bind(this)
        this.loadSource = this.loadSource.bind(this)
    }

    firstUpdated = () => {
        this.canvas = this.renderRoot.querySelector('canvas')
        this.context = this.canvas.getContext('2d')
        this.loadSource(this.src).then(() => {
            if (this.autoplay) this.play()
        })
    }

    updated = (changedProperties) => {
        if (changedProperties.has('width')) {
            this.canvas.width = this.width
            this.renderFrame(false)
        }
        if (changedProperties.has('height')) {
            this.canvas.height = this.height
            this.renderFrame(false)
        }
    }

    render() {
        const { alt } = this.props
        return <canvas role="img" aria-label={alt}></canvas>
    }

    play = () => {
        if (this.animationFrame) cancelAnimationFrame(this.animationFrame)
        this.animationFrame = requestAnimationFrame(this.step)
        this.playing = true
    }

    pause = () => {
        this.playing = false
        if (this.animationFrame) cancelAnimationFrame(this.animationFrame)
    }

    restart = () => {
        this.currentFrame = 1
        if (this.playing) {
            this.play()
        } else {
            this.pause()
            this.renderFrame(false)
        }
    }

    step = () => {
        let previousTimestamp
        return (timestamp) => {
            if (!previousTimestamp) previousTimestamp = timestamp
            const delta = timestamp - previousTimestamp
            const delay = this.frames[this.currentFrame]?.delay
            if (this.playing && delay && delta > delay) {
                previousTimestamp = timestamp
                this.renderFrame()
            }
            this.animationFrame = requestAnimationFrame(this.step)
        }
    }

    renderFrame = (progress = true) => {
        if (!this.frames.length) return
        if (this.currentFrame === this.frames.length - 1) {
            this.currentFrame = 0
        }

        this.context.putImageData(this.frames[this.currentFrame].data, 0, 0)
        if (progress) {
            this.currentFrame = this.currentFrame + 1
        }
    }

    loadSource = async (url) => {
        const response = await fetch(url)
        const buffer = await response.arrayBuffer()
        const uInt8Array = new Uint8Array(buffer)
        const gifReader = new GifReader(uInt8Array)
        const gif = gifData(gifReader)
        const { width, height, frames } = gif
        this.width = width
        this.height = height
        this.frames = frames
        if (!this.alt) {
            this.alt = url
        }
        this.renderFrame(false)
    }
}

function gifData(gif) {
    const frames = Array.from(frameDetails(gif))
    return { width: gif.width, height: gif.height, frames }
}

function* frameDetails(gifReader) {
    const canvas = document.createElement('canvas')
    const context = canvas.getContext('2d')

    if (!context) return

    const frameCount = gifReader.numFrames()
    let previousFrame

    for (let i = 0; i < frameCount; i++) {
        const frameInfo = gifReader.frameInfo(i)
        const imageData = context.createImageData(gifReader.width, gifReader.height)
        if (i > 0 && frameInfo.disposal < 2) {
            imageData.data.set(new Uint8ClampedArray(previousFrame.data.data))
        }
        gifReader.decodeAndBlitFrameRGBA(i, imageData.data)
        previousFrame = {
            data: imageData,
            delay: gifReader.frameInfo(i).delay * 10,
        }
        yield previousFrame
    }
}

However, I'm getting all kinds of TypeScript errors. I also don't know how to style :host css property.

How can I solve it?


Solution

  • Quick link for people who don't want to read this wall of text: repository link

    This was a pretty fun project. I'm not guaranteeing that I support all use-cases, but here is a modern-day implementation that takes advantage of TypeScript. It also prefers the more modern and composable React Hook API over using class components.

    The basic idea is that you have useGifController hook, which is linked to a canvas via a ref. This controller then allows you to handle loading and error states on your own, and then control the GIF rendered in a canvas as needed. So for example, we could write a GifPlayer component as follows:

    GifPlayer.tsx

    import React, { useRef } from 'react'
    import { useGifController } from '../hooks/useGifController'
    
    export function GifPlayer(): JSX.Element | null {
      const canvasRef = useRef<HTMLCanvasElement>(null)
      const gifController = useGifController('/cradle.gif', canvasRef, true)
    
      if (gifController.loading) {
        return null
      }
    
      if (gifController.error) {
        return null
      }
    
      const { playing, play, pause, restart, renderNextFrame, renderPreviousFrame, width, height } = gifController
    
      return (
        <div>
          <canvas {...gifController.canvasProps} ref={canvasRef} />
          <div style={{ display: 'flex', gap: 16, justifyContent: 'space-around' }}>
            <button onClick={renderPreviousFrame}>Previous</button>
            {playing ? <button onClick={pause}>Pause</button> : <button onClick={play}>Play</button>}
            <button onClick={restart}>Restart</button>
            <button onClick={renderNextFrame}>Next</button>
          </div>
          <div>
            <p>Width: {width}</p>
            <p>Height: {height}</p>
          </div>
        </div>
      )
    }
    

    Here, you can see the gifController expects a URL containing the GIF, and a ref to the canvas element. Then, once you handle the loading and error states, you have access to all of the controls GifController provides. play, pause, renderNextFrame, renderPreviousFrame all do exactly what you would expect.

    So, what's inside of this useGifController hook? Well... it's a bit lengthy, but hopefully I've documented this well-enough so you can understand after studying it for a bit.

    useGifController.ts

    import { GifReader } from 'omggif'
    import {
      RefObject,
      DetailedHTMLProps,
      CanvasHTMLAttributes,
      useEffect,
      useState,
      MutableRefObject,
      useRef,
    } from 'react'
    import { extractFrames, Frame } from '../lib/extractFrames'
    
    type HTMLCanvasElementProps = DetailedHTMLProps<CanvasHTMLAttributes<HTMLCanvasElement>, HTMLCanvasElement>
    
    type GifControllerLoading = {
      canvasProps: HTMLCanvasElementProps
      loading: true
      error: false
    }
    
    type GifControllerError = {
      canvasProps: HTMLCanvasElementProps
      loading: false
      error: true
      errorMessage: string
    }
    
    type GifControllerResolved = {
      canvasProps: HTMLCanvasElementProps
      loading: false
      error: false
      frameIndex: MutableRefObject<number>
      playing: boolean
      play: () => void
      pause: () => void
      restart: () => void
      renderFrame: (frame: number) => void
      renderNextFrame: () => void
      renderPreviousFrame: () => void
      width: number
      height: number
    }
    
    type GifController = GifControllerLoading | GifControllerResolved | GifControllerError
    
    export function useGifController(
      url: string,
      canvas: RefObject<HTMLCanvasElement | null>,
      autoplay = false,
    ): GifController {
      type LoadingState = {
        loading: true
        error: false
      }
    
      type ErrorState = {
        loading: false
        error: true
        errorMessage: string
      }
    
      type ResolvedState = {
        loading: false
        error: false
        gifReader: GifReader
        frames: Frame[]
      }
    
      type State = LoadingState | ResolvedState | ErrorState
    
      const ctx = canvas.current?.getContext('2d')
    
      // asynchronous state variables strongly typed as a union such that properties
      // are only defined when `loading === true`.
      const [state, setState] = useState<State>({ loading: true, error: false })
      const [shouldUpdate, setShouldUpdate] = useState(false)
      const [canvasAccessible, setCanvasAccessible] = useState(false)
      const frameIndex = useRef(-1)
    
      // state variable returned by hook
      const [playing, setPlaying] = useState(false)
      // ref that is used internally
      const _playing = useRef(false)
    
      // Load GIF on initial render and when url changes.
      useEffect(() => {
        async function loadGif() {
          const response = await fetch(url)
          const buffer = await response.arrayBuffer()
          const uInt8Array = new Uint8Array(buffer)
    
          // Type cast is necessary because GifReader expects Buffer, which extends
          // Uint8Array. Doesn't *seem* to cause any runtime errors, but I'm sure
          // there's some edge case I'm not covering here.
          const gifReader = new GifReader(uInt8Array as Buffer)
          const frames = extractFrames(gifReader)
    
          if (!frames) {
            setState({ loading: false, error: true, errorMessage: 'Could not extract frames from GIF.' })
          } else {
            setState({ loading: false, error: false, gifReader, frames })
          }
    
          // must trigger re-render to ensure access to canvas ref
          setShouldUpdate(true)
        }
        loadGif()
        // only run this effect on initial render and when URL changes.
        // eslint-disable-next-line
      }, [url])
    
      // update if shouldUpdate gets set to true
      useEffect(() => {
        if (shouldUpdate) {
          setShouldUpdate(false)
        } else if (canvas.current !== null) {
          setCanvasAccessible(true)
        }
      }, [canvas, shouldUpdate])
    
      // if canvasAccessible is set to true, render first frame and then autoplay if
      // specified in hook arguments
      useEffect(() => {
        if (canvasAccessible && frameIndex.current === -1) {
          renderNextFrame()
          autoplay && setPlaying(true)
        }
        // ignore renderNextFrame as it is referentially unstable
        // eslint-disable-next-line
      }, [canvasAccessible])
    
      useEffect(() => {
        if (playing) {
          _playing.current = true
          _iterateRenderLoop()
        } else {
          _playing.current = false
        }
        // ignore _iterateRenderLoop() as it is referentially unstable
        // eslint-disable-next-line
      }, [playing])
    
      if (state.loading === true || !canvas) return { canvasProps: { hidden: true }, loading: true, error: false }
    
      if (state.error === true)
        return { canvasProps: { hidden: true }, loading: false, error: true, errorMessage: state.errorMessage }
    
      const { width, height } = state.gifReader
    
      return {
        canvasProps: { width, height },
        loading: false,
        error: false,
        playing,
        play,
        pause,
        restart,
        frameIndex,
        renderFrame,
        renderNextFrame,
        renderPreviousFrame,
        width,
        height,
      }
    
      function play() {
        if (state.error || state.loading) return
        if (playing) return
        setPlaying(true)
      }
    
      function _iterateRenderLoop() {
        if (state.error || state.loading || !_playing.current) return
    
        const delay = state.frames[frameIndex.current].delay
        setTimeout(() => {
          renderNextFrame()
          _iterateRenderLoop()
        }, delay)
      }
    
      function pause() {
        setPlaying(false)
      }
    
      function restart() {
        frameIndex.current = 0
        setPlaying(true)
      }
    
      function renderFrame(frameIndex: number) {
        if (!ctx || state.loading === true || state.error === true) return
        if (frameIndex < 0 || frameIndex >= state.gifReader.numFrames()) return
        ctx.putImageData(state.frames[frameIndex].imageData, 0, 0)
      }
    
      function renderNextFrame() {
        if (!ctx || state.loading === true || state.error === true) return
        const nextFrame = frameIndex.current + 1 >= state.gifReader.numFrames() ? 0 : frameIndex.current + 1
        renderFrame(nextFrame)
        frameIndex.current = nextFrame
      }
    
      function renderPreviousFrame() {
        if (!ctx || state.loading === true || state.error === true) return
        const prevFrame = frameIndex.current - 1 < 0 ? state.gifReader.numFrames() - 1 : frameIndex.current - 1
        renderFrame(prevFrame)
        frameIndex.current = prevFrame
      }
    }
    

    This in turn depends on extractFrames, which extracts the frames as ImageData objects along with their respective delays.

    extractFrames.ts

    import { GifReader } from 'omggif'
    
    export type Frame = {
      /**
       * A full frame of a GIF represented as an ImageData object. This can be
       * rendered onto a canvas context simply by calling
       * `ctx.putImageData(frame.imageData, 0, 0)`.
       */
      imageData: ImageData
      /**
       * Delay in milliseconds.
       */
      delay: number
    }
    
    /**
     * Function that accepts a `GifReader` instance and returns an array of
     * `ImageData` objects that represent the frames of the gif.
     *
     * @param gifReader The `GifReader` instance.
     * @returns An array of `ImageData` objects representing each frame of the GIF.
     * Or `null` if something went wrong.
     */
    export function extractFrames(gifReader: GifReader): Frame[] | null {
      const frames: Frame[] = []
    
      // the width and height of the complete gif
      const { width, height } = gifReader
    
      // This is the primary canvas that the tempCanvas below renders on top of. The
      // reason for this is that each frame stored internally inside the GIF is a
      // "diff" from the previous frame. To resolve frame 4, we must first resolve
      // frames 1, 2, 3, and then render frame 4 on top. This canvas accumulates the
      // previous frames.
      const canvas = document.createElement('canvas')
      canvas.width = width
      canvas.height = height
      const ctx = canvas.getContext('2d')
      if (!ctx) return null
    
      for (let frameIndex = 0; frameIndex < gifReader.numFrames(); frameIndex++) {
        // the width, height, x, and y of the "dirty" pixels that should be redrawn
        const { width: dirtyWidth, height: dirtyHeight, x: dirtyX, y: dirtyY, disposal, delay } = gifReader.frameInfo(0)
    
        // skip this frame if disposal >= 2; from GIF spec
        if (disposal >= 2) continue
    
        // create hidden temporary canvas that exists only to render the "diff"
        // between the previous frame and the current frame
        const tempCanvas = document.createElement('canvas')
        tempCanvas.width = width
        tempCanvas.height = height
        const tempCtx = tempCanvas.getContext('2d')
        if (!tempCtx) return null
    
        // extract GIF frame data to tempCanvas
        const newImageData = tempCtx.createImageData(width, height)
        gifReader.decodeAndBlitFrameRGBA(frameIndex, newImageData.data)
        tempCtx.putImageData(newImageData, 0, 0, dirtyX, dirtyY, dirtyWidth, dirtyHeight)
    
        // draw the tempCanvas on top. ctx.putImageData(tempCtx.getImageData(...))
        // is too primitive here, since the pixels would be *replaced* by incoming
        // RGBA values instead of layered.
        ctx.drawImage(tempCanvas, 0, 0)
    
        frames.push({
          delay: delay * 10,
          imageData: ctx.getImageData(0, 0, width, height),
        })
      }
    
      return frames
    }
    

    I do plan on making this an NPM package someday. I think it'd be pretty useful.