mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-01 21:03:46 +02:00
Add HLS live mode
This commit is contained in:
+385
-324
@@ -60,6 +60,7 @@ type Playlist = {
|
||||
writtenSegments: PlaylistSegment[];
|
||||
peakBitrate: number | null;
|
||||
averageBitrate: number | null;
|
||||
done: boolean;
|
||||
|
||||
singleFile: {
|
||||
target: Target;
|
||||
@@ -85,6 +86,8 @@ export class HlsMuxer extends Muxer {
|
||||
targetSegmentDuration: number;
|
||||
trackDatas: HlsTrackData[] = [];
|
||||
singleFilePerPlaylist: boolean;
|
||||
isLive: boolean;
|
||||
globalTargetDuration: number;
|
||||
|
||||
playlists: Playlist[] = [];
|
||||
playlistDeclarations: PlaylistDeclaration[] = [];
|
||||
@@ -99,6 +102,8 @@ export class HlsMuxer extends Muxer {
|
||||
this.format = format;
|
||||
this.targetSegmentDuration = format._options.targetDuration ?? 2;
|
||||
this.singleFilePerPlaylist = format._options.singleFilePerPlaylist ?? false;
|
||||
this.isLive = format._options.live ?? false;
|
||||
this.globalTargetDuration = this.targetSegmentDuration;
|
||||
|
||||
this.getPlaylistPath = format._options.getPlaylistPath
|
||||
?? (({ n }) => `playlist-${n}.m3u8`);
|
||||
@@ -423,6 +428,7 @@ export class HlsMuxer extends Muxer {
|
||||
writtenSegments: [],
|
||||
peakBitrate: null,
|
||||
averageBitrate: null,
|
||||
done: false,
|
||||
singleFile: null,
|
||||
};
|
||||
this.playlists.push(playlist);
|
||||
@@ -494,6 +500,7 @@ export class HlsMuxer extends Muxer {
|
||||
|
||||
const playlist = this.playlists.find(x => x.tracks.includes(track));
|
||||
assert(playlist); // If there isn't one then the assignment algo failed innit
|
||||
|
||||
await this.advancePlaylist(playlist);
|
||||
} finally {
|
||||
release();
|
||||
@@ -634,12 +641,19 @@ export class HlsMuxer extends Muxer {
|
||||
}
|
||||
|
||||
async advancePlaylist(playlist: Playlist) {
|
||||
assert(playlist.currentSegmentStartTimestamp !== null);
|
||||
assert(!playlist.done);
|
||||
|
||||
if (!this.allTracksAreKnown(playlist)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist.currentSegmentStartTimestamp === null) {
|
||||
// All tracks are known but we never received any data - all tracks must be closed already
|
||||
await this.onPlaylistDone(playlist);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const trackDatas = this.trackDatas.filter(x => playlist.tracks.includes(x.track));
|
||||
const videoTrack = trackDatas.find(x => x.info.type === 'video') as HlsVideoTrackData | undefined;
|
||||
const audioTrack = trackDatas.find(x => x.info.type === 'audio') as HlsAudioTrackData | undefined;
|
||||
@@ -754,6 +768,12 @@ export class HlsMuxer extends Muxer {
|
||||
}
|
||||
|
||||
if (videoEndIndex === 0 && audioEndIndex === 0) {
|
||||
// No more segments to write - if all tracks are closed, this playlist is done
|
||||
const allClosed = trackDatas.every(x => x.closed);
|
||||
if (allClosed) {
|
||||
await this.onPlaylistDone(playlist);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -824,13 +844,13 @@ export class HlsMuxer extends Muxer {
|
||||
if (request.isRoot) {
|
||||
if (playlist.singleFile) {
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
|
||||
slice.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
|
||||
|
||||
return slice;
|
||||
} else {
|
||||
const target = await this.output._getTarget(request);
|
||||
outputTarget = target;
|
||||
target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end);
|
||||
target.on('write', ({ end }) => segmentSize = Math.max(segmentSize, end));
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -853,12 +873,12 @@ export class HlsMuxer extends Muxer {
|
||||
};
|
||||
|
||||
const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset);
|
||||
slice.onwrite = (_, end) => {
|
||||
slice.on('write', ({ end }) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
};
|
||||
slice.onfinalized = () => {
|
||||
});
|
||||
slice.on('finalized', () => {
|
||||
playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize;
|
||||
};
|
||||
});
|
||||
|
||||
return slice;
|
||||
} else {
|
||||
@@ -877,12 +897,12 @@ export class HlsMuxer extends Muxer {
|
||||
path,
|
||||
isRoot: false,
|
||||
});
|
||||
target.onwrite = (_, end) => {
|
||||
target.on('write', ({ end }) => {
|
||||
playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end);
|
||||
};
|
||||
target.onfinalized = () => {
|
||||
});
|
||||
target.on('finalized', () => {
|
||||
this.format._options.onInit?.(target, playlistInfo);
|
||||
};
|
||||
});
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -968,24 +988,371 @@ export class HlsMuxer extends Muxer {
|
||||
: maxEndTimestamp; // Happens for the last segment for example
|
||||
assert(Number.isFinite(nextSegmentStartTimestamp));
|
||||
|
||||
const segmentDuration = nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp;
|
||||
|
||||
playlist.writtenSegments.push({
|
||||
path: relativeSegmentPath,
|
||||
duration: nextSegmentStartTimestamp - playlist.currentSegmentStartTimestamp,
|
||||
duration: segmentDuration,
|
||||
byteSize: segmentSize,
|
||||
byteOffset: playlist.singleFile
|
||||
? playlist.singleFile.nextOffset
|
||||
: null,
|
||||
});
|
||||
|
||||
this.globalTargetDuration = Math.max(this.globalTargetDuration, segmentDuration);
|
||||
|
||||
playlist.currentSegmentStartTimestamp = nextSegmentStartTimestamp;
|
||||
playlist.currentSegmentStartTimestampIsFixed = true; // After the first segment, the timestamp is now fixed
|
||||
|
||||
if (playlist.singleFile) {
|
||||
playlist.singleFile.nextOffset += segmentSize;
|
||||
}
|
||||
|
||||
if (this.isLive) {
|
||||
await this.writePlaylist(playlist);
|
||||
await this.tryWriteMasterPlaylist();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async onPlaylistDone(playlist: Playlist) {
|
||||
assert(!playlist.done);
|
||||
playlist.done = true;
|
||||
|
||||
if (playlist.singleFile) {
|
||||
await playlist.singleFile.target._flush();
|
||||
await playlist.singleFile.target._finalize();
|
||||
|
||||
this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info);
|
||||
}
|
||||
|
||||
await this.writePlaylist(playlist);
|
||||
|
||||
if (this.isLive && playlist.writtenSegments.length === 0) {
|
||||
await this.tryWriteMasterPlaylist();
|
||||
}
|
||||
}
|
||||
|
||||
private updatePlaylistBitrates(playlist: Playlist) {
|
||||
const segments = playlist.writtenSegments;
|
||||
|
||||
let peakBitrate = 0;
|
||||
let totalBits = 0;
|
||||
let totalDuration = 0;
|
||||
|
||||
// Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is
|
||||
// between 0.5 and 1.5 times the target duration
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
totalDuration += segments[i]!.duration;
|
||||
|
||||
let windowBytes = 0;
|
||||
let windowDuration = 0;
|
||||
|
||||
for (let j = i; j < segments.length; j++) {
|
||||
windowBytes += segments[j]!.byteSize;
|
||||
windowDuration += segments[j]!.duration;
|
||||
|
||||
if (
|
||||
windowDuration >= 0.5 * this.globalTargetDuration
|
||||
&& windowDuration <= 1.5 * this.globalTargetDuration
|
||||
) {
|
||||
peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration);
|
||||
}
|
||||
|
||||
if (windowDuration > 1.5 * this.globalTargetDuration) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no contiguous set falls within the range, use per-segment max
|
||||
if (peakBitrate === 0) {
|
||||
for (const segment of segments) {
|
||||
peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration);
|
||||
}
|
||||
}
|
||||
|
||||
for (const segment of segments) {
|
||||
totalBits += 8 * segment.byteSize;
|
||||
}
|
||||
|
||||
playlist.peakBitrate = peakBitrate;
|
||||
playlist.averageBitrate = totalBits / (totalDuration || 1);
|
||||
}
|
||||
|
||||
private async writePlaylist(playlist: Playlist) {
|
||||
assert(this.output._rootPath !== null);
|
||||
|
||||
this.updatePlaylistBitrates(playlist);
|
||||
|
||||
let hasByteOffsets = false;
|
||||
for (const segment of playlist.writtenSegments) {
|
||||
hasByteOffsets ||= segment.byteOffset !== null;
|
||||
}
|
||||
|
||||
const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack()
|
||||
&& playlist.tracks[0].metadata.hasOnlyKeyPackets;
|
||||
|
||||
let version = 3;
|
||||
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;
|
||||
}
|
||||
|
||||
// In live mode, target duration is not allowed to change, so we use the nominal value
|
||||
const targetDuration = this.isLive ? this.targetSegmentDuration : this.globalTargetDuration;
|
||||
|
||||
const playlistPath = joinPaths(this.output._rootPath, playlist.path);
|
||||
const playlistText = '#EXTM3U\n'
|
||||
+ `#EXT-X-VERSION:${version}\n`
|
||||
+ (!this.isLive ? '#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 for 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 => (
|
||||
`#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec
|
||||
+ (segment.byteOffset !== null
|
||||
? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n`
|
||||
: '')
|
||||
+ `${segment.path}\n`
|
||||
))
|
||||
.join(''))
|
||||
+ (playlist.done
|
||||
? (playlist.writtenSegments.length > 0 ? '\n' : '') + '#EXT-X-ENDLIST\n'
|
||||
: '');
|
||||
|
||||
this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist));
|
||||
|
||||
const target = await this.output._getTarget({ path: playlistPath, isRoot: false });
|
||||
const writer = new Writer(target);
|
||||
writer.start();
|
||||
writer.write(textEncoder.encode(playlistText));
|
||||
|
||||
await writer.flush();
|
||||
await writer.finalize();
|
||||
}
|
||||
|
||||
private async writeMasterPlaylist() {
|
||||
let masterPlaylistText = '#EXTM3U\n';
|
||||
let firstVariantWritten = false;
|
||||
|
||||
let lastGroupId: string | null = null;
|
||||
let groupIdTrackCount = 0;
|
||||
let hasHadDefaultTrackInGroup = false;
|
||||
|
||||
for (const decl of this.playlistDeclarations) {
|
||||
if (decl.groupId === null) {
|
||||
const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack()
|
||||
&& decl.playlist.tracks[0].metadata.hasOnlyKeyPackets;
|
||||
|
||||
const codecs: string[] = [];
|
||||
for (const track of decl.playlist.tracks) {
|
||||
const trackData = this.trackDatas.find(x => x.track === track);
|
||||
const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec;
|
||||
codecs.push(codecString);
|
||||
}
|
||||
|
||||
let peakDeclBitrate = 0;
|
||||
let maxRefAverageBitrate = 0;
|
||||
|
||||
if (decl.references.length > 0) {
|
||||
const firstRef = decl.references[0]!;
|
||||
const firstTrack = firstRef.playlist.tracks[0]!;
|
||||
const trackData = this.trackDatas.find(x => x.track === firstTrack);
|
||||
const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec;
|
||||
codecs.push(codecString);
|
||||
|
||||
for (const ref of decl.references) {
|
||||
assert(ref.playlist.peakBitrate !== null);
|
||||
peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate);
|
||||
maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
assert(decl.playlist.peakBitrate !== null);
|
||||
const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate;
|
||||
const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate;
|
||||
|
||||
if (!firstVariantWritten) {
|
||||
masterPlaylistText += '\n';
|
||||
firstVariantWritten = true;
|
||||
}
|
||||
|
||||
if (isKeyPacketsOnly) {
|
||||
masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`;
|
||||
} else {
|
||||
masterPlaylistText += `#EXT-X-STREAM-INF:`;
|
||||
}
|
||||
|
||||
masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`;
|
||||
|
||||
if (totalAverageBitrate > 0) {
|
||||
masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`;
|
||||
}
|
||||
|
||||
masterPlaylistText += `,CODECS="${codecs.join(',')}"`;
|
||||
|
||||
const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack());
|
||||
if (videoTrack?.isVideoTrack()) {
|
||||
const trackData = this.trackDatas.find(x => x.track === videoTrack) as
|
||||
HlsVideoTrackData | undefined;
|
||||
const decoderConfig = trackData?.info.decoderConfig;
|
||||
if (decoderConfig) {
|
||||
let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth;
|
||||
let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight;
|
||||
|
||||
if (width !== undefined && height !== undefined) {
|
||||
if (
|
||||
videoTrack.metadata.rotation !== undefined
|
||||
&& videoTrack.metadata.rotation % 180 !== 90
|
||||
) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
|
||||
masterPlaylistText += `,RESOLUTION=${width}x${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
// FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF
|
||||
if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) {
|
||||
// Spec requires that frame rate be rounded to 3 decimal places
|
||||
masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isKeyPacketsOnly) {
|
||||
const groupIdForType = new Map<string, string>();
|
||||
for (const ref of decl.references) {
|
||||
assert(ref.groupId !== null);
|
||||
const type = ref.playlist.tracks[0]!.type;
|
||||
groupIdForType.set(type, ref.groupId);
|
||||
}
|
||||
|
||||
for (const [type, id] of groupIdForType) {
|
||||
masterPlaylistText += `,${type.toUpperCase()}="${id}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isKeyPacketsOnly) {
|
||||
// EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute
|
||||
masterPlaylistText += `,URI="${decl.playlist.path}"`;
|
||||
masterPlaylistText += '\n';
|
||||
} else {
|
||||
masterPlaylistText += '\n';
|
||||
masterPlaylistText += `${decl.playlist.path}\n`;
|
||||
}
|
||||
} else {
|
||||
assert(decl.playlist.tracks.length === 1);
|
||||
|
||||
const track = decl.playlist.tracks[0]!;
|
||||
const type = track.type;
|
||||
let name = track.metadata.name ?? null;
|
||||
const languageCode = track.metadata.languageCode;
|
||||
const disposition = track.metadata.disposition;
|
||||
|
||||
if (lastGroupId === null || decl.groupId !== lastGroupId) {
|
||||
groupIdTrackCount = 0;
|
||||
masterPlaylistText += '\n';
|
||||
hasHadDefaultTrackInGroup = false;
|
||||
}
|
||||
lastGroupId = decl.groupId;
|
||||
groupIdTrackCount++;
|
||||
|
||||
masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`;
|
||||
|
||||
if (name !== null && /[\n\r"]/.test(name)) {
|
||||
console.warn(
|
||||
'Dropping track name since it includes a line feed, carriage return, or double quote'
|
||||
+ ' character, which are not allowed in HLS playlist attributes.',
|
||||
);
|
||||
name = null;
|
||||
}
|
||||
|
||||
// Name is required, so we have to set it to SOMETHING
|
||||
name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`;
|
||||
|
||||
masterPlaylistText += `,NAME="${name}"`;
|
||||
|
||||
if (languageCode !== undefined) {
|
||||
masterPlaylistText += `,LANGUAGE="${languageCode}"`;
|
||||
}
|
||||
|
||||
const dispositionPrimary = disposition?.primary ?? false;
|
||||
const dispositionDefault = disposition?.default ?? true;
|
||||
const dispositionForced = disposition?.forced ?? false;
|
||||
|
||||
if (dispositionPrimary && !hasHadDefaultTrackInGroup) {
|
||||
// HLS's "DEFAULT" behaves like our "primary"
|
||||
masterPlaylistText += ',DEFAULT=YES';
|
||||
hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed
|
||||
}
|
||||
|
||||
if (dispositionPrimary || dispositionDefault) {
|
||||
masterPlaylistText += ',AUTOSELECT=YES';
|
||||
}
|
||||
|
||||
if (dispositionForced) {
|
||||
masterPlaylistText += ',FORCED=YES';
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
const trackData = this.trackDatas.find(x => x.track === track) as
|
||||
HlsAudioTrackData | undefined;
|
||||
const decoderConfig = trackData?.info.decoderConfig;
|
||||
|
||||
if (decoderConfig) {
|
||||
masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decl.noUri) {
|
||||
masterPlaylistText += `,URI="${decl.playlist.path}"`;
|
||||
}
|
||||
|
||||
masterPlaylistText += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
this.format._options.onMaster?.(masterPlaylistText);
|
||||
|
||||
const target = await this.output._getTarget({ path: this.output._rootPath!, isRoot: true });
|
||||
const writer = new Writer(target);
|
||||
|
||||
writer.start();
|
||||
writer.write(textEncoder.encode(masterPlaylistText));
|
||||
|
||||
await writer.flush();
|
||||
await writer.finalize();
|
||||
}
|
||||
|
||||
private async tryWriteMasterPlaylist() {
|
||||
assert(this.isLive);
|
||||
|
||||
// The master playlist is written once all playlists have either produced at least one segment or are done
|
||||
for (const playlist of this.playlists) {
|
||||
if (playlist.writtenSegments.length === 0 && !playlist.done) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeMasterPlaylist();
|
||||
}
|
||||
|
||||
async finalize() {
|
||||
assert(this.output._rootPath !== null);
|
||||
const release = await this.mutex.acquire();
|
||||
@@ -994,320 +1361,14 @@ export class HlsMuxer extends Muxer {
|
||||
trackData.closed = true;
|
||||
}
|
||||
|
||||
// Compute a single target duration across all playlists, as the spec mandates they match
|
||||
let globalTargetDuration = this.targetSegmentDuration;
|
||||
for (const playlist of this.playlists) {
|
||||
for (const segment of playlist.writtenSegments) {
|
||||
globalTargetDuration = Math.max(globalTargetDuration, segment.duration);
|
||||
}
|
||||
await Promise.all(this.playlists.map(playlist => (
|
||||
playlist.done ? Promise.resolve() : this.advancePlaylist(playlist)
|
||||
)));
|
||||
|
||||
if (!this.isLive) {
|
||||
await this.writeMasterPlaylist();
|
||||
}
|
||||
|
||||
for (const playlist of this.playlists) {
|
||||
if (playlist.currentSegmentStartTimestamp !== null) {
|
||||
await this.advancePlaylist(playlist);
|
||||
} else {
|
||||
// Never had any data written to it
|
||||
}
|
||||
|
||||
let peakBitrate = 0;
|
||||
let totalBits = 0;
|
||||
let totalDuration = 0;
|
||||
|
||||
// Per spec, peak bitrate is the largest bit rate of any contiguous set of segments whose total duration is
|
||||
// between 0.5 and 1.5 times the target duration
|
||||
const segments = playlist.writtenSegments;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
totalDuration += segments[i]!.duration;
|
||||
|
||||
let windowBytes = 0;
|
||||
let windowDuration = 0;
|
||||
|
||||
for (let j = i; j < segments.length; j++) {
|
||||
windowBytes += segments[j]!.byteSize;
|
||||
windowDuration += segments[j]!.duration;
|
||||
|
||||
if (windowDuration >= 0.5 * globalTargetDuration && windowDuration <= 1.5 * globalTargetDuration) {
|
||||
peakBitrate = Math.max(peakBitrate, 8 * windowBytes / windowDuration);
|
||||
}
|
||||
|
||||
if (windowDuration > 1.5 * globalTargetDuration) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no contiguous set falls within the range, use per-segment max
|
||||
if (peakBitrate === 0) {
|
||||
for (const segment of segments) {
|
||||
peakBitrate = Math.max(peakBitrate, 8 * segment.byteSize / segment.duration);
|
||||
}
|
||||
}
|
||||
|
||||
for (const segment of segments) {
|
||||
totalBits += 8 * segment.byteSize;
|
||||
}
|
||||
|
||||
playlist.peakBitrate = peakBitrate;
|
||||
playlist.averageBitrate = totalBits / (totalDuration || 1);
|
||||
}
|
||||
|
||||
// Write all playlists in parallel
|
||||
const playlistPromises = this.playlists.map(async (playlist) => {
|
||||
if (playlist.singleFile) {
|
||||
await playlist.singleFile.target._flush();
|
||||
await playlist.singleFile.target._finalize();
|
||||
|
||||
this.format._options.onSegment?.(playlist.singleFile.target, playlist.singleFile.info);
|
||||
}
|
||||
|
||||
let hasByteOffsets = false;
|
||||
for (const segment of playlist.writtenSegments) {
|
||||
hasByteOffsets ||= segment.byteOffset !== null;
|
||||
}
|
||||
|
||||
const isKeyPacketsOnly = playlist.tracks[0]!.isVideoTrack()
|
||||
&& playlist.tracks[0].metadata.hasOnlyKeyPackets;
|
||||
|
||||
let version = 3;
|
||||
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(globalTargetDuration)}\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 => (
|
||||
`#EXTINF:${+segment.duration.toFixed(12)},\n` // Trailing comma mandated by spec
|
||||
+ (segment.byteOffset !== null
|
||||
? `#EXT-X-BYTERANGE:${segment.byteSize}@${segment.byteOffset}\n`
|
||||
: '')
|
||||
+ `${segment.path}\n`
|
||||
))
|
||||
.join(''))
|
||||
+ (playlist.writtenSegments.length > 0 ? '\n' : '')
|
||||
+ '#EXT-X-ENDLIST\n';
|
||||
|
||||
this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist));
|
||||
|
||||
const target = await this.output._getTarget({ path: playlistPath, isRoot: false });
|
||||
const writer = new Writer(target);
|
||||
writer.start();
|
||||
writer.write(textEncoder.encode(playlistText));
|
||||
|
||||
await writer.flush();
|
||||
await writer.finalize();
|
||||
});
|
||||
|
||||
const masterPlaylistPromise = (async () => {
|
||||
let masterPlaylistText = '#EXTM3U\n';
|
||||
let firstVariantWritten = false;
|
||||
|
||||
let lastGroupId: string | null = null;
|
||||
let groupIdTrackCount = 0;
|
||||
let hasHadDefaultTrackInGroup = false;
|
||||
|
||||
for (const decl of this.playlistDeclarations) {
|
||||
if (decl.groupId === null) {
|
||||
const isKeyPacketsOnly = decl.playlist.tracks[0]!.isVideoTrack()
|
||||
&& decl.playlist.tracks[0].metadata.hasOnlyKeyPackets;
|
||||
|
||||
const codecs: string[] = [];
|
||||
for (const track of decl.playlist.tracks) {
|
||||
const trackData = this.trackDatas.find(x => x.track === track);
|
||||
const codecString = trackData?.info.decoderConfig.codec ?? track.source._codec;
|
||||
codecs.push(codecString);
|
||||
}
|
||||
|
||||
let peakDeclBitrate = 0;
|
||||
let maxRefAverageBitrate = 0;
|
||||
|
||||
if (decl.references.length > 0) {
|
||||
const firstRef = decl.references[0]!;
|
||||
const firstTrack = firstRef.playlist.tracks[0]!;
|
||||
const trackData = this.trackDatas.find(x => x.track === firstTrack);
|
||||
const codecString = trackData?.info.decoderConfig.codec ?? firstTrack.source._codec;
|
||||
codecs.push(codecString);
|
||||
|
||||
for (const ref of decl.references) {
|
||||
assert(ref.playlist.peakBitrate !== null);
|
||||
peakDeclBitrate = Math.max(peakDeclBitrate, ref.playlist.peakBitrate);
|
||||
maxRefAverageBitrate = Math.max(maxRefAverageBitrate, ref.playlist.averageBitrate ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
assert(decl.playlist.peakBitrate !== null);
|
||||
const totalPeakBitrate = decl.playlist.peakBitrate + peakDeclBitrate;
|
||||
const totalAverageBitrate = (decl.playlist.averageBitrate ?? 0) + maxRefAverageBitrate;
|
||||
|
||||
if (!firstVariantWritten) {
|
||||
masterPlaylistText += '\n';
|
||||
firstVariantWritten = true;
|
||||
}
|
||||
|
||||
if (isKeyPacketsOnly) {
|
||||
masterPlaylistText += `#EXT-X-I-FRAME-STREAM-INF:`;
|
||||
} else {
|
||||
masterPlaylistText += `#EXT-X-STREAM-INF:`;
|
||||
}
|
||||
|
||||
masterPlaylistText += `BANDWIDTH=${Math.ceil(totalPeakBitrate)}`;
|
||||
|
||||
if (totalAverageBitrate > 0) {
|
||||
masterPlaylistText += `,AVERAGE-BANDWIDTH=${Math.ceil(totalAverageBitrate)}`;
|
||||
}
|
||||
|
||||
masterPlaylistText += `,CODECS="${codecs.join(',')}"`;
|
||||
|
||||
const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack());
|
||||
if (videoTrack?.isVideoTrack()) {
|
||||
const trackData = this.trackDatas.find(x => x.track === videoTrack) as
|
||||
HlsVideoTrackData | undefined;
|
||||
const decoderConfig = trackData?.info.decoderConfig;
|
||||
if (decoderConfig) {
|
||||
let width = decoderConfig.displayAspectWidth ?? decoderConfig.codedWidth;
|
||||
let height = decoderConfig.displayAspectHeight ?? decoderConfig.codedHeight;
|
||||
|
||||
if (width !== undefined && height !== undefined) {
|
||||
if (
|
||||
videoTrack.metadata.rotation !== undefined
|
||||
&& videoTrack.metadata.rotation % 180 !== 90
|
||||
) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
|
||||
masterPlaylistText += `,RESOLUTION=${width}x${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
// FRAME-RATE is not defined for EXT-X-I-FRAME-STREAM-INF
|
||||
if (!isKeyPacketsOnly && videoTrack.metadata.frameRate !== undefined) {
|
||||
// Spec requires that frame rate be rounded to 3 decimal places
|
||||
masterPlaylistText += `,FRAME-RATE=${+videoTrack.metadata.frameRate.toFixed(3)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isKeyPacketsOnly) {
|
||||
const groupIdForType = new Map<string, string>();
|
||||
for (const ref of decl.references) {
|
||||
assert(ref.groupId !== null);
|
||||
const type = ref.playlist.tracks[0]!.type;
|
||||
groupIdForType.set(type, ref.groupId);
|
||||
}
|
||||
|
||||
for (const [type, id] of groupIdForType) {
|
||||
masterPlaylistText += `,${type.toUpperCase()}="${id}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isKeyPacketsOnly) {
|
||||
// EXT-X-I-FRAME-STREAM-INF is standalone with a URI attribute
|
||||
masterPlaylistText += `,URI="${decl.playlist.path}"`;
|
||||
masterPlaylistText += '\n';
|
||||
} else {
|
||||
masterPlaylistText += '\n';
|
||||
masterPlaylistText += `${decl.playlist.path}\n`;
|
||||
}
|
||||
} else {
|
||||
assert(decl.playlist.tracks.length === 1);
|
||||
|
||||
const track = decl.playlist.tracks[0]!;
|
||||
const type = track.type;
|
||||
let name = track.metadata.name ?? null;
|
||||
const languageCode = track.metadata.languageCode;
|
||||
const disposition = track.metadata.disposition;
|
||||
|
||||
if (lastGroupId === null || decl.groupId !== lastGroupId) {
|
||||
groupIdTrackCount = 0;
|
||||
masterPlaylistText += '\n';
|
||||
hasHadDefaultTrackInGroup = false;
|
||||
}
|
||||
lastGroupId = decl.groupId;
|
||||
groupIdTrackCount++;
|
||||
|
||||
masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`;
|
||||
|
||||
if (name !== null && /[\n\r"]/.test(name)) {
|
||||
console.warn(
|
||||
'Dropping track name since it includes a line feed, carriage return, or double quote'
|
||||
+ ' character, which are not allowed in HLS playlist attributes.',
|
||||
);
|
||||
name = null;
|
||||
}
|
||||
|
||||
// Name is required, so we have to set it to SOMETHING
|
||||
name ??= `${languageCode ?? decl.groupId}-${groupIdTrackCount}`;
|
||||
|
||||
masterPlaylistText += `,NAME="${name}"`;
|
||||
|
||||
if (languageCode !== undefined) {
|
||||
masterPlaylistText += `,LANGUAGE="${languageCode}"`;
|
||||
}
|
||||
|
||||
const dispositionPrimary = disposition?.primary ?? false;
|
||||
const dispositionDefault = disposition?.default ?? true;
|
||||
const dispositionForced = disposition?.forced ?? false;
|
||||
|
||||
if (dispositionPrimary && !hasHadDefaultTrackInGroup) {
|
||||
// HLS's "DEFAULT" behaves like our "primary"
|
||||
masterPlaylistText += ',DEFAULT=YES';
|
||||
hasHadDefaultTrackInGroup = true; // Only one DEFAULT label per group allowed
|
||||
}
|
||||
|
||||
if (dispositionPrimary || dispositionDefault) {
|
||||
masterPlaylistText += ',AUTOSELECT=YES';
|
||||
}
|
||||
|
||||
if (dispositionForced) {
|
||||
masterPlaylistText += ',FORCED=YES';
|
||||
}
|
||||
|
||||
if (type === 'audio') {
|
||||
const trackData = this.trackDatas.find(x => x.track === track) as
|
||||
HlsAudioTrackData | undefined;
|
||||
const decoderConfig = trackData?.info.decoderConfig;
|
||||
|
||||
if (decoderConfig) {
|
||||
masterPlaylistText += `,CHANNELS="${decoderConfig.numberOfChannels}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!decl.noUri) {
|
||||
masterPlaylistText += `,URI="${decl.playlist.path}"`;
|
||||
}
|
||||
|
||||
masterPlaylistText += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
this.format._options.onMaster?.(masterPlaylistText);
|
||||
|
||||
const rootWriter = await this.output._getRootWriter();
|
||||
rootWriter.write(textEncoder.encode(masterPlaylistText));
|
||||
})();
|
||||
|
||||
await Promise.all([...playlistPromises, masterPlaylistPromise]);
|
||||
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
VideoTrackMetadata,
|
||||
AudioTrackMetadata,
|
||||
SubtitleTrackMetadata,
|
||||
OutputEvents,
|
||||
} from './output';
|
||||
export {
|
||||
OutputFormat,
|
||||
@@ -129,6 +130,7 @@ export {
|
||||
StreamTarget,
|
||||
StreamTargetOptions,
|
||||
StreamTargetChunk,
|
||||
TargetEvents,
|
||||
} from './target';
|
||||
export {
|
||||
AnyIterable,
|
||||
@@ -141,6 +143,7 @@ export {
|
||||
asc,
|
||||
desc,
|
||||
prefer,
|
||||
EventEmitter,
|
||||
} from './misc';
|
||||
export {
|
||||
TrackType,
|
||||
@@ -191,6 +194,7 @@ export {
|
||||
export {
|
||||
Input,
|
||||
InputOptions,
|
||||
InputEvents,
|
||||
InputDisposedError,
|
||||
} from './input';
|
||||
export {
|
||||
|
||||
+22
-11
@@ -17,7 +17,17 @@ import {
|
||||
TrackQuery,
|
||||
} from './input-track';
|
||||
import { PacketRetrievalOptions } from './media-sink';
|
||||
import { arrayArgmin, arrayCount, assert, desc, MaybePromise, polyfillSymbolDispose, prefer, removeItem } from './misc';
|
||||
import {
|
||||
arrayArgmin,
|
||||
arrayCount,
|
||||
assert,
|
||||
desc,
|
||||
EventEmitter,
|
||||
MaybePromise,
|
||||
polyfillSymbolDispose,
|
||||
prefer,
|
||||
removeItem,
|
||||
} from './misc';
|
||||
import { Reader } from './reader';
|
||||
import { Source, SourceRef } from './source';
|
||||
|
||||
@@ -70,7 +80,11 @@ type SourceCacheEntry<S extends Source> = {
|
||||
* @group Input files & tracks
|
||||
* @public
|
||||
*/
|
||||
export class Input<S extends Source = Source> implements Disposable {
|
||||
export type InputEvents = {
|
||||
source: { source: Source; request: SourceRequest | null };
|
||||
};
|
||||
|
||||
export class Input<S extends Source = Source> extends EventEmitter<InputEvents> implements Disposable {
|
||||
/** @internal */
|
||||
_source: SourceRef<S> | ((request: SourceRequest) => MaybePromise<S | SourceRef<S>>);
|
||||
/** @internal */
|
||||
@@ -102,11 +116,6 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
promise: Promise<SourceCacheEntry<S>>;
|
||||
}[] = [];
|
||||
|
||||
/**
|
||||
* Called whenever a source is resolved for internal operations.
|
||||
*/
|
||||
onSource?: (source: Source, request: SourceRequest | null) => unknown;
|
||||
|
||||
/** True if the input has been disposed. */
|
||||
get disposed() {
|
||||
return this._disposed;
|
||||
@@ -117,6 +126,8 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
* called on this instance.
|
||||
*/
|
||||
constructor(options: InputOptions<S>) {
|
||||
super();
|
||||
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
@@ -171,7 +182,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
ref = source;
|
||||
}
|
||||
|
||||
this.onSource?.(ref.source, request);
|
||||
this.emit('source', { source: ref.source, request });
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -246,7 +257,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
let ref: SourceRef;
|
||||
if (this._source instanceof SourceRef) {
|
||||
ref = this._source;
|
||||
this.onSource?.(ref.source, null);
|
||||
this.emit('source', { source: ref.source, request: null });
|
||||
} else {
|
||||
assert(this._entryPath !== null);
|
||||
ref = await this._getSourceUncached({ path: this._entryPath, isRoot: true });
|
||||
@@ -269,7 +280,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
|
||||
/**
|
||||
* Returns the source from which this input file reads data for the entry path. Throws if the source-resolving
|
||||
* function returns a promise; prefer the {@link Input.onSource} callback for those cases.
|
||||
* function returns a promise; prefer the `'source'` event for those cases.
|
||||
*/
|
||||
get source(): S {
|
||||
if (this._source instanceof SourceRef) {
|
||||
@@ -282,7 +293,7 @@ export class Input<S extends Source = Source> implements Disposable {
|
||||
if (source instanceof Promise) {
|
||||
throw new TypeError(
|
||||
'Input.source cannot be used when the source function resolves asynchronously.'
|
||||
+ ' Use the onSource event instead.',
|
||||
+ ' Use the \'source\' event instead.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -1158,3 +1158,43 @@ export const toArray = <T>(x: T | T[]) => {
|
||||
return [x];
|
||||
}
|
||||
};
|
||||
|
||||
type ListenerOptions = {
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
export class EventEmitter<TEvents extends Record<string, unknown>> {
|
||||
private _listeners = new Map<keyof TEvents, Set<{ fn: (data: never) => unknown; once: boolean }>>();
|
||||
|
||||
on<K extends keyof TEvents>(
|
||||
event: K,
|
||||
listener: (data: TEvents[K]) => unknown,
|
||||
options?: ListenerOptions,
|
||||
): () => void {
|
||||
if (!this._listeners.has(event)) {
|
||||
this._listeners.set(event, new Set());
|
||||
}
|
||||
const entry = { fn: listener as (data: never) => void, once: options?.once ?? false };
|
||||
this._listeners.get(event)!.add(entry);
|
||||
|
||||
return () => {
|
||||
this._listeners.get(event)?.delete(entry);
|
||||
};
|
||||
}
|
||||
|
||||
emit<K extends keyof TEvents>(
|
||||
...args: TEvents[K] extends void ? [event: K] : [event: K, data: TEvents[K]]
|
||||
): void {
|
||||
const [event, data] = args;
|
||||
const listeners = this._listeners.get(event);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const entry of listeners) {
|
||||
(entry.fn as (data: unknown) => void)(data);
|
||||
if (entry.once) {
|
||||
listeners.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,6 +1172,7 @@ export type HlsOutputFormatOptions = {
|
||||
segmentFormat: OutputFormat | OutputFormat[];
|
||||
targetDuration?: number;
|
||||
singleFilePerPlaylist?: boolean;
|
||||
live?: boolean;
|
||||
getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<string>;
|
||||
getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise<string>;
|
||||
getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<string>;
|
||||
@@ -1213,6 +1214,9 @@ export class HlsOutputFormat extends OutputFormat {
|
||||
if (options.singleFilePerPlaylist !== undefined && typeof options.singleFilePerPlaylist !== 'boolean') {
|
||||
throw new TypeError('options.singleFilePerPlaylist, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.live !== undefined && typeof options.live !== 'boolean') {
|
||||
throw new TypeError('options.live, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') {
|
||||
throw new TypeError('options.getPlaylistPath, when provided, must be a function.');
|
||||
}
|
||||
|
||||
+10
-9
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation, toArray } from './misc';
|
||||
import { assert, AsyncMutex, EventEmitter, isIso639Dash2LanguageCode, MaybePromise, Rotation, toArray } from './misc';
|
||||
import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata';
|
||||
import { Muxer } from './muxer';
|
||||
import { OutputFormat } from './output-format';
|
||||
@@ -284,10 +284,14 @@ export type OutputOptions<
|
||||
* @group Output files
|
||||
* @public
|
||||
*/
|
||||
export type OutputEvents = {
|
||||
target: { target: Target; request: TargetRequest | null };
|
||||
};
|
||||
|
||||
export class Output<
|
||||
F extends OutputFormat = OutputFormat,
|
||||
T extends Target = Target,
|
||||
> {
|
||||
> extends EventEmitter<OutputEvents> {
|
||||
/** The format of the output file. */
|
||||
readonly format: F;
|
||||
/** @internal */
|
||||
@@ -335,16 +339,13 @@ export class Output<
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever a target is resolved for internal operations.
|
||||
*/
|
||||
onTarget?: (target: Target, request: TargetRequest | null) => unknown;
|
||||
|
||||
/**
|
||||
* Creates a new instance of {@link Output} which can then be used to create a new media file according to the
|
||||
* specified {@link OutputOptions}.
|
||||
*/
|
||||
constructor(options: OutputOptions<F, T>) {
|
||||
super();
|
||||
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
@@ -389,7 +390,7 @@ export class Output<
|
||||
assert(typeof this._target === 'function');
|
||||
|
||||
const target = await this._target(request);
|
||||
this.onTarget?.(target, request);
|
||||
this.emit('target', { target, request });
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -403,7 +404,7 @@ export class Output<
|
||||
target = await this._getTarget({ path: this._rootPath, isRoot: true });
|
||||
} else {
|
||||
target = this._target;
|
||||
this.onTarget?.(this._target, null);
|
||||
this.emit('target', { target: this._target, request: null });
|
||||
}
|
||||
|
||||
const writer = new Writer(target);
|
||||
|
||||
+36
-12
@@ -9,18 +9,28 @@
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import { Output } from './output';
|
||||
import * as nodeAlias from './node';
|
||||
import { assert } from './misc';
|
||||
import { assert, EventEmitter } from './misc';
|
||||
|
||||
const node = typeof nodeAlias !== 'undefined'
|
||||
? nodeAlias // Aliasing it prevents some bundler warnings
|
||||
: undefined!;
|
||||
|
||||
/**
|
||||
* The events emitted by a {@link Target}.
|
||||
* @group Output targets
|
||||
* @public
|
||||
*/
|
||||
export type TargetEvents = {
|
||||
write: { start: number; end: number };
|
||||
finalized: void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base class for targets, specifying where output files are written.
|
||||
* @group Output targets
|
||||
* @public
|
||||
*/
|
||||
export abstract class Target {
|
||||
export abstract class Target extends EventEmitter<TargetEvents> {
|
||||
/** @internal */
|
||||
_output: Output | null = null;
|
||||
|
||||
@@ -43,11 +53,25 @@ export abstract class Target {
|
||||
*
|
||||
* Use this callback to track the size of the output file as it grows. But be warned, this function is chatty and
|
||||
* gets called *extremely* often.
|
||||
* @deprecated Use `target.on('write', ({ start, end }) => ...)` instead.
|
||||
*/
|
||||
onwrite: ((start: number, end: number) => unknown) | null = null;
|
||||
|
||||
/** @deprecated Use `target.on('finalized', () => ...)` instead. */
|
||||
onfinalized: (() => unknown) | null = null;
|
||||
|
||||
/** @internal */
|
||||
_dispatchWrite(start: number, end: number) {
|
||||
this.onwrite?.(start, end);
|
||||
this.emit('write', { start, end });
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_dispatchFinalized() {
|
||||
this.onfinalized?.();
|
||||
this.emit('finalized');
|
||||
}
|
||||
|
||||
slice(offset: number) {
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new TypeError('offset must be a non-negative integer.');
|
||||
@@ -139,7 +163,7 @@ export class BufferTarget extends Target {
|
||||
|
||||
this._maxPos = Math.max(this._maxPos, pos + data.byteLength);
|
||||
|
||||
this.onwrite?.(pos, pos + data.byteLength);
|
||||
this._dispatchWrite(pos, pos + data.byteLength);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -148,7 +172,7 @@ export class BufferTarget extends Target {
|
||||
/** @internal */
|
||||
async _finalize() {
|
||||
this.buffer = this._buffer.slice(0, this._maxPos);
|
||||
this.onfinalized?.();
|
||||
this._dispatchFinalized();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -293,7 +317,7 @@ export class StreamTarget extends Target {
|
||||
|
||||
this._lastWriteEnd = Math.max(this._lastWriteEnd, pos + data.byteLength);
|
||||
|
||||
this.onwrite?.(pos, pos + data.byteLength);
|
||||
this._dispatchWrite(pos, pos + data.byteLength);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -502,7 +526,7 @@ export class StreamTarget extends Target {
|
||||
await this._streamWriter.ready;
|
||||
await this._streamWriter.close();
|
||||
|
||||
this.onfinalized?.();
|
||||
this._dispatchFinalized();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -575,7 +599,7 @@ export class FilePathTarget extends Target {
|
||||
/** @internal */
|
||||
_write(data: Uint8Array, pos: number) {
|
||||
this._streamTarget._write(data, pos);
|
||||
this.onwrite?.(pos, pos + data.byteLength);
|
||||
this._dispatchWrite(pos, pos + data.byteLength);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -586,7 +610,7 @@ export class FilePathTarget extends Target {
|
||||
/** @internal */
|
||||
async _finalize() {
|
||||
await this._streamTarget._finalize();
|
||||
this.onfinalized?.();
|
||||
this._dispatchFinalized();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -608,7 +632,7 @@ export class NullTarget extends Target {
|
||||
/** @internal */
|
||||
|
||||
_write(data: Uint8Array, pos: number) {
|
||||
this.onwrite?.(pos, pos + data.byteLength);
|
||||
this._dispatchWrite(pos, pos + data.byteLength);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -616,7 +640,7 @@ export class NullTarget extends Target {
|
||||
|
||||
/** @internal */
|
||||
async _finalize() {
|
||||
this.onfinalized?.();
|
||||
this._dispatchFinalized();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -643,7 +667,7 @@ export class RangedTarget extends Target {
|
||||
/** @internal */
|
||||
_write(data: Uint8Array, pos: number): void {
|
||||
this._baseTarget._write(data, this._offset + pos);
|
||||
this.onwrite?.(pos, pos + data.byteLength);
|
||||
this._dispatchWrite(pos, pos + data.byteLength);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -653,7 +677,7 @@ export class RangedTarget extends Target {
|
||||
|
||||
/** @internal */
|
||||
async _finalize() {
|
||||
this.onfinalized?.();
|
||||
this._dispatchFinalized();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
||||
Reference in New Issue
Block a user