Compare commits

...
7 Commits
10 changed files with 113 additions and 52 deletions
+1
View File
@@ -75,6 +75,7 @@ export default withMermaid({
{ icon: 'github', link: 'https://github.com/Vanilagy/mediabunny' }, { icon: 'github', link: 'https://github.com/Vanilagy/mediabunny' },
{ icon: 'discord', link: 'https://discord.gg/hmpkyYuS4U' }, { icon: 'discord', link: 'https://discord.gg/hmpkyYuS4U' },
{ icon: 'x', link: 'https://x.com/vanilagy' }, { icon: 'x', link: 'https://x.com/vanilagy' },
{ icon: 'bluesky', link: 'https://bsky.app/profile/vanilagy.bsky.social' },
], ],
search: { search: {
+4 -2
View File
@@ -27,7 +27,9 @@ ESM is preferred because it gives you tree shaking.
You can also just include the library using a script tag in your HTML: You can also just include the library using a script tag in your HTML:
```html ```html
<script src="path/to/mediabunny.js"></script> <script src="mediabunny.cjs"></script>
``` ```
You can download the built distribution file from the [releases page](https://github.com/Vanilagy/mediabunny/releases). This will add a `Mediabunny` object to the global scope.
You can download a built distribution file from the [releases page](https://github.com/Vanilagy/mediabunny/releases). Use the `*.cjs` builds for normal script tag inclusion, or the `*.mjs` builds for script tags with `type="module"` or direct imports via ESM. Including the `mediabunny.d.ts` declaration file in your TypeScript project will declare a global `Mediabunny` namespace.
+1 -1
View File
@@ -237,7 +237,7 @@ await conversion.execute();
</div> </div>
</div> </div>
<div class="flex flex-col-reverse lg:flex-row gap-4 lg:gap-20 lg:items-center"> <div class="flex flex-col-reverse lg:flex-row gap-4 lg:gap-20 items-center">
<div class="relative flex-1 min-w-0"> <div class="relative flex-1 min-w-0">
<div class="absolute size-70 rounded-full bg-[#ff45ac]/0 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 blur-[200px]" /> <div class="absolute size-70 rounded-full bg-[#ff45ac]/0 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 blur-[200px]" />
<img class="relative" src="./assets/inspiring-io.svg"> <img class="relative" src="./assets/inspiring-io.svg">
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.0.4", "version": "1.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.0.4", "version": "1.0.6",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-mediacapture-transform": "^0.1.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.0.4", "version": "1.0.6",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"main": "./dist/bundles/mediabunny.cjs", "main": "./dist/bundles/mediabunny.cjs",
+5 -3
View File
@@ -13,6 +13,7 @@ import { IsobmffReader } from './isobmff/isobmff-reader';
import { EBMLId, EBMLReader } from './matroska/ebml'; import { EBMLId, EBMLReader } from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer'; import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer'; import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from './mp3/mp3-misc';
import { Mp3Reader } from './mp3/mp3-reader'; import { Mp3Reader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer'; import { OggDemuxer } from './ogg/ogg-demuxer';
import { OggReader } from './ogg/ogg-reader'; import { OggReader } from './ogg/ogg-reader';
@@ -245,9 +246,10 @@ export class Mp3InputFormat extends InputFormat {
} }
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find // Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
// another header nearby: // another header right after it:
mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize; mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize;
const secondHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize)); await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + FRAME_HEADER_SIZE);
const secondHeader = mp3Reader.readNextFrameHeader(mp3Reader.pos + FRAME_HEADER_SIZE);
if (!secondHeader) { if (!secondHeader) {
return false; return false;
} }
@@ -257,7 +259,7 @@ export class Mp3InputFormat extends InputFormat {
return false; return false;
} }
// We have found two matching MP3 frames, a strong indicator that this is an MP3 file // We have found two matching consecutive MP3 frames, a strong indicator that this is an MP3 file
return true; return true;
} }
+87 -30
View File
@@ -54,6 +54,7 @@ import {
isIso639Dash2LanguageCode, isIso639Dash2LanguageCode,
roundToMultiple, roundToMultiple,
normalizeRotation, normalizeRotation,
Bitstream,
} from '../misc'; } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { Reader } from '../reader'; import { Reader } from '../reader';
@@ -74,6 +75,7 @@ type InternalTrack = {
fragmentLookupTable: FragmentLookupTableEntry[] | null; fragmentLookupTable: FragmentLookupTableEntry[] | null;
currentFragmentState: FragmentTrackState | null; currentFragmentState: FragmentTrackState | null;
fragments: Fragment[]; fragments: Fragment[];
fragmentsWithKeyFrame: Fragment[];
/** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */ /** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */
editListPreviousSegmentDurations: number; editListPreviousSegmentDurations: number;
/** The media time offset of the main edit list entry (with media time !== -1) */ /** The media time offset of the main edit list entry (with media time !== -1) */
@@ -167,6 +169,7 @@ type FragmentTrackState = {
type FragmentTrackData = { type FragmentTrackData = {
startTimestamp: number; startTimestamp: number;
endTimestamp: number; endTimestamp: number;
firstKeyFrameTimestamp: number | null;
samples: FragmentTrackSample[]; samples: FragmentTrackSample[];
presentationTimestamps: { presentationTimestamps: {
presentationTimestamp: number; presentationTimestamp: number;
@@ -607,6 +610,7 @@ export class IsobmffDemuxer extends Demuxer {
fragmentLookupTable: null, fragmentLookupTable: null,
currentFragmentState: null, currentFragmentState: null,
fragments: [], fragments: [],
fragmentsWithKeyFrame: [],
editListPreviousSegmentDurations: 0, editListPreviousSegmentDurations: 0,
editListOffset: 0, editListOffset: 0,
} satisfies InternalTrack as InternalTrack; } satisfies InternalTrack as InternalTrack;
@@ -701,7 +705,10 @@ export class IsobmffDemuxer extends Demuxer {
} }
if (relevantEntryFound) { if (relevantEntryFound) {
throw new Error('Unsupported edit list: multiple edits are not supported.'); console.warn(
'Unsupported edit list: multiple edits are not currently supported. Only using first edit.',
);
break;
} }
if (mediaTime === -1) { if (mediaTime === -1) {
@@ -710,7 +717,8 @@ export class IsobmffDemuxer extends Demuxer {
} }
if (mediaRate !== 1) { if (mediaRate !== 1) {
throw new Error('Unsupported edit list: media rate must be 1.'); console.warn('Unsupported edit list entry: media rate must be 1.');
break;
} }
track.editListPreviousSegmentDurations = previousSegmentDurations; track.editListPreviousSegmentDurations = previousSegmentDurations;
@@ -942,7 +950,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (sampleSize === 16) { } else if (sampleSize === 16) {
track.info.codec = 'pcm-s16be'; track.info.codec = 'pcm-s16be';
} else { } else {
throw new Error(`Unsupported sample size ${sampleSize} for codec 'twos'.`); console.warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`);
track.info.codec = null;
} }
} else if (lowercaseBoxName === 'sowt') { } else if (lowercaseBoxName === 'sowt') {
if (sampleSize === 8) { if (sampleSize === 8) {
@@ -950,7 +959,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (sampleSize === 16) { } else if (sampleSize === 16) {
track.info.codec = 'pcm-s16'; track.info.codec = 'pcm-s16';
} else { } else {
throw new Error(`Unsupported sample size ${sampleSize} for codec 'sowt'.`); console.warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`);
track.info.codec = null;
} }
} else if (lowercaseBoxName === 'raw ') { } else if (lowercaseBoxName === 'raw ') {
track.info.codec = 'pcm-u8'; track.info.codec = 'pcm-u8';
@@ -1193,7 +1203,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 32) { } else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32'; track.info.codec = 'pcm-s32';
} else { } else {
throw new Error(`Invalid ipcm sample size ${pcmSampleSize}.`); console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} else { } else {
if (pcmSampleSize === 16) { if (pcmSampleSize === 16) {
@@ -1203,7 +1214,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 32) { } else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32be'; track.info.codec = 'pcm-s32be';
} else { } else {
throw new Error(`Invalid ipcm sample size ${pcmSampleSize}.`); console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} }
} else if (track.info.codec === 'pcm-f32be') { } else if (track.info.codec === 'pcm-f32be') {
@@ -1215,7 +1227,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 64) { } else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64'; track.info.codec = 'pcm-f64';
} else { } else {
throw new Error(`Invalid fpcm sample size ${pcmSampleSize}.`); console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} else { } else {
if (pcmSampleSize === 32) { if (pcmSampleSize === 32) {
@@ -1223,7 +1236,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 64) { } else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64be'; track.info.codec = 'pcm-f64be';
} else { } else {
throw new Error(`Invalid fpcm sample size ${pcmSampleSize}.`); console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} }
} }
@@ -1405,8 +1419,27 @@ export class IsobmffDemuxer extends Demuxer {
}; break; }; break;
case 'stz2': { case 'stz2': {
throw new Error('Unsupported.'); const track = this.currentTrack;
}; assert(track);
if (!track.sampleTable) {
break;
}
this.metadataReader.pos += 4; // Version + flags
this.metadataReader.pos += 3; // Reserved
const fieldSize = this.metadataReader.readU8(); // in bits
const sampleCount = this.metadataReader.readU32();
const bytes = this.metadataReader.readBytes(Math.ceil(sampleCount * fieldSize / 8));
const bitstream = new Bitstream(bytes);
for (let i = 0; i < sampleCount; i++) {
const sampleSize = bitstream.readBits(fieldSize);
track.sampleTable.sampleSizes.push(sampleSize);
}
}; break;
case 'stss': { case 'stss': {
const track = this.currentTrack; const track = this.currentTrack;
@@ -1635,6 +1668,16 @@ export class IsobmffDemuxer extends Demuxer {
); );
this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment); this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual(
this.currentTrack.fragmentsWithKeyFrame,
this.currentFragment.moofOffset,
x => x.moofOffset,
);
this.currentTrack.fragmentsWithKeyFrame.splice(insertionIndex + 1, 0, this.currentFragment);
}
const { currentFragmentState } = this.currentTrack; const { currentFragmentState } = this.currentTrack;
assert(currentFragmentState); assert(currentFragmentState);
@@ -1733,7 +1776,8 @@ export class IsobmffDemuxer extends Demuxer {
assert(track.currentFragmentState); assert(track.currentFragmentState);
if (this.currentFragment.trackData.has(track.id)) { if (this.currentFragment.trackData.has(track.id)) {
throw new Error('Can\'t have two trun boxes for the same track in one fragment.'); console.warn('Can\'t have two trun boxes for the same track in one fragment. Ignoring...');
break;
} }
const version = this.metadataReader.readU8(); const version = this.metadataReader.readU8();
@@ -1770,6 +1814,7 @@ export class IsobmffDemuxer extends Demuxer {
const trackData: FragmentTrackData = { const trackData: FragmentTrackData = {
startTimestamp: 0, startTimestamp: 0,
endTimestamp: 0, endTimestamp: 0,
firstKeyFrameTimestamp: null,
samples: [], samples: [],
presentationTimestamps: [], presentationTimestamps: [],
startTimestampIsFinal: false, startTimestampIsFinal: false,
@@ -1831,13 +1876,19 @@ export class IsobmffDemuxer extends Demuxer {
.map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i })) .map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i }))
.sort((a, b) => a.presentationTimestamp - b.presentationTimestamp); .sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);
// Update sample durations based on presentation order for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
for (let i = 0; i < trackData.presentationTimestamps.length - 1; i++) { const currentEntry = trackData.presentationTimestamps[i]!;
const current = trackData.presentationTimestamps[i]!; const currentSample = trackData.samples[currentEntry.sampleIndex]!;
const next = trackData.presentationTimestamps[i + 1]!;
const duration = next.presentationTimestamp - current.presentationTimestamp; if (trackData.firstKeyFrameTimestamp === null && currentSample.isKeyFrame) {
trackData.samples[current.sampleIndex]!.duration = duration; trackData.firstKeyFrameTimestamp = currentSample.presentationTimestamp;
}
if (i < trackData.presentationTimestamps.length - 1) {
// Update sample durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1]!;
currentSample.duration = nextEntry.presentationTimestamp - currentEntry.presentationTimestamp;
}
} }
const firstSample = trackData.samples[trackData.presentationTimestamps[0]!.sampleIndex]!; const firstSample = trackData.samples[trackData.presentationTimestamps[0]!.sampleIndex]!;
@@ -2074,13 +2125,13 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
correctSampleFound: true, correctSampleFound: true,
}; };
} else { } else {
// Walk the list of fragments until we find the next fragment for this track // Walk the list of fragments until we find the next fragment for this track with a key frame
let currentFragment = locationInFragment.fragment; let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) { while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment; currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id); const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) { if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const fragmentIndex = binarySearchExact( const fragmentIndex = binarySearchExact(
this.internalTrack.fragments, this.internalTrack.fragments,
currentFragment.moofOffset, currentFragment.moofOffset,
@@ -2089,9 +2140,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
assert(fragmentIndex !== -1); assert(fragmentIndex !== -1);
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame); const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
if (keyFrameIndex === -1) { assert(keyFrameIndex !== -1); // There must be one
throw new Error('Not supported: Fragment does not contain key sample.');
}
return { return {
fragmentIndex, fragmentIndex,
@@ -2231,26 +2280,34 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
} }
private findKeySampleInFragmentsForTimestamp(timestampInTimescale: number) { private findKeySampleInFragmentsForTimestamp(timestampInTimescale: number) {
const fragmentIndex = binarySearchLessOrEqual( const indexInKeyFrameFragments = binarySearchLessOrEqual(
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is. // This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
this.internalTrack.fragments, this.internalTrack.fragmentsWithKeyFrame,
timestampInTimescale, timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.startTimestamp, x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
); );
let fragmentIndex = -1;
let sampleIndex = -1; let sampleIndex = -1;
let correctSampleFound = false; let correctSampleFound = false;
if (fragmentIndex !== -1) { if (indexInKeyFrameFragments !== -1) {
const fragment = this.internalTrack.fragments[fragmentIndex]!; const fragment = this.internalTrack.fragmentsWithKeyFrame[indexInKeyFrameFragments]!;
// Now, let's find the actual index of the fragment in the list of ALL fragments, not just key frame ones
fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
const trackData = fragment.trackData.get(this.internalTrack.id)!; const trackData = fragment.trackData.get(this.internalTrack.id)!;
const index = findLastIndex(trackData.presentationTimestamps, (x) => { const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const sample = trackData.samples[x.sampleIndex]!; const sample = trackData.samples[x.sampleIndex]!;
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale; return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
}); });
assert(index !== -1); // It's a key frame fragment, so there must be a key frame
if (index === -1) {
throw new Error('Not supported: Fragment does not begin with a key sample.');
}
const entry = trackData.presentationTimestamps[index]!; const entry = trackData.presentationTimestamps[index]!;
sampleIndex = entry.sampleIndex; sampleIndex = entry.sampleIndex;
+7 -12
View File
@@ -484,24 +484,18 @@ export class MatroskaDemuxer extends Demuxer {
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i })) .map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
let hasKeyFrame = false;
for (let i = 0; i < trackData.presentationTimestamps.length; i++) { for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
const entry = trackData.presentationTimestamps[i]!; const currentEntry = trackData.presentationTimestamps[i]!;
const block = trackData.blocks[entry.blockIndex]!; const currentBlock = trackData.blocks[currentEntry.blockIndex]!;
if (block.isKeyFrame) { if (trackData.firstKeyFrameTimestamp === null && currentBlock.isKeyFrame) {
hasKeyFrame = true; trackData.firstKeyFrameTimestamp = currentBlock.timestamp;
if (trackData.firstKeyFrameTimestamp === null && block.isKeyFrame) {
trackData.firstKeyFrameTimestamp = block.timestamp;
}
} }
if (i < trackData.presentationTimestamps.length - 1) { if (i < trackData.presentationTimestamps.length - 1) {
// Update block durations based on presentation order // Update block durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1]!; const nextEntry = trackData.presentationTimestamps[i + 1]!;
const nextBlock = trackData.blocks[nextEntry.blockIndex]!; currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
block.duration = nextBlock.timestamp - block.timestamp;
} }
} }
@@ -520,6 +514,7 @@ export class MatroskaDemuxer extends Demuxer {
); );
track.clusters.splice(insertionIndex + 1, 0, cluster); track.clusters.splice(insertionIndex + 1, 0, cluster);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) { if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual( const insertionIndex = binarySearchLessOrEqual(
track.clustersWithKeyFrame, track.clustersWithKeyFrame,
@@ -1210,7 +1205,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
correctBlockFound: true, correctBlockFound: true,
}; };
} else { } else {
// Walk the list of clusters until we find the next cluster for this track // Walk the list of clusters until we find the next cluster for this track with a key frame
let currentCluster = locationInCluster.cluster; let currentCluster = locationInCluster.cluster;
while (currentCluster.nextCluster) { while (currentCluster.nextCluster) {
currentCluster = currentCluster.nextCluster; currentCluster = currentCluster.nextCluster;
+4
View File
@@ -85,6 +85,10 @@ export const readFrameHeader = (word: number, reader: { pos: number; fileSize: n
reader.pos += 1; reader.pos += 1;
if (firstByte !== 0xff) {
return null;
}
if ((secondByte & 0xe0) !== 0xe0) { if ((secondByte & 0xe0) !== 0xe0) {
return null; return null;
} }
+1 -1
View File
@@ -65,7 +65,7 @@ export class Mp3Reader {
assert(this.fileSize); assert(this.fileSize);
until ??= this.fileSize; until ??= this.fileSize;
while (this.pos < until - FRAME_HEADER_SIZE) { while (this.pos <= until - FRAME_HEADER_SIZE) {
const word = this.readU32(); const word = this.readU32();
this.pos -= 4; this.pos -= 4;