-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
419 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); |
Oops, something went wrong.