Add CMAF format and CMAF/init segment support to HLS muxer

This commit is contained in:
Vanilagy
2026-03-30 17:06:57 +02:00
parent 7b70756961
commit 3bcbcc13f0
16 changed files with 766 additions and 178 deletions
@@ -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
View File
@@ -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,
};
};
+2 -2
View File
@@ -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);
+2
View File
@@ -37,6 +37,8 @@ export {
OutputFormat,
AdtsOutputFormat,
AdtsOutputFormatOptions,
CmafOutputFormat,
CmafOutputFormatOptions,
FlacOutputFormat,
FlacOutputFormatOptions,
HlsOutputFormat,
+1 -1
View File
@@ -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
View File
@@ -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.'
+55 -9
View File
@@ -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
View File
@@ -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);
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+109
View File
@@ -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();
});
+1 -1
View File
@@ -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
View File
@@ -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=');
});
-2
View File
@@ -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??)