From c0c353dbe8b3a3a4afddb55de734025df096b110 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Wed, 26 Nov 2025 23:31:29 +0100 Subject: [PATCH 01/23] Bump patch --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/mp3-encoder/package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 81249a8..2af63f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.25.2", + "version": "1.25.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.25.2", + "version": "1.25.3", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7739,9 +7739,9 @@ } }, "node_modules/mediabunny": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.1.tgz", - "integrity": "sha512-0Rrd47PMCVJbTPA7IJaXPCupV5/RZ/icgr+a0qExRJAr0n5vB4fsGSo+fdHIehG0CrddXtVRvNZwFtJz709yfA==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.2.tgz", + "integrity": "sha512-bS+lFdHsvvWUGK8ARDzhHnjmzLDPLmRfwlqKiXSCk+cdlyjKpiSHdAaKmrI6+2hD9Tmeqr39OQJk67nG/kAJAQ==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12065,7 +12065,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.25.2", + "version": "1.25.3", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 9a6a387..6d1861b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.25.2", + "version": "1.25.3", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index 172e319..a6b8f53 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.25.2", + "version": "1.25.3", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", From 3620b04cafff7fe2ead1b6ec005db661a9cb1978 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:58:12 +0100 Subject: [PATCH 02/23] Fix incomplete AV1 sequence header OBU parsing, fix missing saample close() calls in audio resampling path, add error logging when open samples are GC'd, bump patch --- dev/demux.html | 13 +++++- package-lock.json | 12 +++--- package.json | 2 +- packages/mp3-encoder/package.json | 2 +- src/codec-data.ts | 67 +++++++++++++++++++++++++++++-- src/conversion.ts | 6 ++- src/sample.ts | 67 +++++++++++++++++++++++++++++-- 7 files changed, 152 insertions(+), 17 deletions(-) diff --git a/dev/demux.html b/dev/demux.html index 8d02ae1..8b06ec0 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,7 +14,18 @@ source: new Mediabunny.BlobSource(file), }); - console.log(await input.getTracks()); + const audioTrack = await input.getPrimaryAudioTrack(); + const decoderConfig = await audioTrack.getDecoderConfig(); + console.log(decoderConfig); + + /* + const videoTrack = await input.getPrimaryVideoTrack(); + const sink = new Mediabunny.EncodedPacketSink(videoTrack); + + for await (const packet of sink.packets()) { + console.log(packet); + } + */ /* return; diff --git a/package-lock.json b/package-lock.json index 2af63f6..2b55d39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.25.3", + "version": "1.25.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.25.3", + "version": "1.25.4", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7739,9 +7739,9 @@ } }, "node_modules/mediabunny": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.2.tgz", - "integrity": "sha512-bS+lFdHsvvWUGK8ARDzhHnjmzLDPLmRfwlqKiXSCk+cdlyjKpiSHdAaKmrI6+2hD9Tmeqr39OQJk67nG/kAJAQ==", + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.3.tgz", + "integrity": "sha512-+LDXv/kybsElRQCmMlrdbKmPNvegeIyouGeOUzW5DJ8+M56H4G6wyEL2AJ1A45UcOB+U62qJMV7tuFO9S7yA7g==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12065,7 +12065,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.25.3", + "version": "1.25.4", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index 6d1861b..bcffe30 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.25.3", + "version": "1.25.4", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index a6b8f53..5e60596 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.25.3", + "version": "1.25.4", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/src/codec-data.ts b/src/codec-data.ts index eef04ec..03e1b24 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -590,9 +590,7 @@ export const extractNalUnitTypeForHevc = (data: Uint8Array) => { }; /** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */ -export const extractHevcDecoderConfigurationRecord = ( - packetData: Uint8Array, -) => { +export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => { try { const nalUnits = findNalUnitsInAnnexB(packetData); @@ -1456,6 +1454,69 @@ export const extractAv1CodecInfoFromPacket = ( } } + // Frame size + const frameWidthBitsMinus1 = bitstream.readBits(4); + const frameHeightBitsMinus1 = bitstream.readBits(4); + const n1 = frameWidthBitsMinus1 + 1; + bitstream.skipBits(n1); // max_frame_width_minus_1 + const n2 = frameHeightBitsMinus1 + 1; + bitstream.skipBits(n2); // max_frame_height_minus_1 + + // Frame IDs + let frameIdNumbersPresentFlag = 0; + if (reducedStillPictureHeader) { + frameIdNumbersPresentFlag = 0; + } else { + frameIdNumbersPresentFlag = bitstream.readBits(1); + } + + if (frameIdNumbersPresentFlag) { + bitstream.skipBits(4); // delta_frame_id_length_minus_2 + bitstream.skipBits(3); // additional_frame_id_length_minus_1 + } + + bitstream.skipBits(1); // use_128x128_superblock + bitstream.skipBits(1); // enable_filter_intra + bitstream.skipBits(1); // enable_intra_edge_filter + + if (!reducedStillPictureHeader) { + bitstream.skipBits(1); // enable_interintra_compound + bitstream.skipBits(1); // enable_masked_compound + bitstream.skipBits(1); // enable_warped_motion + bitstream.skipBits(1); // enable_dual_filter + const enableOrderHint = bitstream.readBits(1); + + if (enableOrderHint) { + bitstream.skipBits(1); // enable_jnt_comp + bitstream.skipBits(1); // enable_ref_frame_mvs + } + + const seqChooseScreenContentTools = bitstream.readBits(1); + let seqForceScreenContentTools = 0; + + if (seqChooseScreenContentTools) { + seqForceScreenContentTools = 2; // SELECT_SCREEN_CONTENT_TOOLS + } else { + seqForceScreenContentTools = bitstream.readBits(1); + } + + if (seqForceScreenContentTools > 0) { + const seqChooseIntegerMv = bitstream.readBits(1); + if (!seqChooseIntegerMv) { + bitstream.skipBits(1); // seq_force_integer_mv + } + } + + if (enableOrderHint) { + bitstream.skipBits(3); // order_hint_bits_minus_1 + } + } + + bitstream.skipBits(1); // enable_superres + bitstream.skipBits(1); // enable_cdef + bitstream.skipBits(1); // enable_restoration + + // color_config() const highBitdepth = bitstream.readBits(1); let bitDepth = 8; diff --git a/src/conversion.ts b/src/conversion.ts index 33bbd4d..40ca946 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -1509,7 +1509,10 @@ export class Conversion { targetSampleRate, startTime: this._startTimestamp, endTime: this._endTimestamp, - onSample: sample => this._registerAudioSample(track, trackOptions, source, sample), + onSample: async (sample) => { + await this._registerAudioSample(track, trackOptions, source, sample); + sample.close(); + }, }); const sink = new AudioSampleSink(track); @@ -1521,6 +1524,7 @@ export class Conversion { } await resampler.add(sample); + sample.close(); } await resampler.finalize(); diff --git a/src/sample.ts b/src/sample.ts index ddd4527..3c8ba36 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -21,6 +21,54 @@ import { polyfillSymbolDispose(); +type FinalizationRegistryValue = { + type: 'video'; + data: VideoFrame | OffscreenCanvas | Uint8Array; +} | { + type: 'audio'; + data: AudioData | Uint8Array; +}; + +// Let's manually handle logging the garbage collection errors that are typically logged by the browser. This way, they +// also kick for audio samples (which is normally not the case), making sure any incorrect code is quickly caught. +let lastVideoGcErrorLog = -Infinity; +let lastAudioGcErrorLog = -Infinity; +let finalizationRegistry: FinalizationRegistry | null = null; +if (typeof FinalizationRegistry !== 'undefined') { + finalizationRegistry = new FinalizationRegistry((value) => { + const now = Date.now(); + + if (value.type === 'video') { + if (now - lastVideoGcErrorLog >= 1000) { + // This error is annoying but oh so important + console.error( + `A VideoSample was garbage collected without first being closed. For proper resource management,` + + ` make sure to call close() on all your VideoSamples as soon as you're done using them.`, + ); + + lastVideoGcErrorLog = now; + } + + if (typeof VideoFrame !== 'undefined' && value.data instanceof VideoFrame) { + value.data.close(); // Prevent the browser error since we're logging our own + } + } else { + if (now - lastAudioGcErrorLog >= 1000) { + console.error( + `An AudioSample was garbage collected without first being closed. For proper resource management,` + + ` make sure to call close() on all your AudioSamples as soon as you're done using them.`, + ); + + lastAudioGcErrorLog = now; + } + + if (typeof AudioData !== 'undefined' && value.data instanceof AudioData) { + value.data.close(); + } + } + }); +} + /** * Metadata used for VideoSample initialization. * @group Samples @@ -133,7 +181,11 @@ export class VideoSample implements Disposable { data: VideoFrame | CanvasImageSource | AllowSharedBufferSource, init?: VideoSampleInit, ) { - if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + if ( + data instanceof ArrayBuffer + || (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer) + || ArrayBuffer.isView(data) + ) { if (!init || typeof init !== 'object') { throw new TypeError('init must be an object.'); } @@ -265,6 +317,8 @@ export class VideoSample implements Disposable { } else { throw new TypeError('Invalid data type: Must be a BufferSource or CanvasImageSource.'); } + + finalizationRegistry?.register(this, { type: 'video', data: this._data }, this); } /** Clones this video sample. */ @@ -313,6 +367,8 @@ export class VideoSample implements Disposable { return; } + finalizationRegistry?.unregister(this); + if (isVideoFrame(this._data)) { this._data.close(); } else { @@ -853,8 +909,8 @@ export type AudioSampleCopyToOptions = { */ export class AudioSample implements Disposable { /** @internal */ - _data: AudioData | Uint8Array; /** @internal */ + _data: AudioData | Uint8Array; _closed: boolean = false; /** @@ -955,6 +1011,8 @@ export class AudioSample implements Disposable { this._data = dataBuffer; } + + finalizationRegistry?.register(this, { type: 'audio', data: this._data }, this); } /** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */ @@ -1116,9 +1174,8 @@ export class AudioSample implements Disposable { } } } else { - // Branch for Uint8Array data (non-AudioData) const uint8Data = this._data; - const srcView = new DataView(uint8Data.buffer, uint8Data.byteOffset, uint8Data.byteLength); + const srcView = toDataView(uint8Data); const srcFormat = this.format; const readFn = getReadFunction(srcFormat); @@ -1188,6 +1245,8 @@ export class AudioSample implements Disposable { return; } + finalizationRegistry?.unregister(this); + if (isAudioData(this._data)) { this._data.close(); } else { From e5aeff89e3ba5e349187e07fa5d5a3061ec949f8 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:03:09 +0100 Subject: [PATCH 03/23] Wtf --- src/sample.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sample.ts b/src/sample.ts index 3c8ba36..fae15bf 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -908,9 +908,9 @@ export type AudioSampleCopyToOptions = { * @public */ export class AudioSample implements Disposable { - /** @internal */ /** @internal */ _data: AudioData | Uint8Array; + /** @internal */ _closed: boolean = false; /** From 816bb020fbb43cad9d229f8f3d271d50cdcc6b6f Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:40:41 +0100 Subject: [PATCH 04/23] Fix incorrect conversion trim option typings, change release CI to "trusted publishing" --- .github/workflows/release.yml | 5 +---- package-lock.json | 12 ++++++------ package.json | 2 +- packages/mp3-encoder/package.json | 2 +- src/conversion.ts | 14 ++++++++++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc40201..495b27c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: types: [published] permissions: + id-token: write # Required for OIDC contents: read jobs: @@ -59,10 +60,6 @@ jobs: - name: Publish Mediabunny to npm run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish workspace packages to npm run: npm publish --provenance --access public --workspaces - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package-lock.json b/package-lock.json index 2b55d39..cb3e7fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.25.4", + "version": "1.25.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.25.4", + "version": "1.25.5", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7739,9 +7739,9 @@ } }, "node_modules/mediabunny": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.3.tgz", - "integrity": "sha512-+LDXv/kybsElRQCmMlrdbKmPNvegeIyouGeOUzW5DJ8+M56H4G6wyEL2AJ1A45UcOB+U62qJMV7tuFO9S7yA7g==", + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.4.tgz", + "integrity": "sha512-oEMpnAj3fwP6mel84liF7vG58fl0RMC77jS9nmy0UH45nSfZgQqPj06TRQoje1n9hLpz6Dun4+UyCg6x+EKUvw==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12065,7 +12065,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.25.4", + "version": "1.25.5", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index bcffe30..edce602 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.25.4", + "version": "1.25.5", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index 5e60596..fa6c60d 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.25.4", + "version": "1.25.5", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", diff --git a/src/conversion.ts b/src/conversion.ts index 40ca946..59ccada 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -82,10 +82,16 @@ export type ConversionOptions = { /** Options to trim the input file. */ trim?: { - /** The time in the input file in seconds at which the output file should start. Must be less than `end`. */ - start: number; - /** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */ - end: number; + /** + * The time in the input file in seconds at which the output file should start. Must be less than `end`. + * Defaults to 0 when omitted. + */ + start?: number; + /** + * The time in the input file in seconds at which the output file should end. Must be greater than `start`. + * Defaults to the duration of the input when omitted. + */ + end?: number; }; /** From 71963cbd920b65c37f9049ffc1e7f42d45bb8969 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:43:45 +0100 Subject: [PATCH 05/23] Try again --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 495b27c..d8953a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ jobs: run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts - name: Publish Mediabunny to npm - run: npm publish --provenance --access public + run: npm publish --access public - name: Publish workspace packages to npm - run: npm publish --provenance --access public --workspaces + run: npm publish --access public --workspaces From dd39b8b2caf65567338fb26e8e7bbea9c41e4105 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:48:23 +0100 Subject: [PATCH 06/23] We try again again --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8953a0..a3ef765 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: permissions: write-all steps: - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -28,7 +28,7 @@ jobs: git push origin release - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' From dceb67a065f5e8dce6b96e5ab0b05f775e1bb5a7 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:54:04 +0100 Subject: [PATCH 07/23] SURELY THIS TIME --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3ef765..b2b2155 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,9 @@ jobs: cache: 'npm' registry-url: https://registry.npmjs.org + - name: Update npm + run: npm install -g npm@latest + - name: Get package.json version id: package-json-version run: echo "version=v$(cat package.json | jq '.version' --raw-output)" >> $GITHUB_OUTPUT From 7b3f5dad2aa85b5ba1c21e502d63da8088b6793d Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:57:55 +0100 Subject: [PATCH 08/23] Bump patch --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/mp3-encoder/package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb3e7fd..3015df9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.25.5", + "version": "1.25.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.25.5", + "version": "1.25.6", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7739,9 +7739,9 @@ } }, "node_modules/mediabunny": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.4.tgz", - "integrity": "sha512-oEMpnAj3fwP6mel84liF7vG58fl0RMC77jS9nmy0UH45nSfZgQqPj06TRQoje1n9hLpz6Dun4+UyCg6x+EKUvw==", + "version": "1.25.5", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.5.tgz", + "integrity": "sha512-WMUEe3pOd3AEIEosc1waIFx/Y8iGd+ITouKaTyixH0kdOJoy9IulcNLA7OjLllyoqYKbRQqvlwwYkxJ10k9wpw==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12065,7 +12065,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.25.5", + "version": "1.25.6", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index edce602..335a8e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.25.5", + "version": "1.25.6", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index fa6c60d..b2d3215 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.25.5", + "version": "1.25.6", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", From 3dd7a9cb58788970e779ac8f9942dba7d5ca0b7c Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:04:24 +0100 Subject: [PATCH 09/23] Fix unclosed samples in tests --- test/browser/flac.test.ts | 2 +- test/browser/transparency.test.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/browser/flac.test.ts b/test/browser/flac.test.ts index 2aed0e3..ddc065b 100644 --- a/test/browser/flac.test.ts +++ b/test/browser/flac.test.ts @@ -19,7 +19,7 @@ test('can decode samples from a FLAC file', async () => { const sink = new AudioSampleSink(track); - const sample = await sink.getSample(1); + using sample = await sink.getSample(1); assert(sample); expect(sample.timestamp).toBe(0.9287981859410431); }); diff --git a/test/browser/transparency.test.ts b/test/browser/transparency.test.ts index 35527ae..394c827 100644 --- a/test/browser/transparency.test.ts +++ b/test/browser/transparency.test.ts @@ -21,7 +21,7 @@ test('Can decode transparent video', async () => { expect(await videoTrack.canBeTransparent()).toBe(true); const sink = new VideoSampleSink(videoTrack); - const sample = (await sink.getSample(0.5))!; + using sample = (await sink.getSample(0.5))!; expect(sample.format).toContain('A'); // Probably RGBA expect(sample.hasAlpha).toBe(true); @@ -47,10 +47,10 @@ test('Can decode faulty transparent video and behaves gracefully', async () => { const sink = new VideoSampleSink(videoTrack); - const startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + using startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; expect(startSample.format).toContain('A'); - const secondSample = (await sink.getSample(secondKeyPacket.timestamp))!; + using secondSample = (await sink.getSample(secondKeyPacket.timestamp))!; expect(secondSample.format).not.toContain('A'); // There was no alpha key frame for this one expect(secondSample.hasAlpha).toBe(false); }); @@ -164,7 +164,7 @@ test('Can encode transparent video', async () => { const sink = new VideoSampleSink(videoTrack); - const firstSample = (await sink.getSample(0))!; + using firstSample = (await sink.getSample(0))!; expect(firstSample.format).toContain('A'); probeContext.clearRect(0, 0, probeCanvas.width, probeCanvas.height); @@ -201,7 +201,7 @@ test('Can encode video with alternating transparency', async () => { await output.start(); for (let i = 0; i < 64; i++) { - const sample = new VideoSample(new Uint8Array(640 * 480 * 4), { + using sample = new VideoSample(new Uint8Array(640 * 480 * 4), { format: i % 2 ? 'RGBX' : 'RGBA', codedWidth: 640, codedHeight: 480, @@ -235,7 +235,7 @@ test('Can encode video with alternating transparency', async () => { const sampleSink = new VideoSampleSink(videoTrack); i = 0; - for await (const sample of sampleSink.samples()) { + for await (using sample of sampleSink.samples()) { if (i % 2) { expect(sample.format).not.toContain('A'); } else { @@ -299,7 +299,7 @@ test('Can transmux transparent video, discards alpha by default', async () => { expect(await videoTrack.canBeTransparent()).toBe(false); const sink = new VideoSampleSink(videoTrack); - const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; expect(sample.hasAlpha).toBe(false); }); @@ -331,7 +331,7 @@ test('Can transmux transparent video, can keep alpha', async () => { expect(await videoTrack.canBeTransparent()).toBe(true); const sink = new VideoSampleSink(videoTrack); - const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; expect(sample.format).toContain('A'); expect(sample.hasAlpha).toBe(true); }); @@ -370,7 +370,7 @@ test('Can reencode transparent video, keeping alpha', async () => { expect(videoTrack.displayWidth).toBe(320); const sink = new VideoSampleSink(videoTrack); - const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; + using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!; expect(sample.format).toContain('A'); expect(sample.hasAlpha).toBe(true); }); From 5b703a1c783e4e24b446d12e75877cf317c02e34 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:41:36 +0100 Subject: [PATCH 10/23] Fix MP3 encoder emitting incorrect timestamps, fixed overaggressive audio sample gap filling (fixes #233) --- dev/live.html | 5 ++++- packages/mp3-encoder/src/index.ts | 9 ++++++++- src/media-source.ts | 32 ++++++++++++++++++------------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/dev/live.html b/dev/live.html index afcb991..7178840 100644 --- a/dev/live.html +++ b/dev/live.html @@ -1,6 +1,7 @@ + From cc649ba1925788f386011afc7765555d4f61e528 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:41:00 +0100 Subject: [PATCH 19/23] Add allowRotationMetadata video conversion option (see #247) --- dev/convert.html | 9 +++++---- docs/guide/converting-media-files.md | 3 +++ src/conversion.ts | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index e548520..36a2645 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -100,9 +100,10 @@ }, */ video: () => ({ - width: 720, - frameRate: 30, - bitrate: Mediabunny.QUALITY_VERY_LOW, + allowRotationMetadata: false, + //width: 720, + //frameRate: 30, + //bitrate: Mediabunny.QUALITY_VERY_LOW, //discard: true, /* process: (sample) => { @@ -180,7 +181,7 @@ }, trim: { start: 0, - end: 20 + end: 4 }, }); console.log(conversion); diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index b54c9e1..c527f12 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -120,6 +120,7 @@ type ConversionVideoOptions = { height?: number; fit?: 'fill' | 'contain' | 'cover'; rotate?: 0 | 90 | 180 | 270; + allowRotationMetadata?: boolean; crop?: { left: number; top: number; width: number; height: number }; frameRate?: number; codec?: VideoCodec; @@ -175,6 +176,8 @@ In the rare case that the input video changes size over time, the `fit` field ca `rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file and happens before cropping and resizing. +By default, Mediabunny will try to make use of rotation metadata in the output file to perform the rotation whenever possible. However, if you don't want this to happen, or you want to use Mediabunny to strip all rotation metadata from a file, you can set `allowRotationMetadata` to `false`. + ### Cropping video `crop` can be used to extract a rectangular region from the original video. The rectangle is specified using `left`, `top`, `width` and `height` and is clamped to the dimensions of the video. Cropping is applied after rotation but before resizing. diff --git a/src/conversion.ts b/src/conversion.ts index 59ccada..6ea8994 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -143,6 +143,12 @@ export type ConversionVideoOptions = { * This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata. */ rotate?: Rotation; + /** + * Defaults to `true`. When enabaled, Mediabunny will use the rotation metadata in the output file to perform video + * rotation whenever possible. Set this field to `false` if you want to ensure the output file does not make use of + * rotation metadata and that any rotation is baked into the video frames directly. + */ + allowRotationMetadata?: boolean; /** * Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to * the dimensions of the input video track. Cropping is performed after rotation but before resizing. @@ -304,6 +310,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) { throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.'); } + if (videoOptions?.allowRotationMetadata !== undefined && typeof videoOptions.allowRotationMetadata !== 'boolean') { + throw new TypeError('options.video.allowRotationMetadata, when provided, must be a boolean.'); + } if (videoOptions?.crop !== undefined) { validateCropRectangle(videoOptions.crop, 'options.video.'); } @@ -838,7 +847,8 @@ export class Conversion { let videoSource: VideoSource; const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0)); - const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata; + const canUseRotationMetadata = this.output.format.supportsVideoRotationMetadata + && (trackOptions.allowRotationMetadata ?? true); const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0 ? [track.codedWidth, track.codedHeight] @@ -883,7 +893,7 @@ export class Conversion { // TODO This is suboptimal: Forcing a rerender when both rotation and process are set is not // performance-optimal, but right now there's no other way because we can't change the track rotation // metadata after the output has already started. Should be possible with API changes in v2, though! - || (totalRotation !== 0 && (!outputSupportsRotation || trackOptions.process !== undefined)) + || (totalRotation !== 0 && (!canUseRotationMetadata || trackOptions.process !== undefined)) || !!crop; const alpha = trackOptions.alpha ?? 'discard'; From 1ff8abdaabf725ec5ad74a9e67b6a2c91da80aa2 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:24:19 +0100 Subject: [PATCH 20/23] "Extract function" ahh --- src/media-source.ts | 91 ++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 51 deletions(-) diff --git a/src/media-source.ts b/src/media-source.ts index a5c0c55..f8b25ef 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -1890,38 +1890,49 @@ export class MediaStreamAudioTrackSource extends AudioSource { this._abortController = new AbortController(); + let firstAudioDataTimestamp: number | null = null; + let errored = false; + + const onAudioSample = (audioSample: AudioSample) => { + if (errored) { + audioSample.close(); + return; + } + + if (firstAudioDataTimestamp === null) { + firstAudioDataTimestamp = audioSample.timestamp; + + const muxer = this._connectedTrack!.output._muxer; + if (muxer.firstMediaStreamTimestamp === null) { + muxer.firstMediaStreamTimestamp = performance.now() / 1000; + this._timestampOffset = -firstAudioDataTimestamp; + } else { + this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp) + - firstAudioDataTimestamp; + } + } + + if (this._encoder.getQueueSize() >= 4) { + // Drop data if the encoder is overloaded + audioSample.close(); + return; + } + + void this._encoder.add(audioSample, true) + .catch((error) => { + errored = true; + + this._abortController?.abort(); + this._promiseWithResolvers.reject(error); + void this._audioContext?.suspend(); + }); + }; + if (typeof MediaStreamTrackProcessor !== 'undefined') { // Great, MediaStreamTrackProcessor is supported, this is the preferred way of doing things - let firstAudioDataTimestamp: number | null = null; - const processor = new MediaStreamTrackProcessor({ track: this._track }); const consumer = new WritableStream({ - write: (audioData) => { - if (firstAudioDataTimestamp === null) { - firstAudioDataTimestamp = audioData.timestamp / 1e6; - - const muxer = this._connectedTrack!.output._muxer; - if (muxer.firstMediaStreamTimestamp === null) { - muxer.firstMediaStreamTimestamp = performance.now() / 1000; - this._timestampOffset = -firstAudioDataTimestamp; - } else { - this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp) - - firstAudioDataTimestamp; - } - } - - if (this._encoder.getQueueSize() >= 4) { - // Drop data if the encoder is overloaded - audioData.close(); - return; - } - - void this._encoder.add(new AudioSample(audioData), true) - .catch((error) => { - this._abortController?.abort(); - this._promiseWithResolvers.reject(error); - }); - }, + write: audioData => onAudioSample(new AudioSample(audioData)), }); processor.readable.pipeTo(consumer, { @@ -1949,7 +1960,6 @@ export class MediaStreamAudioTrackSource extends AudioSource { sourceNode.connect(this._scriptProcessorNode); this._scriptProcessorNode.connect(this._audioContext.destination); - let audioReceived = false; let totalDuration = 0; this._scriptProcessorNode.onaudioprocess = (event) => { @@ -1957,28 +1967,7 @@ export class MediaStreamAudioTrackSource extends AudioSource { totalDuration += event.inputBuffer.duration; for (const audioSample of iterator) { - if (!audioReceived) { - audioReceived = true; - - const muxer = this._connectedTrack!.output._muxer; - if (muxer.firstMediaStreamTimestamp === null) { - muxer.firstMediaStreamTimestamp = performance.now() / 1000; - } else { - this._timestampOffset = performance.now() / 1000 - muxer.firstMediaStreamTimestamp; - } - } - - if (this._encoder.getQueueSize() >= 4) { - // Drop data if the encoder is overloaded - audioSample.close(); - continue; - } - - void this._encoder.add(audioSample, true) - .catch((error) => { - void this._audioContext!.suspend(); - this._promiseWithResolvers.reject(error); - }); + onAudioSample(audioSample); } }; } From 84dec01dfdca4065d865e6d97b93f16bacd3d750 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:51:18 +0100 Subject: [PATCH 21/23] Add `pause` and `resume` methods to MediaStreamTrack sources (closes #241) --- dev/live.html | 24 ++++++--- docs/guide/media-sources.md | 20 +++++++ src/media-source.ts | 104 +++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/dev/live.html b/dev/live.html index 7178840..8eef45b 100644 --- a/dev/live.html +++ b/dev/live.html @@ -25,29 +25,41 @@ target: new Mediabunny.BufferTarget(), format: new Mediabunny.Mp4OutputFormat(), }); + let videoSource = null; + let audioSource = null; if (videoTrack) { - const source = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, { + videoSource = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, { codec: 'avc', bitrate: Mediabunny.QUALITY_MEDIUM }); - source.errorPromise.catch((d) => console.log("Hello?????", d)); + videoSource.errorPromise.catch((d) => console.log("Hello?????", d)); - output.addVideoTrack(source); + output.addVideoTrack(videoSource); } if (audioTrack) { - const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, { + audioSource = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, { codec: 'mp3', bitrate: Mediabunny.QUALITY_MEDIUM }); - source.errorPromise.catch((d) => console.log("Hello!!???", d)); + audioSource.errorPromise.catch((d) => console.log("Hello!!???", d)); - output.addAudioTrack(source); + output.addAudioTrack(audioSource); } await output.start(); + setTimeout(() => { + videoSource?.pause(); + audioSource?.pause(); + + setTimeout(() => { + videoSource?.resume(); + audioSource?.resume(); + }, 1000); + }, 1000); + await new Promise(resolve => setTimeout(resolve, 5000)); await output.finalize(); diff --git a/docs/guide/media-sources.md b/docs/guide/media-sources.md index 8c0d2bd..31caa1f 100644 --- a/docs/guide/media-sources.md +++ b/docs/guide/media-sources.md @@ -187,6 +187,16 @@ videoTrackSource.errorPromise.catch((error) => ...); This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `videoTrack` after finalizing the `Output` if you don't need the user's media anymore. +If you want to temporarily stop capturing video frames from this source, you can use the `pause()` and `resume()` methods: +```ts +videoTrackSource.pause(); + +// Later: +videoTrackSource.resume(); +``` + +While paused, video frames emitted by the stream will be ignored. When resumed, video frames are let through again, offset in timestamp such that the result plays back continuously with no gap in playback. Note that pausing does *not* stop the underlying media stream. + ::: info If this source is the only MediaStreamTrack source in the `Output`, then the first video sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other. ::: @@ -344,6 +354,16 @@ audioTrackSource.errorPromise.catch((error) => ...); This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `audioTrack` after finalizing the `Output` if you don't need the user's media anymore. +If you want to temporarily stop capturing audio data from this source, you can use the `pause()` and `resume()` methods: +```ts +audioTrackSource.pause(); + +// Later: +audioTrackSource.resume(); +``` + +While paused, audio data emitted by the stream will be ignored. When resumed, audio data are let through again, offset in timestamp such that the result plays back continuously with no gap in playback. Note that pausing does *not* stop the underlying media stream. + ::: info If this source is the only MediaStreamTrack source in the `Output`, then the first audio sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other. ::: diff --git a/src/media-source.ts b/src/media-source.ts index f8b25ef..7b5cae6 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -1055,6 +1055,12 @@ export class MediaStreamVideoTrackSource extends VideoSource { private _promiseWithResolvers = promiseWithResolvers(); /** @internal */ private _errorPromiseAccessed = false; + /** @internal */ + private _paused = false; + /** @internal */ + private _lastSampleTimestamp: number | null = null; + /** @internal */ + private _pauseOffset = 0; /** A promise that rejects upon any error within this source. This promise never resolves. */ get errorPromise() { @@ -1062,6 +1068,11 @@ export class MediaStreamVideoTrackSource extends VideoSource { return this._promiseWithResolvers.promise; } + /** Whether this source is currently paused as a result of calling `.pause()`. */ + get paused() { + return this._paused; + } + /** * Creates a new {@link MediaStreamVideoTrackSource} from a * [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack), which will pull @@ -1103,8 +1114,29 @@ export class MediaStreamVideoTrackSource extends VideoSource { return; } + const currentTimestamp = videoFrame.timestamp / 1e6; + + if (this._paused) { + const frameSeen = firstVideoFrameTimestamp !== null; + if (frameSeen) { + if (this._lastSampleTimestamp !== null) { + // In addition to dropping this frame, let's also keep track of the time we have lost due to the + // pause. Doing it like this instead of simply keeping track of the paused time is better since + // it retains the frame rate of the underlying source. + const timeDelta = currentTimestamp - this._lastSampleTimestamp; + // We modify this field instead of _timestampOffset since we still might have data in flight + // in the encoder, with which we don't want to mess. + this._pauseOffset -= timeDelta; + } + this._lastSampleTimestamp = currentTimestamp; + } + + videoFrame.close(); + return; + } + if (firstVideoFrameTimestamp === null) { - firstVideoFrameTimestamp = videoFrame.timestamp / 1e6; + firstVideoFrameTimestamp = currentTimestamp; const muxer = this._connectedTrack!.output._muxer; if (muxer.firstMediaStreamTimestamp === null) { @@ -1116,13 +1148,19 @@ export class MediaStreamVideoTrackSource extends VideoSource { } } + this._lastSampleTimestamp = currentTimestamp; + if (this._encoder.getQueueSize() >= 4) { // Drop frames if the encoder is overloaded videoFrame.close(); return; } - void this._encoder.add(new VideoSample(videoFrame), true) + const sample = new VideoSample(videoFrame, { + timestamp: currentTimestamp + this._pauseOffset, + }); + + void this._encoder.add(sample, true) .catch((error) => { errored = true; @@ -1182,6 +1220,19 @@ export class MediaStreamVideoTrackSource extends VideoSource { } } + /** + * Pauses the capture of video frames - any video frames emitted by the underlying media stream will be ignored + * while paused. This does *not* close the underlying `MediaStreamVideoTrack`, it just ignores its output. + */ + pause() { + this._paused = true; + } + + /** Resumes the capture of video frames after being paused. */ + resume() { + this._paused = false; + } + /** @internal */ override async _flushAndClose(forceClose: boolean) { if (this._abortController) { @@ -1857,6 +1908,12 @@ export class MediaStreamAudioTrackSource extends AudioSource { private _promiseWithResolvers = promiseWithResolvers(); /** @internal */ private _errorPromiseAccessed = false; + /** @internal */ + private _paused = false; + /** @internal */ + private _lastSampleTimestamp: number | null = null; + /** @internal */ + private _pauseOffset = 0; /** A promise that rejects upon any error within this source. This promise never resolves. */ get errorPromise() { @@ -1864,6 +1921,11 @@ export class MediaStreamAudioTrackSource extends AudioSource { return this._promiseWithResolvers.promise; } + /** Whether this source is currently paused as a result of calling `.pause()`. */ + get paused() { + return this._paused; + } + /** * Creates a new {@link MediaStreamAudioTrackSource} from a `MediaStreamAudioTrack`, which will pull audio samples * from the stream in real time and encode them according to {@link AudioEncodingConfig}. @@ -1899,6 +1961,27 @@ export class MediaStreamAudioTrackSource extends AudioSource { return; } + const currentTimestamp = audioSample.timestamp; + + if (this._paused) { + const dataSeen = firstAudioDataTimestamp !== null; + if (dataSeen) { + if (this._lastSampleTimestamp !== null) { + // In addition to dropping this sample, let's also keep track of the time we have lost due to + // the pause. Doing it like this instead of simply keeping track of the paused time is better + // since it retains the sample rate of the underlying source. + const timeDelta = currentTimestamp - this._lastSampleTimestamp; + // We modify this field instead of _timestampOffset since we still might have data in flight + // in the encoder, with which we don't want to mess. + this._pauseOffset -= timeDelta; + } + this._lastSampleTimestamp = currentTimestamp; + } + + audioSample.close(); + return; + } + if (firstAudioDataTimestamp === null) { firstAudioDataTimestamp = audioSample.timestamp; @@ -1912,12 +1995,16 @@ export class MediaStreamAudioTrackSource extends AudioSource { } } + this._lastSampleTimestamp = currentTimestamp; + if (this._encoder.getQueueSize() >= 4) { // Drop data if the encoder is overloaded audioSample.close(); return; } + audioSample.setTimestamp(currentTimestamp + this._pauseOffset); + void this._encoder.add(audioSample, true) .catch((error) => { errored = true; @@ -1973,6 +2060,19 @@ export class MediaStreamAudioTrackSource extends AudioSource { } } + /** + * Pauses the capture of audio data - any audio data emitted by the underlying media stream will be ignored + * while paused. This does *not* close the underlying `MediaStreamAudioTrack`, it just ignores its output. + */ + pause() { + this._paused = true; + } + + /** Resumes the capture of audio data after being paused. */ + resume() { + this._paused = false; + } + /** @internal */ override async _flushAndClose(forceClose: boolean) { if (this._abortController) { From f9207396d7af0631e67c5fe7814dcd068a2ca1e2 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:42:31 +0100 Subject: [PATCH 22/23] Bump minor --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/mp3-encoder/package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e7c162..c35e2b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mediabunny", - "version": "1.25.8", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mediabunny", - "version": "1.25.8", + "version": "1.26.0", "license": "MPL-2.0", "workspaces": [ "packages/*" @@ -7739,9 +7739,9 @@ } }, "node_modules/mediabunny": { - "version": "1.25.7", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.7.tgz", - "integrity": "sha512-DL0E1h29HTDaD9bYRXLSSHiAoLbDBksrdYS+4OHWA+aNhQeN+CAGEG7EU6wlhPZ8MOpwXIeC7uv06lo4ziohQQ==", + "version": "1.25.8", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.8.tgz", + "integrity": "sha512-2WCa9WtEbHOvg5rAWXQjVE+O/r01fpj65ymZdA2XhIeaWmdDQ40p32F0WY6tdBi+aeY+4ldwhAOpo0eyVrYTGg==", "license": "MPL-2.0", "peer": true, "workspaces": [ @@ -12065,7 +12065,7 @@ }, "packages/mp3-encoder": { "name": "@mediabunny/mp3-encoder", - "version": "1.25.8", + "version": "1.26.0", "license": "MPL-2.0", "devDependencies": { "@types/emscripten": "^1.40.1" diff --git a/package.json b/package.json index ca34054..178d998 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mediabunny", "author": "Vanilagy", - "version": "1.25.8", + "version": "1.26.0", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "type": "module", "workspaces": [ diff --git a/packages/mp3-encoder/package.json b/packages/mp3-encoder/package.json index a0cc288..fe80545 100644 --- a/packages/mp3-encoder/package.json +++ b/packages/mp3-encoder/package.json @@ -1,7 +1,7 @@ { "name": "@mediabunny/mp3-encoder", "author": "Vanilagy", - "version": "1.25.8", + "version": "1.26.0", "description": "MP3 encoder extension for Mediabunny, based on LAME.", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs", From ea9ba96ae3b7ef7a1835187ad61039ecf0414a6b Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Fri, 12 Dec 2025 10:44:57 +0100 Subject: [PATCH 23/23] Add Dark Mode Remotion Logo in README (#249) * Add Dark Mode Remotion Logo in README * Update logo on website --- README.md | 5 ++++- docs/index.md | 2 +- docs/public/sponsors/remotion-dark.png | Bin 0 -> 13526 bytes docs/public/sponsors/remotion-light.png | Bin 0 -> 19813 bytes docs/public/sponsors/remotion.png | Bin 9622 -> 0 bytes 5 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 docs/public/sponsors/remotion-dark.png create mode 100644 docs/public/sponsors/remotion-light.png delete mode 100644 docs/public/sponsors/remotion.png diff --git a/README.md b/README.md index 6d108d4..738f4fb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
- Remotion + + + Remotion +      diff --git a/docs/index.md b/docs/index.md index 65a5618..2addbdb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -91,7 +91,7 @@ const bundleSizes = [ const sponsors = { gold: [ - { image: '/sponsors/remotion.png', name: 'Remotion', url: 'https://remotion.dev/' }, + { image: '/sponsors/remotion-light.png', name: 'Remotion', url: 'https://remotion.dev/' }, { image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' }, { image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' }, { image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' }, diff --git a/docs/public/sponsors/remotion-dark.png b/docs/public/sponsors/remotion-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..04e6ea1c30c498ea680ec2c1d9d9c47f150366ff GIT binary patch literal 13526 zcmeHu1zTG|uqcv3kOINof){tULV)7#?%qOhD^QBNOG=AdaVZ2XQmnKoRwTGP#fm?A z?|t{a_kP1W=i6^)XJ=-2_TxJJmEl;XQ+fI=ua5x(~W{kiSkd|6Nd5%mFmB79aOge;z4^_ zfinu=zj#cZ;J-`xsr{4rPl)~z^?x*eMEl>|sN^5f{|EmkCWIQY_XMzBD;s;Gpx_Yw zYp5uB1^=uycGfe38mX&^+j_clTibcs*mL{4zy2qMBIz&wgu2^9t?B*U-8{U-{iPWH zgG2lY|3~Iwr2h{VsH+sCk-8TBOHVI*dSPx}ZeB)dYO@pw(<0VN-;A2o9Mr<|6C{3+2Ma?^6>sIvz`p(`8UGD$IZ+0-@2bfCI3;y zHNBkepJx82Uz$(yKREv{+JDU>$@6dW|6?)#Y3YAZpG=j;mgM>GZIi~Puvo=JLE$%4 zQION~M?FNkj(=XzCp$54!#NN2)I9#FGg_*}f#&{K-bQfrFrE&#D5YW*9ishm3dPyx zgp^1SP$4 z-1`ULeOfHUPN`?-?ld{9^9q6y-Xv=U+~Up?vx`vvKpyhdpodAxWS}wL6%+?fyOuw{ z@=MA;BS`-7#F;K{)dnum8|-*Nq$5Xf*`dD?VTavgPB^k4lG)Ln)a{q+PcX4vtSD)J zTL#&Vlv^?MHZ=Qsl2T?RuExMF0V zoku9vOrB>MeV{xB;S}KUMO9u_+Nw9ixk;1tNXtxCf8_Pb0P3yKmU>6O<`}Rs-*ncV zWbks+C3ZJB91+oJ;Ybwo%f5Wt)@YcXi!A>iLh&cg9g_UtqsROqDH%#C_lBG?z<+IKL-Q$Zif`% zO{_U7W7*HVH_&i4yy9{W2q!#z&*pM9N(YrlU#<{KOu9#scs0b_p6{^RakZHqGg8f( zR##R|Im!zUJK7OAy+|X@KAWKQ(T-?Oz*yO~IxDU6@ zI~x66{8Sq@tX0DfzQsqxBLeUTP9P-zz1gDd)uMpwGIP~F1?PZ`++P8NW>nW_WCAW) zviwQ`a4VovDlJ$0O56Iip{JgKu5N|q;Ar_v;mBk2zJNSSahwR1OC1S_&RtW**@ym? zVTeJblbul0X4f`EbZAYZyD7?S5$5V52;Tn#GEx9nSMH{-59czz#si!cG=F9-inh9> z+GsJb#jI+~bB?@k?dwyOaDLLc#`o4y$#Hh)^(O=4#HbaCM*6FgJo@Da#|MfDCU-L1b7850MMdrhrMTRq-!r1g!Fl8K{Oo6OI88|twlp;v5HY8D>k zmG)M0U`CJ=6c3NGBn)w$`XRS7HNETIkt)%54=K^w&>qk&Y1++UUj-wQ6+s02HpSgd z7++Vxa9li~I@#{^}0l|b)FdkrKvF4qv9-UggLi`wSV(8y zVR3+SLI$xUo2E!rw?`3^dNs$4<5N+iIKwO;E8IhNZ#5M-!I!4qa_A5)p`bhY^NWO+ z8&J=dCg0L;QxiB8!qXbzC-KXCu46De°iH6^|g`cufJJ)X#`rbaRAty`!F2V73L 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 YSkPHoKaPIs-`6@SiW&;F@>UW53)RFT<^TWy literal 0 HcmV?d00001 diff --git a/docs/public/sponsors/remotion-light.png b/docs/public/sponsors/remotion-light.png new file mode 100644 index 0000000000000000000000000000000000000000..74b359227ba6646c0933da3f767083fe9c44d2b2 GIT binary patch literal 19813 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 KHmU`CJoyhZX(V9) literal 0 HcmV?d00001 diff --git a/docs/public/sponsors/remotion.png b/docs/public/sponsors/remotion.png deleted file mode 100644 index f76b0618f9d022027fe1de5b5834060c87fefd5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9622 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