mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Various fixes
This commit is contained in:
+21
-2
@@ -17,14 +17,33 @@
|
||||
source: new Mediabunny.BlobSource(file),
|
||||
});
|
||||
|
||||
const audTrack = await input.getPrimaryAudioTrack();
|
||||
console.log((await audTrack.getDecoderConfig()).description.join(','));
|
||||
return;
|
||||
|
||||
const manifest = new Mediabunny.Input({
|
||||
entryPath: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/redundant.m3u8',
|
||||
entryPath: 'https://s3.amazonaws.com/qa.jwplayer.com/hlsjs/muxed-fmp4/hls.m3u8',
|
||||
source: ({ path }) => new Mediabunny.UrlSource(path),
|
||||
formats: Mediabunny.ALL_FORMATS,
|
||||
});
|
||||
|
||||
/*
|
||||
const tracks = await manifest.getTracks();
|
||||
console.log(tracks)
|
||||
//await input.hydrateAllTracks();
|
||||
console.log(tracks, tracks.map(x => [x.getFirstTimestamp(), x.computeDuration()]));
|
||||
|
||||
return;
|
||||
*/
|
||||
|
||||
const videoTrack = await manifest.getPrimaryVideoTrack();
|
||||
const audioTrack = await manifest.getPrimaryAudioTrack();
|
||||
|
||||
console.log(await videoTrack.getFirstTimestamp(), await audioTrack.getFirstTimestamp());
|
||||
/*
|
||||
for await (using sample of sink.samples()) {
|
||||
console.log(sample.timestamp);
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const manifest = new Mediabunny.ManifestInput({
|
||||
|
||||
@@ -196,7 +196,60 @@ Codec encodability checks take [custom encoders](#custom-encoders) into account.
|
||||
|
||||
## Querying codec decodability
|
||||
|
||||
Whether a codec can be decoded depends on the specific codec configuration of an `InputTrack`; you can use its [`canDecode`](./reading-media-files#codec-information) method to check.
|
||||
If you already have an `InputTrack`, you can check its decodability using its [`canDecode`](./reading-media-files#codec-information) method, which uses the track's actual codec configuration:
|
||||
```ts
|
||||
const canDecodeTrack = await inputTrack.canDecode(); // => boolean
|
||||
```
|
||||
|
||||
However, you can also gauge decodability even in the absence of any concrete track. `canDecode` tests whether a codec can be decoded using typical settings:
|
||||
```ts
|
||||
import { canDecode } from 'mediabunny';
|
||||
|
||||
canDecode('avc'); // => Promise<boolean>
|
||||
canDecode('opus'); // => Promise<boolean>
|
||||
```
|
||||
Video codecs are checked using 1280x720, while audio codecs are checked using 2 channels, 48 kHz.
|
||||
|
||||
You can also check decodability using specific configurations:
|
||||
```ts
|
||||
import { canDecodeVideo, canDecodeAudio } from 'mediabunny';
|
||||
|
||||
canDecodeVideo('hevc', {
|
||||
codedWidth: 1920, codedHeight: 1080
|
||||
}); // => Promise<boolean>
|
||||
|
||||
canDecodeAudio('aac', {
|
||||
numberOfChannels: 1, sampleRate: 44100
|
||||
}); // => Promise<boolean>
|
||||
```
|
||||
|
||||
All additional properties of [`VideoDecoderConfig`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder/configure#config) and [`AudioDecoderConfig`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder/configure#config) can be used here as well.
|
||||
|
||||
---
|
||||
|
||||
In addition, you can use the following functions to check decodability for multiple codecs at once, getting back a list of supported codecs:
|
||||
```ts
|
||||
import {
|
||||
getDecodableCodecs,
|
||||
getDecodableVideoCodecs,
|
||||
getDecodableAudioCodecs,
|
||||
} from 'mediabunny';
|
||||
|
||||
getDecodableCodecs(); // => Promise<MediaCodec[]>
|
||||
getDecodableVideoCodecs(); // => Promise<VideoCodec[]>
|
||||
getDecodableAudioCodecs(); // => Promise<AudioCodec[]>
|
||||
|
||||
// These functions also accept optional configuration options.
|
||||
// Here, we check which of AVC, HEVC and VP8 can be decoded at 1920x1080:
|
||||
getDecodableVideoCodecs(
|
||||
['avc', 'hevc', 'vp8'],
|
||||
{ codedWidth: 1920, codedHeight: 1080 },
|
||||
); // => Promise<VideoCodec[]>
|
||||
```
|
||||
|
||||
::: info
|
||||
Codec decodability checks take [custom decoders](#custom-decoders) into account.
|
||||
:::
|
||||
|
||||
## Custom coders
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WrappedAudioBuffer,
|
||||
WrappedCanvas,
|
||||
asc,
|
||||
canDecodeAudio,
|
||||
desc,
|
||||
prefer,
|
||||
} from 'mediabunny';
|
||||
@@ -113,10 +114,10 @@ const initMediaPlayer = async (resource: File | string) => {
|
||||
});
|
||||
|
||||
videoTrack = await input.getPrimaryVideoTrack({
|
||||
filter: async track => (await track.resolve('displayHeight')) <= 720,
|
||||
filter: async track => (await track.resolve('displayHeight')) < 1080,
|
||||
});
|
||||
audioTrack = await input.getPrimaryAudioTrack({
|
||||
filter: track => !videoTrack || videoTrack.canBePairedWith(track),
|
||||
sortBy: track => prefer(track.canBePairedWith(videoTrack)),
|
||||
});
|
||||
|
||||
await videoTrack?.hydrate();
|
||||
@@ -127,6 +128,8 @@ const initMediaPlayer = async (resource: File | string) => {
|
||||
await audioTrack?.computeDuration() ?? 0,
|
||||
);
|
||||
|
||||
console.log(videoTrack, audioTrack, totalDuration);
|
||||
|
||||
// https://test-streams.mux.dev/test_001/stream.m3u8
|
||||
// https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_050.ts
|
||||
} else {
|
||||
|
||||
+41
-3
File diff suppressed because one or more lines are too long
+263
@@ -0,0 +1,263 @@
|
||||
/*!
|
||||
* Copyright (c) 2026-present, Vanilagy and contributors
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import {
|
||||
AUDIO_CODECS,
|
||||
AudioCodec,
|
||||
buildAudioCodecString,
|
||||
buildVideoCodecString,
|
||||
guessDescriptionForAudio,
|
||||
guessDescriptionForVideo,
|
||||
inferCodecFromCodecString,
|
||||
MediaCodec,
|
||||
PCM_AUDIO_CODECS,
|
||||
VIDEO_CODECS,
|
||||
VideoCodec,
|
||||
} from './codec';
|
||||
import { customAudioDecoders, customVideoDecoders } from './custom-coder';
|
||||
import { isAllowSharedBufferSource, SetOptional } from './misc';
|
||||
|
||||
const canDecodeVideoMemo = new Map<string, Promise<boolean>>();
|
||||
const canDecodeAudioMemo = new Map<string, Promise<boolean>>();
|
||||
|
||||
const validateVideoDecodingConfig = (codec: VideoCodec, options: SetOptional<VideoDecoderConfig, 'codec'>) => {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.codec !== undefined && typeof options.codec !== 'string') {
|
||||
throw new TypeError('options.codec, when provided, must be a string.');
|
||||
}
|
||||
if (options.codec !== undefined && inferCodecFromCodecString(options.codec) !== codec) {
|
||||
throw new TypeError(`options.codec, when provided, must match the specified codec (${codec}).`);
|
||||
}
|
||||
if (
|
||||
options.codedWidth !== undefined
|
||||
&& (!Number.isInteger(options.codedWidth) || options.codedWidth <= 0)
|
||||
) {
|
||||
throw new TypeError('options.codedWidth, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
options.codedHeight !== undefined
|
||||
&& (!Number.isInteger(options.codedHeight) || options.codedHeight <= 0)
|
||||
) {
|
||||
throw new TypeError('options.codedHeight, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
options.displayAspectWidth !== undefined
|
||||
&& (!Number.isInteger(options.displayAspectWidth) || options.displayAspectWidth <= 0)
|
||||
) {
|
||||
throw new TypeError('options.displayAspectWidth, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
options.displayAspectHeight !== undefined
|
||||
&& (!Number.isInteger(options.displayAspectHeight) || options.displayAspectHeight <= 0)
|
||||
) {
|
||||
throw new TypeError('options.displayAspectHeight, when provided, must be a positive integer.');
|
||||
}
|
||||
if (options.description !== undefined && !isAllowSharedBufferSource(options.description)) {
|
||||
throw new TypeError('options.description, when provided, must be a buffer source.');
|
||||
}
|
||||
if (
|
||||
options.hardwareAcceleration !== undefined
|
||||
&& !['no-preference', 'prefer-hardware', 'prefer-software'].includes(options.hardwareAcceleration)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'options.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
|
||||
+ ' \'prefer-software\'.',
|
||||
);
|
||||
}
|
||||
if (options.optimizeForLatency !== undefined && typeof options.optimizeForLatency !== 'boolean') {
|
||||
throw new TypeError('options.optimizeForLatency, when provided, must be a boolean.');
|
||||
}
|
||||
};
|
||||
|
||||
const validateAudioDecodingConfig = (
|
||||
codec: AudioCodec,
|
||||
options: SetOptional<AudioDecoderConfig, 'codec' | 'numberOfChannels' | 'sampleRate'>,
|
||||
) => {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.codec !== undefined && typeof options.codec !== 'string') {
|
||||
throw new TypeError('options.codec, when provided, must be a string.');
|
||||
}
|
||||
if (options.codec !== undefined && inferCodecFromCodecString(options.codec) !== codec) {
|
||||
throw new TypeError(`options.codec, when provided, must match the specified codec (${codec}).`);
|
||||
}
|
||||
if (
|
||||
options.numberOfChannels !== undefined
|
||||
&& (!Number.isInteger(options.numberOfChannels) || options.numberOfChannels <= 0)
|
||||
) {
|
||||
throw new TypeError('options.numberOfChannels, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
options.sampleRate !== undefined
|
||||
&& (!Number.isInteger(options.sampleRate) || options.sampleRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.sampleRate, when provided, must be a positive integer.');
|
||||
}
|
||||
if (options.description !== undefined && !isAllowSharedBufferSource(options.description)) {
|
||||
throw new TypeError('options.description, when provided, must be a buffer source.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the browser is able to decode the given codec.
|
||||
* @group Decoding
|
||||
* @public
|
||||
*/
|
||||
export const canDecode = (codec: MediaCodec) => {
|
||||
if ((VIDEO_CODECS as readonly string[]).includes(codec)) {
|
||||
return canDecodeVideo(codec as VideoCodec);
|
||||
} else if ((AUDIO_CODECS as readonly string[]).includes(codec)) {
|
||||
return canDecodeAudio(codec as AudioCodec);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the browser is able to decode the given video codec with the given parameters.
|
||||
* @group Decoding
|
||||
* @public
|
||||
*/
|
||||
export const canDecodeVideo = async (
|
||||
codec: VideoCodec,
|
||||
options: SetOptional<VideoDecoderConfig, 'codec'> = {},
|
||||
) => {
|
||||
if (!VIDEO_CODECS.includes(codec)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
validateVideoDecodingConfig(codec, options);
|
||||
|
||||
const resolvedOptions: VideoDecoderConfig = {
|
||||
...options,
|
||||
codedWidth: options.codedWidth ?? 1280,
|
||||
codedHeight: options.codedHeight ?? 720,
|
||||
codec: options.codec ?? buildVideoCodecString(codec, 1280, 720, 1e6),
|
||||
};
|
||||
resolvedOptions.description ??= guessDescriptionForVideo(resolvedOptions);
|
||||
|
||||
const key = JSON.stringify(resolvedOptions);
|
||||
const memoized = canDecodeVideoMemo.get(key);
|
||||
if (memoized) {
|
||||
return memoized;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (customVideoDecoders.some(x => x.supports(codec, resolvedOptions))) {
|
||||
return true;
|
||||
}
|
||||
if (typeof VideoDecoder === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const support = await VideoDecoder.isConfigSupported(resolvedOptions);
|
||||
return support.supported === true;
|
||||
})();
|
||||
canDecodeVideoMemo.set(key, promise);
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the browser is able to decode the given audio codec with the given parameters.
|
||||
* @group Decoding
|
||||
* @public
|
||||
*/
|
||||
export const canDecodeAudio = async (
|
||||
codec: AudioCodec,
|
||||
options: SetOptional<AudioDecoderConfig, 'codec' | 'numberOfChannels' | 'sampleRate'> = {},
|
||||
) => {
|
||||
if (!AUDIO_CODECS.includes(codec)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
validateAudioDecodingConfig(codec, options);
|
||||
|
||||
const resolvedOptions: AudioDecoderConfig = {
|
||||
...options,
|
||||
numberOfChannels: options.numberOfChannels ?? 2,
|
||||
sampleRate: options.sampleRate ?? 48000,
|
||||
codec: options.codec ?? buildAudioCodecString(codec, 2, 48000),
|
||||
};
|
||||
|
||||
if (resolvedOptions.description === undefined) {
|
||||
const generatedDescription = guessDescriptionForAudio(resolvedOptions);
|
||||
if (generatedDescription === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
resolvedOptions.description = generatedDescription;
|
||||
}
|
||||
|
||||
const key = JSON.stringify(resolvedOptions);
|
||||
const memoized = canDecodeAudioMemo.get(key);
|
||||
if (memoized) {
|
||||
return memoized;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (customAudioDecoders.some(x => x.supports(codec, resolvedOptions))) {
|
||||
return true;
|
||||
}
|
||||
if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof AudioDecoder === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const support = await AudioDecoder.isConfigSupported(resolvedOptions);
|
||||
return support.supported === true;
|
||||
})();
|
||||
canDecodeAudioMemo.set(key, promise);
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of all media codecs that can be decoded by the browser.
|
||||
* @group Decoding
|
||||
* @public
|
||||
*/
|
||||
export const getDecodableCodecs = async (): Promise<MediaCodec[]> => {
|
||||
const [videoCodecs, audioCodecs] = await Promise.all([
|
||||
getDecodableVideoCodecs(),
|
||||
getDecodableAudioCodecs(),
|
||||
]);
|
||||
|
||||
return [...videoCodecs, ...audioCodecs];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of all video codecs that can be decoded by the browser.
|
||||
* @group Decoding
|
||||
* @public
|
||||
*/
|
||||
export const getDecodableVideoCodecs = async (
|
||||
checkedCodecs: VideoCodec[] = VIDEO_CODECS as unknown as VideoCodec[],
|
||||
options?: SetOptional<VideoDecoderConfig, 'codec'>,
|
||||
): Promise<VideoCodec[]> => {
|
||||
const bools = await Promise.all(checkedCodecs.map(codec => canDecodeVideo(codec, options)));
|
||||
return checkedCodecs.filter((_, i) => bools[i]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of all audio codecs that can be decoded by the browser.
|
||||
* @group Decoding
|
||||
* @public
|
||||
*/
|
||||
export const getDecodableAudioCodecs = async (
|
||||
checkedCodecs: AudioCodec[] = AUDIO_CODECS as unknown as AudioCodec[],
|
||||
options?: SetOptional<AudioDecoderConfig, 'codec' | 'numberOfChannels' | 'sampleRate'>,
|
||||
): Promise<AudioCodec[]> => {
|
||||
const bools = await Promise.all(checkedCodecs.map(codec => canDecodeAudio(codec, options)));
|
||||
return checkedCodecs.filter((_, i) => bools[i]);
|
||||
};
|
||||
+89
-92
@@ -25,6 +25,9 @@ import { customAudioEncoders, customVideoEncoders } from './custom-coder';
|
||||
import { isFirefox } from './misc';
|
||||
import { EncodedPacket } from './packet';
|
||||
|
||||
const canEncodeVideoMemo = new Map<string, Promise<boolean>>();
|
||||
const canEncodeAudioMemo = new Map<string, Promise<boolean>>();
|
||||
|
||||
/**
|
||||
* Configuration object that controls video encoding. Can be used to set codec, quality, and more.
|
||||
* @group Encoding
|
||||
@@ -494,38 +497,7 @@ export const canEncodeVideo = async (
|
||||
}
|
||||
validateVideoEncodingAdditionalOptions(codec, restOptions);
|
||||
|
||||
let encoderConfig: VideoEncoderConfig | null = null;
|
||||
|
||||
if (customVideoEncoders.length > 0) {
|
||||
encoderConfig ??= buildVideoEncoderConfig({
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
bitrate,
|
||||
framerate: undefined,
|
||||
...restOptions,
|
||||
});
|
||||
|
||||
if (customVideoEncoders.some(x => x.supports(codec, encoderConfig!))) {
|
||||
// There's a custom encoder
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof VideoEncoder === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasOddDimension = width % 2 === 1 || height % 2 === 1;
|
||||
if (
|
||||
hasOddDimension
|
||||
&& (codec === 'avc' || codec === 'hevc')
|
||||
) {
|
||||
// Disallow odd dimensions for certain codecs
|
||||
return false;
|
||||
}
|
||||
|
||||
encoderConfig ??= buildVideoEncoderConfig({
|
||||
const encoderConfig = buildVideoEncoderConfig({
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
@@ -535,46 +507,74 @@ export const canEncodeVideo = async (
|
||||
alpha: 'discard', // Since we handle alpha ourselves
|
||||
});
|
||||
|
||||
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||
if (!support.supported) {
|
||||
return false;
|
||||
const key = JSON.stringify(encoderConfig);
|
||||
const memoized = canEncodeVideoMemo.get(key);
|
||||
if (memoized) {
|
||||
return memoized;
|
||||
}
|
||||
|
||||
if (isFirefox()) {
|
||||
// isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore, we
|
||||
// just try encoding a frame to see if it actually works.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/222
|
||||
const promise = (async () => {
|
||||
if (customVideoEncoders.some(x => x.supports(codec, encoderConfig))) {
|
||||
// There's a custom encoder
|
||||
return true;
|
||||
}
|
||||
if (typeof VideoEncoder === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
try {
|
||||
const encoder = new VideoEncoder({
|
||||
output: () => {},
|
||||
error: () => resolve(false),
|
||||
});
|
||||
encoder.configure(encoderConfig);
|
||||
const hasOddDimension = width % 2 === 1 || height % 2 === 1;
|
||||
if (
|
||||
hasOddDimension
|
||||
&& (codec === 'avc' || codec === 'hevc')
|
||||
) {
|
||||
// Disallow odd dimensions for certain codecs
|
||||
return false;
|
||||
}
|
||||
|
||||
const frameData = new Uint8Array(width * height * 4);
|
||||
const frame = new VideoFrame(frameData, {
|
||||
format: 'RGBA',
|
||||
codedWidth: width,
|
||||
codedHeight: height,
|
||||
timestamp: 0,
|
||||
});
|
||||
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||
if (!support.supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
if (isFirefox()) {
|
||||
// isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore,
|
||||
// we just try encoding a frame to see if it actually works.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/222
|
||||
|
||||
await encoder.flush();
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
try {
|
||||
const encoder = new VideoEncoder({
|
||||
output: () => {},
|
||||
error: () => resolve(false),
|
||||
});
|
||||
encoder.configure(encoderConfig);
|
||||
|
||||
const frameData = new Uint8Array(width * height * 4);
|
||||
const frame = new VideoFrame(frameData, {
|
||||
format: 'RGBA',
|
||||
codedWidth: width,
|
||||
codedHeight: height,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
|
||||
await encoder.flush();
|
||||
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
canEncodeVideoMemo.set(key, promise);
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -611,32 +611,7 @@ export const canEncodeAudio = async (
|
||||
}
|
||||
validateAudioEncodingAdditionalOptions(codec, restOptions);
|
||||
|
||||
let encoderConfig: AudioEncoderConfig | null = null;
|
||||
|
||||
if (customAudioEncoders.length > 0) {
|
||||
encoderConfig ??= buildAudioEncoderConfig({
|
||||
codec,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
bitrate,
|
||||
...restOptions,
|
||||
});
|
||||
|
||||
if (customAudioEncoders.some(x => x.supports(codec, encoderConfig!))) {
|
||||
// There's a custom encoder
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
|
||||
return true; // Because we encode these ourselves
|
||||
}
|
||||
|
||||
if (typeof AudioEncoder === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
encoderConfig ??= buildAudioEncoderConfig({
|
||||
const encoderConfig = buildAudioEncoderConfig({
|
||||
codec,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
@@ -644,8 +619,30 @@ export const canEncodeAudio = async (
|
||||
...restOptions,
|
||||
});
|
||||
|
||||
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
||||
return support.supported === true;
|
||||
const key = JSON.stringify(encoderConfig);
|
||||
const memoized = canEncodeAudioMemo.get(key);
|
||||
if (memoized) {
|
||||
return memoized;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (customAudioEncoders.some(x => x.supports(codec, encoderConfig))) {
|
||||
// There's a custom encoder
|
||||
return true;
|
||||
}
|
||||
if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
|
||||
return true; // Because we encode these ourselves
|
||||
}
|
||||
if (typeof AudioEncoder === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const support = await AudioEncoder.isConfigSupported(encoderConfig);
|
||||
return support.supported === true;
|
||||
})();
|
||||
canEncodeAudioMemo.set(key, promise);
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export class AttributeList {
|
||||
inValue = true;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
if (key) {
|
||||
this._attributes[key.toLowerCase()] = value;
|
||||
this._attributes[key.trim().toLowerCase()] = value;
|
||||
}
|
||||
|
||||
key = '';
|
||||
|
||||
@@ -209,7 +209,10 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
keyFormat: attributes.get('keyformat') ?? 'identity',
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption method '${method}'.`);
|
||||
throw new Error(
|
||||
`Unsupported encryption method '${method}'. If you think this method should be supported,`
|
||||
+ ` please raise an issue.`,
|
||||
);
|
||||
}
|
||||
} else if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
const value = line.slice(22);
|
||||
@@ -241,11 +244,11 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
// backward from that tag (using EXTINF durations and/or media
|
||||
// timestamps) to associate dates with those segments."
|
||||
const lastSegment = last(segments)!;
|
||||
const lastSegmentEnd = lastSegment.relativeTimestamp + lastSegment.duration;
|
||||
const lastSegmentEnd = lastSegment.timestamp + lastSegment.duration;
|
||||
const offset = dateTimeSeconds - lastSegmentEnd;
|
||||
|
||||
for (const segment of segments) {
|
||||
segment.relativeTimestamp += offset;
|
||||
segment.timestamp += offset;
|
||||
segment.relativeToUnixEpoch = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ export {
|
||||
NON_PCM_AUDIO_CODECS,
|
||||
SUBTITLE_CODECS,
|
||||
} from './codec';
|
||||
export {
|
||||
canDecode,
|
||||
canDecodeVideo,
|
||||
canDecodeAudio,
|
||||
getDecodableCodecs,
|
||||
getDecodableVideoCodecs,
|
||||
getDecodableAudioCodecs,
|
||||
} from './decode';
|
||||
export {
|
||||
VideoEncodingConfig,
|
||||
VideoEncodingAdditionalOptions,
|
||||
@@ -119,6 +127,7 @@ export {
|
||||
Rational,
|
||||
Rectangle,
|
||||
Rotation,
|
||||
SetOptional,
|
||||
SetRequired,
|
||||
asc,
|
||||
desc,
|
||||
|
||||
@@ -463,6 +463,13 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
||||
*/
|
||||
export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Sets all keys K of T to be optional.
|
||||
* @group Miscellaneous
|
||||
* @public
|
||||
*/
|
||||
export type SetOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Merges two RequestInit objects with special handling for headers.
|
||||
* Headers are merged case-insensitively, but original casing is preserved.
|
||||
|
||||
+3
-3
@@ -21,7 +21,7 @@ export type SegmentLocation = {
|
||||
export class Segment {
|
||||
input: SegmentedInput;
|
||||
location: SegmentLocation;
|
||||
relativeTimestamp: number;
|
||||
timestamp: number;
|
||||
relativeToUnixEpoch: boolean;
|
||||
duration: number;
|
||||
title: string | null;
|
||||
@@ -32,7 +32,7 @@ export class Segment {
|
||||
constructor(
|
||||
input: SegmentedInput,
|
||||
location: SegmentLocation,
|
||||
relativeTimestamp: number,
|
||||
timestamp: number,
|
||||
relativeToUnixEpoch: boolean,
|
||||
duration: number,
|
||||
title: string | null,
|
||||
@@ -42,7 +42,7 @@ export class Segment {
|
||||
) {
|
||||
this.input = input;
|
||||
this.location = location;
|
||||
this.relativeTimestamp = relativeTimestamp;
|
||||
this.timestamp = timestamp;
|
||||
this.relativeToUnixEpoch = relativeToUnixEpoch;
|
||||
this.duration = duration;
|
||||
this.title = title;
|
||||
|
||||
@@ -67,7 +67,7 @@ export abstract class SegmentedInput {
|
||||
|
||||
async getSegmentAt(timestamp: number) {
|
||||
const segments = await this.getSegments();
|
||||
const index = binarySearchLessOrEqual(segments, timestamp, x => x.relativeTimestamp);
|
||||
const index = binarySearchLessOrEqual(segments, timestamp, x => x.timestamp);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
@@ -175,24 +175,24 @@ class SegmentedInputDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
if (firstSegment === segment) {
|
||||
return firstSegment.relativeTimestamp - firstSegmentFirstTimestamp;
|
||||
return firstSegment.timestamp - firstSegmentFirstTimestamp;
|
||||
}
|
||||
|
||||
const segmentFirstTimestamp = await input.getFirstTimestamp();
|
||||
const segmentElapsed = segment.relativeTimestamp - firstSegment.relativeTimestamp;
|
||||
const segmentElapsed = segment.timestamp - firstSegment.timestamp;
|
||||
const inputElapsed = segmentFirstTimestamp - firstSegmentFirstTimestamp;
|
||||
const difference = inputElapsed - segmentElapsed;
|
||||
|
||||
if (Math.abs(difference) <= Math.min(0.25, segmentElapsed)) { // Heuristic
|
||||
// We're close enough
|
||||
return firstSegment.relativeTimestamp - firstSegmentFirstTimestamp;
|
||||
return firstSegment.timestamp - firstSegmentFirstTimestamp;
|
||||
} else {
|
||||
// Ideally, each segment has absolute timestamps that are relative to some outside clock which is
|
||||
// consistent across segments. This is often the case, but not always. Either the container format used is
|
||||
// not timestamped at all (like ADTS), or the segments are just fucky. In this case, use the segment's
|
||||
// relative timestamp to determine where we are, and completely offset out the segment's input start
|
||||
// timestamp.
|
||||
return segment.relativeTimestamp - segmentFirstTimestamp;
|
||||
return segment.timestamp - segmentFirstTimestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ class SegmentedInputInputTrackBacking implements InputTrackBacking {
|
||||
),
|
||||
// The 1e8 assumes a max of 100 MB per second, highly unlikely to be hit, so this should guarantee
|
||||
// monotonically increasing sequence numbers across segments.
|
||||
sequenceNumber: Math.floor(1e8 * segment.relativeTimestamp) + packet.sequenceNumber,
|
||||
sequenceNumber: Math.floor(1e8 * segment.timestamp) + packet.sequenceNumber,
|
||||
});
|
||||
|
||||
this.packetInfos.set(modified, {
|
||||
|
||||
+1
-21
@@ -641,27 +641,6 @@ export class UrlSource extends Source {
|
||||
// logic for that has vanished for now. Leaving a comment here if this becomes relevant again.
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private _getTotalLengthFromRangeResponse(response: Response) {
|
||||
const contentRange = response.headers.get('Content-Range');
|
||||
if (contentRange) {
|
||||
const match = /\/(\d+)/.exec(contentRange);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
return Number(contentLength);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Partial HTTP response (status 206) must surface either Content-Range or'
|
||||
+ ' Content-Length header.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_dispose() {
|
||||
this._orchestrator.dispose();
|
||||
@@ -1847,6 +1826,7 @@ class ReadOrchestrator {
|
||||
const index = this.workers.indexOf(worker);
|
||||
assert(index !== -1);
|
||||
|
||||
worker.running = false;
|
||||
this.workers.splice(index, 1);
|
||||
|
||||
if (this.fileSize === null) {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
- "manifest" -> "playlist"?
|
||||
- Retaining Input instances for segments without clogging up memory indefinitely
|
||||
|
||||
idea: discontinuities with extra decoder config on the packet.
|
||||
Also, why not just have the packet metadata on the packet? I think that would make things easier overall.
|
||||
Also: for robustness, do a different track matching algorithm for hls playback. so not use pid but make it simpler like
|
||||
if theres only 1 video track then its obvious yknow. Need to see if this is actually needed in the case of ext-x-discontinuity
|
||||
|
||||
- EXT-X-MEDIA (DONE, but surface more metadata from it, like language and shit) - should it be passed down to the tracks??
|
||||
- getSegments() API? Any point in partially reading the playlist file? Idk
|
||||
|
||||
- Timestamp across variants; i think the date should actually be used. Make the timestamp relative to the date? How does that play with timeResolution?
|
||||
|
||||
- ALL_FORMATS but for HLS only
|
||||
|
||||
|
||||
Reference in New Issue
Block a user