mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 16:53:47 +02:00
Add CMAF format and CMAF/init segment support to HLS muxer
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
MpegTsOutputFormat,
|
||||
AdtsOutputFormat,
|
||||
StreamTarget,
|
||||
CmafOutputFormat,
|
||||
} from 'mediabunny';
|
||||
|
||||
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
|
||||
@@ -119,8 +120,8 @@ const generateVideo = async () => {
|
||||
*/
|
||||
},
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new AdtsOutputFormat(), new MpegTsOutputFormat()],
|
||||
// singleFilePerPlaylist: true,
|
||||
segmentFormat: new CmafOutputFormat(),
|
||||
singleFilePerPlaylist: true,
|
||||
getPlaylistPath: info => `sussex-${info.n}.m3u8`,
|
||||
}),
|
||||
});
|
||||
@@ -146,7 +147,7 @@ const generateVideo = async () => {
|
||||
|
||||
// For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext
|
||||
let audioBufferSource: AudioBufferSource | null = null;
|
||||
let audioBufferSource2: AudioBufferSource | null = null;
|
||||
const audioBufferSource2: AudioBufferSource | null = null;
|
||||
|
||||
// Retrieve the first audio codec supported by this browser that can be contained in the output format
|
||||
const audioCodec = await getFirstEncodableAudioCodec(output.format.getSupportedAudioCodecs(), {
|
||||
@@ -160,11 +161,13 @@ const generateVideo = async () => {
|
||||
});
|
||||
output.addAudioTrack(audioBufferSource);
|
||||
|
||||
/*
|
||||
audioBufferSource2 = new AudioBufferSource({
|
||||
codec: audioCodec,
|
||||
bitrate: QUALITY_HIGH,
|
||||
});
|
||||
output.addAudioTrack(audioBufferSource2, { languageCode: 'esp' });
|
||||
*/
|
||||
} else {
|
||||
alert('Your browser doesn\'t support audio encoding, so we won\'t include audio in the output file.');
|
||||
}
|
||||
@@ -208,8 +211,8 @@ const generateVideo = async () => {
|
||||
await audioBufferSource.add(audioBuffer);
|
||||
audioBufferSource.close();
|
||||
|
||||
await audioBufferSource2!.add(audioBuffer);
|
||||
audioBufferSource2!.close();
|
||||
// await audioBufferSource2!.add(audioBuffer);
|
||||
// audioBufferSource2!.close();
|
||||
}
|
||||
|
||||
clearInterval(progressInterval);
|
||||
|
||||
+125
-33
@@ -1,6 +1,6 @@
|
||||
import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
|
||||
import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source';
|
||||
import { arrayArgmax, assert, findLastIndex, joinPaths, textEncoder, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { arrayArgmax, assert, findLastIndex, joinPaths, textEncoder, toArray, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { Muxer } from '../muxer';
|
||||
import {
|
||||
Output,
|
||||
@@ -11,11 +11,17 @@ import {
|
||||
OutputVideoTrack,
|
||||
TrackType,
|
||||
} from '../output';
|
||||
import { HlsOutputFormat, HlsOutputFormatOptions, HlsOutputSegmentInfo, OutputFormat } from '../output-format';
|
||||
import {
|
||||
HlsOutputFormat,
|
||||
HlsOutputFormatOptions,
|
||||
HlsOutputPlaylistInfo,
|
||||
HlsOutputSegmentInfo,
|
||||
OutputFormat,
|
||||
} from '../output-format';
|
||||
import { Writer } from '../writer';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { SubtitleCue, SubtitleMetadata } from '../subtitles';
|
||||
import { Target } from '../target';
|
||||
import { NullTarget, Target } from '../target';
|
||||
|
||||
type HlsTrackData = {
|
||||
track: OutputTrack;
|
||||
@@ -34,6 +40,13 @@ type HlsTrackData = {
|
||||
type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } };
|
||||
type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } };
|
||||
|
||||
type PlaylistSegment = {
|
||||
path: string;
|
||||
duration: number;
|
||||
byteSize: number;
|
||||
byteOffset: number | null;
|
||||
};
|
||||
|
||||
type Playlist = {
|
||||
id: number;
|
||||
path: string;
|
||||
@@ -43,12 +56,8 @@ type Playlist = {
|
||||
currentSegmentStartTimestamp: number | null;
|
||||
currentSegmentStartTimestampIsFixed: boolean;
|
||||
nextSegmentId: number;
|
||||
writtenSegments: {
|
||||
path: string;
|
||||
duration: number;
|
||||
byteSize: number;
|
||||
byteOffset: number | null;
|
||||
}[];
|
||||
initSegment: PlaylistSegment | null;
|
||||
writtenSegments: PlaylistSegment[];
|
||||
peakBitrate: number | null;
|
||||
averageBitrate: number | null;
|
||||
|
||||
@@ -71,6 +80,7 @@ export class HlsMuxer extends Muxer {
|
||||
format: HlsOutputFormat;
|
||||
getPlaylistPath: NonNullable<HlsOutputFormatOptions['getPlaylistPath']>;
|
||||
getSegmentPath: NonNullable<HlsOutputFormatOptions['getSegmentPath']>;
|
||||
getInitPath: NonNullable<HlsOutputFormatOptions['getInitPath']>;
|
||||
|
||||
targetSegmentDuration: number;
|
||||
trackDatas: HlsTrackData[] = [];
|
||||
@@ -96,6 +106,8 @@ export class HlsMuxer extends Muxer {
|
||||
?? (info => info.isSingleFile
|
||||
? `segments-${info.playlist.n}${info.format.fileExtension}`
|
||||
: `segment-${info.playlist.n}-${info.n}${info.format.fileExtension}`);
|
||||
this.getInitPath = format._options.getInitPath
|
||||
?? (playlist => `init-${playlist.n}${playlist.segmentFormat.fileExtension}`);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -348,7 +360,7 @@ export class HlsMuxer extends Muxer {
|
||||
codecs.push(track.source._codec);
|
||||
}
|
||||
|
||||
for (const format of this.format._options.segmentFormats) {
|
||||
for (const format of toArray(this.format._options.segmentFormat)) {
|
||||
const supportedCodecs = format.getSupportedCodecs();
|
||||
const trackCounts = format.getSupportedTrackCounts();
|
||||
|
||||
@@ -387,21 +399,15 @@ export class HlsMuxer extends Muxer {
|
||||
throw new Error('Internal error: track is already registered in a playlist.'); // Should be unreachable
|
||||
}
|
||||
|
||||
const format = deduceSegmentFormat(tracks);
|
||||
|
||||
const id = this.playlists.length + 1;
|
||||
const path = await this.getPlaylistPath({
|
||||
n: id,
|
||||
tracks,
|
||||
segmentFormat: format,
|
||||
});
|
||||
if (typeof path !== 'string') {
|
||||
throw new TypeError('options.getPlaylistPath must return or resolve to a string');
|
||||
}
|
||||
if (/[\n\r"]/.test(path)) {
|
||||
throw new TypeError(
|
||||
'Playlist paths cannot contain line feed, carriage return, or double quote characters.',
|
||||
);
|
||||
}
|
||||
|
||||
const format = deduceSegmentFormat(tracks);
|
||||
validatePlaylistPath(path);
|
||||
|
||||
const playlist: Playlist = {
|
||||
id: this.playlists.length + 1,
|
||||
@@ -411,6 +417,7 @@ export class HlsMuxer extends Muxer {
|
||||
currentSegmentStartTimestamp: null,
|
||||
currentSegmentStartTimestampIsFixed: false,
|
||||
nextSegmentId: 1,
|
||||
initSegment: null,
|
||||
writtenSegments: [],
|
||||
peakBitrate: null,
|
||||
averageBitrate: null,
|
||||
@@ -762,10 +769,7 @@ export class HlsMuxer extends Muxer {
|
||||
n: playlist.nextSegmentId,
|
||||
format: playlist.segmentFormat,
|
||||
isSingleFile: true,
|
||||
playlist: {
|
||||
n: playlist.id,
|
||||
tracks: playlist.tracks,
|
||||
},
|
||||
playlist: toPlaylistInfo(playlist),
|
||||
};
|
||||
|
||||
relativeSegmentPath = await this.getSegmentPath(segmentInfo);
|
||||
@@ -797,10 +801,7 @@ export class HlsMuxer extends Muxer {
|
||||
n: playlist.nextSegmentId,
|
||||
format: playlist.segmentFormat,
|
||||
isSingleFile: false,
|
||||
playlist: {
|
||||
n: playlist.id,
|
||||
tracks: playlist.tracks,
|
||||
},
|
||||
playlist: toPlaylistInfo(playlist),
|
||||
};
|
||||
|
||||
relativeSegmentPath = await this.getSegmentPath(segmentInfo);
|
||||
@@ -818,7 +819,7 @@ export class HlsMuxer extends Muxer {
|
||||
format: playlist.segmentFormat,
|
||||
rootPath: fullSegmentPath,
|
||||
target: async (request) => {
|
||||
if (request.path === fullSegmentPath) {
|
||||
if (request.isRoot) {
|
||||
if (playlist.singleFile) {
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
|
||||
@@ -835,6 +836,55 @@ export class HlsMuxer extends Muxer {
|
||||
|
||||
return this.output._getTarget(request);
|
||||
},
|
||||
initTarget: async () => {
|
||||
if (playlist.initSegment) {
|
||||
// We already have an init segment from a previous segment
|
||||
return new NullTarget();
|
||||
}
|
||||
|
||||
if (playlist.singleFile) {
|
||||
playlist.initSegment = {
|
||||
path: playlist.singleFile.path,
|
||||
duration: 0,
|
||||
byteSize: 0,
|
||||
byteOffset: 0,
|
||||
};
|
||||
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.onwrite = (_, end) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
};
|
||||
slice.onfinalized = () => {
|
||||
playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize;
|
||||
};
|
||||
|
||||
return slice;
|
||||
} else {
|
||||
const playlistInfo = toPlaylistInfo(playlist);
|
||||
const path = await this.getInitPath(playlistInfo);
|
||||
validateInitPath(path);
|
||||
|
||||
playlist.initSegment = {
|
||||
path,
|
||||
duration: 0,
|
||||
byteSize: 0,
|
||||
byteOffset: null,
|
||||
};
|
||||
|
||||
const target = await this.output._getTarget({
|
||||
path,
|
||||
isRoot: false,
|
||||
});
|
||||
target.onwrite = (_, end) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
};
|
||||
target.onfinalized = () => {
|
||||
this.format._options.onInit?.(target, playlistInfo);
|
||||
};
|
||||
|
||||
return target;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
let maxEndTimestamp = -Infinity;
|
||||
@@ -987,13 +1037,28 @@ export class HlsMuxer extends Muxer {
|
||||
if (isKeyPacketsOnly || hasByteOffsets) {
|
||||
version = 4;
|
||||
}
|
||||
if (playlist.initSegment) {
|
||||
version = 5;
|
||||
}
|
||||
if (playlist.initSegment && !isKeyPacketsOnly) {
|
||||
// "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY"
|
||||
version = 6;
|
||||
}
|
||||
|
||||
const playlistPath = joinPaths(this.output._rootPath!, playlist.path);
|
||||
const playlistText = '#EXTM3U\n'
|
||||
+ `#EXT-X-VERSION:${version}\n`
|
||||
+ '#EXT-X-PLAYLIST-TYPE:VOD\n'
|
||||
+ `#EXT-X-TARGETDURATION:${Math.ceil(targetDuration)}\n` // Must be a "decimal-integer"
|
||||
+ '#EXT-X-INDEPENDENT-SEGMENTS\n' // TODO not when live
|
||||
+ (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '')
|
||||
+ (playlist.initSegment
|
||||
? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"`
|
||||
+ (playlist.initSegment.byteOffset !== null
|
||||
? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"`
|
||||
: '')
|
||||
+ '\n')
|
||||
: '')
|
||||
+ '\n'
|
||||
+ (playlist.writtenSegments
|
||||
.map(segment => (
|
||||
@@ -1007,10 +1072,7 @@ export class HlsMuxer extends Muxer {
|
||||
+ (playlist.writtenSegments.length > 0 ? '\n' : '')
|
||||
+ '#EXT-X-ENDLIST\n';
|
||||
|
||||
this.format._options.onPlaylist?.(playlistText, {
|
||||
n: playlist.id,
|
||||
tracks: playlist.tracks,
|
||||
});
|
||||
this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist));
|
||||
|
||||
const target = await this.output._getTarget({ path: playlistPath, isRoot: false });
|
||||
const writer = new Writer(target);
|
||||
@@ -1220,6 +1282,17 @@ export class HlsMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
const validatePlaylistPath = (path: string) => {
|
||||
if (typeof path !== 'string') {
|
||||
throw new TypeError('options.getPlaylistPath must return or resolve to a string');
|
||||
}
|
||||
if (/[\n\r"]/.test(path)) {
|
||||
throw new TypeError(
|
||||
'Playlist paths cannot contain line feed, carriage return, or double quote characters.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateSegmentPath = (path: string) => {
|
||||
if (typeof path !== 'string') {
|
||||
throw new TypeError('options.getSegmentPath must return or resolve to a string');
|
||||
@@ -1230,3 +1303,22 @@ const validateSegmentPath = (path: string) => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateInitPath = (path: string) => {
|
||||
if (typeof path !== 'string') {
|
||||
throw new TypeError('options.getInitPath must return or resolve to a string');
|
||||
}
|
||||
if (/[\n\r"]/.test(path)) {
|
||||
throw new TypeError(
|
||||
'Init paths cannot contain line feed, carriage return, or double quote characters.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toPlaylistInfo = (playlist: Playlist): HlsOutputPlaylistInfo => {
|
||||
return {
|
||||
n: playlist.id,
|
||||
tracks: playlist.tracks,
|
||||
segmentFormat: playlist.segmentFormat,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
this.nextLines = null;
|
||||
|
||||
if (!lines) {
|
||||
using ref = await this.demuxer.input._getSourceUncached({ path: this.path });
|
||||
using ref = await this.demuxer.input._getSourceUncached({ path: this.path, isRoot: false });
|
||||
const reader = new Reader(ref.source);
|
||||
|
||||
const slice = await reader.requestEntireFile();
|
||||
@@ -559,7 +559,7 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
|
||||
const stream = createAes128CbcDecryptStream(ciphertextReader, async () => {
|
||||
using keyRef = await this.input._getSourceCached(
|
||||
{ path: hlsSegment.encryption!.keyUri },
|
||||
{ path: hlsSegment.encryption!.keyUri, isRoot: false },
|
||||
ENCRYPTION_KEY_CACHE_GROUP,
|
||||
);
|
||||
const keyReader = new Reader(keyRef.source);
|
||||
|
||||
@@ -37,6 +37,8 @@ export {
|
||||
OutputFormat,
|
||||
AdtsOutputFormat,
|
||||
AdtsOutputFormatOptions,
|
||||
CmafOutputFormat,
|
||||
CmafOutputFormatOptions,
|
||||
FlacOutputFormat,
|
||||
FlacOutputFormatOptions,
|
||||
HlsOutputFormat,
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ export class Mp4InputFormat extends IsobmffInputFormat {
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) return false;
|
||||
|
||||
return readAscii(slice, 4) === 'moof'; // Not a legal segment start, but seen in practice (sigh)
|
||||
return readAscii(slice, 4) === 'moof'; // Seen in HLS for example
|
||||
}
|
||||
|
||||
get name() {
|
||||
|
||||
+3
-2
@@ -28,6 +28,7 @@ export const ENCRYPTION_KEY_CACHE_GROUP = 2;
|
||||
|
||||
export type SourceRequest = {
|
||||
path: string;
|
||||
isRoot: boolean;
|
||||
};
|
||||
|
||||
const sourceRequestsAreEqual = (a: SourceRequest, b: SourceRequest) => {
|
||||
@@ -248,7 +249,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
this.onSource?.(ref.source, null);
|
||||
} else {
|
||||
assert(this._entryPath !== null);
|
||||
ref = await this._getSourceUncached({ path: this._entryPath });
|
||||
ref = await this._getSourceUncached({ path: this._entryPath, isRoot: true });
|
||||
this._sourceRefs.push(ref);
|
||||
}
|
||||
|
||||
@@ -277,7 +278,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
|
||||
assert(this._entryPath !== null);
|
||||
|
||||
const source = this._source({ path: this._entryPath });
|
||||
const source = this._source({ path: this._entryPath, isRoot: true });
|
||||
if (source instanceof Promise) {
|
||||
throw new TypeError(
|
||||
'Input.source cannot be used when the source function resolves asynchronously.'
|
||||
|
||||
@@ -57,7 +57,7 @@ export class IsobmffBoxWriter {
|
||||
*/
|
||||
offsets = new WeakMap<Box, number>();
|
||||
|
||||
constructor(private writer: Writer) {}
|
||||
constructor(public writer: Writer) {}
|
||||
|
||||
writeU32(value: number) {
|
||||
this.helperView.setUint32(0, value, false);
|
||||
@@ -279,6 +279,7 @@ export const ftyp = (details: {
|
||||
isQuickTime: boolean;
|
||||
holdsAvc: boolean;
|
||||
fragmented: boolean;
|
||||
cmaf: boolean;
|
||||
}) => {
|
||||
// You can find the full logic for this at
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/de2fb43e785773738c660cdafb9309b1ef1bc80d/libavformat/movenc.c#L5518
|
||||
@@ -296,14 +297,27 @@ export const ftyp = (details: {
|
||||
}
|
||||
|
||||
if (details.fragmented) {
|
||||
return box('ftyp', [
|
||||
ascii('iso5'), // Major brand
|
||||
u32(minorVersion), // Minor version
|
||||
// Compatible brands
|
||||
ascii('iso5'),
|
||||
ascii('iso6'),
|
||||
ascii('mp41'),
|
||||
]);
|
||||
if (details.cmaf) {
|
||||
return box('ftyp', [
|
||||
ascii('iso5'), // Major brand
|
||||
u32(minorVersion), // Minor version
|
||||
// Compatible brands
|
||||
ascii('iso5'),
|
||||
ascii('iso6'),
|
||||
ascii('mp41'),
|
||||
ascii('cmfc'),
|
||||
ascii('dash'),
|
||||
]);
|
||||
} else {
|
||||
return box('ftyp', [
|
||||
ascii('iso5'), // Major brand
|
||||
u32(minorVersion), // Minor version
|
||||
// Compatible brands
|
||||
ascii('iso5'),
|
||||
ascii('iso6'),
|
||||
ascii('mp41'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return box('ftyp', [
|
||||
@@ -316,6 +330,38 @@ export const ftyp = (details: {
|
||||
]);
|
||||
};
|
||||
|
||||
/** Segment Type Box */
|
||||
export const styp = () => box('styp', [
|
||||
ascii('iso5'), // Major brand
|
||||
u32(0), // Minor version
|
||||
// Compatible brands
|
||||
ascii('iso5'),
|
||||
ascii('iso6'),
|
||||
ascii('mp41'),
|
||||
ascii('cmfc'),
|
||||
ascii('dash'),
|
||||
]);
|
||||
|
||||
/** Segment Index Box */
|
||||
export const sidx = (muxer: IsobmffMuxer, referencedSize: number) => {
|
||||
let duration = muxer.maxWrittenEndTimestamp - muxer.minWrittenTimestamp;
|
||||
if (!Number.isFinite(duration)) {
|
||||
duration = 0;
|
||||
}
|
||||
|
||||
return fullBox('sidx', 1, 0, [
|
||||
u32(1), // Reference ID
|
||||
u32(GLOBAL_TIMESCALE), // Timescale
|
||||
u64(intoTimescale(muxer.minWrittenTimestamp, GLOBAL_TIMESCALE)), // Earliest presentation time
|
||||
u64(0), // First offset
|
||||
u16(0), // Reserved
|
||||
u16(1), // Reference count
|
||||
u32(referencedSize & 0x7fffffff), // Reference type (0) + referenced size
|
||||
u32(intoTimescale(duration, GLOBAL_TIMESCALE)), // Subsegment duration
|
||||
u32(0), // Starts with SAP + SAP type + SAP delta time (no information provided)
|
||||
]);
|
||||
};
|
||||
|
||||
/** Movie Sample Data Box. Contains the actual frames/samples of the media. */
|
||||
export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSize: reserveLargeSize });
|
||||
|
||||
|
||||
+135
-32
@@ -6,13 +6,27 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
|
||||
import {
|
||||
Box,
|
||||
free,
|
||||
ftyp,
|
||||
IsobmffBoxWriter,
|
||||
mdat,
|
||||
mfra,
|
||||
moof,
|
||||
moov,
|
||||
sidx,
|
||||
styp,
|
||||
vtta,
|
||||
vttc,
|
||||
vtte,
|
||||
} from './isobmff-boxes';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
|
||||
import { Writer } from '../writer';
|
||||
import { BufferTarget } from '../target';
|
||||
import { BufferTarget, Target } from '../target';
|
||||
import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc';
|
||||
import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat } from '../output-format';
|
||||
import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat, CmafOutputFormat } from '../output-format';
|
||||
import { inlineTimestampRegex, SubtitleConfig, SubtitleCue, SubtitleMetadata } from '../subtitles';
|
||||
import { aacChannelMap, aacFrequencyTable, buildAacAudioSpecificConfig } from '../../shared/aac-misc';
|
||||
import {
|
||||
@@ -151,12 +165,15 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round =
|
||||
|
||||
export class IsobmffMuxer extends Muxer {
|
||||
format: IsobmffOutputFormat;
|
||||
private writer!: Writer;
|
||||
private boxWriter!: IsobmffBoxWriter;
|
||||
private writer: Writer | null = null;
|
||||
private boxWriter: IsobmffBoxWriter | null = null;
|
||||
private initWriter: Writer | null = null;
|
||||
private initBoxWriter: IsobmffBoxWriter | null = null;
|
||||
private fastStart!: NonNullable<IsobmffOutputFormatOptions['fastStart']>;
|
||||
isFragmented!: boolean;
|
||||
|
||||
isQuickTime: boolean;
|
||||
isCmaf: boolean;
|
||||
|
||||
private auxTarget = new BufferTarget();
|
||||
private auxWriter = new Writer(this.auxTarget);
|
||||
@@ -174,53 +191,90 @@ export class IsobmffMuxer extends Muxer {
|
||||
private nextFragmentNumber = 1;
|
||||
// Only relevant for fragmented files, to make sure new fragments start with the highest timestamp seen so far
|
||||
private maxWrittenTimestamp = -Infinity;
|
||||
minWrittenTimestamp = Infinity;
|
||||
maxWrittenEndTimestamp = -Infinity;
|
||||
private minimumFragmentDuration: number;
|
||||
private segmentHeaderSize: number | null = null;
|
||||
|
||||
constructor(output: Output, format: IsobmffOutputFormat) {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.isQuickTime = format instanceof MovOutputFormat;
|
||||
this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? 1;
|
||||
this.isCmaf = format instanceof CmafOutputFormat;
|
||||
this.minimumFragmentDuration = format._options.minimumFragmentDuration
|
||||
?? (format instanceof CmafOutputFormat ? Infinity : 1);
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
||||
if (!this.isCmaf) {
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
||||
|
||||
// If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as the
|
||||
// memory usage remains identical
|
||||
const fastStartDefault = this.writer.target instanceof BufferTarget ? 'in-memory' : false;
|
||||
this.fastStart = this.format._options.fastStart ?? fastStartDefault;
|
||||
this.isFragmented = this.fastStart === 'fragmented';
|
||||
// If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as
|
||||
// the memory usage remains identical
|
||||
this.fastStart = this.format._options.fastStart
|
||||
?? (this.writer.target instanceof BufferTarget ? 'in-memory' : false);
|
||||
this.isFragmented = this.fastStart === 'fragmented';
|
||||
} else {
|
||||
this.fastStart = 'fragmented';
|
||||
this.isFragmented = true;
|
||||
}
|
||||
|
||||
if (this.fastStart === 'in-memory' || this.isFragmented) {
|
||||
this.writer.ensureMonotonicity();
|
||||
this.writer?.ensureMonotonicity();
|
||||
}
|
||||
|
||||
if (this.isCmaf) {
|
||||
if (this.output._initTarget === null) {
|
||||
throw new Error(
|
||||
`CMAF outputs require the initTarget field in OutputOptions to be set; the init segment`
|
||||
+ ` will be written to it.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set up the init writer to which we'll write the init segment
|
||||
const initTarget = this.output._initTarget instanceof Target
|
||||
? this.output._initTarget
|
||||
: await this.output._initTarget();
|
||||
const initWriter = new Writer(initTarget);
|
||||
initWriter.start();
|
||||
|
||||
this.initWriter = initWriter;
|
||||
this.initBoxWriter = new IsobmffBoxWriter(initWriter);
|
||||
}
|
||||
|
||||
const holdsAvc = this.output._tracks.some(x => x.isVideoTrack() && x.source._codec === 'avc');
|
||||
|
||||
// Write the header
|
||||
{
|
||||
const boxWriter = this.initBoxWriter ?? this.boxWriter;
|
||||
assert(boxWriter);
|
||||
|
||||
if (this.format._options.onFtyp) {
|
||||
this.writer.startTrackingWrites();
|
||||
boxWriter.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
this.boxWriter.writeBox(ftyp({
|
||||
boxWriter.writeBox(ftyp({
|
||||
isQuickTime: this.isQuickTime,
|
||||
holdsAvc: holdsAvc,
|
||||
fragmented: this.isFragmented,
|
||||
cmaf: this.isCmaf,
|
||||
}));
|
||||
|
||||
if (this.format._options.onFtyp) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
const { data, start } = boxWriter.writer.stopTrackingWrites();
|
||||
this.format._options.onFtyp(data, start);
|
||||
}
|
||||
}
|
||||
|
||||
this.ftypSize = this.writer.getPos();
|
||||
this.ftypSize = boxWriter.writer.getPos();
|
||||
|
||||
if (this.isCmaf) {
|
||||
await this.initWriter!.flush();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fastStart === 'in-memory') {
|
||||
// We're write at finalization
|
||||
@@ -239,6 +293,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
} else if (this.isFragmented) {
|
||||
// We write the moov box once we write out the first fragment to make sure we get the decoder configs
|
||||
} else {
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
if (this.format._options.onMdat) {
|
||||
this.writer.startTrackingWrites();
|
||||
}
|
||||
@@ -247,7 +304,7 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.boxWriter.writeBox(this.mdat);
|
||||
}
|
||||
|
||||
await this.writer.flush();
|
||||
await this.writer?.flush();
|
||||
|
||||
release();
|
||||
}
|
||||
@@ -1003,11 +1060,14 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
if (this.isFragmented) {
|
||||
this.maxWrittenTimestamp = Math.max(this.maxWrittenTimestamp, sample.timestamp);
|
||||
this.maxWrittenEndTimestamp = Math.max(this.maxWrittenEndTimestamp, sample.timestamp + sample.duration);
|
||||
this.minWrittenTimestamp = Math.min(this.minWrittenTimestamp, sample.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeCurrentChunk(trackData: IsobmffTrackData) {
|
||||
assert(!this.isFragmented);
|
||||
assert(this.writer);
|
||||
|
||||
if (!trackData.currentChunk) return;
|
||||
|
||||
@@ -1078,26 +1138,51 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeFragment(flushWriter = true) {
|
||||
private async finalizeFragment(flushWriter = !this.isCmaf) {
|
||||
assert(this.isFragmented);
|
||||
|
||||
const fragmentNumber = this.nextFragmentNumber++;
|
||||
|
||||
if (fragmentNumber === 1) {
|
||||
const boxWriter = this.initBoxWriter ?? this.boxWriter;
|
||||
assert(boxWriter);
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
this.writer.startTrackingWrites();
|
||||
boxWriter.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
// Write the moov box now that we have all decoder configs
|
||||
const movieBox = moov(this);
|
||||
this.boxWriter.writeBox(movieBox);
|
||||
boxWriter.writeBox(movieBox);
|
||||
|
||||
if (this.format._options.onMoov) {
|
||||
const { data, start } = this.writer.stopTrackingWrites();
|
||||
const { data, start } = boxWriter.writer.stopTrackingWrites();
|
||||
this.format._options.onMoov(data, start);
|
||||
}
|
||||
|
||||
if (this.isCmaf) {
|
||||
assert(this.initWriter);
|
||||
await this.initWriter.flush();
|
||||
await this.initWriter.finalize(); // Init segment is done
|
||||
|
||||
// Only now, init the main writer; this way the init writer is fully done before the main writer is
|
||||
// even acquired
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
||||
|
||||
this.writer.ensureMonotonicity();
|
||||
|
||||
const stypSize = this.boxWriter.measureBox(styp());
|
||||
const sidxSize = this.boxWriter.measureBox(sidx(this, 0));
|
||||
this.segmentHeaderSize = stypSize + sidxSize;
|
||||
|
||||
this.writer.seek(this.segmentHeaderSize); // Make room for the header to be written later
|
||||
}
|
||||
}
|
||||
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
// Not all tracks need to be present in every fragment
|
||||
const tracksInFragment = this.trackDatas.filter(x => x.currentChunk);
|
||||
|
||||
@@ -1179,6 +1264,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
|
||||
private async registerSampleFastStartReserve(trackData: IsobmffTrackData, sample: Sample) {
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
if (this.allTracksAreKnown()) {
|
||||
if (!this.mdat) {
|
||||
// We finally know all tracks, let's reserve space for the moov box
|
||||
@@ -1296,6 +1384,9 @@ export class IsobmffMuxer extends Muxer {
|
||||
}
|
||||
}
|
||||
|
||||
assert(this.writer);
|
||||
assert(this.boxWriter);
|
||||
|
||||
if (this.fastStart === 'in-memory') {
|
||||
this.mdat = mdat(false);
|
||||
let mdatSize: number;
|
||||
@@ -1359,15 +1450,27 @@ export class IsobmffMuxer extends Muxer {
|
||||
this.format._options.onMdat(data, start);
|
||||
}
|
||||
} else if (this.isFragmented) {
|
||||
// Append the mfra box to the end of the file for better random access
|
||||
const startPos = this.writer.getPos();
|
||||
const mfraBox = mfra(this.trackDatas);
|
||||
this.boxWriter.writeBox(mfraBox);
|
||||
if (this.isCmaf) {
|
||||
const contentSize = this.segmentHeaderSize !== null
|
||||
? this.writer.getPos() - this.segmentHeaderSize
|
||||
: 0;
|
||||
|
||||
// Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size
|
||||
const mfraBoxSize = this.writer.getPos() - startPos;
|
||||
this.writer.seek(this.writer.getPos() - 4);
|
||||
this.boxWriter.writeU32(mfraBoxSize);
|
||||
this.writer.seek(0);
|
||||
|
||||
// Write styp and sidx to the start; we recently made space for these
|
||||
this.boxWriter.writeBox(styp());
|
||||
this.boxWriter.writeBox(sidx(this, contentSize));
|
||||
} else {
|
||||
// Append the mfra box to the end of the file for better random access
|
||||
const startPos = this.writer.getPos();
|
||||
const mfraBox = mfra(this.trackDatas);
|
||||
this.boxWriter.writeBox(mfraBox);
|
||||
|
||||
// Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size
|
||||
const mfraBoxSize = this.writer.getPos() - startPos;
|
||||
this.writer.seek(this.writer.getPos() - 4);
|
||||
this.boxWriter.writeU32(mfraBoxSize);
|
||||
}
|
||||
} else {
|
||||
assert(this.mdat);
|
||||
|
||||
|
||||
@@ -1150,3 +1150,11 @@ export const rejectAfter = (ms: number, message = 'Promise rejected') => {
|
||||
setTimeout(() => reject(new Error(message)), ms);
|
||||
});
|
||||
};
|
||||
|
||||
export const toArray = <T>(x: T | T[]) => {
|
||||
if (Array.isArray(x)) {
|
||||
return x;
|
||||
} else {
|
||||
return [x];
|
||||
}
|
||||
};
|
||||
|
||||
+80
-9
@@ -29,7 +29,7 @@ import { Output, OutputTrack, TrackType } from './output';
|
||||
import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer';
|
||||
import { WaveMuxer } from './wave/wave-muxer';
|
||||
import { HlsMuxer } from './hls/hls-muxer';
|
||||
import { MaybePromise } from './misc';
|
||||
import { MaybePromise, toArray } from './misc';
|
||||
import { Target } from './target';
|
||||
|
||||
/**
|
||||
@@ -333,6 +333,66 @@ export class Mp4OutputFormat extends IsobmffOutputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CMAF-specific output options.
|
||||
* @group Output formats
|
||||
* @public
|
||||
*/
|
||||
export type CmafOutputFormatOptions = Omit<IsobmffOutputFormatOptions, 'fastStart'> & {
|
||||
/**
|
||||
* Controls the minimum duration of each fragment, in seconds. New fragments will only be created when the current
|
||||
* fragment is longer than this value. Defaults to `Infinity`, meaning the file will contain only one fragment.
|
||||
*/
|
||||
minimumFragmentDuration?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a single Common Media Application Format (CMAF) segment. An init segment will be written to the
|
||||
* {@link Target} specified in {@link OutputOptions.initTarget}. Supports most codecs.
|
||||
* @group Output formats
|
||||
* @public
|
||||
*/
|
||||
export class CmafOutputFormat extends IsobmffOutputFormat {
|
||||
/** Creates a new {@link CmafOutputFormat} configured with the specified `options`. */
|
||||
constructor(options?: CmafOutputFormatOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get _name() {
|
||||
return 'CMAF';
|
||||
}
|
||||
|
||||
get fileExtension() {
|
||||
return '.m4s';
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
getSupportedCodecs(): MediaCodec[] {
|
||||
return [
|
||||
...VIDEO_CODECS,
|
||||
...NON_PCM_AUDIO_CODECS,
|
||||
|
||||
// These are supported via ISO/IEC 23003-5:
|
||||
'pcm-s16',
|
||||
'pcm-s16be',
|
||||
'pcm-s24',
|
||||
'pcm-s24be',
|
||||
'pcm-s32',
|
||||
'pcm-s32be',
|
||||
'pcm-f32',
|
||||
'pcm-f32be',
|
||||
'pcm-f64',
|
||||
'pcm-f64be',
|
||||
|
||||
...SUBTITLE_CODECS,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickTime File Format (QTFF), often called MOV. Supports all video and audio codecs, but not subtitle codecs.
|
||||
* @group Output formats
|
||||
@@ -1098,6 +1158,7 @@ export class MpegTsOutputFormat extends OutputFormat {
|
||||
export type HlsOutputPlaylistInfo = {
|
||||
n: number;
|
||||
tracks: OutputTrack[];
|
||||
segmentFormat: OutputFormat;
|
||||
};
|
||||
|
||||
export type HlsOutputSegmentInfo = {
|
||||
@@ -1108,16 +1169,18 @@ export type HlsOutputSegmentInfo = {
|
||||
};
|
||||
|
||||
export type HlsOutputFormatOptions = {
|
||||
segmentFormats: OutputFormat[];
|
||||
segmentFormat: OutputFormat | OutputFormat[];
|
||||
targetDuration?: number;
|
||||
singleFilePerPlaylist?: boolean;
|
||||
getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<string>;
|
||||
getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise<string>;
|
||||
getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<string>;
|
||||
|
||||
onMaster?: (content: string) => unknown;
|
||||
onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown;
|
||||
// Document how this is called for the single-file mode
|
||||
onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown;
|
||||
onInit?: (target: Target, info: HlsOutputPlaylistInfo) => unknown;
|
||||
};
|
||||
|
||||
export class HlsOutputFormat extends OutputFormat {
|
||||
@@ -1130,11 +1193,16 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (
|
||||
!Array.isArray(options.segmentFormats)
|
||||
|| options.segmentFormats.length === 0
|
||||
|| !options.segmentFormats.every(format => format instanceof OutputFormat)
|
||||
!(options.segmentFormat instanceof OutputFormat)
|
||||
&& (
|
||||
!Array.isArray(options.segmentFormat)
|
||||
|| options.segmentFormat.length === 0
|
||||
|| !options.segmentFormat.every(format => format instanceof OutputFormat)
|
||||
)
|
||||
) {
|
||||
throw new TypeError('options.segmentFormats must be a non-empty array of OutputFormat instances.');
|
||||
throw new TypeError(
|
||||
'options.segmentFormat must be an OutputFormat or a non-empty array of OutputFormat instances.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.targetDuration !== undefined
|
||||
@@ -1160,6 +1228,9 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
if (options.onSegment !== undefined && typeof options.onSegment !== 'function') {
|
||||
throw new TypeError('options.onSegment, when provided, must be a function.');
|
||||
}
|
||||
if (options.onInit !== undefined && typeof options.onInit !== 'function') {
|
||||
throw new TypeError('options.onInit, when provided, must be a function.');
|
||||
}
|
||||
|
||||
super();
|
||||
|
||||
@@ -1185,7 +1256,7 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
}
|
||||
|
||||
getSupportedCodecs(): MediaCodec[] {
|
||||
const uniqueCodecs = new Set(this._options.segmentFormats.flatMap(x => x.getSupportedCodecs()));
|
||||
const uniqueCodecs = new Set(toArray(this._options.segmentFormat).flatMap(x => x.getSupportedCodecs()));
|
||||
return [...uniqueCodecs];
|
||||
}
|
||||
|
||||
@@ -1194,7 +1265,7 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
let supportsAudio = false;
|
||||
let supportsSubtitle = false;
|
||||
|
||||
for (const format of this._options.segmentFormats) {
|
||||
for (const format of toArray(this._options.segmentFormat)) {
|
||||
const trackCounts = format.getSupportedTrackCounts();
|
||||
supportsVideo ||= trackCounts.video.max > 0;
|
||||
supportsAudio ||= trackCounts.audio.max > 0;
|
||||
@@ -1210,7 +1281,7 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
}
|
||||
|
||||
get supportsVideoRotationMetadata(): boolean {
|
||||
return this._options.segmentFormats.some(format => format.supportsVideoRotationMetadata);
|
||||
return toArray(this._options.segmentFormat).some(format => format.supportsVideoRotationMetadata);
|
||||
}
|
||||
|
||||
get supportsTimestampedMediaData(): boolean {
|
||||
|
||||
+73
-52
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation } from './misc';
|
||||
import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation, toArray } from './misc';
|
||||
import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata';
|
||||
import { Muxer } from './muxer';
|
||||
import { OutputFormat } from './output-format';
|
||||
@@ -14,27 +14,6 @@ import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-s
|
||||
import { Target } from './target';
|
||||
import { Writer } from './writer';
|
||||
|
||||
export type TargetRequest = {
|
||||
path: string;
|
||||
isRoot: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The options for creating an Output object.
|
||||
* @group Output files
|
||||
* @public
|
||||
*/
|
||||
export type OutputOptions<
|
||||
F extends OutputFormat = OutputFormat,
|
||||
T extends Target = Target,
|
||||
> = {
|
||||
/** The format of the output file. */
|
||||
format: F;
|
||||
/** The target to which the file will be written. */
|
||||
target: T | ((request: TargetRequest) => MaybePromise<T>);
|
||||
rootPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* List of all track types.
|
||||
* @group Miscellaneous
|
||||
@@ -163,12 +142,8 @@ export const outputTracksArePairable = (a: OutputTrack, b: OutputTrack) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aGroups = Array.isArray(a.metadata.group)
|
||||
? a.metadata.group
|
||||
: [a.metadata.group!];
|
||||
const bGroups = Array.isArray(b.metadata.group)
|
||||
? b.metadata.group
|
||||
: [b.metadata.group!];
|
||||
const aGroups = toArray(a.metadata.group!);
|
||||
const bGroups = toArray(b.metadata.group!);
|
||||
|
||||
for (const aGroup of aGroups) {
|
||||
const pairableInSameGroup = a.type !== b.type
|
||||
@@ -282,6 +257,28 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
|
||||
}
|
||||
};
|
||||
|
||||
export type TargetRequest = {
|
||||
path: string;
|
||||
isRoot: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The options for creating an Output object.
|
||||
* @group Output files
|
||||
* @public
|
||||
*/
|
||||
export type OutputOptions<
|
||||
F extends OutputFormat = OutputFormat,
|
||||
T extends Target = Target,
|
||||
> = {
|
||||
/** The format of the output file. */
|
||||
format: F;
|
||||
/** The target to which the file will be written. */
|
||||
target: T | ((request: TargetRequest) => MaybePromise<T>);
|
||||
rootPath?: string;
|
||||
initTarget?: T | (() => MaybePromise<T>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main class orchestrating the creation of a new media file.
|
||||
* @group Output files
|
||||
@@ -301,6 +298,8 @@ export class Output<
|
||||
/** @internal */
|
||||
_rootPath: string | null;
|
||||
/** @internal */
|
||||
_initTarget: T | (() => MaybePromise<T>) | null;
|
||||
/** @internal */
|
||||
_muxer: Muxer;
|
||||
/** @internal */
|
||||
_rootWriterPromise: Promise<Writer> | null = null;
|
||||
@@ -353,7 +352,7 @@ export class Output<
|
||||
throw new TypeError('options.format must be an OutputFormat.');
|
||||
}
|
||||
if (!(options.target instanceof Target) && typeof options.target !== 'function') {
|
||||
throw new TypeError('options.target must be a Target.');
|
||||
throw new TypeError('options.target must be a Target or a function that returns or resolves to a Target.');
|
||||
}
|
||||
if (options.target instanceof Target) {
|
||||
if (options.target._output) {
|
||||
@@ -367,11 +366,22 @@ export class Output<
|
||||
if (typeof options.target === 'function' && options.rootPath === undefined) {
|
||||
throw new Error('options.rootPath must be provided when options.target is a function.');
|
||||
}
|
||||
if (
|
||||
options.initTarget !== undefined
|
||||
&& !(options.initTarget instanceof Target)
|
||||
&& typeof options.initTarget !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'options.getInitTarget, when provided, must be a Target or a function that returns or resolves to'
|
||||
+ ' a Target.',
|
||||
);
|
||||
}
|
||||
|
||||
this.format = options.format;
|
||||
this._target = options.target;
|
||||
|
||||
this._rootPath = options.rootPath ?? null;
|
||||
this._initTarget = options.initTarget ?? null;
|
||||
this._muxer = options.format._createMuxer(this);
|
||||
}
|
||||
|
||||
@@ -612,12 +622,14 @@ export class Output<
|
||||
|
||||
const release = await this._mutex.acquire();
|
||||
|
||||
await this._muxer.start();
|
||||
try {
|
||||
await this._muxer.start();
|
||||
|
||||
const promises = this._tracks.map(track => track.source._start());
|
||||
await Promise.all(promises);
|
||||
|
||||
release();
|
||||
const promises = this._tracks.map(track => track.source._start());
|
||||
await Promise.all(promises);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -641,7 +653,12 @@ export class Output<
|
||||
console.warn('Output has already been canceled.');
|
||||
return this._cancelPromise;
|
||||
} else if (this.state === 'finalizing' || this.state === 'finalized') {
|
||||
console.warn('Output has already been finalized.');
|
||||
// Don't wanna warn when finalizing since that shows a warning when finalization fails and then cancel
|
||||
// is called
|
||||
if (this.state === 'finalized') {
|
||||
console.warn('Output has already been finalized.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -650,14 +667,16 @@ export class Output<
|
||||
|
||||
const release = await this._mutex.acquire();
|
||||
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
|
||||
await Promise.all(promises);
|
||||
try {
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
|
||||
await Promise.all(promises);
|
||||
|
||||
if (this._rootWriterPromise) {
|
||||
await (await this._rootWriterPromise).close();
|
||||
if (this._rootWriterPromise) {
|
||||
await (await this._rootWriterPromise).close();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
release();
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -682,20 +701,22 @@ export class Output<
|
||||
|
||||
const release = await this._mutex.acquire();
|
||||
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(false));
|
||||
await Promise.all(promises);
|
||||
try {
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(false));
|
||||
await Promise.all(promises);
|
||||
|
||||
await this._muxer.finalize();
|
||||
await this._muxer.finalize();
|
||||
|
||||
if (this._rootWriterPromise) {
|
||||
const rootWriter = await this._rootWriterPromise;
|
||||
await rootWriter.flush();
|
||||
await rootWriter.finalize();
|
||||
if (this._rootWriterPromise) {
|
||||
const rootWriter = await this._rootWriterPromise;
|
||||
await rootWriter.flush();
|
||||
await rootWriter.finalize();
|
||||
}
|
||||
|
||||
this.state = 'finalized';
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
this.state = 'finalized';
|
||||
|
||||
release();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -304,7 +304,10 @@ export class StreamTarget extends Target {
|
||||
}
|
||||
|
||||
assert(this._streamWriter);
|
||||
if (this._sections.length === 0) return;
|
||||
|
||||
if (this._sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: {
|
||||
start: number;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { CmafOutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { CanvasSource } from '../../src/media-source.js';
|
||||
import { QUALITY_HIGH } from '../../src/encode.js';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
|
||||
test('CMAF throws without initTarget', async () => {
|
||||
const output = new Output({
|
||||
format: new CmafOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const canvas = new OffscreenCanvas(640, 480);
|
||||
const videoSource = new CanvasSource(canvas, {
|
||||
codec: 'avc',
|
||||
bitrate: QUALITY_HIGH,
|
||||
});
|
||||
output.addVideoTrack(videoSource);
|
||||
|
||||
await expect(output.start()).rejects.toThrow('initTarget');
|
||||
});
|
||||
|
||||
test('CMAF with video track', async () => {
|
||||
const initTarget = new BufferTarget();
|
||||
let initTargetCalled = false;
|
||||
|
||||
const output = new Output({
|
||||
format: new CmafOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
initTarget: () => {
|
||||
initTargetCalled = true;
|
||||
return initTarget;
|
||||
},
|
||||
});
|
||||
|
||||
const canvas = new OffscreenCanvas(640, 480);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = '#ff0000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const videoSource = new CanvasSource(canvas, {
|
||||
codec: 'avc',
|
||||
bitrate: QUALITY_HIGH,
|
||||
});
|
||||
output.addVideoTrack(videoSource);
|
||||
|
||||
await output.start();
|
||||
|
||||
const fps = 30;
|
||||
const frameDuration = 1 / fps;
|
||||
for (let i = 0; i < fps; i++) {
|
||||
await videoSource.add(i * frameDuration, frameDuration);
|
||||
}
|
||||
|
||||
await output.finalize();
|
||||
|
||||
expect(initTargetCalled).toBe(true);
|
||||
|
||||
// Reading the segment without initInput should throw because the moov box is in the init segment
|
||||
using segmentInput = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
await expect(segmentInput.getTracks()).rejects.toThrow('initInput');
|
||||
|
||||
// Reading with initInput should work
|
||||
using initInput = new Input({
|
||||
source: new BufferSource(initTarget.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
using segmentInputWithInit = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
initInput,
|
||||
});
|
||||
|
||||
const tracks = await segmentInputWithInit.getTracks();
|
||||
expect(tracks).toHaveLength(1);
|
||||
expect(tracks[0]!.isVideoTrack()).toBe(true);
|
||||
});
|
||||
|
||||
test('CMAF with empty video track', async () => {
|
||||
const initTarget = new BufferTarget();
|
||||
|
||||
const output = new Output({
|
||||
format: new CmafOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
initTarget,
|
||||
});
|
||||
|
||||
const canvas = new OffscreenCanvas(640, 480);
|
||||
const videoSource = new CanvasSource(canvas, {
|
||||
codec: 'avc',
|
||||
bitrate: QUALITY_HIGH,
|
||||
});
|
||||
output.addVideoTrack(videoSource);
|
||||
|
||||
await output.start();
|
||||
await output.finalize();
|
||||
|
||||
expect(initTarget.buffer).toBeDefined();
|
||||
expect(output.target.buffer).toBeDefined();
|
||||
});
|
||||
@@ -27,7 +27,7 @@ test('createAesDecryptStream', async () => {
|
||||
const source = new BufferSource(ciphertext);
|
||||
const reader = new Reader(source);
|
||||
|
||||
const stream = createAes128CbcDecryptStream(reader, () => ({ key, iv }));
|
||||
const stream = createAes128CbcDecryptStream(reader, () => ({ key, iv }), () => {});
|
||||
const streamReader = stream.getReader();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
+160
-29
@@ -1,6 +1,6 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { Output, OutputTrackGroup } from '../../src/output.js';
|
||||
import { HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js';
|
||||
import { CmafOutputFormat, HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget, NullTarget, StreamTarget, StreamTargetChunk } from '../../src/target.js';
|
||||
import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js';
|
||||
import { HlsMuxer } from '../../src/hls/hls-muxer.js';
|
||||
@@ -19,7 +19,7 @@ const audioSource = (codec: AudioCodec = 'aac') => new EncodedAudioPacketSource(
|
||||
test('Playlist assignment, single video', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -41,7 +41,7 @@ test('Playlist assignment, single video', async () => {
|
||||
test('Playlist assignment, single audio', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -63,7 +63,7 @@ test('Playlist assignment, single audio', async () => {
|
||||
test('Playlist assignment, multiple video', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -91,7 +91,7 @@ test('Playlist assignment, multiple video', async () => {
|
||||
test('Playlist assignment, multiple audio', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -119,7 +119,7 @@ test('Playlist assignment, multiple audio', async () => {
|
||||
test('Playlist assignment, multiple video with different metadata #1', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -155,7 +155,7 @@ test('Playlist assignment, multiple video with different metadata #1', async ()
|
||||
test('Playlist assignment, multiple video with different metadata #2', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -191,7 +191,7 @@ test('Playlist assignment, multiple video with different metadata #2', async ()
|
||||
test('Playlist assignment, multiple audio with different metadata', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -227,7 +227,7 @@ test('Playlist assignment, multiple audio with different metadata', async () =>
|
||||
test('Playlist assignment, video and audio', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -250,7 +250,7 @@ test('Playlist assignment, video and audio', async () => {
|
||||
test('Playlist assignment, one video and multiple audio', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -292,7 +292,7 @@ test('Playlist assignment, one video and multiple audio', async () => {
|
||||
test('Playlist assignment, multiple video and one audio', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -334,7 +334,7 @@ test('Playlist assignment, multiple video and one audio', async () => {
|
||||
test('Playlist assignment, multiple video and audio', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -388,7 +388,7 @@ test('Playlist assignment, multiple video and audio', async () => {
|
||||
test('Playlist assignment, video and audio in different groups', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -421,7 +421,7 @@ test('Playlist assignment, video and audio in different groups', async () => {
|
||||
test('Playlist assignment, multiple video and audio in pairs', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -467,7 +467,7 @@ test('Playlist assignment, multiple video and audio in pairs', async () => {
|
||||
test('Playlist assignment, multiple video and audio with some unpaired', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -525,7 +525,7 @@ test('Playlist assignment, multiple video and audio with some unpaired', async (
|
||||
test('Playlist assignment, multiple video and audio with multiple groups', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -594,7 +594,7 @@ test('Playlist assignment, multiple video and audio with multiple groups', async
|
||||
test('Playlist assignment, video with multiple audio codecs', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -639,7 +639,7 @@ test('Playlist assignment, video with multiple audio codecs', async () => {
|
||||
test('Playlist assignment, audio with multiple video codecs', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -677,7 +677,7 @@ test('Playlist assignment, audio with multiple video codecs', async () => {
|
||||
test('Playlist assignment, multiple video with conflicting audio interests', async () => {
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -730,7 +730,7 @@ test('Playlist assignment, video paired with video', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -767,7 +767,7 @@ test('Playlist assignment, audio paired with audio', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -830,7 +830,7 @@ const setUpSegmentationEnvironment = async (options: {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()], // No ADTS for simplicity
|
||||
segmentFormat: new MpegTsOutputFormat(), // No ADTS for simplicity
|
||||
}),
|
||||
target: (request) => {
|
||||
const target = new BufferTarget();
|
||||
@@ -940,6 +940,7 @@ test('Segmentation, empty', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXT-X-ENDLIST
|
||||
`,
|
||||
@@ -969,6 +970,7 @@ test('Segmentation, simple', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1005,6 +1007,7 @@ test('Segmentation, reaching until end of second segment', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1043,6 +1046,7 @@ test('Segmentation, reaching until end of second segment with a final key packet
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1076,6 +1080,7 @@ test('Segmentation, reaching until end of second segment with a final key packet
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1114,6 +1119,7 @@ test('Segmentation, reaching until end of second segment with packet durations',
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1154,6 +1160,7 @@ test('Segmentation, reaching until end of second segment with packet durations (
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1187,6 +1194,7 @@ test('Segmentation, only one key packet', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:3.5,
|
||||
segment-1-1.ts
|
||||
@@ -1228,6 +1236,7 @@ test('Segmentation, key packets before the end of a segment (maximized segment d
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.5,
|
||||
segment-1-1.ts
|
||||
@@ -1270,6 +1279,7 @@ test('Segmentation, full segment duration recovery after shorter segment', async
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.5,
|
||||
segment-1-1.ts
|
||||
@@ -1303,6 +1313,7 @@ test('Segmentation, packet start timestamp intersecting with end timestamp of pr
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1331,6 +1342,7 @@ test('Segmentation, last video packet is included', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.5,
|
||||
segment-1-1.ts
|
||||
@@ -1362,13 +1374,13 @@ test('Segmentation, video not lining up with segment boundaries', async () => {
|
||||
|
||||
await env.output.finalize();
|
||||
expect(env.segmentCount).toBe(3);
|
||||
console.log(await env.lastSegmentVideoTimestamps);
|
||||
expect(await env.lastSegmentVideoTimestamps).toEqual([3.6, 4.05]);
|
||||
|
||||
expect(env.result).toBe(`#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.8,
|
||||
segment-1-1.ts
|
||||
@@ -1404,13 +1416,13 @@ test('Segmentation, audio not lining up with segment boundaries', async () => {
|
||||
|
||||
await env.output.finalize();
|
||||
expect(env.segmentCount).toBe(3);
|
||||
console.log(await env.lastSegmentAudioTimestamps);
|
||||
expect(await env.lastSegmentAudioTimestamps).toEqual([3.6, 4.05]);
|
||||
|
||||
expect(env.result).toBe(`#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.8,
|
||||
segment-1-1.ts
|
||||
@@ -1446,6 +1458,7 @@ test('Segmentation, non-zero start time', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1481,6 +1494,7 @@ test('Segmentation, B-frames before key frame', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1522,6 +1536,7 @@ test('Segmentation, dual-track, single segment', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1575,6 +1590,7 @@ test('Segmentation, dual-track, video dictates the segmentation', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:3
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.5,
|
||||
segment-1-1.ts
|
||||
@@ -1630,6 +1646,7 @@ test('Segmentation, dual-track, video dictates the segmentation, inverted', asyn
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:3
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.5,
|
||||
segment-1-1.ts
|
||||
@@ -1676,6 +1693,7 @@ test('Segmentation, dual-track, audio ending after video', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1732,6 +1750,7 @@ test('Segmentation, dual-track, audio ending after video in separate segment', a
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:3
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.ts
|
||||
@@ -1768,6 +1787,7 @@ test('Segmentation, dual-track, end timestamp with duration', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.8,
|
||||
segment-1-1.ts
|
||||
@@ -1808,6 +1828,7 @@ test('Segmentation, dual-track, closing writes segment', async () => {
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
|
||||
#EXTINF:1.5,
|
||||
segment-1-1.ts
|
||||
@@ -1824,7 +1845,7 @@ test('onSegment, onPlaylist, onMaster events', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
onSegment,
|
||||
onPlaylist,
|
||||
onMaster,
|
||||
@@ -1884,7 +1905,7 @@ test('Single-file mode', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
singleFilePerPlaylist: true,
|
||||
}),
|
||||
target: (request) => {
|
||||
@@ -1932,7 +1953,7 @@ test('StreamTarget, write is called for each target', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: (request) => {
|
||||
writeCounts.set(request.path, 0);
|
||||
@@ -1976,7 +1997,7 @@ test('I-frame stream', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
onMaster: (text) => { masterText = text; },
|
||||
onPlaylist: (text) => { playlistText = text; },
|
||||
}),
|
||||
@@ -2013,7 +2034,7 @@ test('I-frame stream, pairing warning', async () => {
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormats: [new MpegTsOutputFormat()],
|
||||
segmentFormat: new MpegTsOutputFormat(),
|
||||
}),
|
||||
target: () => new NullTarget(),
|
||||
rootPath: '',
|
||||
@@ -2037,3 +2058,113 @@ test('I-frame stream, pairing warning', async () => {
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('CMAF segmentation', async () => {
|
||||
let playlistText: string | null = null;
|
||||
const writtenPaths = new Set<string>();
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new CmafOutputFormat(),
|
||||
}),
|
||||
target: (request) => {
|
||||
writtenPaths.add(request.path);
|
||||
const target = new BufferTarget();
|
||||
|
||||
if (request.path.includes('playlist')) {
|
||||
target.onfinalized = () => {
|
||||
playlistText = new TextDecoder().decode(target.buffer!);
|
||||
};
|
||||
}
|
||||
|
||||
return target;
|
||||
},
|
||||
rootPath: '',
|
||||
});
|
||||
|
||||
const source = videoSource();
|
||||
output.addVideoTrack(source);
|
||||
|
||||
await output.start();
|
||||
|
||||
await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata);
|
||||
|
||||
await output.finalize();
|
||||
|
||||
expect(writtenPaths).toContain('init-1.m4s');
|
||||
expect(writtenPaths).toContain('segment-1-1.m4s');
|
||||
expect(writtenPaths).toContain('segment-1-2.m4s');
|
||||
|
||||
expect(playlistText).toBe(`#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-MAP:URI="init-1.m4s"
|
||||
|
||||
#EXTINF:2,
|
||||
segment-1-1.m4s
|
||||
#EXTINF:1.5,
|
||||
segment-1-2.m4s
|
||||
|
||||
#EXT-X-ENDLIST
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
test('CMAF segmentation, single file per playlist', async () => {
|
||||
let playlistText: string | null = null;
|
||||
const writtenPaths = new Set<string>();
|
||||
|
||||
const output = new Output({
|
||||
format: new HlsOutputFormat({
|
||||
segmentFormat: new CmafOutputFormat(),
|
||||
singleFilePerPlaylist: true,
|
||||
}),
|
||||
target: (request) => {
|
||||
writtenPaths.add(request.path);
|
||||
const target = new BufferTarget();
|
||||
|
||||
if (request.path.includes('playlist')) {
|
||||
target.onfinalized = () => {
|
||||
playlistText = new TextDecoder().decode(target.buffer!);
|
||||
};
|
||||
}
|
||||
|
||||
return target;
|
||||
},
|
||||
rootPath: '',
|
||||
});
|
||||
|
||||
const source = videoSource();
|
||||
output.addVideoTrack(source);
|
||||
|
||||
await output.start();
|
||||
|
||||
await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata);
|
||||
await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata);
|
||||
|
||||
await output.finalize();
|
||||
|
||||
// Init and segment files should have been written
|
||||
expect(writtenPaths).toContain('segments-1.m4s');
|
||||
expect(writtenPaths).not.toContain('init-1.m4s');
|
||||
|
||||
expect(playlistText).not.toBeNull();
|
||||
expect(playlistText!.match(/#EXT-X-BYTERANGE/g)).toHaveLength(2);
|
||||
expect(playlistText).toContain('#EXT-X-VERSION:6');
|
||||
expect(playlistText).toContain('#EXT-X-MAP:URI=');
|
||||
});
|
||||
|
||||
@@ -5,5 +5,3 @@ Also, why not just have the packet metadata on the packet? I think that would ma
|
||||
- getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field?
|
||||
|
||||
- Add an HLS "live mode". Challenge, where does bitrate come from?
|
||||
|
||||
- fmp4 muxing (CMAF??)
|
||||
Reference in New Issue
Block a user