deadcoder0904
deadcoder0904

Reputation: 8693

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?

Upvotes: 3

Views: 622

Answers (1)

dlq
dlq

Reputation: 3229

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.

Upvotes: 1

Related Questions