mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-10 09:13:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2a23bcb0 | ||
|
|
b63d86226f | ||
|
|
3371813c33 | ||
|
|
1fe115ac83 | ||
|
|
7e42e298c5 | ||
|
|
597b68b299 | ||
|
|
f5803598e8 | ||
|
|
b98d8e58ca | ||
|
|
dd60ca71b3 | ||
|
|
4b99fd08ca | ||
|
|
5b238c6d0f | ||
|
|
2f83e07a55 | ||
|
|
1aaded9241 | ||
|
|
5a6b849ff8 | ||
|
|
b60d4cafed | ||
|
|
95091cce9c |
@@ -15,6 +15,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Merge main into release branch
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git checkout release
|
||||||
|
git merge origin/main --no-ff -m "Merge main into release for tag ${{ github.event.release.tag_name }}"
|
||||||
|
git push origin release
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
+4
-2
@@ -70,8 +70,10 @@
|
|||||||
},
|
},
|
||||||
*/
|
*/
|
||||||
video: {
|
video: {
|
||||||
forceTranscode: true,
|
frameRate: 27.123,
|
||||||
codec: 'av1',
|
//width: 320,
|
||||||
|
//forceTranscode: true,
|
||||||
|
//codec: 'av1',
|
||||||
//discard: true,
|
//discard: true,
|
||||||
//width: 1280,
|
//width: 1280,
|
||||||
//discard: true,
|
//discard: true,
|
||||||
|
|||||||
@@ -16,6 +16,28 @@
|
|||||||
source
|
source
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack();
|
||||||
|
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||||
|
|
||||||
|
let thing = await sink.getFirstPacket();
|
||||||
|
while (thing) {
|
||||||
|
console.log(thing);
|
||||||
|
if (thing.timestamp >= 2.4) break;
|
||||||
|
|
||||||
|
thing = await sink.getNextPacket(thing);
|
||||||
|
|
||||||
|
}
|
||||||
|
console.log("done")
|
||||||
|
|
||||||
|
/*
|
||||||
|
for await (const packet of sink.packets()) {
|
||||||
|
console.log(packet);
|
||||||
|
|
||||||
|
if (packet.timestamp >= 2.4) break;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
const packetSink = new Mediabunny.EncodedPacketSink(videoTrack);
|
const packetSink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||||
const sampleSink = new Mediabunny.VideoSampleSink(videoTrack);
|
const sampleSink = new Mediabunny.VideoSampleSink(videoTrack);
|
||||||
@@ -29,6 +51,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("don")
|
console.log("don")
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
console.time()
|
console.time()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ It has the following features:
|
|||||||
- Trimming
|
- Trimming
|
||||||
- Video resizing & fitting
|
- Video resizing & fitting
|
||||||
- Video rotation
|
- Video rotation
|
||||||
|
- Video frame rate adjustment
|
||||||
- Audio resampling
|
- Audio resampling
|
||||||
- Audio up/downmixing
|
- Audio up/downmixing
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ type ConversionOptions = {
|
|||||||
height?: number;
|
height?: number;
|
||||||
fit?: 'fill' | 'contain' | 'cover';
|
fit?: 'fill' | 'contain' | 'cover';
|
||||||
rotate?: 0 | 90 | 180 | 270;
|
rotate?: 0 | 90 | 180 | 270;
|
||||||
|
frameRate?: number;
|
||||||
codec?: VideoCodec;
|
codec?: VideoCodec;
|
||||||
bitrate?: number | Quality;
|
bitrate?: number | Quality;
|
||||||
forceTranscode?: boolean;
|
forceTranscode?: boolean;
|
||||||
@@ -141,6 +143,10 @@ The `width`, `height` and `fit` properties control how the video is resized. If
|
|||||||
|
|
||||||
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
|
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
|
||||||
|
|
||||||
|
### Adjusting frame rate
|
||||||
|
|
||||||
|
The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable).
|
||||||
|
|
||||||
### Transcoding video
|
### Transcoding video
|
||||||
|
|
||||||
Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#video-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks).
|
Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#video-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks).
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ const output = new Output({
|
|||||||
const uploadComplete = fetch('https://example.com/upload', {
|
const uploadComplete = fetch('https://example.com/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: readable,
|
body: readable,
|
||||||
|
duplex: 'half',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': output.format.mimeType,
|
'Content-Type': output.format.mimeType,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const compressFile = async (file: File) => {
|
|||||||
|
|
||||||
// Display the final media file
|
// Display the final media file
|
||||||
videoElement.style.display = '';
|
videoElement.style.display = '';
|
||||||
videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!]));
|
videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!], { type: output.format.mimeType }));
|
||||||
void videoElement.play();
|
void videoElement.play();
|
||||||
|
|
||||||
compressionFacts.style.display = '';
|
compressionFacts.style.display = '';
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ const generateVideo = async () => {
|
|||||||
videoInfo.style.display = '';
|
videoInfo.style.display = '';
|
||||||
|
|
||||||
// Display and play the resulting media file
|
// Display and play the resulting media file
|
||||||
const videoBlob = new Blob([output.target.buffer!], { type: 'video/mp4' });
|
const videoBlob = new Blob([output.target.buffer!], { type: output.format.mimeType });
|
||||||
resultVideo.src = URL.createObjectURL(videoBlob);
|
resultVideo.src = URL.createObjectURL(videoBlob);
|
||||||
void resultVideo.play();
|
void resultVideo.play();
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"author": "Vanilagy",
|
"author": "Vanilagy",
|
||||||
"version": "1.4.2",
|
"version": "1.5.0",
|
||||||
"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",
|
||||||
|
|||||||
+143
-10
@@ -77,6 +77,11 @@ export type ConversionOptions = {
|
|||||||
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
||||||
*/
|
*/
|
||||||
rotate?: Rotation;
|
rotate?: Rotation;
|
||||||
|
/**
|
||||||
|
* The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will
|
||||||
|
* be used (which may be variable).
|
||||||
|
*/
|
||||||
|
frameRate?: number;
|
||||||
/** The desired output video codec. */
|
/** The desired output video codec. */
|
||||||
codec?: VideoCodec;
|
codec?: VideoCodec;
|
||||||
/** The desired bitrate of the output video. */
|
/** The desired bitrate of the output video. */
|
||||||
@@ -261,8 +266,14 @@ export class Conversion {
|
|||||||
if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) {
|
if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) {
|
||||||
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
options.video?.frameRate !== undefined
|
||||||
|
&& (!Number.isFinite(options.video.frameRate) || options.video.frameRate <= 0)
|
||||||
|
) {
|
||||||
|
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
|
||||||
|
}
|
||||||
if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
|
if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
|
||||||
throw new TypeError('options.video, when provided, must be an object.');
|
throw new TypeError('options.audio, when provided, must be an object.');
|
||||||
}
|
}
|
||||||
if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
|
if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
|
||||||
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
|
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
|
||||||
@@ -470,7 +481,10 @@ export class Conversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstTimestamp = await track.getFirstTimestamp();
|
const firstTimestamp = await track.getFirstTimestamp();
|
||||||
const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0;
|
const needsTranscode = !!this._options.video?.forceTranscode
|
||||||
|
|| this._startTimestamp > 0
|
||||||
|
|| firstTimestamp < 0
|
||||||
|
|| !!this._options.video?.frameRate;
|
||||||
const needsRerender = width !== originalWidth
|
const needsRerender = width !== originalWidth
|
||||||
|| height !== originalHeight
|
|| height !== originalHeight
|
||||||
|| (totalRotation !== 0 && !outputSupportsRotation);
|
|| (totalRotation !== 0 && !outputSupportsRotation);
|
||||||
@@ -547,10 +561,10 @@ export class Conversion {
|
|||||||
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (needsRerender) {
|
|
||||||
const source = new VideoSampleSource(encodingConfig);
|
const source = new VideoSampleSource(encodingConfig);
|
||||||
videoSource = source;
|
videoSource = source;
|
||||||
|
|
||||||
|
if (needsRerender) {
|
||||||
this._trackPromises.push((async () => {
|
this._trackPromises.push((async () => {
|
||||||
await this._started;
|
await this._started;
|
||||||
|
|
||||||
@@ -562,6 +576,27 @@ export class Conversion {
|
|||||||
poolSize: 1,
|
poolSize: 1,
|
||||||
});
|
});
|
||||||
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
||||||
|
const frameRate = this._options.video?.frameRate;
|
||||||
|
|
||||||
|
let lastCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
|
||||||
|
let lastCanvasTimestamp: number | null = null;
|
||||||
|
let lastCanvasEndTimestamp: number | null = null;
|
||||||
|
|
||||||
|
/** Repeats the last sample to pad out the time until the specified timestamp. */
|
||||||
|
const padFrames = async (until: number) => {
|
||||||
|
assert(lastCanvas);
|
||||||
|
assert(frameRate !== undefined);
|
||||||
|
|
||||||
|
const frameDifference = Math.round((until - lastCanvasTimestamp!) * frameRate);
|
||||||
|
|
||||||
|
for (let i = 1; i < frameDifference; i++) {
|
||||||
|
const sample = new VideoSample(lastCanvas, {
|
||||||
|
timestamp: lastCanvasTimestamp! + i / frameRate,
|
||||||
|
duration: 1 / frameRate,
|
||||||
|
});
|
||||||
|
await source.add(sample);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for await (const { canvas, timestamp, duration } of iterator) {
|
for await (const { canvas, timestamp, duration } of iterator) {
|
||||||
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
||||||
@@ -572,38 +607,135 @@ export class Conversion {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
|
||||||
|
lastCanvasEndTimestamp = timestamp + duration;
|
||||||
|
|
||||||
|
if (frameRate !== undefined) {
|
||||||
|
// Logic for skipping/repeating frames when a frame rate is set
|
||||||
|
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
|
||||||
|
|
||||||
|
if (lastCanvas !== null) {
|
||||||
|
if (alignedTimestamp <= lastCanvasTimestamp!) {
|
||||||
|
lastCanvas = canvas;
|
||||||
|
lastCanvasTimestamp = alignedTimestamp;
|
||||||
|
|
||||||
|
// Skip this sample, since we already added one for this frame
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Check if we may need to repeat the previous frame
|
||||||
|
await padFrames(alignedTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustedSampleTimestamp = alignedTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
const sample = new VideoSample(canvas, {
|
const sample = new VideoSample(canvas, {
|
||||||
timestamp: Math.max(timestamp - this._startTimestamp, 0),
|
timestamp: adjustedSampleTimestamp,
|
||||||
duration,
|
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
await source.add(sample);
|
await source.add(sample);
|
||||||
|
|
||||||
|
if (frameRate !== undefined) {
|
||||||
|
lastCanvas = canvas;
|
||||||
|
lastCanvasTimestamp = adjustedSampleTimestamp;
|
||||||
|
} else {
|
||||||
sample.close();
|
sample.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCanvas) {
|
||||||
|
assert(lastCanvasEndTimestamp !== null);
|
||||||
|
assert(frameRate !== undefined);
|
||||||
|
|
||||||
|
// If necessary, pad until the end timestamp of the last sample
|
||||||
|
await padFrames(Math.floor(lastCanvasEndTimestamp * frameRate) / frameRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
source.close();
|
||||||
|
this._synchronizer.closeTrack(track.id);
|
||||||
})());
|
})());
|
||||||
} else {
|
} else {
|
||||||
const source = new VideoSampleSource(encodingConfig);
|
|
||||||
videoSource = source;
|
|
||||||
|
|
||||||
this._trackPromises.push((async () => {
|
this._trackPromises.push((async () => {
|
||||||
await this._started;
|
await this._started;
|
||||||
|
|
||||||
const sink = new VideoSampleSink(track);
|
const sink = new VideoSampleSink(track);
|
||||||
|
const frameRate = this._options.video?.frameRate;
|
||||||
|
|
||||||
|
let lastSample: VideoSample | null = null;
|
||||||
|
let lastSampleTimestamp: number | null = null;
|
||||||
|
let lastSampleEndTimestamp: number | null = null;
|
||||||
|
|
||||||
|
/** Repeats the last sample to pad out the time until the specified timestamp. */
|
||||||
|
const padFrames = async (until: number) => {
|
||||||
|
assert(lastSample);
|
||||||
|
assert(frameRate !== undefined);
|
||||||
|
|
||||||
|
const frameDifference = Math.round((until - lastSampleTimestamp!) * frameRate);
|
||||||
|
|
||||||
|
for (let i = 1; i < frameDifference; i++) {
|
||||||
|
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
|
||||||
|
lastSample.setDuration(1 / frameRate);
|
||||||
|
await source.add(lastSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSample.close();
|
||||||
|
};
|
||||||
|
|
||||||
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
||||||
await this._synchronizer.wait(sample.timestamp);
|
await this._synchronizer.wait(sample.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
sample.setTimestamp(Math.max(sample.timestamp - this._startTimestamp, 0));
|
|
||||||
|
|
||||||
if (this._canceled) {
|
if (this._canceled) {
|
||||||
|
lastSample?.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
||||||
|
lastSampleEndTimestamp = sample.timestamp + sample.duration;
|
||||||
|
|
||||||
|
if (frameRate !== undefined) {
|
||||||
|
// Logic for skipping/repeating frames when a frame rate is set
|
||||||
|
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
|
||||||
|
|
||||||
|
if (lastSample !== null) {
|
||||||
|
if (alignedTimestamp <= lastSampleTimestamp!) {
|
||||||
|
lastSample.close();
|
||||||
|
lastSample = sample;
|
||||||
|
lastSampleTimestamp = alignedTimestamp;
|
||||||
|
|
||||||
|
// Skip this sample, since we already added one for this frame
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Check if we may need to repeat the previous frame
|
||||||
|
await padFrames(alignedTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustedSampleTimestamp = alignedTimestamp;
|
||||||
|
sample.setDuration(1 / frameRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
sample.setTimestamp(adjustedSampleTimestamp);
|
||||||
await source.add(sample);
|
await source.add(sample);
|
||||||
|
|
||||||
|
if (frameRate !== undefined) {
|
||||||
|
lastSample = sample;
|
||||||
|
lastSampleTimestamp = adjustedSampleTimestamp;
|
||||||
|
} else {
|
||||||
sample.close();
|
sample.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSample) {
|
||||||
|
assert(lastSampleEndTimestamp !== null);
|
||||||
|
assert(frameRate !== undefined);
|
||||||
|
|
||||||
|
// If necessary, pad until the end timestamp of the last sample
|
||||||
|
await padFrames(Math.floor(lastSampleEndTimestamp * frameRate) / frameRate);
|
||||||
|
}
|
||||||
|
|
||||||
source.close();
|
source.close();
|
||||||
this._synchronizer.closeTrack(track.id);
|
this._synchronizer.closeTrack(track.id);
|
||||||
@@ -612,6 +744,7 @@ export class Conversion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.output.addVideoTrack(videoSource, {
|
this.output.addVideoTrack(videoSource, {
|
||||||
|
frameRate: this._options.video?.frameRate,
|
||||||
languageCode: track.languageCode,
|
languageCode: track.languageCode,
|
||||||
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1941,7 +1941,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFirstPacket(options: PacketRetrievalOptions) {
|
async getFirstPacket(options: PacketRetrievalOptions) {
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const regularPacket = await this.fetchPacketForSampleIndex(0, options);
|
||||||
|
if (regularPacket || !this.internalTrack.demuxer.isFragmented) {
|
||||||
|
// If there's a non-fragmented packet, always prefer that
|
||||||
|
return regularPacket;
|
||||||
|
}
|
||||||
|
|
||||||
return this.performFragmentedLookup(
|
return this.performFragmentedLookup(
|
||||||
() => {
|
() => {
|
||||||
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
|
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
|
||||||
@@ -1978,9 +1983,6 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fetchPacketForSampleIndex(0, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapTimestampIntoTimescale(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
|
// 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
|
// that the number was originally an integer divided by the timescale. For stability, it's best
|
||||||
@@ -1991,29 +1993,37 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||||
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
||||||
|
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||||
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||||
|
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
|
||||||
|
|
||||||
|
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
|
return regularPacket;
|
||||||
|
}
|
||||||
|
|
||||||
return this.performFragmentedLookup(
|
return this.performFragmentedLookup(
|
||||||
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
|
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
|
||||||
timestampInTimescale,
|
timestampInTimescale,
|
||||||
timestampInTimescale,
|
timestampInTimescale,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
||||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
|
||||||
return this.fetchPacketForSampleIndex(sampleIndex, options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const regularSampleIndex = this.packetToSampleIndex.get(packet);
|
||||||
|
|
||||||
|
if (regularSampleIndex !== undefined) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
|
return this.fetchPacketForSampleIndex(regularSampleIndex + 1, options);
|
||||||
|
}
|
||||||
|
|
||||||
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
||||||
if (locationInFragment === undefined) {
|
if (locationInFragment === undefined) {
|
||||||
throw new Error('Packet was not created from this track.');
|
throw new Error('Packet was not created from this track.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
|
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
|
||||||
const fragmentSample = trackData.samples[locationInFragment.sampleIndex]!;
|
|
||||||
|
|
||||||
const fragmentIndex = binarySearchExact(
|
const fragmentIndex = binarySearchExact(
|
||||||
this.internalTrack.fragments,
|
this.internalTrack.fragments,
|
||||||
@@ -2061,23 +2071,27 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentSample.presentationTimestamp,
|
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
|
||||||
Infinity,
|
Infinity,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleIndex = this.packetToSampleIndex.get(packet);
|
|
||||||
if (sampleIndex === undefined) {
|
|
||||||
throw new Error('Packet was not created from this track.');
|
|
||||||
}
|
|
||||||
return this.fetchPacketForSampleIndex(sampleIndex + 1, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
|
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||||
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
||||||
|
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||||
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||||
|
const keyFrameSampleIndex = sampleIndex === -1
|
||||||
|
? -1
|
||||||
|
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
||||||
|
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
|
||||||
|
|
||||||
|
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
|
return regularPacket;
|
||||||
|
}
|
||||||
|
|
||||||
return this.performFragmentedLookup(
|
return this.performFragmentedLookup(
|
||||||
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
|
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
|
||||||
timestampInTimescale,
|
timestampInTimescale,
|
||||||
@@ -2086,23 +2100,21 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
||||||
|
const regularSampleIndex = this.packetToSampleIndex.get(packet);
|
||||||
|
if (regularSampleIndex !== undefined) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, regularSampleIndex);
|
||||||
const keyFrameSampleIndex = sampleIndex === -1
|
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
|
||||||
? -1
|
|
||||||
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
|
||||||
return this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
|
||||||
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
||||||
if (locationInFragment === undefined) {
|
if (locationInFragment === undefined) {
|
||||||
throw new Error('Packet was not created from this track.');
|
throw new Error('Packet was not created from this track.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
|
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
|
||||||
const fragmentSample = trackData.samples[locationInFragment.sampleIndex]!;
|
|
||||||
|
|
||||||
const fragmentIndex = binarySearchExact(
|
const fragmentIndex = binarySearchExact(
|
||||||
this.internalTrack.fragments,
|
this.internalTrack.fragments,
|
||||||
@@ -2157,21 +2169,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentSample.presentationTimestamp,
|
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
|
||||||
Infinity,
|
Infinity,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleIndex = this.packetToSampleIndex.get(packet);
|
|
||||||
if (sampleIndex === undefined) {
|
|
||||||
throw new Error('Packet was not created from this track.');
|
|
||||||
}
|
|
||||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
||||||
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, sampleIndex);
|
|
||||||
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchPacketForSampleIndex(sampleIndex: number, options: PacketRetrievalOptions) {
|
private async fetchPacketForSampleIndex(sampleIndex: number, options: PacketRetrievalOptions) {
|
||||||
if (sampleIndex === -1) {
|
if (sampleIndex === -1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -2701,3 +2704,7 @@ const extractRotationFromMatrix = (matrix: TransformationMatrix) => {
|
|||||||
// Invert the rotation because matrices are post-multiplied in ISOBMFF
|
// Invert the rotation because matrices are post-multiplied in ISOBMFF
|
||||||
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
|
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sampleTableIsEmpty = (sampleTable: SampleTable) => {
|
||||||
|
return sampleTable.sampleSizes.length === 0;
|
||||||
|
};
|
||||||
|
|||||||
+39
-1
@@ -499,7 +499,13 @@ export class EBMLReader {
|
|||||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||||
this.pos += length;
|
this.pos += length;
|
||||||
|
|
||||||
return String.fromCharCode(...new Uint8Array(view.buffer, offset, length));
|
// Actual string length might be shorter due to null terminators
|
||||||
|
let strLength = 0;
|
||||||
|
while (strLength < length && view.getUint8(offset + strLength) !== 0) {
|
||||||
|
strLength += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.fromCharCode(...new Uint8Array(view.buffer, offset, strLength));
|
||||||
}
|
}
|
||||||
|
|
||||||
readElementId() {
|
readElementId() {
|
||||||
@@ -588,6 +594,38 @@ export const CODEC_STRING_MAP: Partial<Record<MediaCodec, string>> = {
|
|||||||
'webvtt': 'S_TEXT/WEBVTT',
|
'webvtt': 'S_TEXT/WEBVTT',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const readVarInt = (data: Uint8Array, offset: number) => {
|
||||||
|
if (offset >= data.length) {
|
||||||
|
throw new Error('Offset out of bounds.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the first byte to determine the width of the variable-length integer
|
||||||
|
const firstByte = data[offset]!;
|
||||||
|
|
||||||
|
// Find the position of VINT_MARKER, which determines the width
|
||||||
|
let width = 1;
|
||||||
|
let mask = 1 << 7;
|
||||||
|
while ((firstByte & mask) === 0 && width < 8) {
|
||||||
|
width++;
|
||||||
|
mask >>= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + width > data.length) {
|
||||||
|
throw new Error('VarInt extends beyond data bounds.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// First byte's value needs the marker bit cleared
|
||||||
|
let value = firstByte & (mask - 1);
|
||||||
|
|
||||||
|
// Read remaining bytes
|
||||||
|
for (let i = 1; i < width; i++) {
|
||||||
|
value *= 1 << 8;
|
||||||
|
value += data[offset + i]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value, width };
|
||||||
|
};
|
||||||
|
|
||||||
export function assertDefinedSize(size: number | null): asserts size is number {
|
export function assertDefinedSize(size: number | null): asserts size is number {
|
||||||
if (size === null) {
|
if (size === null) {
|
||||||
throw new Error('Undefined element size is used in a place where it is not supported.');
|
throw new Error('Undefined element size is used in a place where it is not supported.');
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
LEVEL_0_AND_1_EBML_IDS,
|
LEVEL_0_AND_1_EBML_IDS,
|
||||||
MAX_HEADER_SIZE,
|
MAX_HEADER_SIZE,
|
||||||
MIN_HEADER_SIZE,
|
MIN_HEADER_SIZE,
|
||||||
|
readVarInt,
|
||||||
} from './ebml';
|
} from './ebml';
|
||||||
import { buildMatroskaMimeType } from './matroska-misc';
|
import { buildMatroskaMimeType } from './matroska-misc';
|
||||||
|
|
||||||
@@ -107,12 +108,20 @@ type ClusterTrackData = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum BlockLacing {
|
||||||
|
None,
|
||||||
|
Xiph,
|
||||||
|
FixedSize,
|
||||||
|
Ebml,
|
||||||
|
}
|
||||||
|
|
||||||
type ClusterBlock = {
|
type ClusterBlock = {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
isKeyFrame: boolean;
|
isKeyFrame: boolean;
|
||||||
referencedTimestamps: number[];
|
referencedTimestamps: number[];
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
|
lacing: BlockLacing;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CuePoint = {
|
type CuePoint = {
|
||||||
@@ -133,6 +142,7 @@ type InternalTrack = {
|
|||||||
inputTrack: InputTrack | null;
|
inputTrack: InputTrack | null;
|
||||||
codecId: string | null;
|
codecId: string | null;
|
||||||
codecPrivate: Uint8Array | null;
|
codecPrivate: Uint8Array | null;
|
||||||
|
defaultDuration: number | null;
|
||||||
languageCode: string;
|
languageCode: string;
|
||||||
info:
|
info:
|
||||||
| null
|
| null
|
||||||
@@ -220,13 +230,16 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
|
|
||||||
const fileSize = await this.input.source.getSize();
|
const fileSize = await this.input.source.getSize();
|
||||||
|
|
||||||
|
// Loop over all top-level elements in the file
|
||||||
while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
|
while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
|
||||||
await this.metadataReader.reader.loadRange(
|
await this.metadataReader.reader.loadRange(
|
||||||
this.metadataReader.pos,
|
this.metadataReader.pos,
|
||||||
this.metadataReader.pos + MAX_HEADER_SIZE,
|
this.metadataReader.pos + MAX_HEADER_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { id, size } = this.metadataReader.readElementHeader();
|
const header = this.metadataReader.readElementHeader();
|
||||||
|
const id = header.id;
|
||||||
|
let size = header.size;
|
||||||
const startPos = this.metadataReader.pos;
|
const startPos = this.metadataReader.pos;
|
||||||
|
|
||||||
if (id === EBMLId.EBML) {
|
if (id === EBMLId.EBML) {
|
||||||
@@ -242,6 +255,26 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
// and only segment
|
// and only segment
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else if (id === EBMLId.Cluster) {
|
||||||
|
// Clusters are not a top-level element in Matroska, but some files contain a Segment whose size
|
||||||
|
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
|
||||||
|
// we find a top-level cluster, attribute it to the previous segment.
|
||||||
|
|
||||||
|
if (size === null) {
|
||||||
|
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
|
||||||
|
// determine its size.
|
||||||
|
const nextElementPos = await this.clusterReader.searchForNextElementId(
|
||||||
|
LEVEL_0_AND_1_EBML_IDS,
|
||||||
|
fileSize,
|
||||||
|
);
|
||||||
|
size = (nextElementPos ?? fileSize) - startPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = last(this.segments);
|
||||||
|
if (lastSegment) {
|
||||||
|
// Extend the previous segment's size
|
||||||
|
lastSegment.elementEndPos = startPos + size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertDefinedSize(size);
|
assertDefinedSize(size);
|
||||||
@@ -471,16 +504,20 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
this.readContiguousElements(this.clusterReader, size);
|
this.readContiguousElements(this.clusterReader, size);
|
||||||
|
|
||||||
for (const [trackId, trackData] of cluster.trackData) {
|
for (const [trackId, trackData] of cluster.trackData) {
|
||||||
let blockReferencesExist = false;
|
const track = segment.tracks.find(x => x.id === trackId) ?? null;
|
||||||
|
|
||||||
// This must hold, as track datas only get created if a block for that track is encountered
|
// This must hold, as track datas only get created if a block for that track is encountered
|
||||||
assert(trackData.blocks.length > 0);
|
assert(trackData.blocks.length > 0);
|
||||||
|
|
||||||
|
let blockReferencesExist = false;
|
||||||
|
let hasLacedBlocks = false;
|
||||||
|
|
||||||
for (let i = 0; i < trackData.blocks.length; i++) {
|
for (let i = 0; i < trackData.blocks.length; i++) {
|
||||||
const block = trackData.blocks[i]!;
|
const block = trackData.blocks[i]!;
|
||||||
block.timestamp += cluster.timestamp;
|
block.timestamp += cluster.timestamp;
|
||||||
|
|
||||||
blockReferencesExist ||= block.referencedTimestamps.length > 0;
|
blockReferencesExist ||= block.referencedTimestamps.length > 0;
|
||||||
|
hasLacedBlocks ||= block.lacing !== BlockLacing.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockReferencesExist) {
|
if (blockReferencesExist) {
|
||||||
@@ -503,8 +540,28 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
// 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]!;
|
||||||
currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
|
currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
|
||||||
|
} else if (currentBlock.duration === 0) {
|
||||||
|
if (track?.defaultDuration != null) {
|
||||||
|
if (currentBlock.lacing === BlockLacing.None) {
|
||||||
|
currentBlock.duration = track.defaultDuration;
|
||||||
|
} else {
|
||||||
|
// Handled by the lace resolution code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLacedBlocks) {
|
||||||
|
// Perform lace resolution. Here, we expand each laced block into multiple blocks where each contains
|
||||||
|
// one frame of the lace. We do this after determining block timestamps so we can properly distribute
|
||||||
|
// the block's duration across the laced frames.
|
||||||
|
this.expandLacedBlocks(trackData.blocks, track);
|
||||||
|
|
||||||
|
// Recompute since blocks have changed
|
||||||
|
trackData.presentationTimestamps = trackData.blocks
|
||||||
|
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
const firstBlock = trackData.blocks[trackData.presentationTimestamps[0]!.blockIndex]!;
|
const firstBlock = trackData.blocks[trackData.presentationTimestamps[0]!.blockIndex]!;
|
||||||
const lastBlock = trackData.blocks[last(trackData.presentationTimestamps)!.blockIndex]!;
|
const lastBlock = trackData.blocks[last(trackData.presentationTimestamps)!.blockIndex]!;
|
||||||
@@ -512,7 +569,6 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
trackData.startTimestamp = firstBlock.timestamp;
|
trackData.startTimestamp = firstBlock.timestamp;
|
||||||
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
|
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
|
||||||
|
|
||||||
const track = segment.tracks.find(x => x.id === trackId);
|
|
||||||
if (track) {
|
if (track) {
|
||||||
const insertionIndex = binarySearchLessOrEqual(
|
const insertionIndex = binarySearchLessOrEqual(
|
||||||
track.clusters,
|
track.clusters,
|
||||||
@@ -561,6 +617,120 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
return trackData;
|
return trackData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expandLacedBlocks(blocks: ClusterBlock[], track: InternalTrack | null) {
|
||||||
|
// https://www.matroska.org/technical/notes.html#block-lacing
|
||||||
|
|
||||||
|
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
||||||
|
const originalBlock = blocks[blockIndex]!;
|
||||||
|
if (originalBlock.lacing === BlockLacing.None) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = originalBlock.data;
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
const frameSizes: number[] = [];
|
||||||
|
const frameCount = data[pos]! + 1;
|
||||||
|
pos++;
|
||||||
|
|
||||||
|
switch (originalBlock.lacing) {
|
||||||
|
case BlockLacing.Xiph: {
|
||||||
|
let totalUsedSize = 0;
|
||||||
|
|
||||||
|
// Xiph lacing, just like in Ogg
|
||||||
|
for (let i = 0; i < frameCount - 1; i++) {
|
||||||
|
let frameSize = 0;
|
||||||
|
|
||||||
|
while (pos < data.length) {
|
||||||
|
const value = data[pos]!;
|
||||||
|
frameSize += value;
|
||||||
|
pos++;
|
||||||
|
|
||||||
|
if (value < 255) {
|
||||||
|
frameSizes.push(frameSize);
|
||||||
|
totalUsedSize += frameSize;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the last frame's size from whatever's left
|
||||||
|
frameSizes.push(data.length - (pos + totalUsedSize));
|
||||||
|
}; break;
|
||||||
|
|
||||||
|
case BlockLacing.FixedSize: {
|
||||||
|
// Fixed size lacing: all frames have same size
|
||||||
|
const totalDataSize = data.length - 1; // Minus the frame count byte
|
||||||
|
const frameSize = Math.floor(totalDataSize / frameCount);
|
||||||
|
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
|
frameSizes.push(frameSize);
|
||||||
|
}
|
||||||
|
}; break;
|
||||||
|
|
||||||
|
case BlockLacing.Ebml: {
|
||||||
|
// EBML lacing: first size absolute, subsequent ones are coded as signed differences from the last
|
||||||
|
const firstResult = readVarInt(data, pos);
|
||||||
|
let currentSize = firstResult.value;
|
||||||
|
frameSizes.push(currentSize);
|
||||||
|
pos += firstResult.width;
|
||||||
|
|
||||||
|
let totalUsedSize = currentSize;
|
||||||
|
|
||||||
|
for (let i = 1; i < frameCount - 1; i++) {
|
||||||
|
const diffResult = readVarInt(data, pos);
|
||||||
|
const unsignedDiff = diffResult.value;
|
||||||
|
const bias = (1 << (diffResult.width * 7 - 1)) - 1; // Typo-corrected version of 2^((7*n)-1)^-1
|
||||||
|
const diff = unsignedDiff - bias;
|
||||||
|
|
||||||
|
currentSize += diff;
|
||||||
|
frameSizes.push(currentSize);
|
||||||
|
pos += diffResult.width;
|
||||||
|
|
||||||
|
totalUsedSize += currentSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the last frame's size from whatever's left
|
||||||
|
frameSizes.push(data.length - (pos + totalUsedSize));
|
||||||
|
}; break;
|
||||||
|
|
||||||
|
default: assert(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(frameSizes.length === frameCount);
|
||||||
|
|
||||||
|
blocks.splice(blockIndex, 1); // Remove the original block
|
||||||
|
let dataOffset = pos;
|
||||||
|
|
||||||
|
// Now, let's insert each frame as its own block
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
|
const frameSize = frameSizes[i]!;
|
||||||
|
const frameData = data.subarray(dataOffset, dataOffset + frameSize);
|
||||||
|
|
||||||
|
const blockDuration = originalBlock.duration || (frameCount * (track?.defaultDuration ?? 0));
|
||||||
|
|
||||||
|
// Distribute timestamps evenly across the block duration
|
||||||
|
const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);
|
||||||
|
const frameDuration = blockDuration / frameCount;
|
||||||
|
|
||||||
|
blocks.splice(blockIndex + i, 0, {
|
||||||
|
timestamp: frameTimestamp,
|
||||||
|
duration: frameDuration,
|
||||||
|
isKeyFrame: originalBlock.isKeyFrame,
|
||||||
|
referencedTimestamps: originalBlock.referencedTimestamps,
|
||||||
|
data: frameData,
|
||||||
|
lacing: BlockLacing.None,
|
||||||
|
});
|
||||||
|
|
||||||
|
dataOffset += frameSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockIndex += frameCount; // Skip the blocks we just added
|
||||||
|
blockIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readContiguousElements(reader: EBMLReader, totalSize: number) {
|
readContiguousElements(reader: EBMLReader, totalSize: number) {
|
||||||
const startIndex = reader.pos;
|
const startIndex = reader.pos;
|
||||||
|
|
||||||
@@ -632,6 +802,7 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
inputTrack: null,
|
inputTrack: null,
|
||||||
codecId: null,
|
codecId: null,
|
||||||
codecPrivate: null,
|
codecPrivate: null,
|
||||||
|
defaultDuration: null,
|
||||||
languageCode: UNDETERMINED_LANGUAGE,
|
languageCode: UNDETERMINED_LANGUAGE,
|
||||||
info: null,
|
info: null,
|
||||||
};
|
};
|
||||||
@@ -793,6 +964,13 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
this.currentTrack.codecPrivate = reader.readBytes(size);
|
this.currentTrack.codecPrivate = reader.readBytes(size);
|
||||||
}; break;
|
}; break;
|
||||||
|
|
||||||
|
case EBMLId.DefaultDuration: {
|
||||||
|
if (!this.currentTrack) break;
|
||||||
|
|
||||||
|
this.currentTrack.defaultDuration
|
||||||
|
= this.currentTrack.segment.timestampFactor * reader.readUnsignedInt(size) / 1e9;
|
||||||
|
}; break;
|
||||||
|
|
||||||
case EBMLId.Language: {
|
case EBMLId.Language: {
|
||||||
if (!this.currentTrack) break;
|
if (!this.currentTrack) break;
|
||||||
|
|
||||||
@@ -954,14 +1132,16 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
|
|
||||||
const flags = reader.readU8();
|
const flags = reader.readU8();
|
||||||
const isKeyFrame = !!(flags & 0x80);
|
const isKeyFrame = !!(flags & 0x80);
|
||||||
|
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
|
||||||
|
|
||||||
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
|
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
|
||||||
trackData.blocks.push({
|
trackData.blocks.push({
|
||||||
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
|
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
|
||||||
duration: 0,
|
duration: 0, // Will set later
|
||||||
isKeyFrame,
|
isKeyFrame,
|
||||||
referencedTimestamps: [],
|
referencedTimestamps: [],
|
||||||
data: reader.readBytes(size - (reader.pos - dataStartPos)),
|
data: reader.readBytes(size - (reader.pos - dataStartPos)),
|
||||||
|
lacing,
|
||||||
});
|
});
|
||||||
}; break;
|
}; break;
|
||||||
|
|
||||||
@@ -985,16 +1165,17 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
const trackNumber = reader.readVarInt();
|
const trackNumber = reader.readVarInt();
|
||||||
const relativeTimestamp = reader.readS16();
|
const relativeTimestamp = reader.readS16();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const flags = reader.readU8();
|
const flags = reader.readU8();
|
||||||
|
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
|
||||||
|
|
||||||
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
|
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
|
||||||
this.currentBlock = {
|
this.currentBlock = {
|
||||||
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
|
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
|
||||||
duration: 0,
|
duration: 0, // Will set later
|
||||||
isKeyFrame: true,
|
isKeyFrame: true,
|
||||||
referencedTimestamps: [],
|
referencedTimestamps: [],
|
||||||
data: reader.readBytes(size - (reader.pos - dataStartPos)),
|
data: reader.readBytes(size - (reader.pos - dataStartPos)),
|
||||||
|
lacing,
|
||||||
};
|
};
|
||||||
trackData.blocks.push(this.currentBlock);
|
trackData.blocks.push(this.currentBlock);
|
||||||
}; break;
|
}; break;
|
||||||
@@ -1117,7 +1298,6 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
|
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
|
||||||
const block = trackData.blocks[locationInCluster.blockIndex]!;
|
|
||||||
|
|
||||||
const clusterIndex = binarySearchExact(
|
const clusterIndex = binarySearchExact(
|
||||||
this.internalTrack.clusters,
|
this.internalTrack.clusters,
|
||||||
@@ -1165,7 +1345,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
block.timestamp,
|
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
|
||||||
Infinity,
|
Infinity,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
@@ -1189,7 +1369,6 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
|
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
|
||||||
const block = trackData.blocks[locationInCluster.blockIndex]!;
|
|
||||||
|
|
||||||
const clusterIndex = binarySearchExact(
|
const clusterIndex = binarySearchExact(
|
||||||
this.internalTrack.clusters,
|
this.internalTrack.clusters,
|
||||||
@@ -1244,7 +1423,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
block.timestamp,
|
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
|
||||||
Infinity,
|
Infinity,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user