diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 31d25a7..4fc52dd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,8 +2,9 @@ name: Lint on: push: + branches: + - main pull_request: - types: [opened, reopened] jobs: lint: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2538950..c689d63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,8 +2,9 @@ name: Test on: push: + branches: + - main pull_request: - types: [opened, reopened] jobs: test: diff --git a/docs/guide/converting-media-files.md b/docs/guide/converting-media-files.md index 58b6bce..8a3daa4 100644 --- a/docs/guide/converting-media-files.md +++ b/docs/guide/converting-media-files.md @@ -124,6 +124,7 @@ type ConversionVideoOptions = { codec?: VideoCodec; bitrate?: number | Quality; alpha?: 'discard' | 'keep'; // Defaults to 'discard' + keyFrameInterval?: number; forceTranscode?: boolean; }; ``` @@ -180,6 +181,8 @@ Use the `codec` property to control the codec of the output track. This should b Use the `bitrate` property to control the bitrate of the output video. For example, you can use this field to compress the video track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value. +Use the `keyFrameInterval` property to control the maximum interval in seconds between key frames in the output video. Setting this fields forces a transcode. + If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`. ## Audio options diff --git a/docs/index.md b/docs/index.md index 9da3ad8..700a077 100644 --- a/docs/index.md +++ b/docs/index.md @@ -109,6 +109,7 @@ const sponsors = { { image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' }, { image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' }, { image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' }, + { image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' }, { image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' }, { image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' }, { image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' }, diff --git a/src/conversion.ts b/src/conversion.ts index 69cb82e..8b75826 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -166,6 +166,14 @@ export type ConversionVideoOptions = { * VP9. */ alpha?: 'discard' | 'keep'; + /** + * The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key + * frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them + * all the same key frame interval. + * + * Setting this fields forces a transcode. + */ + keyFrameInterval?: number; /** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */ forceTranscode?: boolean; }; @@ -252,6 +260,12 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) if (videoOptions?.alpha !== undefined && !['discard', 'keep'].includes(videoOptions.alpha)) { throw new TypeError('options.video.alpha, when provided, must be either \'discard\' or \'keep\'.'); } + if ( + videoOptions?.keyFrameInterval !== undefined + && (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0) + ) { + throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.'); + } }; const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => { @@ -775,7 +789,8 @@ export class Conversion { const needsTranscode = !!trackOptions.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0 - || !!trackOptions.frameRate; + || !!trackOptions.frameRate + || trackOptions.keyFrameInterval !== undefined; let needsRerender = width !== originalWidth || height !== originalHeight || (totalRotation !== 0 && !outputSupportsRotation) @@ -858,6 +873,7 @@ export class Conversion { const encodingConfig: VideoEncodingConfig = { codec: encodableCodec, bitrate, + keyFrameInterval: trackOptions.keyFrameInterval, sizeChangeBehavior: trackOptions.fit ?? 'passThrough', alpha, onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration), diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index 6c7de90..e1d047c 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -539,14 +539,9 @@ export class IsobmffDemuxer extends Demuxer { // lookup starts sequentially from the start, incrementally summing up all fragment durations. It's sort // of implicit, but it ends up working nicely. - const lookupEntryIndex = binarySearchExact( - track.fragmentLookupTable, - fragment.moofOffset, - x => x.moofOffset, - ); - if (lookupEntryIndex !== -1) { + const lookupEntry = track.fragmentLookupTable.find(x => x.moofOffset === fragment.moofOffset); + if (lookupEntry) { // There's a lookup entry, let's use its timestamp - const lookupEntry = track.fragmentLookupTable[lookupEntryIndex]!; offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp); } else { const lastCacheIndex = binarySearchLessOrEqual( @@ -1742,6 +1737,20 @@ export class IsobmffDemuxer extends Demuxer { moofOffset, }); } + + // Sort by timestamp in case it's not naturally sorted + track.fragmentLookupTable.sort((a, b) => a.timestamp - b.timestamp); + + // Remove multiple entries for the same time + for (let i = 0; i < track.fragmentLookupTable.length - 1; i++) { + const entry1 = track.fragmentLookupTable[i]!; + const entry2 = track.fragmentLookupTable[i + 1]!; + + if (entry1.timestamp === entry2.timestamp) { + track.fragmentLookupTable.splice(i + 1, 1); + i--; + } + } }; break; case 'moof': { @@ -2747,6 +2756,8 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { // The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied // the match. In this case, let's search again but using the lookup entry before that. const previousLookupEntry = this.internalTrack.fragmentLookupTable[lookupEntryIndex - 1]; + assert(!previousLookupEntry || previousLookupEntry.timestamp < lookupEntry.timestamp); + const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity; return this.performFragmentedLookup( null, diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 865745f..bc1fdc2 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -541,53 +541,49 @@ export class MatroskaDemuxer extends Demuxer { // Put default tracks first this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); - // Sort cue points by cluster position (required for the next algorithm) - this.currentSegment.cuePoints.sort((a, b) => a.clusterPosition - b.clusterPosition); - - // Now, let's distribute the cue points to each track. Ideally, each track has their own cue point, but some - // Matroska files may only specify cue points for a single track. In this case, we still wanna use those cue - // points for all tracks. - const allTrackIds = this.currentSegment.tracks.map(x => x.id); - const remainingTrackIds = new Set(); - let lastClusterPosition: number | null = null; - let lastCuePoint: CuePoint | null = null; + // Now, let's distribute the cue points to the tracks + const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x])); + // Assign cue points to their respective tracks for (const cuePoint of this.currentSegment.cuePoints) { - if (cuePoint.clusterPosition !== lastClusterPosition) { - for (const id of remainingTrackIds) { - // These tracks didn't receive a cue point for the last cluster, so let's give them one - assert(lastCuePoint); - const track = this.currentSegment.tracks.find(x => x.id === id)!; - track.cuePoints.push(lastCuePoint); - } - - for (const id of allTrackIds) { - remainingTrackIds.add(id); - } + const track = idToTrack.get(cuePoint.trackId); + if (track) { + track.cuePoints.push(cuePoint); } - - lastCuePoint = cuePoint; - - if (!remainingTrackIds.has(cuePoint.trackId)) { - continue; - } - - const track = this.currentSegment.tracks.find(x => x.id === cuePoint.trackId)!; - track.cuePoints.push(cuePoint); - - remainingTrackIds.delete(cuePoint.trackId); - lastClusterPosition = cuePoint.clusterPosition; - } - - for (const id of remainingTrackIds) { - assert(lastCuePoint); - const track = this.currentSegment.tracks.find(x => x.id === id)!; - track.cuePoints.push(lastCuePoint); } for (const track of this.currentSegment.tracks) { // Sort cue points by time track.cuePoints.sort((a, b) => a.time - b.time); + + // Remove multiple cue points for the same time + for (let i = 0; i < track.cuePoints.length - 1; i++) { + const cuePoint1 = track.cuePoints[i]!; + const cuePoint2 = track.cuePoints[i + 1]!; + + if (cuePoint1.time === cuePoint2.time) { + track.cuePoints.splice(i + 1, 1); + i--; + } + } + } + + let trackWithMostCuePoints: InternalTrack | null = null; + let maxCuePointCount = -Infinity; + for (const track of this.currentSegment.tracks) { + if (track.cuePoints.length > maxCuePointCount) { + maxCuePointCount = track.cuePoints.length; + trackWithMostCuePoints = track; + } + } + + // For every track that has received 0 cue points (can happen, often only the video track receives cue points), + // we still want to have better seeking. Therefore, let's give it the cue points of the track with the most cue + // points, which should provide us with the most fine-grained seeking. + for (const track of this.currentSegment.tracks) { + if (track.cuePoints.length === 0) { + track.cuePoints = trackWithMostCuePoints!.cuePoints; + } } this.currentSegment = null; @@ -2239,6 +2235,8 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { // The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this // case, let's search again but using the cue point before that. const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1]; + assert(!previousCuePoint || previousCuePoint.time < cuePoint.time); + const newSearchTimestamp = previousCuePoint?.time ?? -Infinity; return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options); }