mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 14:23:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bfa855f25 | ||
|
|
d768f3c63b | ||
|
|
c4d13ed698 | ||
|
|
5e933d9362 | ||
|
|
904ac2e366 | ||
|
|
4b12468008 | ||
|
|
2277810853 | ||
|
|
19b1c015b6 |
@@ -49,6 +49,8 @@ Core features include:
|
||||
npm install mediabunny
|
||||
```
|
||||
|
||||
Requires any JavaScript environment that can run ECMAScript 2021 or later. Mediabunny is expected to be run in modern browsers. For types, TypeScript 5.7 or later is required.
|
||||
|
||||
### Read file metadata
|
||||
|
||||
```js
|
||||
|
||||
+6
-4
@@ -21,7 +21,7 @@
|
||||
chunked: true,
|
||||
chunkSize: 2**20
|
||||
});
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat();
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat({});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Cancel';
|
||||
@@ -38,8 +38,8 @@
|
||||
target
|
||||
}),
|
||||
audio: {
|
||||
codec: 'opus',
|
||||
bitrate: 128000,
|
||||
//codec: 'opus',
|
||||
//bitrate: 128000,
|
||||
//numberOfChannels: 1,
|
||||
//sampleRate: 4000
|
||||
//discard: true
|
||||
@@ -70,6 +70,8 @@
|
||||
},
|
||||
*/
|
||||
video: {
|
||||
forceTranscode: true,
|
||||
codec: 'av1',
|
||||
//discard: true,
|
||||
//width: 1280,
|
||||
//discard: true,
|
||||
@@ -87,7 +89,7 @@
|
||||
},
|
||||
trim: {
|
||||
start: 0,
|
||||
end: 10
|
||||
end: 20
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
@@ -16,6 +16,62 @@
|
||||
source
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const packetSink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
const sampleSink = new Mediabunny.VideoSampleSink(videoTrack);
|
||||
|
||||
for await (const packet of packetSink.packets(undefined, undefined, {verifyType: true})) {
|
||||
const guess = packet.type;
|
||||
const real = await videoTrack.determinePacketType(packet);
|
||||
|
||||
if (guess !== real) {
|
||||
console.log(guess, real, packet);
|
||||
}
|
||||
}
|
||||
console.log("don")
|
||||
|
||||
/*
|
||||
console.time()
|
||||
for await (const packet of packetSink.packets(undefined, undefined, { verifyType: true })) {
|
||||
//console.log(packet)
|
||||
}
|
||||
console.timeEnd()
|
||||
*/
|
||||
|
||||
//console.log(await packetSink.getPacket(6.666666666666667, { verifyType: true }))
|
||||
|
||||
/*
|
||||
for await (const packet of packetSink.packets()) {
|
||||
const guess = packet.type;
|
||||
const real = await videoTrack.determinePacketType(packet);
|
||||
|
||||
if (guess !== real) {
|
||||
console.log(guess, real, packet);
|
||||
}
|
||||
}
|
||||
console.log("done")
|
||||
*/
|
||||
|
||||
/*
|
||||
const timestamp = 6.666666666666667;
|
||||
const thePacket = await packetSink.getPacket(timestamp);
|
||||
|
||||
console.log(videoTrack.codec, thePacket, await videoTrack.determinePacketType(thePacket));
|
||||
|
||||
sampleSink.getSample(thePacket.timestamp);
|
||||
*/
|
||||
|
||||
/*
|
||||
let packet = await packetSink.getFirstPacket();
|
||||
while (packet) {
|
||||
console.log(packet)
|
||||
const sample = await sampleSink.getSample(packet.timestamp);
|
||||
|
||||
packet = await packetSink.getNextKeyPacket(packet);
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1920;
|
||||
canvas.height = 1080;
|
||||
@@ -30,6 +86,7 @@
|
||||
console.log(sample);
|
||||
|
||||
sample.draw(ctx, 1500, 500, 50, 50, 0, 0);
|
||||
*/
|
||||
|
||||
/*
|
||||
let timestamps = [];
|
||||
|
||||
@@ -4,11 +4,13 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import llmstxt from 'vitepress-plugin-llms';
|
||||
import { HeadConfig } from 'vitepress';
|
||||
|
||||
const DESCRIPTION = 'A JavaScript library for reading, writing, and converting media files. Directly in the browser,'
|
||||
+ ' and faster than anybunny else.';
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default withMermaid({
|
||||
title: 'Mediabunny',
|
||||
description: 'A JavaScript library for reading, writing, and converting media files. Directly in the browser, and'
|
||||
+ ' faster than anybunny else.',
|
||||
description: DESCRIPTION,
|
||||
cleanUrls: true,
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
|
||||
@@ -16,9 +18,13 @@ export default withMermaid({
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:site_name', content: 'Mediabunny' }],
|
||||
['meta', { property: 'og:url', content: 'https://mediabunny.dev/' }],
|
||||
['meta', { property: 'og:image', content: '/mediabunny-og-image.png' }],
|
||||
['meta', { property: 'og:image', content: 'https://mediabunny.dev/mediabunny-og-image.png' }],
|
||||
['meta', { property: 'og:locale', content: 'en-US' }],
|
||||
['meta', { property: 'og:description', content: DESCRIPTION }],
|
||||
['meta', { name: 'twitter:image', content: 'https://mediabunny.dev/mediabunny-og-image.png' }],
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
['meta', { name: 'twitter:site', content: '@vanilagy' }],
|
||||
['meta', { name: 'twitter:description', content: DESCRIPTION }],
|
||||
],
|
||||
themeConfig: {
|
||||
logo: '/mediabunny-logo.svg',
|
||||
@@ -114,6 +120,7 @@ export default withMermaid({
|
||||
|
||||
((pageData.frontmatter['head'] ??= []) as HeadConfig[]).push(
|
||||
['meta', { property: 'og:title', content: title }],
|
||||
['meta', { property: 'twitter:title', content: title }],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ bun add mediabunny
|
||||
```
|
||||
:::
|
||||
|
||||
::: info
|
||||
Requires any JavaScript environment that can run ECMAScript 2021 or later. Mediabunny is expected to be run in modern browsers. For types, TypeScript 5.7 or later is required.
|
||||
:::
|
||||
|
||||
Then, simply import it like this:
|
||||
```ts
|
||||
import { ... } from 'mediabunny'; // ESM
|
||||
|
||||
@@ -144,6 +144,32 @@ for await (const packet of sink.packets(start, end)) {
|
||||
|
||||
The `packets` method is more performant than manual iteration as it will intelligently preload future packets before they are needed.
|
||||
|
||||
#### Verifying key packets
|
||||
|
||||
By default, packet types are determined using the metadata provided by the containing file. Some files can erroneously label some delta packets as key packets, leading to potential decoder errors. To be guaranteed that a key packet is actually a key packet, you can enable the `verifyKeyPackets` option:
|
||||
```ts
|
||||
// If the packet returned by this method has type: 'key', it's guaranteed
|
||||
// to be a key packet.
|
||||
await sink.getPacket(5, { verifyKeyPackets: true });
|
||||
|
||||
// Returned packets are guaranteed to be key packets
|
||||
await sink.getKeyPacket(10, { verifyKeyPackets: true });
|
||||
await sink.getNextKeyPacket(packet, { verifyKeyPackets: true });
|
||||
|
||||
// Also works for the iterator:
|
||||
for await (const packet of sink.packets(
|
||||
undefined,
|
||||
undefined,
|
||||
{ verifyKeyPackets: true },
|
||||
)) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
::: info
|
||||
`verifyKeyPackets` only works when `metadataOnly` is not also enabled.
|
||||
:::
|
||||
|
||||
#### Metadata-only packet retrieval
|
||||
|
||||
Sometimes, you're only interested in a packet's metadata (timestamp, duration, type, ...) and not in its encoded media data. All methods on `EncodedPacketSink` accept a final `options` parameter which you can use to retrieve [metadata-only packets](./packets-and-samples#metadata-only-packets):
|
||||
|
||||
@@ -227,8 +227,11 @@ const output = new Output({
|
||||
The following options are available:
|
||||
```ts
|
||||
type WavOutputFormatOptions = {
|
||||
large?: boolean;
|
||||
onHeader?: (data: Uint8Array, position: number) => unknown;
|
||||
};
|
||||
```
|
||||
- `large`\
|
||||
When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible for regular WAVE files.
|
||||
- `onHeader`\
|
||||
Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the start of the data chunk (with a placeholder size of 0).
|
||||
@@ -133,6 +133,16 @@ encodedPacket.type; // => PacketType ('key' | 'delta')
|
||||
|
||||
For example, in a video track, it is common to have a key frame about every few seconds. When seeking, if the user seeks to a position shortly after a key frame, the decoded data can be shown quickly; if they seek far away from a key frame, the decoder must first crunch through many delta frames before it can show anything.
|
||||
|
||||
#### Determining a packet's actual type
|
||||
|
||||
The `type` field is derived from metadata in the containing file, which can sometimes (in rare cases) be incorrect. To determine a packet's actual type with certainty, you can do this:
|
||||
```ts
|
||||
// `packet` must come from the InputTrack `track`
|
||||
const type = await track.determinePacketType(packet); // => PacketType | null
|
||||
```
|
||||
|
||||
This determines the packet's type by looking into its bitstream. `null` is returned when the type couldn't be determined.
|
||||
|
||||
---
|
||||
|
||||
You can query the packet's timing information:
|
||||
|
||||
@@ -286,6 +286,18 @@ See [Media sinks](./media-sinks) for a full list of sinks.
|
||||
|
||||
### Examples
|
||||
|
||||
Loop over all raw encoded packets of a track:
|
||||
```ts
|
||||
import { EncodedPacketSink } from 'mediabunny';
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet.timestamp);
|
||||
}
|
||||
```
|
||||
|
||||
Here we iterate over all samples (frames) of a video track:
|
||||
```ts
|
||||
import { VideoSampleSink } from 'mediabunny';
|
||||
|
||||
@@ -95,6 +95,7 @@ const sponsors = {
|
||||
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -38,6 +38,7 @@ const startRecording = async () => {
|
||||
mainContainer.style.display = 'none';
|
||||
videoElement.src = '';
|
||||
downloadButton.style.display = 'none';
|
||||
errorElement.textContent = '';
|
||||
|
||||
// Paint a white background to the canvas
|
||||
context.fillStyle = 'white';
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
|
||||
|
||||
<p id="error-element" class="text-red-500"></p>
|
||||
<p id="warning-element" class="text-amber-500 mb-1"></p>
|
||||
|
||||
<div id="player" class="relative bg-black rounded-xl shrink min-h-14 min-w-0 w-full max-w-5xl overflow-hidden select-none" style="display: none;">
|
||||
<canvas class="size-full object-contain" width="1280" height="720"></canvas>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const volumeIconWrapper = document.querySelector('#volume-icon-wrapper') as HTML
|
||||
const volumeButton = document.querySelector('#volume-button') as HTMLButtonElement;
|
||||
const fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
|
||||
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
|
||||
const warningElement = document.querySelector('#warning-element') as HTMLDivElement;
|
||||
|
||||
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
|
||||
|
||||
@@ -81,6 +82,8 @@ const initMediaPlayer = async (file: File) => {
|
||||
fileNameElement.textContent = file.name;
|
||||
horizontalRule.style.display = '';
|
||||
playerContainer.style.display = 'none';
|
||||
errorElement.textContent = '';
|
||||
warningElement.textContent = '';
|
||||
|
||||
// Create an Input from the file
|
||||
const input = new Input({
|
||||
@@ -95,17 +98,38 @@ const initMediaPlayer = async (file: File) => {
|
||||
let videoTrack = await input.getPrimaryVideoTrack();
|
||||
let audioTrack = await input.getPrimaryAudioTrack();
|
||||
|
||||
if (!(await videoTrack?.canDecode())) {
|
||||
// We can't decode the video track, so treat it like there is no video track
|
||||
videoTrack = null;
|
||||
let problemMessage = '';
|
||||
|
||||
if (videoTrack) {
|
||||
if (videoTrack.codec === null) {
|
||||
problemMessage += 'Unsupported video codec. ';
|
||||
videoTrack = null;
|
||||
} else if (!(await videoTrack.canDecode())) {
|
||||
problemMessage += 'Unable to decode the video track. ';
|
||||
videoTrack = null;
|
||||
}
|
||||
}
|
||||
if (!(await audioTrack?.canDecode())) {
|
||||
// We can't decode the audio track, so treat it like there is no audio track
|
||||
audioTrack = null;
|
||||
|
||||
if (audioTrack) {
|
||||
if (audioTrack.codec === null) {
|
||||
problemMessage += 'Unsupported audio codec. ';
|
||||
audioTrack = null;
|
||||
} else if (!(await audioTrack.canDecode())) {
|
||||
problemMessage += 'Unable to decode the audio track. ';
|
||||
audioTrack = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoTrack && !audioTrack) {
|
||||
throw new Error('Media file has no playable video or audio track.');
|
||||
if (!problemMessage) {
|
||||
problemMessage = 'No audio or video track found.';
|
||||
}
|
||||
|
||||
throw new Error(problemMessage);
|
||||
}
|
||||
|
||||
if (problemMessage) {
|
||||
warningElement.textContent = problemMessage;
|
||||
}
|
||||
|
||||
// We must create the audio context with the matching sample rate for correct acoustic results
|
||||
|
||||
@@ -15,7 +15,7 @@ const THUMBNAIL_SIZE = 200;
|
||||
const generateThumbnails = async (file: File) => {
|
||||
fileNameElement.textContent = file.name;
|
||||
horizontalRule.style.display = '';
|
||||
errorElement.innerHTML = '';
|
||||
errorElement.textContent = '';
|
||||
thumbnailContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
@@ -30,6 +30,14 @@ const generateThumbnails = async (file: File) => {
|
||||
throw new Error('File has no video track.');
|
||||
}
|
||||
|
||||
if (videoTrack.codec === null) {
|
||||
throw new Error('Unsupported video codec.');
|
||||
}
|
||||
|
||||
if (!(await videoTrack.canDecode())) {
|
||||
throw new Error('Unable to decode the video track.');
|
||||
}
|
||||
|
||||
// Compute width and height of the thumbnails such that the larger dimension is equal to THUMBNAIL_SIZE
|
||||
const width = videoTrack.displayWidth > videoTrack.displayHeight
|
||||
? THUMBNAIL_SIZE
|
||||
|
||||
Generated
+6
-6
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.2",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.11",
|
||||
"@types/dom-webcodecs": "^0.1.15"
|
||||
"@types/dom-webcodecs": "0.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
@@ -2443,9 +2443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
|
||||
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
|
||||
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.2",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"main": "./dist/bundles/mediabunny.cjs",
|
||||
@@ -31,7 +31,7 @@
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"dev": "vite",
|
||||
"examples:build": "vite build",
|
||||
"fix-build-import-paths": "find dist -name \"*.js\" -type f -exec sed -i -r \"s/ from '([^']+)';/ from '\\1.js';/g\" {} \\;",
|
||||
"fix-build-import-paths": "find dist -name \"*.js\" -type f -exec sed -i '' \"s/ from '\\([^']*\\)';/ from '\\1.js';/g\" {} \\;",
|
||||
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts"
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.11",
|
||||
"@types/dom-webcodecs": "^0.1.15"
|
||||
"@types/dom-webcodecs": "0.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
|
||||
+342
-180
@@ -7,7 +7,18 @@
|
||||
*/
|
||||
|
||||
import { VP9_LEVEL_TABLE } from './codec';
|
||||
import { assert, Bitstream, last, readExpGolomb, readSignedExpGolomb, toDataView } from './misc';
|
||||
import { InputVideoTrack } from './input-track';
|
||||
import {
|
||||
assert,
|
||||
assertNever,
|
||||
Bitstream,
|
||||
last,
|
||||
readExpGolomb,
|
||||
readSignedExpGolomb,
|
||||
toDataView,
|
||||
toUint8Array,
|
||||
} from './misc';
|
||||
import { EncodedPacket, PacketType } from './packet';
|
||||
|
||||
// References for AVC/HEVC code:
|
||||
// ISO 14496-15
|
||||
@@ -72,6 +83,39 @@ const findNalUnitsInAnnexB = (packetData: Uint8Array) => {
|
||||
return nalUnits;
|
||||
};
|
||||
|
||||
/** Finds all NAL units in an AVC packet in length-prefixed format. */
|
||||
const findNalUnitsInLengthPrefixed = (packetData: Uint8Array, lengthSize: 1 | 2 | 3 | 4) => {
|
||||
const nalUnits: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const dataView = new DataView(packetData.buffer, packetData.byteOffset, packetData.byteLength);
|
||||
|
||||
while (offset + lengthSize <= packetData.length) {
|
||||
let nalUnitLength: number;
|
||||
if (lengthSize === 1) {
|
||||
nalUnitLength = dataView.getUint8(offset);
|
||||
} else if (lengthSize === 2) {
|
||||
nalUnitLength = dataView.getUint16(offset, false);
|
||||
} else if (lengthSize === 3) {
|
||||
nalUnitLength = (dataView.getUint16(offset, false) << 8) + dataView.getUint8(offset + 2);
|
||||
} else if (lengthSize === 4) {
|
||||
nalUnitLength = dataView.getUint32(offset, false);
|
||||
} else {
|
||||
assertNever(lengthSize);
|
||||
assert(false);
|
||||
}
|
||||
|
||||
offset += lengthSize;
|
||||
|
||||
const nalUnit = packetData.subarray(offset, offset + nalUnitLength);
|
||||
nalUnits.push(nalUnit);
|
||||
|
||||
offset += nalUnitLength;
|
||||
}
|
||||
|
||||
return nalUnits;
|
||||
};
|
||||
|
||||
const removeEmulationPreventionBytes = (data: Uint8Array) => {
|
||||
const result: number[] = [];
|
||||
const len = data.length;
|
||||
@@ -879,30 +923,6 @@ export const extractVp9CodecInfoFromPacket = (
|
||||
// https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.7-20170222-draft.pdf
|
||||
// http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf
|
||||
|
||||
// Handle superframe
|
||||
const lastByte = packet[packet.length - 1];
|
||||
if (lastByte && (lastByte & 0xe0) === 0xc0) { // Is superframe
|
||||
const bytesPerFrameSize = ((lastByte & 0x18) >> 3) + 1;
|
||||
const numFrames = (lastByte & 0x07) + 1;
|
||||
const indexSize = 2 + numFrames * bytesPerFrameSize;
|
||||
|
||||
// Verify matching marker bytes
|
||||
if (packet[packet.length - indexSize] !== lastByte) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get first frame size
|
||||
let frameSize = 0;
|
||||
const offset = packet.length - indexSize + 1;
|
||||
|
||||
for (let i = 0; i < bytesPerFrameSize; i++) {
|
||||
if (!packet[offset + i]) return null;
|
||||
frameSize |= packet[offset + i]! << (8 * i);
|
||||
}
|
||||
|
||||
packet = packet.subarray(0, frameSize);
|
||||
}
|
||||
|
||||
const bitstream = new Bitstream(packet);
|
||||
|
||||
// Frame marker (0b10)
|
||||
@@ -1048,13 +1068,8 @@ export type Av1CodecInfo = {
|
||||
chromaSamplePosition: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* When AV1 codec information is not provided by the container, we can still try to extract the information by digging
|
||||
* into the AV1 bitstream.
|
||||
*/
|
||||
export const extractAv1CodecInfoFromPacket = (
|
||||
packet: Uint8Array,
|
||||
): Av1CodecInfo | null => {
|
||||
/** Iterates over all OBUs in an AV1 packet bistream. */
|
||||
export function* iterateAv1PacketObus(packet: Uint8Array) {
|
||||
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
|
||||
|
||||
const bitstream = new Bitstream(packet);
|
||||
@@ -1064,7 +1079,6 @@ export const extractAv1CodecInfoFromPacket = (
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const byte = bitstream.readAlignedByte();
|
||||
if (byte === undefined) return 0;
|
||||
|
||||
value |= ((byte & 0x7f) << (i * 7));
|
||||
|
||||
@@ -1088,11 +1102,11 @@ export const extractAv1CodecInfoFromPacket = (
|
||||
|
||||
while (bitstream.getBitsLeft() >= 8) {
|
||||
// Parse OBU header
|
||||
const obuHeader = bitstream.readBits(8);
|
||||
|
||||
const obuType = (obuHeader >> 3) & 0xf;
|
||||
const obuExtension = (obuHeader >> 2) & 0x1;
|
||||
const obuHasSizeField = (obuHeader >> 1) & 0x1;
|
||||
bitstream.skipBits(1);
|
||||
const obuType = bitstream.readBits(4);
|
||||
const obuExtension = bitstream.readBits(1);
|
||||
const obuHasSizeField = bitstream.readBits(1);
|
||||
bitstream.skipBits(1);
|
||||
|
||||
// Skip extension header if present
|
||||
if (obuExtension) {
|
||||
@@ -1103,159 +1117,180 @@ export const extractAv1CodecInfoFromPacket = (
|
||||
let obuSize: number;
|
||||
if (obuHasSizeField) {
|
||||
const obuSizeValue = readLeb128();
|
||||
if (obuSizeValue === null) return null; // It was invalid
|
||||
if (obuSizeValue === null) return; // It was invalid
|
||||
obuSize = obuSizeValue;
|
||||
} else {
|
||||
// Calculate remaining bits and convert to bytes, rounding down
|
||||
obuSize = Math.floor(bitstream.getBitsLeft() / 8);
|
||||
}
|
||||
|
||||
// We're only interested in Sequence Header OBU (type 1)
|
||||
if (obuType === 1) {
|
||||
// Read sequence header fields
|
||||
const seqProfile = bitstream.readBits(3);
|
||||
assert(bitstream.pos % 8 === 0);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const stillPicture = bitstream.readBits(1);
|
||||
|
||||
const reducedStillPictureHeader = bitstream.readBits(1);
|
||||
|
||||
let seqLevel = 0;
|
||||
let seqTier = 0;
|
||||
let bufferDelayLengthMinus1 = 0;
|
||||
|
||||
if (reducedStillPictureHeader) {
|
||||
seqLevel = bitstream.readBits(5);
|
||||
} else {
|
||||
// Parse timing_info_present_flag
|
||||
const timingInfoPresentFlag = bitstream.readBits(1);
|
||||
|
||||
if (timingInfoPresentFlag) {
|
||||
// Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)
|
||||
bitstream.skipBits(32); // num_units_in_display_tick
|
||||
bitstream.skipBits(32); // time_scale
|
||||
const equalPictureInterval = bitstream.readBits(1);
|
||||
|
||||
if (equalPictureInterval) {
|
||||
// Skip num_ticks_per_picture_minus_1 (uvlc)
|
||||
// Since this is variable length, we'd need to implement uvlc reading
|
||||
// For now, we'll return null as this is rare
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse decoder_model_info_present_flag
|
||||
const decoderModelInfoPresentFlag = bitstream.readBits(1);
|
||||
|
||||
if (decoderModelInfoPresentFlag) {
|
||||
// Store buffer_delay_length_minus_1 instead of just skipping
|
||||
bufferDelayLengthMinus1 = bitstream.readBits(5);
|
||||
bitstream.skipBits(32); // num_units_in_decoding_tick
|
||||
bitstream.skipBits(5); // buffer_removal_time_length_minus_1
|
||||
bitstream.skipBits(5); // frame_presentation_time_length_minus_1
|
||||
}
|
||||
|
||||
// Parse operating_points_cnt_minus_1
|
||||
const operatingPointsCntMinus1 = bitstream.readBits(5);
|
||||
|
||||
// For each operating point
|
||||
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
|
||||
// operating_point_idc[i]
|
||||
bitstream.skipBits(12);
|
||||
|
||||
// seq_level_idx[i]
|
||||
const seqLevelIdx = bitstream.readBits(5);
|
||||
|
||||
if (i === 0) {
|
||||
seqLevel = seqLevelIdx;
|
||||
}
|
||||
|
||||
if (seqLevelIdx > 7) {
|
||||
// seq_tier[i]
|
||||
const seqTierTemp = bitstream.readBits(1);
|
||||
if (i === 0) {
|
||||
seqTier = seqTierTemp;
|
||||
}
|
||||
}
|
||||
|
||||
if (decoderModelInfoPresentFlag) {
|
||||
// decoder_model_present_for_this_op[i]
|
||||
const decoderModelPresentForThisOp = bitstream.readBits(1);
|
||||
|
||||
if (decoderModelPresentForThisOp) {
|
||||
const n = bufferDelayLengthMinus1 + 1;
|
||||
bitstream.skipBits(n); // decoder_buffer_delay[op]
|
||||
bitstream.skipBits(n); // encoder_buffer_delay[op]
|
||||
bitstream.skipBits(1); // low_delay_mode_flag[op]
|
||||
}
|
||||
}
|
||||
|
||||
// initial_display_delay_present_flag
|
||||
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
|
||||
|
||||
if (initialDisplayDelayPresentFlag) {
|
||||
// initial_display_delay_minus_1[i]
|
||||
bitstream.skipBits(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highBitdepth = bitstream.readBits(1);
|
||||
|
||||
let bitDepth = 8;
|
||||
if (seqProfile === 2 && highBitdepth) {
|
||||
const twelveBit = bitstream.readBits(1);
|
||||
bitDepth = twelveBit ? 12 : 10;
|
||||
} else if (seqProfile <= 2) {
|
||||
bitDepth = highBitdepth ? 10 : 8;
|
||||
}
|
||||
|
||||
let monochrome = 0;
|
||||
if (seqProfile !== 1) {
|
||||
monochrome = bitstream.readBits(1);
|
||||
}
|
||||
|
||||
let chromaSubsamplingX = 1;
|
||||
let chromaSubsamplingY = 1;
|
||||
let chromaSamplePosition = 0;
|
||||
|
||||
if (!monochrome) {
|
||||
if (seqProfile === 0) {
|
||||
chromaSubsamplingX = 1;
|
||||
chromaSubsamplingY = 1;
|
||||
} else if (seqProfile === 1) {
|
||||
chromaSubsamplingX = 0;
|
||||
chromaSubsamplingY = 0;
|
||||
} else {
|
||||
if (bitDepth === 12) {
|
||||
chromaSubsamplingX = bitstream.readBits(1);
|
||||
if (chromaSubsamplingX) {
|
||||
chromaSubsamplingY = bitstream.readBits(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chromaSubsamplingX && chromaSubsamplingY) {
|
||||
chromaSamplePosition = bitstream.readBits(2);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profile: seqProfile,
|
||||
level: seqLevel,
|
||||
tier: seqTier,
|
||||
bitDepth,
|
||||
monochrome,
|
||||
chromaSubsamplingX,
|
||||
chromaSubsamplingY,
|
||||
chromaSamplePosition,
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: obuType,
|
||||
data: packet.subarray(bitstream.pos / 8, bitstream.pos / 8 + obuSize),
|
||||
};
|
||||
|
||||
// Move to next OBU
|
||||
// The OBU size is in bytes, so skip that many bytes.
|
||||
bitstream.skipBits(obuSize * 8);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When AV1 codec information is not provided by the container, we can still try to extract the information by digging
|
||||
* into the AV1 bitstream.
|
||||
*/
|
||||
export const extractAv1CodecInfoFromPacket = (
|
||||
packet: Uint8Array,
|
||||
): Av1CodecInfo | null => {
|
||||
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
|
||||
|
||||
for (const { type, data } of iterateAv1PacketObus(packet)) {
|
||||
if (type !== 1) {
|
||||
continue; // 1 == OBU_SEQUENCE_HEADER
|
||||
}
|
||||
|
||||
const bitstream = new Bitstream(data);
|
||||
|
||||
// Read sequence header fields
|
||||
const seqProfile = bitstream.readBits(3);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const stillPicture = bitstream.readBits(1);
|
||||
|
||||
const reducedStillPictureHeader = bitstream.readBits(1);
|
||||
|
||||
let seqLevel = 0;
|
||||
let seqTier = 0;
|
||||
let bufferDelayLengthMinus1 = 0;
|
||||
|
||||
if (reducedStillPictureHeader) {
|
||||
seqLevel = bitstream.readBits(5);
|
||||
} else {
|
||||
// Parse timing_info_present_flag
|
||||
const timingInfoPresentFlag = bitstream.readBits(1);
|
||||
|
||||
if (timingInfoPresentFlag) {
|
||||
// Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)
|
||||
bitstream.skipBits(32); // num_units_in_display_tick
|
||||
bitstream.skipBits(32); // time_scale
|
||||
const equalPictureInterval = bitstream.readBits(1);
|
||||
|
||||
if (equalPictureInterval) {
|
||||
// Skip num_ticks_per_picture_minus_1 (uvlc)
|
||||
// Since this is variable length, we'd need to implement uvlc reading
|
||||
// For now, we'll return null as this is rare
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse decoder_model_info_present_flag
|
||||
const decoderModelInfoPresentFlag = bitstream.readBits(1);
|
||||
|
||||
if (decoderModelInfoPresentFlag) {
|
||||
// Store buffer_delay_length_minus_1 instead of just skipping
|
||||
bufferDelayLengthMinus1 = bitstream.readBits(5);
|
||||
bitstream.skipBits(32); // num_units_in_decoding_tick
|
||||
bitstream.skipBits(5); // buffer_removal_time_length_minus_1
|
||||
bitstream.skipBits(5); // frame_presentation_time_length_minus_1
|
||||
}
|
||||
|
||||
// Parse operating_points_cnt_minus_1
|
||||
const operatingPointsCntMinus1 = bitstream.readBits(5);
|
||||
|
||||
// For each operating point
|
||||
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
|
||||
// operating_point_idc[i]
|
||||
bitstream.skipBits(12);
|
||||
|
||||
// seq_level_idx[i]
|
||||
const seqLevelIdx = bitstream.readBits(5);
|
||||
|
||||
if (i === 0) {
|
||||
seqLevel = seqLevelIdx;
|
||||
}
|
||||
|
||||
if (seqLevelIdx > 7) {
|
||||
// seq_tier[i]
|
||||
const seqTierTemp = bitstream.readBits(1);
|
||||
if (i === 0) {
|
||||
seqTier = seqTierTemp;
|
||||
}
|
||||
}
|
||||
|
||||
if (decoderModelInfoPresentFlag) {
|
||||
// decoder_model_present_for_this_op[i]
|
||||
const decoderModelPresentForThisOp = bitstream.readBits(1);
|
||||
|
||||
if (decoderModelPresentForThisOp) {
|
||||
const n = bufferDelayLengthMinus1 + 1;
|
||||
bitstream.skipBits(n); // decoder_buffer_delay[op]
|
||||
bitstream.skipBits(n); // encoder_buffer_delay[op]
|
||||
bitstream.skipBits(1); // low_delay_mode_flag[op]
|
||||
}
|
||||
}
|
||||
|
||||
// initial_display_delay_present_flag
|
||||
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
|
||||
|
||||
if (initialDisplayDelayPresentFlag) {
|
||||
// initial_display_delay_minus_1[i]
|
||||
bitstream.skipBits(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const highBitdepth = bitstream.readBits(1);
|
||||
|
||||
let bitDepth = 8;
|
||||
if (seqProfile === 2 && highBitdepth) {
|
||||
const twelveBit = bitstream.readBits(1);
|
||||
bitDepth = twelveBit ? 12 : 10;
|
||||
} else if (seqProfile <= 2) {
|
||||
bitDepth = highBitdepth ? 10 : 8;
|
||||
}
|
||||
|
||||
let monochrome = 0;
|
||||
if (seqProfile !== 1) {
|
||||
monochrome = bitstream.readBits(1);
|
||||
}
|
||||
|
||||
let chromaSubsamplingX = 1;
|
||||
let chromaSubsamplingY = 1;
|
||||
let chromaSamplePosition = 0;
|
||||
|
||||
if (!monochrome) {
|
||||
if (seqProfile === 0) {
|
||||
chromaSubsamplingX = 1;
|
||||
chromaSubsamplingY = 1;
|
||||
} else if (seqProfile === 1) {
|
||||
chromaSubsamplingX = 0;
|
||||
chromaSubsamplingY = 0;
|
||||
} else {
|
||||
if (bitDepth === 12) {
|
||||
chromaSubsamplingX = bitstream.readBits(1);
|
||||
if (chromaSubsamplingX) {
|
||||
chromaSubsamplingY = bitstream.readBits(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chromaSubsamplingX && chromaSubsamplingY) {
|
||||
chromaSamplePosition = bitstream.readBits(2);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
profile: seqProfile,
|
||||
level: seqLevel,
|
||||
tier: seqTier,
|
||||
bitDepth,
|
||||
monochrome,
|
||||
chromaSubsamplingX,
|
||||
chromaSubsamplingY,
|
||||
chromaSamplePosition,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1392,3 +1427,130 @@ export const parseModesFromVorbisSetupPacket = (setupHeader: Uint8Array) => {
|
||||
|
||||
return { modeBlockflags };
|
||||
};
|
||||
|
||||
/** Determines a packet's type (key or delta) by digging into the packet bitstream. */
|
||||
export const determineVideoPacketType = async (
|
||||
videoTrack: InputVideoTrack,
|
||||
packet: EncodedPacket,
|
||||
): Promise<PacketType | null> => {
|
||||
assert(videoTrack.codec);
|
||||
|
||||
switch (videoTrack.codec) {
|
||||
case 'avc': {
|
||||
const decoderConfig = await videoTrack.getDecoderConfig();
|
||||
assert(decoderConfig);
|
||||
|
||||
let nalUnits: Uint8Array[];
|
||||
|
||||
if (decoderConfig.description) {
|
||||
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
|
||||
|
||||
const bytes = toUint8Array(decoderConfig.description);
|
||||
const lengthSizeMinusOne = bytes[4]! & 0b11;
|
||||
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
|
||||
|
||||
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
|
||||
} else {
|
||||
// Stream is in Annex B format
|
||||
nalUnits = findNalUnitsInAnnexB(packet.data);
|
||||
}
|
||||
|
||||
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === 5);
|
||||
return isKeyframe ? 'key' : 'delta';
|
||||
};
|
||||
|
||||
case 'hevc': {
|
||||
const decoderConfig = await videoTrack.getDecoderConfig();
|
||||
assert(decoderConfig);
|
||||
|
||||
let nalUnits: Uint8Array[];
|
||||
|
||||
if (decoderConfig.description) {
|
||||
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
|
||||
|
||||
const bytes = toUint8Array(decoderConfig.description);
|
||||
const lengthSizeMinusOne = bytes[21]! & 0b11;
|
||||
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
|
||||
|
||||
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
|
||||
} else {
|
||||
// Stream is in Annex B format
|
||||
nalUnits = findNalUnitsInAnnexB(packet.data);
|
||||
}
|
||||
|
||||
const isKeyframe = nalUnits.some((x) => {
|
||||
const type = extractNalUnitTypeForHevc(x);
|
||||
return 16 <= type && type <= 23;
|
||||
});
|
||||
return isKeyframe ? 'key' : 'delta';
|
||||
};
|
||||
|
||||
case 'vp8': {
|
||||
// VP8, once again, by far the easiest to deal with.
|
||||
const frameType = packet.data[0]! & 0b1;
|
||||
return frameType === 0 ? 'key' : 'delta';
|
||||
};
|
||||
|
||||
case 'vp9': {
|
||||
const bitstream = new Bitstream(packet.data);
|
||||
|
||||
if (bitstream.readBits(2) !== 2) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const profileLowBit = bitstream.readBits(1);
|
||||
const profileHighBit = bitstream.readBits(1);
|
||||
const profile = (profileHighBit << 1) + profileLowBit;
|
||||
|
||||
// Skip reserved bit for profile 3
|
||||
if (profile === 3) {
|
||||
bitstream.skipBits(1);
|
||||
}
|
||||
|
||||
const showExistingFrame = bitstream.readBits(1);
|
||||
if (showExistingFrame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameType = bitstream.readBits(1);
|
||||
return frameType === 0 ? 'key' : 'delta';
|
||||
};
|
||||
|
||||
case 'av1': {
|
||||
let reducedStillPictureHeader = false;
|
||||
|
||||
for (const { type, data } of iterateAv1PacketObus(packet.data)) {
|
||||
if (type === 1) { // OBU_SEQUENCE_HEADER
|
||||
const bitstream = new Bitstream(data);
|
||||
|
||||
bitstream.skipBits(4);
|
||||
reducedStillPictureHeader = !!bitstream.readBits(1);
|
||||
} else if (
|
||||
type === 3 // OBU_FRAME_HEADER
|
||||
|| type === 6 // OBU_FRAME
|
||||
|| type === 7 // OBU_REDUNDANT_FRAME_HEADER
|
||||
) {
|
||||
if (reducedStillPictureHeader) {
|
||||
return 'key';
|
||||
}
|
||||
|
||||
const bitstream = new Bitstream(data);
|
||||
const showExistingFrame = bitstream.readBits(1);
|
||||
if (showExistingFrame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameType = bitstream.readBits(2);
|
||||
return frameType === 0 ? 'key' : 'delta';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
default: {
|
||||
assertNever(videoTrack.codec);
|
||||
assert(false);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
+1
-1
@@ -498,7 +498,7 @@ export class Conversion {
|
||||
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
|
||||
: undefined;
|
||||
|
||||
for await (const packet of sink.packets(undefined, endPacket)) {
|
||||
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
|
||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||
await this._synchronizer.wait(packet.timestamp);
|
||||
}
|
||||
|
||||
+1
-1
@@ -291,7 +291,7 @@ export class WaveInputFormat extends InputFormat {
|
||||
|
||||
const riffReader = new RiffReader(input._mainReader);
|
||||
const riffType = riffReader.readAscii(4);
|
||||
if (riffType !== 'RIFF' && riffType !== 'RIFX') {
|
||||
if (riffType !== 'RIFF' && riffType !== 'RIFX' && riffType !== 'RF64') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+42
-3
@@ -7,11 +7,12 @@
|
||||
*/
|
||||
|
||||
import { AudioCodec, MediaCodec, VideoCodec } from './codec';
|
||||
import { determineVideoPacketType } from './codec-data';
|
||||
import { customAudioDecoders, customVideoDecoders } from './custom-coder';
|
||||
import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink';
|
||||
import { assert, Rotation } from './misc';
|
||||
import { TrackType } from './output';
|
||||
import { EncodedPacket } from './packet';
|
||||
import { EncodedPacket, PacketType } from './packet';
|
||||
|
||||
/**
|
||||
* Contains aggregate statistics about the encoded packets of a track.
|
||||
@@ -62,6 +63,11 @@ export abstract class InputTrack {
|
||||
abstract getCodecParameterString(): Promise<string | null>;
|
||||
/** Checks if this track's packets can be decoded by the browser. */
|
||||
abstract canDecode(): Promise<boolean>;
|
||||
/**
|
||||
* For a given packet of this track, this method determines the actual type of this packet (key/delta) by looking
|
||||
* into its bitstream. Returns null if the type couldn't be determined.
|
||||
*/
|
||||
abstract determinePacketType(packet: EncodedPacket): Promise<PacketType | null>;
|
||||
|
||||
/** Returns true iff this track is a video track. */
|
||||
isVideoTrack(): this is InputVideoTrack {
|
||||
@@ -222,7 +228,10 @@ export class InputVideoTrack extends InputTrack {
|
||||
|| (colorSpace.matrix as string) === 'bt2020-ncl';
|
||||
}
|
||||
|
||||
/** Returns the decoder configuration for decoding the track's packets using a VideoDecoder. */
|
||||
/**
|
||||
* Returns the decoder configuration for decoding the track's packets using a VideoDecoder. Returns null if the
|
||||
* track's codec is unknown.
|
||||
*/
|
||||
getDecoderConfig() {
|
||||
return this._backing.getDecoderConfig();
|
||||
}
|
||||
@@ -257,6 +266,21 @@ export class InputVideoTrack extends InputTrack {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async determinePacketType(packet: EncodedPacket): Promise<PacketType | null> {
|
||||
if (!(packet instanceof EncodedPacket)) {
|
||||
throw new TypeError('packet must be an EncodedPacket.');
|
||||
}
|
||||
if (packet.isMetadataOnly) {
|
||||
throw new TypeError('packet must not be metadata-only to determine its type.');
|
||||
}
|
||||
|
||||
if (this.codec === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return determineVideoPacketType(this, packet);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InputAudioTrackBacking extends InputTrackBacking {
|
||||
@@ -299,7 +323,10 @@ export class InputAudioTrack extends InputTrack {
|
||||
return this._backing.getSampleRate();
|
||||
}
|
||||
|
||||
/** Returns the decoder configuration for decoding the track's packets using an AudioDecoder. */
|
||||
/**
|
||||
* Returns the decoder configuration for decoding the track's packets using an AudioDecoder. Returns null if the
|
||||
* track's codec is unknown.
|
||||
*/
|
||||
getDecoderConfig() {
|
||||
return this._backing.getDecoderConfig();
|
||||
}
|
||||
@@ -338,4 +365,16 @@ export class InputAudioTrack extends InputTrack {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async determinePacketType(packet: EncodedPacket): Promise<PacketType | null> {
|
||||
if (!(packet instanceof EncodedPacket)) {
|
||||
throw new TypeError('packet must be an EncodedPacket.');
|
||||
}
|
||||
|
||||
if (this.codec === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'key'; // No audio codec with delta packets
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,8 +694,7 @@ export class MatroskaMuxer extends Muxer {
|
||||
|
||||
const bitstream = new Bitstream(chunk.data);
|
||||
|
||||
// Check if it's a "superframe"
|
||||
if (bitstream.readBits(2) !== 0b10) return;
|
||||
bitstream.skipBits(2);
|
||||
|
||||
const profileLowBit = bitstream.readBits(1);
|
||||
const profileHighBit = bitstream.readBits(1);
|
||||
|
||||
+88
-10
@@ -39,6 +39,14 @@ export type PacketRetrievalOptions = {
|
||||
* be loaded.
|
||||
*/
|
||||
metadataOnly?: boolean;
|
||||
|
||||
/**
|
||||
* When set to true, key packets will be verified upon retrieval by looking into the packet's bitstream.
|
||||
* If not enabled, the packet types will be determined solely by what's stored in the containing file and may be
|
||||
* incorrect, potentially leading to decoder errors. Since determining a packet's actual type requires looking into
|
||||
* its data, this option cannot be enabled together with `metadataOnly`.
|
||||
*/
|
||||
verifyKeyPackets?: boolean;
|
||||
};
|
||||
|
||||
const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
|
||||
@@ -48,6 +56,12 @@ const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
|
||||
if (options.metadataOnly !== undefined && typeof options.metadataOnly !== 'boolean') {
|
||||
throw new TypeError('options.metadataOnly, when defined, must be a boolean.');
|
||||
}
|
||||
if (options.verifyKeyPackets !== undefined && typeof options.verifyKeyPackets !== 'boolean') {
|
||||
throw new TypeError('options.verifyKeyPackets, when defined, must be a boolean.');
|
||||
}
|
||||
if (options.verifyKeyPackets && options.metadataOnly) {
|
||||
throw new TypeError('options.verifyKeyPackets and options.metadataOnly cannot be enabled together.');
|
||||
}
|
||||
};
|
||||
|
||||
const validateTimestamp = (timestamp: number) => {
|
||||
@@ -56,6 +70,30 @@ const validateTimestamp = (timestamp: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const maybeFixPacketType = (
|
||||
track: InputTrack,
|
||||
promise: Promise<EncodedPacket | null>,
|
||||
options: PacketRetrievalOptions,
|
||||
) => {
|
||||
if (options.verifyKeyPackets) {
|
||||
return promise.then(async (packet) => {
|
||||
if (!packet || packet.type === 'delta') {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const determinedType = await track.determinePacketType(packet);
|
||||
if (determinedType) {
|
||||
// @ts-expect-error Technically readonly
|
||||
packet.type = determinedType;
|
||||
}
|
||||
|
||||
return packet;
|
||||
});
|
||||
} else {
|
||||
return promise;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sink for retrieving encoded packets from an input track.
|
||||
* @public
|
||||
@@ -78,7 +116,8 @@ export class EncodedPacketSink {
|
||||
*/
|
||||
getFirstPacket(options: PacketRetrievalOptions = {}) {
|
||||
validatePacketRetrievalOptions(options);
|
||||
return this._track._backing.getFirstPacket(options);
|
||||
|
||||
return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +131,8 @@ export class EncodedPacketSink {
|
||||
getPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
|
||||
validateTimestamp(timestamp);
|
||||
validatePacketRetrievalOptions(options);
|
||||
return this._track._backing.getPacket(timestamp, options);
|
||||
|
||||
return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +144,8 @@ export class EncodedPacketSink {
|
||||
throw new TypeError('packet must be an EncodedPacket.');
|
||||
}
|
||||
validatePacketRetrievalOptions(options);
|
||||
return this._track._backing.getNextPacket(packet, options);
|
||||
|
||||
return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,24 +155,60 @@ export class EncodedPacketSink {
|
||||
* last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first
|
||||
* key packet in the track.
|
||||
*
|
||||
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
|
||||
*
|
||||
* @param timestamp - The timestamp used for retrieval, in seconds.
|
||||
*/
|
||||
getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
|
||||
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}): Promise<EncodedPacket | null> {
|
||||
validateTimestamp(timestamp);
|
||||
validatePacketRetrievalOptions(options);
|
||||
return this._track._backing.getKeyPacket(timestamp, options);
|
||||
|
||||
if (!options.verifyKeyPackets) {
|
||||
return this._track._backing.getKeyPacket(timestamp, options);
|
||||
}
|
||||
|
||||
const packet = await this._track._backing.getKeyPacket(timestamp, options);
|
||||
if (!packet || packet.type === 'delta') {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const determinedType = await this._track.determinePacketType(packet);
|
||||
if (determinedType === 'delta') {
|
||||
// Try returning the previous key packet (in hopes that it's actually a key packet)
|
||||
return this.getKeyPacket(packet.timestamp - 1 / this._track.timeResolution, options);
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last
|
||||
* key packet.
|
||||
*
|
||||
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
|
||||
*/
|
||||
getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) {
|
||||
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}): Promise<EncodedPacket | null> {
|
||||
if (!(packet instanceof EncodedPacket)) {
|
||||
throw new TypeError('packet must be an EncodedPacket.');
|
||||
}
|
||||
validatePacketRetrievalOptions(options);
|
||||
return this._track._backing.getNextKeyPacket(packet, options);
|
||||
|
||||
if (!options.verifyKeyPackets) {
|
||||
return this._track._backing.getNextKeyPacket(packet, options);
|
||||
}
|
||||
|
||||
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
|
||||
if (!nextPacket || nextPacket.type === 'delta') {
|
||||
return nextPacket;
|
||||
}
|
||||
|
||||
const determinedType = await this._track.determinePacketType(nextPacket);
|
||||
if (determinedType === 'delta') {
|
||||
// Try returning the next key packet (in hopes that it's actually a key packet)
|
||||
return this.getNextKeyPacket(nextPacket, options);
|
||||
}
|
||||
|
||||
return nextPacket;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,7 +423,8 @@ export abstract class BaseMediaSampleSink<
|
||||
});
|
||||
|
||||
const packetSink = this._createPacketSink();
|
||||
const keyPacket = await packetSink.getKeyPacket(startTimestamp) ?? await packetSink.getFirstPacket();
|
||||
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
|
||||
?? await packetSink.getFirstPacket();
|
||||
if (!keyPacket) {
|
||||
return;
|
||||
}
|
||||
@@ -364,7 +442,7 @@ export abstract class BaseMediaSampleSink<
|
||||
? null
|
||||
: packet.type === 'key' && packet.timestamp === endTimestamp
|
||||
? packet
|
||||
: await packetSink.getNextKeyPacket(packet);
|
||||
: await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
|
||||
|
||||
if (keyPacket) {
|
||||
endPacket = keyPacket;
|
||||
@@ -567,7 +645,7 @@ export abstract class BaseMediaSampleSink<
|
||||
}
|
||||
|
||||
const targetPacket = await packetSink.getPacket(timestamp);
|
||||
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp);
|
||||
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp, { verifyKeyPackets: true });
|
||||
|
||||
if (!keyPacket) {
|
||||
if (maxSequenceNumber !== -1) {
|
||||
|
||||
+9
-5
@@ -48,7 +48,7 @@ export class Bitstream {
|
||||
this.pos = 8 * byteOffset;
|
||||
}
|
||||
|
||||
readBit() {
|
||||
private readBit() {
|
||||
const byteIndex = Math.floor(this.pos / 8);
|
||||
const byte = this.bytes[byteIndex] ?? 0;
|
||||
const bitIndex = 0b111 - (this.pos & 0b111);
|
||||
@@ -59,6 +59,10 @@ export class Bitstream {
|
||||
}
|
||||
|
||||
readBits(n: number) {
|
||||
if (n === 1) {
|
||||
return this.readBit();
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
@@ -100,7 +104,7 @@ export class Bitstream {
|
||||
/** Reads an exponential-Golomb universal code from a Bitstream. */
|
||||
export const readExpGolomb = (bitstream: Bitstream) => {
|
||||
let leadingZeroBits = 0;
|
||||
while (bitstream.readBit() === 0 && leadingZeroBits < 32) {
|
||||
while (bitstream.readBits(1) === 0 && leadingZeroBits < 32) {
|
||||
leadingZeroBits++;
|
||||
}
|
||||
|
||||
@@ -511,15 +515,15 @@ export const retriedFetch = async (
|
||||
try {
|
||||
return await fetch(url, requestInit);
|
||||
} catch (error) {
|
||||
console.error('Retrying failed fetch. Error:', error);
|
||||
|
||||
attempts++;
|
||||
|
||||
const retryDelayInSeconds = getRetryDelay(attempts);
|
||||
|
||||
if (retryDelayInSeconds === null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Retrying failed fetch. Error:', error);
|
||||
|
||||
if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) {
|
||||
throw new TypeError('Retry delay must be a non-negative finite number.');
|
||||
}
|
||||
|
||||
@@ -546,6 +546,12 @@ export class Mp3OutputFormat extends OutputFormat {
|
||||
* @public
|
||||
*/
|
||||
export type WavOutputFormatOptions = {
|
||||
/**
|
||||
* When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible
|
||||
* for regular WAVE files.
|
||||
*/
|
||||
large?: boolean;
|
||||
|
||||
/**
|
||||
* Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the
|
||||
* start of the data chunk (with a placeholder size of 0).
|
||||
@@ -565,6 +571,9 @@ export class WavOutputFormat extends OutputFormat {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.large !== undefined && typeof options.large !== 'boolean') {
|
||||
throw new TypeError('options.large, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.onHeader !== undefined && typeof options.onHeader !== 'function') {
|
||||
throw new TypeError('options.onHeader, when provided, must be a function.');
|
||||
}
|
||||
|
||||
+27
-2
@@ -218,7 +218,8 @@ export class UrlSource extends Source {
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
if (!range) {
|
||||
if (response.status === 200) {
|
||||
// The server didn't return 206 Partial Content, so it's not a range response
|
||||
this._fullData = buffer;
|
||||
}
|
||||
|
||||
@@ -252,6 +253,26 @@ export class UrlSource extends Source {
|
||||
return this._fullData.byteLength;
|
||||
}
|
||||
|
||||
// First, try a HEAD request to get the size
|
||||
try {
|
||||
const headResponse = await retriedFetch(
|
||||
this._url,
|
||||
mergeObjectsDeeply(this._options.requestInit ?? {}, {
|
||||
method: 'HEAD',
|
||||
}),
|
||||
this._options.getRetryDelay ?? (() => null),
|
||||
);
|
||||
|
||||
if (headResponse.ok) {
|
||||
const contentLength = headResponse.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
return parseInt(contentLength);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// We tried
|
||||
}
|
||||
|
||||
// Try a range request to get the Content-Range header
|
||||
const rangeResponse = await retriedFetch(
|
||||
this._url,
|
||||
@@ -267,9 +288,13 @@ export class UrlSource extends Source {
|
||||
if (contentRange) {
|
||||
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
|
||||
if (match && match[1]) {
|
||||
return parseInt(match[1], 10);
|
||||
return parseInt(match[1]);
|
||||
}
|
||||
}
|
||||
} else if (rangeResponse.status === 200) {
|
||||
// The server just returned the whole thing
|
||||
this._fullData = await rangeResponse.arrayBuffer();
|
||||
return this._fullData.byteLength;
|
||||
}
|
||||
|
||||
// If the range request didn't provide the size, make a full GET request
|
||||
|
||||
@@ -35,6 +35,21 @@ export class RiffReader {
|
||||
return view.getUint32(offset, this.littleEndian);
|
||||
}
|
||||
|
||||
readU64() {
|
||||
let low: number;
|
||||
let high: number;
|
||||
|
||||
if (this.littleEndian) {
|
||||
low = this.readU32();
|
||||
high = this.readU32();
|
||||
} else {
|
||||
high = this.readU32();
|
||||
low = this.readU32();
|
||||
}
|
||||
|
||||
return high * 0x100000000 + low;
|
||||
}
|
||||
|
||||
readAscii(length: number) {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||
this.pos += length;
|
||||
|
||||
@@ -14,14 +14,20 @@ export class RiffWriter {
|
||||
|
||||
constructor(private writer: Writer) {}
|
||||
|
||||
writeU16(value: number) {
|
||||
this.helperView.setUint16(0, value, true);
|
||||
this.writer.write(this.helper.subarray(0, 2));
|
||||
}
|
||||
|
||||
writeU32(value: number) {
|
||||
this.helperView.setUint32(0, value, true);
|
||||
this.writer.write(this.helper.subarray(0, 4));
|
||||
}
|
||||
|
||||
writeU16(value: number) {
|
||||
this.helperView.setUint16(0, value, true);
|
||||
this.writer.write(this.helper.subarray(0, 2));
|
||||
writeU64(value: number) {
|
||||
this.helperView.setUint32(0, value, true);
|
||||
this.helperView.setUint32(4, Math.floor(value / 2 ** 32), true);
|
||||
this.writer.write(this.helper);
|
||||
}
|
||||
|
||||
writeAscii(text: string) {
|
||||
|
||||
@@ -53,9 +53,13 @@ export class WaveDemuxer extends Demuxer {
|
||||
const actualFileSize = await this.metadataReader.reader.source.getSize();
|
||||
|
||||
const riffType = this.metadataReader.readAscii(4);
|
||||
this.metadataReader.littleEndian = riffType === 'RIFF';
|
||||
this.metadataReader.littleEndian = riffType !== 'RIFX';
|
||||
|
||||
const totalFileSize = Math.min(this.metadataReader.readU32() + 8, actualFileSize);
|
||||
const isRf64 = riffType === 'RF64';
|
||||
|
||||
const outerChunkSize = this.metadataReader.readU32();
|
||||
|
||||
let totalFileSize = isRf64 ? actualFileSize : Math.min(outerChunkSize + 8, actualFileSize);
|
||||
const format = this.metadataReader.readAscii(4);
|
||||
|
||||
if (format !== 'WAVE') {
|
||||
@@ -63,6 +67,9 @@ export class WaveDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
this.metadataReader.pos = 12;
|
||||
let chunksRead = 0;
|
||||
let dataChunkSize: number | null = null;
|
||||
|
||||
while (this.metadataReader.pos < totalFileSize) {
|
||||
await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + 8);
|
||||
|
||||
@@ -70,14 +77,28 @@ export class WaveDemuxer extends Demuxer {
|
||||
const chunkSize = this.metadataReader.readU32();
|
||||
const startPos = this.metadataReader.pos;
|
||||
|
||||
if (isRf64 && chunksRead === 0 && chunkId !== 'ds64') {
|
||||
throw new Error('Invalid RF64 file: First chunk must be "ds64".');
|
||||
}
|
||||
|
||||
if (chunkId === 'fmt ') {
|
||||
await this.parseFmtChunk(chunkSize);
|
||||
} else if (chunkId === 'data') {
|
||||
dataChunkSize ??= chunkSize;
|
||||
|
||||
this.dataStart = this.metadataReader.pos;
|
||||
this.dataSize = Math.min(chunkSize, totalFileSize - this.dataStart);
|
||||
this.dataSize = Math.min(dataChunkSize, totalFileSize - this.dataStart);
|
||||
} else if (chunkId === 'ds64') {
|
||||
// File and data chunk sizes are defined in here instead
|
||||
|
||||
const riffChunkSize = this.metadataReader.readU64();
|
||||
dataChunkSize = this.metadataReader.readU64();
|
||||
|
||||
totalFileSize = Math.min(riffChunkSize + 8, actualFileSize);
|
||||
}
|
||||
|
||||
this.metadataReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
|
||||
chunksRead++;
|
||||
}
|
||||
|
||||
if (!this.audioInfo) {
|
||||
|
||||
+57
-9
@@ -18,10 +18,13 @@ import { assert } from '../misc';
|
||||
|
||||
export class WaveMuxer extends Muxer {
|
||||
private format: WavOutputFormat;
|
||||
private isRf64: boolean;
|
||||
private writer: Writer;
|
||||
private riffWriter: RiffWriter;
|
||||
private headerWritten = false;
|
||||
private dataSize = 0;
|
||||
private sampleRate: number | null = null;
|
||||
private sampleCount = 0;
|
||||
|
||||
constructor(output: Output, format: WavOutputFormat) {
|
||||
super(output);
|
||||
@@ -29,6 +32,7 @@ export class WaveMuxer extends Muxer {
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
this.riffWriter = new RiffWriter(output._writer);
|
||||
this.isRf64 = !!format._options.large;
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -58,13 +62,22 @@ export class WaveMuxer extends Muxer {
|
||||
assert(meta.decoderConfig);
|
||||
|
||||
this.writeHeader(track, meta.decoderConfig);
|
||||
this.sampleRate = meta.decoderConfig.sampleRate;
|
||||
this.headerWritten = true;
|
||||
}
|
||||
|
||||
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
|
||||
|
||||
if (!this.isRf64 && this.writer.getPos() + packet.data.byteLength >= 2 ** 32) {
|
||||
throw new Error(
|
||||
'Adding more audio data would exceed the maximum RIFF size of 4 GiB. To write larger files, use'
|
||||
+ ' RF64 by setting `large: true` in the WavOutputFormatOptions.',
|
||||
);
|
||||
}
|
||||
|
||||
this.writer.write(packet.data);
|
||||
this.dataSize += packet.data.byteLength;
|
||||
this.sampleCount += Math.round(packet.duration * this.sampleRate!);
|
||||
|
||||
await this.writer.flush();
|
||||
} finally {
|
||||
@@ -101,10 +114,26 @@ export class WaveMuxer extends Muxer {
|
||||
const blockSize = pcmInfo.sampleSize * channels;
|
||||
|
||||
// RIFF header
|
||||
this.riffWriter.writeAscii('RIFF');
|
||||
this.riffWriter.writeU32(0); // File size placeholder
|
||||
this.riffWriter.writeAscii(this.isRf64 ? 'RF64' : 'RIFF');
|
||||
|
||||
if (this.isRf64) {
|
||||
this.riffWriter.writeU32(0xffffffff); // Not used in RF64
|
||||
} else {
|
||||
this.riffWriter.writeU32(0); // File size placeholder
|
||||
}
|
||||
|
||||
this.riffWriter.writeAscii('WAVE');
|
||||
|
||||
if (this.isRf64) {
|
||||
this.riffWriter.writeAscii('ds64');
|
||||
this.riffWriter.writeU32(28); // Chunk size
|
||||
this.riffWriter.writeU64(0); // RIFF size placeholder
|
||||
this.riffWriter.writeU64(0); // Data size placeholder
|
||||
this.riffWriter.writeU64(0); // Sample count placeholder
|
||||
this.riffWriter.writeU32(0); // Table length
|
||||
// Empty table
|
||||
}
|
||||
|
||||
// fmt chunk
|
||||
this.riffWriter.writeAscii('fmt ');
|
||||
this.riffWriter.writeU32(16); // Chunk size
|
||||
@@ -117,7 +146,12 @@ export class WaveMuxer extends Muxer {
|
||||
|
||||
// data chunk
|
||||
this.riffWriter.writeAscii('data');
|
||||
this.riffWriter.writeU32(0); // Data size placeholder
|
||||
|
||||
if (this.isRf64) {
|
||||
this.riffWriter.writeU32(0xffffffff); // Not used in RF64
|
||||
} else {
|
||||
this.riffWriter.writeU32(0); // Data size placeholder
|
||||
}
|
||||
|
||||
if (this.format._options.onHeader) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
@@ -130,13 +164,27 @@ export class WaveMuxer extends Muxer {
|
||||
|
||||
const endPos = this.writer.getPos();
|
||||
|
||||
// Write file size
|
||||
this.writer.seek(4);
|
||||
this.riffWriter.writeU32(this.dataSize + 36); // File size - 8
|
||||
if (this.isRf64) {
|
||||
// Write riff size
|
||||
this.writer.seek(20);
|
||||
this.riffWriter.writeU64(endPos - 8);
|
||||
|
||||
// Write data chunk size
|
||||
this.writer.seek(40);
|
||||
this.riffWriter.writeU32(this.dataSize);
|
||||
// Write data size
|
||||
this.writer.seek(28);
|
||||
this.riffWriter.writeU64(this.dataSize);
|
||||
|
||||
// Write sample count
|
||||
this.writer.seek(36);
|
||||
this.riffWriter.writeU64(this.sampleCount);
|
||||
} else {
|
||||
// Write file size
|
||||
this.writer.seek(4);
|
||||
this.riffWriter.writeU32(endPos - 8);
|
||||
|
||||
// Write data chunk size
|
||||
this.writer.seek(40);
|
||||
this.riffWriter.writeU32(this.dataSize);
|
||||
}
|
||||
|
||||
this.writer.seek(endPos);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user