From 6cdaa06c077317823a7343b5b4028783cc3ff725 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:21:41 +0100 Subject: [PATCH] Add limited edit list support --- dev/convert.html | 7 +++- src/conversion.ts | 17 ++++++---- src/isobmff/isobmff-demuxer.ts | 60 ++++++++++++++++++++++++++++++---- todo.txt | 1 - 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index 4af9517..697c30c 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -28,6 +28,9 @@ format: outputFormat, target }), + audio: { + //discard: true + }, /* video: { discard: true, @@ -43,6 +46,7 @@ codec: 'aac', }, */ + /* video: { codec: 'avc', }, @@ -50,9 +54,10 @@ codec: 'mp3', bitrate: 320000 }, + */ trim: { start: 0, - end: 60 + end: 20 }, onProgress: (event) => { progress.value = event.completion; diff --git a/src/conversion.ts b/src/conversion.ts index 51eb3a5..c7c7057 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -341,7 +341,8 @@ class Conversion { height = ceilToMultipleOfTwo(this.options.video.height); } - const needsReencode = !!this.options.video?.forceReencode || this.startTimestamp > 0; + const firstTimestamp = await track.getFirstTimestamp(); + const needsReencode = !!this.options.video?.forceReencode || this.startTimestamp > 0 || firstTimestamp < 0; const needsResize = width !== originalWidth || height !== originalHeight; let videoCodecs = this.output.format.getSupportedVideoCodecs(); @@ -513,11 +514,14 @@ class Conversion { const originalNumberOfChannels = track.numberOfChannels; const originalSampleRate = track.sampleRate; + const firstTimestamp = await track.getFirstTimestamp(); + let numberOfChannels = this.options.audio?.numberOfChannels ?? originalNumberOfChannels; let sampleRate = this.options.audio?.sampleRate ?? originalSampleRate; let needsResample = numberOfChannels !== originalNumberOfChannels || sampleRate !== originalSampleRate - || this.startTimestamp > 0; + || this.startTimestamp > 0 + || firstTimestamp < 0; let audioCodecs = this.output.format.getSupportedAudioCodecs(); if ( @@ -671,13 +675,12 @@ class Conversion { await track.computeDuration() - this.startTimestamp, this.endTimestamp - this.startTimestamp, ); - const totalFrameCount = Math.round(trackDuration * FALLBACK_SAMPLE_RATE); - - const MAX_CHUNK_LENGTH = 5 * FALLBACK_SAMPLE_RATE; + const totalFrameCount = Math.round(trackDuration * targetSampleRate); + const maxChunkLength = 5 * targetSampleRate; let currentContextStartFrame = 0; let currentContext: OfflineAudioContext | null = new OfflineAudioContext({ - length: Math.min(totalFrameCount - currentContextStartFrame, MAX_CHUNK_LENGTH), + length: Math.min(totalFrameCount - currentContextStartFrame, maxChunkLength), numberOfChannels: targetNumberOfChannels, sampleRate: targetSampleRate, }); @@ -719,7 +722,7 @@ class Conversion { const newLength = Math.min( totalFrameCount - currentContextStartFrame, - MAX_CHUNK_LENGTH, + maxChunkLength, ); currentContext = newLength > 0 ? new OfflineAudioContext({ diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 8362169..8985e8a 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -57,6 +57,7 @@ type InternalTrack = { fragmentLookupTable: FragmentLookupTableEntry[] | null; currentFragmentState: FragmentTrackState | null; fragments: Fragment[]; + editListOffset: number; } & ({ info: null; } | { @@ -547,7 +548,8 @@ export class IsobmffDemuxer extends Demuxer { case 'mdia': case 'minf': case 'dinf': - case 'mfra': { + case 'mfra': + case 'edts': { this.readContiguousBoxes(boxInfo.contentSize); }; break; @@ -582,6 +584,7 @@ export class IsobmffDemuxer extends Demuxer { fragmentLookupTable: null, currentFragmentState: null, fragments: [], + editListOffset: 0, } satisfies InternalTrack as InternalTrack; this.currentTrack = track; @@ -654,6 +657,47 @@ export class IsobmffDemuxer extends Demuxer { } }; break; + case 'elst': { + const track = this.currentTrack; + assert(track); + + const version = this.metadataReader.readU8(); + this.metadataReader.pos += 3; // Flags + + let relevantEntryFound = false; + + const entryCount = this.metadataReader.readU32(); + for (let i = 0; i < entryCount; i++) { + const segmentDuration = version === 1 + ? this.metadataReader.readU64() + : this.metadataReader.readU32(); + const mediaTime = version === 1 + ? this.metadataReader.readI64() + : this.metadataReader.readI32(); + const mediaRate = this.metadataReader.readFixed_16_16(); + + if (segmentDuration === 0) { + // Don't care + continue; + } + + if (relevantEntryFound) { + throw new Error('Unsupported edit list: multiple edits are not supported.'); + } + + if (mediaTime === -1) { + throw new Error('Unsupported edit list: no empty edits allowed.'); + } + + if (mediaRate !== 1) { + throw new Error('Unsupported edit list: media rate must be 1.'); + } + + track.editListOffset = mediaTime; + relevantEntryFound = true; + } + }; break; + case 'mdhd': { const track = this.currentTrack; assert(track); @@ -1796,15 +1840,15 @@ abstract class IsobmffTrackBacking< return this.fetchSampleForSampleIndex(0, options); } - private intoTimescale(timestamp: number) { + private mapTimestampIntoTimescale(timestamp: number) { // Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely // that the number was originally an integer divided by the timescale. For stability, it's best // to return the integer in this case. - return roundToPrecision(timestamp * this.internalTrack.timescale, 14); + return roundToPrecision(timestamp * this.internalTrack.timescale, 14) + this.internalTrack.editListOffset; } async getSample(timestamp: number, options: SampleRetrievalOptions) { - const timestampInTimescale = this.intoTimescale(timestamp); + const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp); if (this.internalTrack.demuxer.isFragmented) { return this.performFragmentedLookup( @@ -1890,7 +1934,7 @@ abstract class IsobmffTrackBacking< } async getKeySample(timestamp: number, options: SampleRetrievalOptions) { - const timestampInTimescale = this.intoTimescale(timestamp); + const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp); if (this.internalTrack.demuxer.isFragmented) { return this.performFragmentedLookup( @@ -2014,7 +2058,8 @@ abstract class IsobmffTrackBacking< data = this.internalTrack.demuxer.chunkReader.readBytes(sampleInfo.sampleSize); } - const timestamp = sampleInfo.presentationTimestamp / this.internalTrack.timescale; + const timestamp = (sampleInfo.presentationTimestamp - this.internalTrack.editListOffset) + / this.internalTrack.timescale; const duration = sampleInfo.duration / this.internalTrack.timescale; const sample = this.createSample( data, @@ -2049,7 +2094,8 @@ abstract class IsobmffTrackBacking< data = this.internalTrack.demuxer.chunkReader.readBytes(fragmentSample.byteSize); } - const timestamp = fragmentSample.presentationTimestamp / this.internalTrack.timescale; + const timestamp = (fragmentSample.presentationTimestamp - this.internalTrack.editListOffset) + / this.internalTrack.timescale; const duration = fragmentSample.duration / this.internalTrack.timescale; const sample = this.createSample( data, diff --git a/todo.txt b/todo.txt index fcb946a..c37bc4b 100644 --- a/todo.txt +++ b/todo.txt @@ -1,4 +1,3 @@ -- Throw if edit list detected - A stream source?? Or like a callback-driven source - onHeader, etc callbacks for Matroska - https://github.com/Vanilagy/mp4-muxer/issues/83 tell him it's possible now \ No newline at end of file