Add some HLS events, add configurable target duration, add public-API OutputTrack

This commit is contained in:
Vanilagy
2026-03-27 13:37:14 +01:00
parent 5bc1a31b8e
commit 17fac170c1
8 changed files with 243 additions and 64 deletions
+26 -9
View File
@@ -11,9 +11,10 @@ import {
OutputVideoTrack, OutputVideoTrack,
TrackType, TrackType,
} from '../output'; } from '../output';
import { HlsOutputFormat, HlsOutputFormatOptions, OutputFormat } from '../output-format'; import { HlsOutputFormat, HlsOutputFormatOptions, HlsOutputSegmentInfo, OutputFormat } from '../output-format';
import { EncodedPacket } from '../packet'; import { EncodedPacket } from '../packet';
import { SubtitleCue, SubtitleMetadata } from '../subtitles'; import { SubtitleCue, SubtitleMetadata } from '../subtitles';
import { Target } from '../target';
type HlsTrackData = { type HlsTrackData = {
track: OutputTrack; track: OutputTrack;
@@ -59,7 +60,7 @@ export class HlsMuxer extends Muxer {
getPlaylistPath: NonNullable<HlsOutputFormatOptions['getPlaylistPath']>; getPlaylistPath: NonNullable<HlsOutputFormatOptions['getPlaylistPath']>;
getSegmentPath: NonNullable<HlsOutputFormatOptions['getSegmentPath']>; getSegmentPath: NonNullable<HlsOutputFormatOptions['getSegmentPath']>;
targetSegmentDuration = 2; targetSegmentDuration: number;
trackDatas: HlsTrackData[] = []; trackDatas: HlsTrackData[] = [];
playlists: Playlist[] = []; playlists: Playlist[] = [];
@@ -73,6 +74,7 @@ export class HlsMuxer extends Muxer {
super(output); super(output);
this.format = format; this.format = format;
this.targetSegmentDuration = format._options.targetDuration ?? 5;
this.getPlaylistPath = format._options.getPlaylistPath this.getPlaylistPath = format._options.getPlaylistPath
?? (({ n }) => `playlist-${n}.m3u8`); ?? (({ n }) => `playlist-${n}.m3u8`);
@@ -303,10 +305,10 @@ export class HlsMuxer extends Muxer {
let candidateScore = -Infinity; let candidateScore = -Infinity;
for (const track of tracks) { for (const track of tracks) {
if (track.type === 'video') { if (track.isVideoTrack()) {
videoCount++; videoCount++;
requiresRotationMetadata ||= (track.metadata.rotation ?? 0) !== 0; requiresRotationMetadata ||= (track.metadata.rotation ?? 0) !== 0;
} else if (track.type === 'audio') { } else if (track.isAudioTrack()) {
audioCount++; audioCount++;
} }
@@ -715,14 +717,16 @@ export class HlsMuxer extends Muxer {
} }
// We can finalize a new segment! Let's first get the path // We can finalize a new segment! Let's first get the path
const relativeSegmentPath = await this.getSegmentPath({ const segmentInfo: HlsOutputSegmentInfo = {
n: playlist.nextSegmentId, n: playlist.nextSegmentId,
format: playlist.segmentFormat, format: playlist.segmentFormat,
playlist: { playlist: {
n: playlist.id, n: playlist.id,
tracks: playlist.tracks, tracks: playlist.tracks,
}, },
}); };
const relativeSegmentPath = await this.getSegmentPath(segmentInfo);
if (typeof relativeSegmentPath !== 'string') { if (typeof relativeSegmentPath !== 'string') {
throw new TypeError('options.getSegmentPath must return or resolve to a string'); throw new TypeError('options.getSegmentPath must return or resolve to a string');
} }
@@ -737,6 +741,8 @@ export class HlsMuxer extends Muxer {
playlist.nextSegmentId++; playlist.nextSegmentId++;
let segmentSize = 0; let segmentSize = 0;
let outputTarget: Target | null = null;
const output = new Output({ const output = new Output({
format: playlist.segmentFormat, format: playlist.segmentFormat,
rootPath: fullSegmentPath, rootPath: fullSegmentPath,
@@ -744,6 +750,7 @@ export class HlsMuxer extends Muxer {
const target = await this.output._getTarget(request); const target = await this.output._getTarget(request);
if (request.path === fullSegmentPath) { if (request.path === fullSegmentPath) {
outputTarget = target;
target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end); target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
} }
@@ -803,6 +810,9 @@ export class HlsMuxer extends Muxer {
throw e; throw e;
} }
assert(outputTarget);
this.format._options.onSegment?.(outputTarget, segmentInfo);
if (videoEndIndex > 0) { if (videoEndIndex > 0) {
assert(videoTrack); assert(videoTrack);
videoTrack.packets.splice(0, videoEndIndex); videoTrack.packets.splice(0, videoEndIndex);
@@ -883,7 +893,12 @@ export class HlsMuxer extends Muxer {
+ '\n' + '\n'
+ '#EXT-X-ENDLIST\n'; + '#EXT-X-ENDLIST\n';
const target = await this.output._getTarget({ path: playlistPath }); this.format._options.onPlaylist?.(playlistText, {
n: playlist.id,
tracks: playlist.tracks,
});
const target = await this.output._getTarget({ path: playlistPath, isRoot: false });
const writer = target._createWriter(); const writer = target._createWriter();
writer.write(textEncoder.encode(playlistText)); writer.write(textEncoder.encode(playlistText));
await writer.finalize(); await writer.finalize();
@@ -942,8 +957,8 @@ export class HlsMuxer extends Muxer {
masterPlaylistText += `,CODECS="${codecs.join(',')}"`; masterPlaylistText += `,CODECS="${codecs.join(',')}"`;
const videoTrack = decl.playlist.tracks.find(x => x.type === 'video'); const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack());
if (videoTrack) { if (videoTrack?.isVideoTrack()) {
const trackData = this.trackDatas.find(x => x.track === videoTrack) as const trackData = this.trackDatas.find(x => x.track === videoTrack) as
HlsVideoTrackData | undefined; HlsVideoTrackData | undefined;
const decoderConfig = trackData?.info.decoderConfig; const decoderConfig = trackData?.info.decoderConfig;
@@ -1059,6 +1074,8 @@ export class HlsMuxer extends Muxer {
} }
} }
this.format._options.onMaster?.(masterPlaylistText);
const rootWriter = await this.output._getRootWriter(); const rootWriter = await this.output._getRootWriter();
rootWriter.write(textEncoder.encode(masterPlaylistText)); rootWriter.write(textEncoder.encode(masterPlaylistText));
})(); })();
+5 -1
View File
@@ -23,6 +23,11 @@ if ((globalThis as Record<symbol, unknown>)[MEDIABUNNY_LOADED_SYMBOL]) {
export { export {
Output, Output,
OutputOptions, OutputOptions,
OutputTrack,
OutputVideoTrack,
OutputAudioTrack,
OutputSubtitleTrack,
OutputTrackGroup,
BaseTrackMetadata, BaseTrackMetadata,
VideoTrackMetadata, VideoTrackMetadata,
AudioTrackMetadata, AudioTrackMetadata,
@@ -136,7 +141,6 @@ export {
prefer, prefer,
} from './misc'; } from './misc';
export { export {
OutputTrackGroup,
TrackType, TrackType,
ALL_TRACK_TYPES, ALL_TRACK_TYPES,
} from './output'; } from './output';
+1 -1
View File
@@ -199,7 +199,7 @@ export class IsobmffMuxer extends Muxer {
this.writer.ensureMonotonicity = true; this.writer.ensureMonotonicity = true;
} }
const holdsAvc = this.output._tracks.some(x => x.type === 'video' && x.source._codec === 'avc'); const holdsAvc = this.output._tracks.some(x => x.isVideoTrack() && x.source._codec === 'avc');
// Write the header // Write the header
{ {
+6 -3
View File
@@ -10,6 +10,7 @@ import { buildAacAudioSpecificConfig, parseAacAudioSpecificConfig } from '../sha
import { import {
AUDIO_CODECS, AUDIO_CODECS,
AudioCodec, AudioCodec,
MediaCodec,
parsePcmCodec, parsePcmCodec,
PCM_AUDIO_CODECS, PCM_AUDIO_CODECS,
PcmAudioCodec, PcmAudioCodec,
@@ -62,6 +63,8 @@ import {
* @public * @public
*/ */
export abstract class MediaSource { export abstract class MediaSource {
/** @internal */
abstract readonly _codec: MediaCodec;
/** @internal */ /** @internal */
_connectedTrack: OutputTrack | null = null; _connectedTrack: OutputTrack | null = null;
/** @internal */ /** @internal */
@@ -154,7 +157,7 @@ export abstract class VideoSource extends MediaSource {
/** @internal */ /** @internal */
override _connectedTrack: OutputVideoTrack | null = null; override _connectedTrack: OutputVideoTrack | null = null;
/** @internal */ /** @internal */
_codec: VideoCodec; override readonly _codec: VideoCodec;
/** Internal constructor. */ /** Internal constructor. */
constructor(codec: VideoCodec) { constructor(codec: VideoCodec) {
@@ -1475,7 +1478,7 @@ export abstract class AudioSource extends MediaSource {
/** @internal */ /** @internal */
override _connectedTrack: OutputAudioTrack | null = null; override _connectedTrack: OutputAudioTrack | null = null;
/** @internal */ /** @internal */
_codec: AudioCodec; override readonly _codec: AudioCodec;
/** Internal constructor. */ /** Internal constructor. */
constructor(codec: AudioCodec) { constructor(codec: AudioCodec) {
@@ -2459,7 +2462,7 @@ export abstract class SubtitleSource extends MediaSource {
/** @internal */ /** @internal */
override _connectedTrack: OutputSubtitleTrack | null = null; override _connectedTrack: OutputSubtitleTrack | null = null;
/** @internal */ /** @internal */
_codec: SubtitleCodec; override readonly _codec: SubtitleCodec;
/** Internal constructor. */ /** Internal constructor. */
constructor(codec: SubtitleCodec) { constructor(codec: SubtitleCodec) {
+22 -1
View File
@@ -30,6 +30,7 @@ import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer';
import { WaveMuxer } from './wave/wave-muxer'; import { WaveMuxer } from './wave/wave-muxer';
import { HlsMuxer } from './hls/hls-muxer'; import { HlsMuxer } from './hls/hls-muxer';
import { MaybePromise } from './misc'; import { MaybePromise } from './misc';
import { Target } from './target';
/** /**
* Specifies an inclusive range of integers. * Specifies an inclusive range of integers.
@@ -1099,7 +1100,7 @@ export type HlsOutputPlaylistInfo = {
tracks: OutputTrack[]; tracks: OutputTrack[];
}; };
type HlsOutputSegmentInfo = { export type HlsOutputSegmentInfo = {
n: number; n: number;
format: OutputFormat; format: OutputFormat;
playlist: HlsOutputPlaylistInfo; playlist: HlsOutputPlaylistInfo;
@@ -1107,8 +1108,13 @@ type HlsOutputSegmentInfo = {
export type HlsOutputFormatOptions = { export type HlsOutputFormatOptions = {
segmentFormats: OutputFormat[]; segmentFormats: OutputFormat[];
targetDuration?: number;
getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<string>; getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<string>;
getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise<string>; getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise<string>;
onMaster?: (content: string) => unknown;
onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown;
onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown;
}; };
export class HlsOutputFormat extends OutputFormat { export class HlsOutputFormat extends OutputFormat {
@@ -1127,12 +1133,27 @@ export class HlsOutputFormat extends OutputFormat {
) { ) {
throw new TypeError('options.segmentFormats must be a non-empty array of OutputFormat instances.'); throw new TypeError('options.segmentFormats must be a non-empty array of OutputFormat instances.');
} }
if (
options.targetDuration !== undefined
&& (typeof options.targetDuration !== 'number' || options.targetDuration <= 0)
) {
throw new TypeError('options.targetDuration, when provided, must be a positive number.');
}
if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') { if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') {
throw new TypeError('options.getPlaylistPath, when provided, must be a function.'); throw new TypeError('options.getPlaylistPath, when provided, must be a function.');
} }
if (options.getSegmentPath !== undefined && typeof options.getSegmentPath !== 'function') { if (options.getSegmentPath !== undefined && typeof options.getSegmentPath !== 'function') {
throw new TypeError('options.getSegmentPath, when provided, must be a function.'); throw new TypeError('options.getSegmentPath, when provided, must be a function.');
} }
if (options.onMaster !== undefined && typeof options.onMaster !== 'function') {
throw new TypeError('options.onMaster, when provided, must be a function.');
}
if (options.onPlaylist !== undefined && typeof options.onPlaylist !== 'function') {
throw new TypeError('options.onPlaylist, when provided, must be a function.');
}
if (options.onSegment !== undefined && typeof options.onSegment !== 'function') {
throw new TypeError('options.onSegment, when provided, must be a function.');
}
super(); super();
+118 -43
View File
@@ -16,6 +16,7 @@ import { Writer } from './writer';
export type TargetRequest = { export type TargetRequest = {
path: string; path: string;
isRoot: boolean;
}; };
/** /**
@@ -47,27 +48,101 @@ export const ALL_TRACK_TYPES = ['video', 'audio', 'subtitle'] as const;
*/ */
export type TrackType = typeof ALL_TRACK_TYPES[number]; export type TrackType = typeof ALL_TRACK_TYPES[number];
export type OutputTrack = { /**
id: number; * Represents a track added to an {@link Output}.
output: Output; * @group Output files
type: TrackType; * @public
} & ({ */
type: 'video'; export abstract class OutputTrack {
source: VideoSource; /** @internal */
metadata: VideoTrackMetadata; readonly id: number;
} | { /** The {@link Output} this track belongs to. */
type: 'audio'; readonly output: Output;
source: AudioSource; /** The type of this track. */
metadata: AudioTrackMetadata; readonly type: TrackType;
} | { /** The media source providing data for this track. */
type: 'subtitle'; readonly source: MediaSource;
source: SubtitleSource; /** The metadata associated with this track. */
metadata: SubtitleTrackMetadata; readonly metadata: BaseTrackMetadata;
});
export type OutputVideoTrack = OutputTrack & { type: 'video' }; /** @internal */
export type OutputAudioTrack = OutputTrack & { type: 'audio' }; protected constructor(
export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' }; id: number,
output: Output,
type: TrackType,
source: MediaSource,
metadata: BaseTrackMetadata,
) {
this.id = id;
this.output = output;
this.type = type;
this.source = source;
this.metadata = metadata;
}
/** Returns true if and only if this track is a video track. */
isVideoTrack(): this is OutputVideoTrack {
return this.type === 'video';
}
/** Returns true if and only if this track is an audio track. */
isAudioTrack(): this is OutputAudioTrack {
return this.type === 'audio';
}
/** Returns true if and only if this track is a subtitle track. */
isSubtitleTrack(): this is OutputSubtitleTrack {
return this.type === 'subtitle';
}
}
/**
* An {@link OutputTrack} containing video data.
* @group Output files
* @public
*/
export class OutputVideoTrack extends OutputTrack {
declare readonly type: 'video';
declare readonly source: VideoSource;
declare readonly metadata: VideoTrackMetadata;
/** @internal */
constructor(id: number, output: Output, source: VideoSource, metadata: VideoTrackMetadata) {
super(id, output, 'video', source, metadata);
}
}
/**
* An {@link OutputTrack} containing audio data.
* @group Output files
* @public
*/
export class OutputAudioTrack extends OutputTrack {
declare readonly type: 'audio';
declare readonly source: AudioSource;
declare readonly metadata: AudioTrackMetadata;
/** @internal */
constructor(id: number, output: Output, source: AudioSource, metadata: AudioTrackMetadata) {
super(id, output, 'audio', source, metadata);
}
}
/**
* An {@link OutputTrack} containing subtitle data.
* @group Output files
* @public
*/
export class OutputSubtitleTrack extends OutputTrack {
declare readonly type: 'subtitle';
declare readonly source: SubtitleSource;
declare readonly metadata: SubtitleTrackMetadata;
/** @internal */
constructor(id: number, output: Output, source: SubtitleSource, metadata: SubtitleTrackMetadata) {
super(id, output, 'subtitle', source, metadata);
}
}
export class OutputTrackGroup { export class OutputTrackGroup {
/** @internal */ /** @internal */
@@ -246,7 +321,7 @@ export class Output<
} }
assert(this._rootPath !== null); assert(this._rootPath !== null);
const returnValue = this._target({ path: this._rootPath }); const returnValue = this._target({ path: this._rootPath, isRoot: true });
if (returnValue instanceof Promise) { if (returnValue instanceof Promise) {
throw new TypeError( throw new TypeError(
'Output.target cannot be used when the target function resolves asynchronously.', 'Output.target cannot be used when the target function resolves asynchronously.',
@@ -310,7 +385,7 @@ export class Output<
if (typeof this._target === 'function') { if (typeof this._target === 'function') {
assert(this._rootPath !== null); assert(this._rootPath !== null);
const rootTarget = await this._getTarget({ path: this._rootPath }); const rootTarget = await this._getTarget({ path: this._rootPath, isRoot: true });
writer = rootTarget._createWriter(); writer = rootTarget._createWriter();
} else { } else {
writer = this._target._createWriter(); writer = this._target._createWriter();
@@ -346,7 +421,9 @@ export class Output<
const metadataCopy = { ...metadata }; const metadataCopy = { ...metadata };
metadataCopy.group ??= this._defaultTrackGroup; metadataCopy.group ??= this._defaultTrackGroup;
this._addTrack('video', source, metadataCopy); return this._addTrack(new OutputVideoTrack(
this._tracks.length + 1, this, source, metadataCopy,
));
} }
/** Adds an audio track to the output with the given source. Can only be called before the output is started. */ /** Adds an audio track to the output with the given source. Can only be called before the output is started. */
@@ -359,7 +436,9 @@ export class Output<
const metadataCopy = { ...metadata }; const metadataCopy = { ...metadata };
metadataCopy.group ??= this._defaultTrackGroup; metadataCopy.group ??= this._defaultTrackGroup;
this._addTrack('audio', source, metadataCopy); return this._addTrack(new OutputAudioTrack(
this._tracks.length + 1, this, source, metadataCopy,
));
} }
/** Adds a subtitle track to the output with the given source. Can only be called before the output is started. */ /** Adds a subtitle track to the output with the given source. Can only be called before the output is started. */
@@ -372,7 +451,9 @@ export class Output<
const metadataCopy = { ...metadata }; const metadataCopy = { ...metadata };
metadataCopy.group ??= this._defaultTrackGroup; metadataCopy.group ??= this._defaultTrackGroup;
this._addTrack('subtitle', source, metadataCopy); return this._addTrack(new OutputSubtitleTrack(
this._tracks.length + 1, this, source, metadataCopy,
));
} }
/** /**
@@ -392,26 +473,26 @@ export class Output<
} }
/** @internal */ /** @internal */
private _addTrack(type: OutputTrack['type'], source: MediaSource, metadata: BaseTrackMetadata) { private _addTrack<T extends OutputTrack>(track: T) {
if (this.state !== 'pending') { if (this.state !== 'pending') {
throw new Error('Cannot add track after output has been started or canceled.'); throw new Error('Cannot add track after output has been started or canceled.');
} }
if (source._connectedTrack) { if (track.source._connectedTrack) {
throw new Error('Source is already used for a track.'); throw new Error('Source is already used for a track.');
} }
// Verify maximum track count constraints // Verify maximum track count constraints
const supportedTrackCounts = this.format.getSupportedTrackCounts(); const supportedTrackCounts = this.format.getSupportedTrackCounts();
const presentTracksOfThisType = this._tracks.reduce( const presentTracksOfThisType = this._tracks.reduce(
(count, track) => count + (track.type === type ? 1 : 0), (count, t) => count + (t.type === track.type ? 1 : 0),
0, 0,
); );
const maxCount = supportedTrackCounts[type].max; const maxCount = supportedTrackCounts[track.type].max;
if (presentTracksOfThisType === maxCount) { if (presentTracksOfThisType === maxCount) {
throw new Error( throw new Error(
maxCount === 0 maxCount === 0
? `${this.format._name} does not support ${type} tracks.` ? `${this.format._name} does not support ${track.type} tracks.`
: (`${this.format._name} does not support more than ${maxCount} ${type} track` : (`${this.format._name} does not support more than ${maxCount} ${track.type} track`
+ `${maxCount === 1 ? '' : 's'}.`), + `${maxCount === 1 ? '' : 's'}.`),
); );
} }
@@ -423,15 +504,7 @@ export class Output<
); );
} }
const track = { if (track.isVideoTrack()) {
id: this._tracks.length + 1,
output: this,
type,
source: source as unknown,
metadata,
} as OutputTrack;
if (track.type === 'video') {
const supportedVideoCodecs = this.format.getSupportedVideoCodecs(); const supportedVideoCodecs = this.format.getSupportedVideoCodecs();
if (supportedVideoCodecs.length === 0) { if (supportedVideoCodecs.length === 0) {
@@ -446,7 +519,7 @@ export class Output<
+ this.format._codecUnsupportedHint(track.source._codec), + this.format._codecUnsupportedHint(track.source._codec),
); );
} }
} else if (track.type === 'audio') { } else if (track.isAudioTrack()) {
const supportedAudioCodecs = this.format.getSupportedAudioCodecs(); const supportedAudioCodecs = this.format.getSupportedAudioCodecs();
if (supportedAudioCodecs.length === 0) { if (supportedAudioCodecs.length === 0) {
@@ -461,7 +534,7 @@ export class Output<
+ this.format._codecUnsupportedHint(track.source._codec), + this.format._codecUnsupportedHint(track.source._codec),
); );
} }
} else if (track.type === 'subtitle') { } else if (track.isSubtitleTrack()) {
const supportedSubtitleCodecs = this.format.getSupportedSubtitleCodecs(); const supportedSubtitleCodecs = this.format.getSupportedSubtitleCodecs();
if (supportedSubtitleCodecs.length === 0) { if (supportedSubtitleCodecs.length === 0) {
@@ -479,7 +552,9 @@ export class Output<
} }
this._tracks.push(track); this._tracks.push(track);
source._connectedTrack = track; track.source._connectedTrack = track;
return track;
} }
/** /**
+63
View File
@@ -831,6 +831,7 @@ const setUpSegmentationEnvironment = async (options: {
const output = new Output({ const output = new Output({
format: new HlsOutputFormat({ format: new HlsOutputFormat({
segmentFormats: [new MpegTsOutputFormat()], // No ADTS for simplicity segmentFormats: [new MpegTsOutputFormat()], // No ADTS for simplicity
targetDuration: 2,
}), }),
target: (request) => { target: (request) => {
const target = new BufferTarget(); const target = new BufferTarget();
@@ -1731,3 +1732,65 @@ segment-1-1.ts
`, `,
); );
}); });
test('onSegment, onPlaylist, onMaster events', async () => {
const onSegment = vi.fn();
const onPlaylist = vi.fn();
const onMaster = vi.fn();
const output = new Output({
format: new HlsOutputFormat({
segmentFormats: [new MpegTsOutputFormat()],
targetDuration: 2,
onSegment,
onPlaylist,
onMaster,
}),
target: () => new BufferTarget(),
rootPath: '',
});
const source = videoSource();
output.addVideoTrack(source);
await output.start();
// First segment
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);
expect(onSegment).toHaveBeenCalledTimes(0);
// Second segment starts, first one is finalized
await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata);
expect(onSegment).toHaveBeenCalledTimes(1);
expect(onSegment.mock.calls[0]![1]).toEqual(expect.objectContaining({ n: 1 }));
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);
expect(onPlaylist).not.toHaveBeenCalled();
expect(onMaster).not.toHaveBeenCalled();
await output.finalize();
// Second segment finalized on close
expect(onSegment).toHaveBeenCalledTimes(2);
expect(onSegment.mock.calls[1]![1]).toEqual(expect.objectContaining({ n: 2 }));
// Both segment calls should have a Target as the first argument
expect(onSegment.mock.calls[0]![0]).toBeDefined();
expect(onSegment.mock.calls[1]![0]).toBeDefined();
// Playlist and master should have been called once each
expect(onPlaylist).toHaveBeenCalledTimes(1);
expect(typeof onPlaylist.mock.calls[0]![0]).toBe('string');
expect(onPlaylist.mock.calls[0]![0]).toContain('#EXTM3U');
expect(onPlaylist.mock.calls[0]![1]).toEqual(expect.objectContaining({ n: 1 }));
expect(onMaster).toHaveBeenCalledTimes(1);
expect(typeof onMaster.mock.calls[0]![0]).toBe('string');
expect(onMaster.mock.calls[0]![0]).toContain('#EXTM3U');
});
+2 -6
View File
@@ -6,10 +6,6 @@ Also, why not just have the packet metadata on the packet? I think that would ma
- Add an HLS "live mode". Challenge, where does bitrate come from? - Add an HLS "live mode". Challenge, where does bitrate come from?
events:
- onPlaylist
- onSegment
- onMaster
- hls muxer starting offset - hls muxer starting offset
- hls demuxer offset by segment number * targetduration - hls demuxer offset by segment number * targetduration
- fmp4 muxing (CMAF??)