Compare commits

..
14 Commits
16 changed files with 183 additions and 68 deletions
+4 -4
View File
@@ -24,7 +24,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const outputFormat = new Mediabunny.Mp3OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -41,7 +41,7 @@
target
}),
audio: {
discard: true,
//discard: true,
//codec: 'opus',
//bitrate: 128000,
//numberOfChannels: 1,
@@ -94,8 +94,8 @@
//height: 100,
}),
trim: {
start: 0,
end: 20
//start: 0,
//end: 20
},
});
console.log(conversion);
+2
View File
@@ -123,6 +123,7 @@ const sampleSource = new VideoSampleSource({
});
await sampleSource.add(videoSample);
videoSample.close(); // If it's not needed anymore
// You may optionally force samples to be encoded as key frames:
await sampleSource.add(videoSample, { keyFrame: true });
@@ -285,6 +286,7 @@ const sampleSource = new AudioSampleSource({
});
await sampleSource.add(audioSample);
audioSample.close(); // If it's not needed anymore
```
### `AudioBufferSource`
+2 -1
View File
@@ -94,6 +94,7 @@ const sponsors = {
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ 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' },
@@ -339,7 +340,7 @@ await conversion.execute();
<div class="flex flex-wrap mt-1 justify-center">
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
<img :src="sponsor.image" class="size-8 rounded-full">
<p class="!my-0 !font-medium text-xs !leading-4">{{ sponsor.name }}</p>
<p class="!my-0 !font-medium text-xs !leading-4 text-center">{{ sponsor.name }}</p>
</a>
</div>
</template>
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.7.2",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.7.2",
"version": "1.8.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -5900,9 +5900,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.7.1.tgz",
"integrity": "sha512-y9s+Vf6TLhXeVjvlJFSmHRrwUQi2CJCMhxthm6nfhBO1XHJhcOqNOxu4CjhYyjoWgVZB5pkPKyWgZxpPOZfglQ==",
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.7.6.tgz",
"integrity": "sha512-PtWscl5vdsBYwtNSO0J21lAGUiweudmTmHabz4NWuQ+mAUvtG1ocupyYpT18IflteVtdtNMqxKKPPbvlcHuk0w==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -9017,7 +9017,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.7.2",
"version": "1.8.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.7.2",
"version": "1.8.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+2 -1
View File
@@ -108,7 +108,7 @@ For simplicity, all built WASM artifacts are included in the repo, since these r
### Prerequisites
[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html). The recommended way is using the emsdk, which involves cloning a repo and running a few commands.
[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html). The recommended way is using the emsdk, which involves cloning a repo and running a few commands. The following commands assume Emscripten is sourced in.
### Compiling LAME:
@@ -139,6 +139,7 @@ emcc src/lame-bridge.c build/libmp3lame.a \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s SINGLE_FILE=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s ENVIRONMENT=web,worker \
-s EXPORTED_RUNTIME_METHODS=cwrap,HEAPU8 \
-s EXPORTED_FUNCTIONS=_malloc,_free \
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.7.2",
"version": "1.8.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+2 -2
View File
@@ -7,7 +7,7 @@
*/
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
import { FRAME_HEADER_SIZE, readFrameHeader } from '../../../shared/mp3-misc';
import { FRAME_HEADER_SIZE, readFrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc';
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
import createWorker from './encode.worker';
@@ -28,7 +28,7 @@ class Mp3Encoder extends CustomAudioEncoder {
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
return codec === 'mp3'
&& (config.numberOfChannels === 1 || config.numberOfChannels === 2)
&& (config.sampleRate === 32000 || config.sampleRate === 44100 || config.sampleRate === 48000);
&& Object.values(SAMPLING_RATES).some(x => x.includes(config.sampleRate));
}
async init() {
+2 -2
View File
@@ -671,7 +671,7 @@ export class Conversion {
}
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
lastCanvasEndTimestamp = timestamp + duration;
lastCanvasEndTimestamp = adjustedSampleTimestamp + duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
@@ -757,7 +757,7 @@ export class Conversion {
}
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
lastSampleEndTimestamp = sample.timestamp + sample.duration;
lastSampleEndTimestamp = adjustedSampleTimestamp + sample.duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
+16 -3
View File
@@ -10,7 +10,7 @@ import { Demuxer } from './demuxer';
import { Input } from './input';
import { IsobmffDemuxer } from './isobmff/isobmff-demuxer';
import { IsobmffReader } from './isobmff/isobmff-reader';
import { EBMLId, EBMLReader } from './matroska/ebml';
import { EBMLId, EBMLReader, MIN_HEADER_SIZE } from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
@@ -106,6 +106,10 @@ export class QuickTimeInputFormat extends IsobmffInputFormat {
}
}
function foo() {
return 5;
}
/**
* Matroska file format.
* @public
@@ -120,6 +124,12 @@ export class MatroskaInputFormat extends InputFormat {
const ebmlReader = new EBMLReader(input._mainReader);
const varIntSize = ebmlReader.readVarIntSize();
if (varIntSize === null) {
return false;
}
foo();
if (varIntSize < 1 || varIntSize > 8) {
return false;
}
@@ -135,8 +145,11 @@ export class MatroskaInputFormat extends InputFormat {
}
const startPos = ebmlReader.pos;
while (ebmlReader.pos < startPos + dataSize) {
const { id, size } = ebmlReader.readElementHeader();
while (ebmlReader.pos <= startPos + dataSize - MIN_HEADER_SIZE) {
const header = ebmlReader.readElementHeader();
if (!header) break;
const { id, size } = header;
const dataStartPos = ebmlReader.pos;
if (size === null) return false;
+25 -6
View File
@@ -379,7 +379,7 @@ export class EBMLWriter {
const MAX_VAR_INT_SIZE = 8;
export const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size
export const MAX_HEADER_SIZE = 4 + MAX_VAR_INT_SIZE; // 4-byte ID and 8-byte size
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export class EBMLReader {
pos = 0;
@@ -411,9 +411,13 @@ export class EBMLReader {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
const firstByte = view.getUint8(offset);
if (firstByte === 0) {
return null; // Invalid VINT
}
let width = 1;
let mask = 0x80;
while ((firstByte & mask) === 0 && width < 8) {
while ((firstByte & mask) === 0) {
width++;
mask >>= 1;
}
@@ -426,10 +430,14 @@ export class EBMLReader {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
const firstByte = view.getUint8(offset);
if (firstByte === 0) {
return null; // Invalid VINT
}
// Find the position of VINT_MARKER, which determines the width
let width = 1;
let mask = 1 << 7;
while ((firstByte & mask) === 0 && width < MAX_VAR_INT_SIZE) {
while ((firstByte & mask) === 0) {
width++;
mask >>= 1;
}
@@ -509,8 +517,11 @@ export class EBMLReader {
readElementId() {
const size = this.readVarIntSize();
const id = this.readUnsignedInt(size);
if (size === null) {
return null;
}
const id = this.readUnsignedInt(size);
return id;
}
@@ -538,6 +549,10 @@ export class EBMLReader {
readElementHeader() {
const id = this.readElementId();
if (id === null) {
return null;
}
const size = this.readElementSize();
return { id, size };
@@ -548,13 +563,17 @@ export class EBMLReader {
const loadChunkSize = 2 ** 20; // 1 MiB
const idsSet = new Set(ids);
while (this.pos < until - MAX_HEADER_SIZE) {
if (!this.reader.rangeIsLoaded(this.pos, this.pos + MAX_HEADER_SIZE)) {
while (this.pos <= until - MIN_HEADER_SIZE) {
if (!this.reader.rangeIsLoaded(this.pos, Math.min(this.pos + MAX_HEADER_SIZE, until))) {
await this.reader.loadRange(this.pos, Math.min(this.pos + loadChunkSize, until));
}
const elementStartPos = this.pos;
const elementHeader = this.readElementHeader();
if (!elementHeader) {
break;
}
if (idsSet.has(elementHeader.id)) {
return elementStartPos;
}
+38 -7
View File
@@ -239,6 +239,10 @@ export class MatroskaDemuxer extends Demuxer {
);
const header = this.metadataReader.readElementHeader();
if (!header) {
break; // Zero padding at the end of the file triggers this, for example
}
const id = header.id;
let size = header.size;
const startPos = this.metadataReader.pos;
@@ -318,14 +322,19 @@ export class MatroskaDemuxer extends Demuxer {
);
let clusterEncountered = false;
while (this.metadataReader.pos < this.currentSegment.elementEndPos) {
while (this.metadataReader.pos <= this.currentSegment.elementEndPos - MIN_HEADER_SIZE) {
await this.metadataReader.reader.loadRange(
this.metadataReader.pos,
this.metadataReader.pos + MAX_HEADER_SIZE,
);
const elementStartPos = this.metadataReader.pos;
const { id, size } = this.metadataReader.readElementHeader();
const header = this.metadataReader.readElementHeader();
if (!header) {
break;
}
const { id, size } = header;
const dataStartPos = this.metadataReader.pos;
const metadataElementIndex = METADATA_ELEMENTS.findIndex(x => x.id === id);
@@ -392,7 +401,10 @@ export class MatroskaDemuxer extends Demuxer {
this.metadataReader.pos,
this.metadataReader.pos + 2 ** 12, // Load a larger range, assuming the correct element will be there
);
const { id, size } = this.metadataReader.readElementHeader();
const header = this.metadataReader.readElementHeader();
if (!header) continue;
const { id, size } = header;
if (id !== target.id) continue;
assertDefinedSize(size);
@@ -469,6 +481,8 @@ export class MatroskaDemuxer extends Demuxer {
const elementStartPos = this.metadataReader.pos;
const elementHeader = this.metadataReader.readElementHeader();
assert(elementHeader);
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = this.metadataReader.pos;
@@ -720,12 +734,20 @@ export class MatroskaDemuxer extends Demuxer {
const startIndex = reader.pos;
while (reader.pos - startIndex <= totalSize - MIN_HEADER_SIZE) {
this.traverseElement(reader);
const foundElement = this.traverseElement(reader);
if (!foundElement) {
break;
}
}
}
traverseElement(reader: EBMLReader) {
const { id, size } = reader.readElementHeader();
traverseElement(reader: EBMLReader): boolean {
const header = reader.readElementHeader();
if (!header) {
return false;
}
const { id, size } = header;
const dataStartPos = reader.pos;
assertDefinedSize(size);
@@ -1113,6 +1135,8 @@ export class MatroskaDemuxer extends Demuxer {
if (!this.currentCluster) break;
const trackNumber = reader.readVarInt();
if (trackNumber === null) break;
const relativeTimestamp = reader.readS16();
const flags = reader.readU8();
@@ -1148,6 +1172,8 @@ export class MatroskaDemuxer extends Demuxer {
if (!this.currentCluster) break;
const trackNumber = reader.readVarInt();
if (trackNumber === null) break;
const relativeTimestamp = reader.readS16();
const flags = reader.readU8();
@@ -1184,6 +1210,7 @@ export class MatroskaDemuxer extends Demuxer {
}
reader.pos = dataStartPos + size;
return true;
}
}
@@ -1562,7 +1589,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
}
while (metadataReader.pos < segment.elementEndPos) {
while (metadataReader.pos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (prevCluster) {
const trackData = prevCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
@@ -1582,6 +1609,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_HEADER_SIZE);
const elementStartPos = metadataReader.pos;
const elementHeader = metadataReader.readElementHeader();
if (!elementHeader) {
break;
}
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = metadataReader.pos;
+30 -29
View File
@@ -323,17 +323,17 @@ class VideoEncoderWrapper {
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(videoSample, finalEncodeOptions))
.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
videoSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
// We clone the sample so it cannot be closed on us from the outside before it reaches the encoder
const clonedSample = videoSample.clone();
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(clonedSample, finalEncodeOptions))
.then(() => this.customEncoderQueueSize--)
.catch((error: Error) => this.encoderError ??= error)
.finally(() => {
clonedSample.close();
// `videoSample` gets closed in the finally block at the end of the method
});
if (this.customEncoderQueueSize >= 4) {
@@ -440,6 +440,7 @@ class VideoEncoderWrapper {
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta);
},
error: (error) => {
error.stack = new Error().stack; // Provide a more useful stack trace
this.encoderError ??= error;
},
});
@@ -483,7 +484,6 @@ class VideoEncoderWrapper {
checkForEncoderError() {
if (this.encoderError) {
this.encoderError.stack = new Error().stack; // Provide a more useful stack trace
throw this.encoderError;
}
}
@@ -939,17 +939,17 @@ class AudioEncoderWrapper {
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(audioSample))
.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
audioSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
// We clone the sample so it cannot be closed on us from the outside before it reaches the encoder
const clonedSample = audioSample.clone();
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(clonedSample))
.then(() => this.customEncoderQueueSize--)
.catch((error: Error) => this.encoderError ??= error)
.finally(() => {
clonedSample.close();
// `audioSample` gets closed in the finally block at the end of the method
});
if (this.customEncoderQueueSize >= 4) {
@@ -1128,6 +1128,7 @@ class AudioEncoderWrapper {
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta);
},
error: (error) => {
error.stack = new Error().stack; // Provide a more useful stack trace
this.encoderError ??= error;
},
});
@@ -1266,7 +1267,6 @@ class AudioEncoderWrapper {
checkForEncoderError() {
if (this.encoderError) {
this.encoderError.stack = new Error().stack; // Provide a more useful stack trace
throw this.encoderError;
}
}
@@ -1334,16 +1334,17 @@ export class AudioBufferSource extends AudioSource {
* @returns A Promise that resolves once the output is ready to receive more samples. You should await this Promise
* to respect writer and encoder backpressure.
*/
add(audioBuffer: AudioBuffer) {
async add(audioBuffer: AudioBuffer) {
if (!(audioBuffer instanceof AudioBuffer)) {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const audioSamples = AudioSample.fromAudioBuffer(audioBuffer, this._accumulatedTime);
const promises = audioSamples.map(sample => this._encoder.add(sample, true));
const iterator = AudioSample._fromAudioBuffer(audioBuffer, this._accumulatedTime);
this._accumulatedTime += audioBuffer.duration;
return Promise.all(promises);
for (const audioSample of iterator) {
await this._encoder.add(audioSample, true);
}
}
/** @internal */
@@ -1466,10 +1467,10 @@ export class MediaStreamAudioTrackSource extends AudioSource {
let totalDuration = 0;
this._scriptProcessorNode.onaudioprocess = (event) => {
const audioSamples = AudioSample.fromAudioBuffer(event.inputBuffer, totalDuration);
const iterator = AudioSample._fromAudioBuffer(event.inputBuffer, totalDuration);
totalDuration += event.inputBuffer.duration;
for (const audioSample of audioSamples) {
for (const audioSample of iterator) {
if (!audioReceived) {
audioReceived = true;
+46 -3
View File
@@ -1067,6 +1067,49 @@ export class AudioSample {
(this.timestamp as number) = newTimestamp;
}
/** @internal */
static* _fromAudioBuffer(audioBuffer: AudioBuffer, timestamp: number) {
if (!(audioBuffer instanceof AudioBuffer)) {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 48000 * 5; // 5 seconds of mono 48 kHz audio per sample
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const totalFrames = audioBuffer.length;
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
let currentRelativeFrame = 0;
let remainingFrames = totalFrames;
// Create AudioSamples in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
channel,
currentRelativeFrame,
);
}
yield new AudioSample({
format: 'f32-planar',
sampleRate,
numberOfFrames: framesToCopy,
numberOfChannels,
timestamp: timestamp + currentRelativeFrame / sampleRate,
data: chunkData,
});
currentRelativeFrame += framesToCopy;
remainingFrames -= framesToCopy;
}
}
/**
* Creates AudioSamples from an AudioBuffer, starting at the given timestamp in seconds. Typically creates exactly
* one sample, but may create multiple if the AudioBuffer is exceedingly large.
@@ -1076,7 +1119,7 @@ export class AudioSample {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 64 * 1024 * 1024;
const MAX_FLOAT_COUNT = 48000 * 5; // 5 seconds of mono 48 kHz audio per sample
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
@@ -1088,14 +1131,14 @@ export class AudioSample {
const result: AudioSample[] = [];
// Create AudioData in a chunked fashion so we don't create huge Float32Arrays
// Create AudioSamples in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, channel * framesToCopy + framesToCopy),
chunkData.subarray(channel * framesToCopy, (channel + 1) * framesToCopy),
channel,
currentRelativeFrame,
);
+5 -1
View File
@@ -314,7 +314,11 @@ export class UrlSource extends Source {
} else if (rangeResponse.status === 200) {
// The server just returned the whole thing
this._fullData = await rangeResponse.arrayBuffer();
return this._fullData.byteLength;
if (this._fullData.byteLength !== 1) {
return this._fullData.byteLength;
} else {
// The server responded with 200, but returned only the requested range, so skip the response
}
}
// If the range request didn't provide the size, make a full GET request