将 GIF 播放器 Web 组件转换为 React 组件?



我想将 GIF 播放器 Web 组件转换为 React 组件。

我尝试找到一个 React GIF 播放器库,但没有一个工作正常。

目前,GIF 播放器 Web 组件看起来很有前途,但它在 React 中不可用。它看起来像:

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

但是,我不知道如何将其转换为 React 组件。

我想在打字稿中转换它。我已经设法将其转换为:

// 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
}
}

但是,我收到各种TypeScript错误。我也不知道如何:hostcss 属性的样式。

我该如何解决?

不想阅读这堵文字墙的人的快速链接:存储库链接

这是一个非常有趣的项目。我不保证我支持所有用例,但这里有一个利用 TypeScript 的现代实现。它还更喜欢更现代和可组合的 React Hook API,而不是使用类组件。

基本思想是你有useGifController钩子,它通过ref链接到画布。然后,此控制器允许您自行处理加载和错误状态,然后根据需要控制在画布中渲染的 GIF。例如,我们可以编写一个GifPlayer组件,如下所示:

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>
)
}

在这里,您可以看到gifController需要一个包含 GIF 的 URL 和对 canvas 元素的ref。然后,一旦处理了loadingerror状态,您就可以访问GifController提供的所有控件。playpauserenderNextFramerenderPreviousFrame都完全符合你的期望。

那么,这个useGifController钩子里面有什么呢?井。。。它有点长,但希望我已经记录得足够好,以便您在研究一段时间后可以理解。

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
}
}

这又取决于extractFrames,它将帧提取为ImageData对象以及它们各自的延迟。

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
}

我确实计划有一天将其制作为 NPM 包。我认为这会非常有用。

最新更新