Merge branch 'main' into stop-retrying-on-cors-error

This commit is contained in:
David P.
2025-10-02 10:59:54 +02:00
committed by GitHub
7 changed files with 80 additions and 49 deletions
+2 -1
View File
@@ -2,8 +2,9 @@ name: Lint
on:
push:
branches:
- main
pull_request:
types: [opened, reopened]
jobs:
lint:
+2 -1
View File
@@ -2,8 +2,9 @@ name: Test
on:
push:
branches:
- main
pull_request:
types: [opened, reopened]
jobs:
test:
+3
View File
@@ -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
+1
View File
@@ -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' },
+17 -1
View File
@@ -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),
+18 -7
View File
@@ -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,
+37 -39
View File
@@ -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<number>();
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);
}