Skip to content

Commit

Permalink
[gem] Add benchmark
Browse files Browse the repository at this point in the history
  • Loading branch information
mantou132 committed Jun 17, 2024
1 parent c7a0c5f commit 4caa4a6
Show file tree
Hide file tree
Showing 5 changed files with 419 additions and 5 deletions.
114 changes: 114 additions & 0 deletions packages/gem-examples/src/benchmark/demuxer_mp4.js
Original file line number Diff line number Diff line change
@@ -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 };
82 changes: 82 additions & 0 deletions packages/gem-examples/src/benchmark/fps.ts
Original file line number Diff line number Diff line change
@@ -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<NesboxFpsElement> = 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}`;
};
}
160 changes: 160 additions & 0 deletions packages/gem-examples/src/benchmark/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/// <reference types="vite/client" />
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`
<style>
:host {
width: ${this.ratio}px;
height: ${this.ratio}px;
background: ${this.color};
}
</style>
`;
}
}

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<State> {
@refobject canvasRef: RefObject<HTMLCanvasElement>;

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<string>) => 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`
<style>
.grid {
grid-template-columns: repeat(${width / ratio}, 1fr);
}
</style>
<div class="info">
<span>Memory: ${number}${unit}</span>
<nesbox-fps></nesbox-fps>
Radio:
<dy-radio-group disabled @change=${this.#onChange} .value=${String(ratio)} .options=${this.#options}>
</dy-radio-group>
</div>
${repeat(
[canvasKey],
(k) => k,
() => html`<canvas ref=${this.canvasRef.ref} width=${width / ratio} height=${height / ratio}></canvas>`,
)}
<div class="grid">
${this.#pixelsPosition.map((index) => {
const color = pixels.slice(index, index + 4) as unknown as RGBA;
return html`<app-pixel ratio=${ratio} color=${rgbToRgbColor(color)}></app-pixel>`;
})}
</div>
`;
}
}

render(
html`
<gem-examples-layout>
<app-root slot="main"></app-root>
</gem-examples-layout>
`,
document.body,
);
Loading

0 comments on commit 4caa4a6

Please sign in to comment.