From 4caa4a696fe0c6c0b5cedea48a85b40fc4eb2cc1 Mon Sep 17 00:00:00 2001 From: mantou132 <709922234@qq.com> Date: Tue, 18 Jun 2024 00:06:02 +0800 Subject: [PATCH] [gem] Add benchmark --- .../gem-examples/src/benchmark/demuxer_mp4.js | 114 +++++++++++++ packages/gem-examples/src/benchmark/fps.ts | 82 +++++++++ packages/gem-examples/src/benchmark/index.ts | 160 ++++++++++++++++++ packages/gem-examples/src/benchmark/worker.ts | 58 +++++++ packages/gem/src/lib/element.ts | 10 +- 5 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 packages/gem-examples/src/benchmark/demuxer_mp4.js create mode 100644 packages/gem-examples/src/benchmark/fps.ts create mode 100644 packages/gem-examples/src/benchmark/index.ts create mode 100644 packages/gem-examples/src/benchmark/worker.ts diff --git a/packages/gem-examples/src/benchmark/demuxer_mp4.js b/packages/gem-examples/src/benchmark/demuxer_mp4.js new file mode 100644 index 00000000..dfb17933 --- /dev/null +++ b/packages/gem-examples/src/benchmark/demuxer_mp4.js @@ -0,0 +1,114 @@ +// 'https://w3c.github.io/webcodecs/samples/video-decode-display/demuxer_mp4.js'; + +/* eslint-disable import/no-unresolved */ +import MP4Box, { DataStream } from 'https://esm.sh/mp4box'; + +// Wraps an MP4Box File as a WritableStream underlying sink. +class MP4FileSink { + #setStatus = null; + #file = null; + #offset = 0; + + constructor(file, setStatus) { + this.#file = file; + this.#setStatus = setStatus; + } + + write(chunk) { + // MP4Box.js requires buffers to be ArrayBuffers, but we have a Uint8Array. + const buffer = new ArrayBuffer(chunk.byteLength); + new Uint8Array(buffer).set(chunk); + + // Inform MP4Box where in the file this chunk is from. + buffer.fileStart = this.#offset; + this.#offset += buffer.byteLength; + + // Append chunk. + this.#setStatus('fetch', (this.#offset / 1024 ** 2).toFixed(1) + ' MiB'); + this.#file.appendBuffer(buffer); + } + + close() { + this.#setStatus('fetch', 'Done'); + this.#file.flush(); + } +} + +// Demuxes the first video track of an MP4 file using MP4Box, calling +// `onConfig()` and `onChunk()` with appropriate WebCodecs objects. +class MP4Demuxer { + #onConfig = null; + #onChunk = null; + #setStatus = null; + #file = null; + + constructor(uri, { onConfig, onChunk, setStatus }) { + this.#onConfig = onConfig; + this.#onChunk = onChunk; + this.#setStatus = setStatus; + + // Configure an MP4Box File for demuxing. + this.#file = MP4Box.createFile(); + this.#file.onError = (error) => setStatus('demux', error); + this.#file.onReady = this.#onReady.bind(this); + this.#file.onSamples = this.#onSamples.bind(this); + + // Fetch the file and pipe the data through. + const fileSink = new MP4FileSink(this.#file, setStatus); + fetch(uri).then((response) => { + // highWaterMark should be large enough for smooth streaming, but lower is + // better for memory usage. + response.body.pipeTo(new WritableStream(fileSink, { highWaterMark: 2 })); + }); + } + + // Get the appropriate `description` for a specific track. Assumes that the + // track is H.264, H.265, VP8, VP9, or AV1. + #description(track) { + const trak = this.#file.getTrackById(track.id); + for (const entry of trak.mdia.minf.stbl.stsd.entries) { + const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C; + if (box) { + const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN); + box.write(stream); + return new Uint8Array(stream.buffer, 8); // Remove the box header. + } + } + throw new Error('avcC, hvcC, vpcC, or av1C box not found'); + } + + #onReady(info) { + this.#setStatus('demux', 'Ready'); + const track = info.videoTracks[0]; + + // Generate and emit an appropriate VideoDecoderConfig. + this.#onConfig({ + // Browser doesn't support parsing full vp8 codec (eg: `vp08.00.41.08`), + // they only support `vp8`. + codec: track.codec.startsWith('vp08') ? 'vp8' : track.codec, + codedHeight: track.video.height, + codedWidth: track.video.width, + description: this.#description(track), + }); + + // Start demuxing. + this.#file.setExtractionOptions(track.id); + this.#file.start(); + } + + #onSamples(track_id, ref, samples) { + // Generate and emit an EncodedVideoChunk for each demuxed sample. + for (const sample of samples) { + this.#onChunk( + new EncodedVideoChunk({ + type: sample.is_sync ? 'key' : 'delta', + timestamp: (1e6 * sample.cts) / sample.timescale, + duration: (1e6 * sample.duration) / sample.timescale, + data: sample.data, + }), + ); + } + } +} + +export { MP4Demuxer }; diff --git a/packages/gem-examples/src/benchmark/fps.ts b/packages/gem-examples/src/benchmark/fps.ts new file mode 100644 index 00000000..9c244d15 --- /dev/null +++ b/packages/gem-examples/src/benchmark/fps.ts @@ -0,0 +1,82 @@ +import { + GemElement, + html, + adoptedStyle, + customElement, + createCSSSheet, + css, + connectStore, + useStore, +} from '@mantou/gem'; + +export const fpsStyle = createCSSSheet(css` + :host { + font-variant-numeric: tabular-nums; + } +`); + +const [store, update] = useStore({ + min: 0, + max: 0, + fps: 0, + avgFps: 0, +}); + +const frames: number[] = []; +let lastFrameTime = performance.now(); +let timer = 0; + +const tick = () => { + const now = performance.now(); + const delta = now - lastFrameTime; + if (delta === 0) return; + lastFrameTime = now; + + const fps = Math.round(1000 / delta); + frames.push(fps); + if (frames.length > 100) { + frames.shift(); + } + + let min = Infinity; + let max = Infinity; + const sum = frames.reduce((acc, val) => { + acc += val; + min = Math.min(val, min); + max = Math.max(val, max); + return acc; + }); + const avgFps = Math.round(sum / frames.length); + + update({ fps, avgFps, min, max }); + + timer = requestAnimationFrame(tick); +}; + +/** + * @customElement nesbox-fps + */ +@customElement('nesbox-fps') +@adoptedStyle(fpsStyle) +@connectStore(store) +export class NesboxFpsElement extends GemElement { + static instanceSet: Set = new Set(); + + mounted = () => { + NesboxFpsElement.instanceSet.add(this); + if (NesboxFpsElement.instanceSet.size === 1) { + timer = requestAnimationFrame(tick); + } + }; + + unmounted = () => { + NesboxFpsElement.instanceSet.delete(this); + if (NesboxFpsElement.instanceSet.size === 0) { + cancelAnimationFrame(timer); + } + }; + + render = () => { + return html`FPS: ${store.fps}`; + }; +} diff --git a/packages/gem-examples/src/benchmark/index.ts b/packages/gem-examples/src/benchmark/index.ts new file mode 100644 index 00000000..2973f21a --- /dev/null +++ b/packages/gem-examples/src/benchmark/index.ts @@ -0,0 +1,160 @@ +/// +import { + html, + customElement, + GemElement, + render, + attribute, + numattribute, + createCSSSheet, + css, + adoptedStyle, + refobject, + RefObject, + repeat, +} from '@mantou/gem'; +import { RGBA, rgbToRgbColor } from 'duoyun-ui/lib/color'; +import { formatTraffic } from 'duoyun-ui/lib/number'; + +// eslint-disable-next-line import/default +import Worker from './worker?worker'; + +import 'duoyun-ui/elements/radio'; +import '../elements/layout'; +import './fps'; + +@customElement('app-pixel') +export class Pixel extends GemElement { + @attribute color: string; + @numattribute ratio: number; + render() { + return html` + + `; + } +} + +const style = createCSSSheet(css` + :host { + display: grid; + place-items: center; + width: 100%; + height: 100%; + box-sizing: border-box; + } + canvas { + position: absolute; + opacity: 0.5; + right: 0; + top: 0; + width: 200px; + } + .info { + display: flex; + align-items: center; + gap: 1em; + } + .grid { + display: grid; + } +`); + +type State = { canvasKey: number; pixels: Uint8ClampedArray; width: number; height: number; ratio: number }; + +@customElement('app-root') +@adoptedStyle(style) +export class App extends GemElement { + @refobject canvasRef: RefObject; + + state: State = { + canvasKey: 0, + ratio: 10, + width: 0, + height: 0, + pixels: new Uint8ClampedArray(), + }; + + #pixelsPosition: number[] = []; + + willMount = () => { + this.memo( + () => { + const { width, height, ratio } = this.state; + this.#pixelsPosition = Array.from({ length: (height * width) / ratio / ratio }, (_, i) => i * 4); + }, + () => [this.state.width, this.state.height, this.state.ratio], + ); + }; + + mounted = () => { + const worker = new Worker(); + + worker.addEventListener('message', (evt) => { + const { width, height, pixels, canvasKey } = evt.data; + this.setState({ width, height, canvasKey, pixels: new Uint8ClampedArray(pixels) }); + }); + + this.effect( + () => { + const offscreenCanvas = this.canvasRef.element!.transferControlToOffscreen(); + worker.postMessage( + { + ratio: this.state.ratio, + canvas: offscreenCanvas, + }, + [offscreenCanvas], + ); + }, + () => [this.state.canvasKey, this.state.ratio], + ); + }; + + #options = [{ label: '40' }, { label: '20' }, { label: '10' }]; + + #onChange = (evt: CustomEvent) => this.setState({ ratio: Number(evt.detail) }); + + render() { + const { canvasKey, width, height, ratio, pixels } = this.state; + const { number, unit } = formatTraffic((performance as any).memory.usedJSHeapSize); + return html` + +
+ Memory: ${number}${unit} + + Radio: + + +
+ ${repeat( + [canvasKey], + (k) => k, + () => html``, + )} +
+ ${this.#pixelsPosition.map((index) => { + const color = pixels.slice(index, index + 4) as unknown as RGBA; + return html``; + })} +
+ `; + } +} + +render( + html` + + + + `, + document.body, +); diff --git a/packages/gem-examples/src/benchmark/worker.ts b/packages/gem-examples/src/benchmark/worker.ts new file mode 100644 index 00000000..df78b08d --- /dev/null +++ b/packages/gem-examples/src/benchmark/worker.ts @@ -0,0 +1,58 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import { MP4Demuxer } from './demuxer_mp4'; + +// https://w3c.github.io/webcodecs/samples/video-decode-display/worker.js + +const frameList: VideoFrame[] = []; + +const decoder = new VideoDecoder({ + output: (e) => { + frameList.push(e); + }, + error: console.error, +}); + +new MP4Demuxer('https://storage.googleapis.com/media-session/caminandes/short.mp4', { + onConfig(config: VideoDecoderConfig) { + decoder.configure(config); + }, + onChunk(chunk: EncodedVideoChunk) { + decoder.decode(chunk); + }, + setStatus: (_e: any) => { + // + }, +}); + +let timer = 0; +self.addEventListener('message', (e) => { + self.cancelAnimationFrame(timer); + + const { canvas, ratio } = e.data as { canvas: OffscreenCanvas; ratio: number }; + const ctx = canvas.getContext('2d')!; + const draw = () => { + const frame = frameList.shift(); + if (frame) { + const { codedWidth: width, codedHeight: height } = frame; + ctx.drawImage(frame, 0, 0, width / ratio, height / ratio); + const pixels = ctx.getImageData(0, 0, width / ratio, height / ratio).data.buffer; + frame.close(); + try { + self.postMessage( + { + width, + height, + pixels, + canvasKey: width * height, + }, + { transfer: [pixels] }, + ); + } catch (err) { + console.error(err); + } + } + timer = self.requestAnimationFrame(draw); + }; + timer = self.requestAnimationFrame(draw); +}); diff --git a/packages/gem/src/lib/element.ts b/packages/gem/src/lib/element.ts index 1798445a..6381715b 100644 --- a/packages/gem/src/lib/element.ts +++ b/packages/gem/src/lib/element.ts @@ -252,9 +252,9 @@ export abstract class GemElement> extends HTMLElemen /** * @helper - * 记录副作用回调和值,在 `constructor`/`mounted` 中使用 - * 回调到返回值如果是函数将再卸载时执行 - * 第一次执行时 `oldDeps` 为空 + * 记录副作用回调和值,在 `constructor`/`mounted` 中使用; + * 回调到返回值如果是函数将再卸载时执行; + * 第一次执行时 `oldDeps` 为空; * * ```js * class App extends GemElement { @@ -282,9 +282,9 @@ export abstract class GemElement> extends HTMLElemen /** * @helper - * 在 `render` 前执行回调,不要在里面使用 `setState` + * 在 `render` 前执行回调,不要在里面使用 `setState`; * 和 `effect` 一样接受依赖数组参数,在 `constructor`/`willMount` 中使用; - * 第一次执行时 `oldDeps` 为空 + * 第一次执行时 `oldDeps` 为空; * * ```js * class App extends GemElement {