mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Start work on HlsOutputFormat
This commit is contained in:
@@ -83,26 +83,32 @@ const initMediaPlayer = async (resource: File | string) => {
|
||||
pause();
|
||||
}
|
||||
|
||||
const dirHandle = await showDirectoryPicker({ mode: 'read' });
|
||||
|
||||
void videoFrameIterator?.return();
|
||||
void audioBufferIterator?.return();
|
||||
asyncId++;
|
||||
|
||||
fileLoaded = false;
|
||||
fileNameElement.textContent = resource instanceof File ? resource.name : resource;
|
||||
fileNameElement.textContent = 'pish'; // resource instanceof File ? resource.name : resource;
|
||||
horizontalRule.style.display = '';
|
||||
loadingElement.style.display = '';
|
||||
playerContainer.style.display = 'none';
|
||||
errorElement.textContent = '';
|
||||
warningElement.textContent = '';
|
||||
|
||||
let start = 0;
|
||||
const start = 0;
|
||||
|
||||
let videoTrack: InputVideoTrack | null = null;
|
||||
let audioTrack: InputAudioTrack | null = null;
|
||||
if (typeof resource === 'string' && resource.includes('.m3u8')) {
|
||||
if (true || typeof resource === 'string' && resource.includes('.m3u8')) {
|
||||
const input = new Input({
|
||||
entryPath: resource,
|
||||
source: ({ path }) => new UrlSource(path),
|
||||
entryPath: 'playlist.m3u8',
|
||||
source: async ({ path }) => {
|
||||
const fileHandle = await dirHandle.getFileHandle(path);
|
||||
const file = await fileHandle.getFile();
|
||||
return new BlobSource(file);
|
||||
},
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
// const variant = (await manifestInput.getVariants())[0]!;
|
||||
@@ -132,9 +138,9 @@ const initMediaPlayer = async (resource: File | string) => {
|
||||
|
||||
console.log(videoTrack, audioTrack, totalDuration);
|
||||
|
||||
start = totalDuration - 2;
|
||||
// start = totalDuration - 2;
|
||||
|
||||
totalDuration += 3600;
|
||||
// totalDuration += 3600;
|
||||
|
||||
// https://test-streams.mux.dev/test_001/stream.m3u8
|
||||
// https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_050.ts
|
||||
@@ -764,6 +770,10 @@ document.addEventListener('dragover', (event) => {
|
||||
event.dataTransfer!.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
void initMediaPlayer();
|
||||
}, { once: true });
|
||||
|
||||
document.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getFirstEncodableAudioCodec,
|
||||
getFirstEncodableVideoCodec,
|
||||
OutputFormat,
|
||||
HlsOutputFormat,
|
||||
} from 'mediabunny';
|
||||
|
||||
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
|
||||
@@ -66,6 +67,11 @@ const generateVideo = async () => {
|
||||
let progressInterval = -1;
|
||||
|
||||
try {
|
||||
const dirHandle = await showDirectoryPicker({ mode: 'readwrite' });
|
||||
for await (const [name, handle] of dirHandle) {
|
||||
await dirHandle.removeEntry(name, { recursive: true });
|
||||
}
|
||||
|
||||
// Let's set some DOM state
|
||||
renderButton.disabled = true;
|
||||
renderButton.textContent = 'Generating...';
|
||||
@@ -86,8 +92,19 @@ const generateVideo = async () => {
|
||||
|
||||
// Create a new output file
|
||||
output = new Output({
|
||||
target: new BufferTarget(), // Stored in memory
|
||||
format: new Mp4OutputFormat(),
|
||||
rootPath: 'playlist.m3u8',
|
||||
target: ({ path }) => {
|
||||
const target = new BufferTarget();
|
||||
target.onfinalized = async () => {
|
||||
const fileHandle = await dirHandle.getFileHandle(path, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(target.buffer!);
|
||||
await writable.close();
|
||||
};
|
||||
|
||||
return target;
|
||||
}, // Stored in memory
|
||||
format: new HlsOutputFormat(),
|
||||
});
|
||||
|
||||
// Retrieve the first video codec supported by this browser that can be contained in the output format
|
||||
@@ -103,6 +120,7 @@ const generateVideo = async () => {
|
||||
const canvasSource = new CanvasSource(renderCanvas, {
|
||||
codec: videoCodec,
|
||||
bitrate: QUALITY_HIGH,
|
||||
keyFrameInterval: 2,
|
||||
});
|
||||
output.addVideoTrack(canvasSource, { frameRate });
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Writer } from '../writer';
|
||||
|
||||
export class AdtsMuxer extends Muxer {
|
||||
private format: AdtsOutputFormat;
|
||||
private writer: Writer;
|
||||
private writer!: Writer;
|
||||
private header: Uint8Array | null = null;
|
||||
private headerBitstream: Bitstream | null = null;
|
||||
private inputIsAdts: boolean | null = null;
|
||||
@@ -29,14 +29,19 @@ export class AdtsMuxer extends Muxer {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
|
||||
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
|
||||
const id3Writer = new Id3V2Writer(this.writer);
|
||||
id3Writer.writeId3V2Tag(this.output._metadataTags);
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
|
||||
@@ -33,7 +33,7 @@ const STREAMINFO_SIZE = 38;
|
||||
const STREAMINFO_BLOCK_SIZE = 34;
|
||||
|
||||
export class FlacMuxer extends Muxer {
|
||||
private writer: Writer;
|
||||
private writer!: Writer;
|
||||
private metadataWritten = false;
|
||||
|
||||
private blockSizes: number[] = [];
|
||||
@@ -48,12 +48,16 @@ export class FlacMuxer extends Muxer {
|
||||
constructor(output: Output, format: FlacOutputFormat) {
|
||||
super(output);
|
||||
|
||||
this.writer = output._writer;
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.writer.write(FLAC_HEADER);
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
writeHeader({
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec';
|
||||
import { EncodedAudioPacketSource, EncodedVideoPacketSource, MediaSource } from '../media-source';
|
||||
import { assert, joinPaths, last, textEncoder } from '../misc';
|
||||
import { Muxer } from '../muxer';
|
||||
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
|
||||
import { MpegTsOutputFormat } from '../output-format';
|
||||
import { EncodedPacket } from '../packet';
|
||||
import { SubtitleCue, SubtitleMetadata } from '../subtitles';
|
||||
import { Writer } from '../writer';
|
||||
|
||||
type HlsTrackData = {
|
||||
track: OutputTrack;
|
||||
packets: EncodedPacket[];
|
||||
info: {
|
||||
type: 'video';
|
||||
decoderConfig: VideoDecoderConfig;
|
||||
} | {
|
||||
type: 'audio';
|
||||
decoderConfig: AudioDecoderConfig;
|
||||
};
|
||||
};
|
||||
type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } };
|
||||
type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } };
|
||||
|
||||
export class HlsMuxer extends Muxer {
|
||||
targetSegmentDuration = 2;
|
||||
trackDatas: HlsTrackData[] = [];
|
||||
currentSegmentStartTimestamp: number | null = null;
|
||||
nextSegmentId = 1;
|
||||
writtenSegments: {
|
||||
path: string;
|
||||
duration: number;
|
||||
}[] = [];
|
||||
|
||||
constructor(output: Output) {
|
||||
if (typeof output.target !== 'function') {
|
||||
throw new TypeError('HLS outputs require `OutputOptions.target` to be a function.');
|
||||
}
|
||||
|
||||
super(output);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Nada
|
||||
}
|
||||
|
||||
async getMimeType(): Promise<string> {
|
||||
throw new Error('TODO');
|
||||
}
|
||||
|
||||
private allTracksAreKnown() {
|
||||
for (const track of this.output._tracks) {
|
||||
if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) {
|
||||
return false; // We haven't seen a sample from this open track yet
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
override async onTrackClose(track: OutputTrack) {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
try {
|
||||
if (!this.trackDatas.some(x => x.track === track)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ting();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
getVideoTrackData(track: OutputVideoTrack, meta?: EncodedVideoChunkMetadata) {
|
||||
let trackData = this.trackDatas.find(x => x.track === track) as HlsVideoTrackData;
|
||||
if (trackData) {
|
||||
return trackData;
|
||||
}
|
||||
|
||||
validateVideoChunkMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta?.decoderConfig);
|
||||
|
||||
trackData = {
|
||||
track,
|
||||
packets: [],
|
||||
info: {
|
||||
type: 'video',
|
||||
decoderConfig: meta.decoderConfig,
|
||||
},
|
||||
};
|
||||
this.trackDatas.push(trackData);
|
||||
|
||||
return trackData;
|
||||
}
|
||||
|
||||
getAudioTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) {
|
||||
let trackData = this.trackDatas.find(x => x.track === track) as HlsAudioTrackData;
|
||||
if (trackData) {
|
||||
return trackData;
|
||||
}
|
||||
|
||||
validateAudioChunkMetadata(meta);
|
||||
|
||||
assert(meta);
|
||||
assert(meta?.decoderConfig);
|
||||
|
||||
trackData = {
|
||||
track,
|
||||
packets: [],
|
||||
info: {
|
||||
type: 'audio',
|
||||
decoderConfig: meta.decoderConfig,
|
||||
},
|
||||
};
|
||||
this.trackDatas.push(trackData);
|
||||
|
||||
return trackData;
|
||||
}
|
||||
|
||||
async addEncodedVideoPacket(
|
||||
track: OutputVideoTrack,
|
||||
packet: EncodedPacket,
|
||||
meta?: EncodedVideoChunkMetadata,
|
||||
) {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
try {
|
||||
const trackData = this.getVideoTrackData(track, meta);
|
||||
|
||||
const timestamp = this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
|
||||
const adjustedPacket = packet.clone({ timestamp });
|
||||
|
||||
trackData.packets.push(adjustedPacket);
|
||||
|
||||
if (this.currentSegmentStartTimestamp === null) {
|
||||
this.currentSegmentStartTimestamp = adjustedPacket.timestamp;
|
||||
} else {
|
||||
this.currentSegmentStartTimestamp = Math.min(
|
||||
this.currentSegmentStartTimestamp,
|
||||
adjustedPacket.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
await this.ting();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async addEncodedAudioPacket(
|
||||
track: OutputAudioTrack,
|
||||
packet: EncodedPacket,
|
||||
meta?: EncodedAudioChunkMetadata,
|
||||
) {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
try {
|
||||
const trackData = this.getAudioTrackData(track, meta);
|
||||
|
||||
const timestamp = this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
|
||||
const adjustedPacket = packet.clone({ timestamp });
|
||||
|
||||
trackData.packets.push(adjustedPacket);
|
||||
|
||||
if (this.currentSegmentStartTimestamp === null) {
|
||||
this.currentSegmentStartTimestamp = adjustedPacket.timestamp;
|
||||
} else {
|
||||
this.currentSegmentStartTimestamp = Math.min(
|
||||
this.currentSegmentStartTimestamp,
|
||||
adjustedPacket.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
await this.ting();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async addSubtitleCue(
|
||||
track: OutputSubtitleTrack,
|
||||
cue: SubtitleCue,
|
||||
meta?: SubtitleMetadata,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
async ting(isFinalCall = false) {
|
||||
assert(this.currentSegmentStartTimestamp !== null);
|
||||
|
||||
if (!this.allTracksAreKnown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const currentSegmentEndTimestamp = this.currentSegmentStartTimestamp + this.targetSegmentDuration;
|
||||
|
||||
let videoKeyEndTimestamp: number | null = null;
|
||||
let videoEndTimestamp: number | null = null;
|
||||
let audioEndTimestamp: number | null = null;
|
||||
let flushAllVideo = false;
|
||||
let flushAllAudio = false;
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
for (let i = 0; i < trackData.packets.length; i++) {
|
||||
const packet = trackData.packets[i]!;
|
||||
const endTimestamp = packet.timestamp + packet.duration;
|
||||
|
||||
if (trackData.info.type === 'video') {
|
||||
videoEndTimestamp = Math.max(videoEndTimestamp ?? -Infinity, endTimestamp);
|
||||
|
||||
if (i > 0 && packet.type === 'key') {
|
||||
if (
|
||||
videoKeyEndTimestamp !== null
|
||||
&& packet.timestamp > currentSegmentEndTimestamp
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
videoKeyEndTimestamp = packet.timestamp;
|
||||
}
|
||||
} else if (trackData.info.type === 'audio') {
|
||||
if (
|
||||
audioEndTimestamp !== null
|
||||
&& packet.timestamp > currentSegmentEndTimestamp
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
audioEndTimestamp = packet.timestamp;
|
||||
|
||||
const endTimestamp = packet.timestamp + packet.duration;
|
||||
if (endTimestamp <= currentSegmentEndTimestamp) {
|
||||
audioEndTimestamp = Math.max(audioEndTimestamp ?? -Infinity, endTimestamp);
|
||||
if (i === trackData.packets.length - 1) {
|
||||
flushAllAudio = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let endTimestamp: number | null = null;
|
||||
if (videoKeyEndTimestamp !== null && videoEndTimestamp! > currentSegmentEndTimestamp) {
|
||||
endTimestamp = videoKeyEndTimestamp;
|
||||
} else {
|
||||
if (isFinalCall) {
|
||||
endTimestamp = videoEndTimestamp;
|
||||
flushAllVideo = true;
|
||||
}
|
||||
|
||||
if (audioEndTimestamp !== null) {
|
||||
endTimestamp = Math.max(endTimestamp ?? -Infinity, audioEndTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (endTimestamp === null) {
|
||||
return;
|
||||
} else {
|
||||
if (!isFinalCall && endTimestamp < currentSegmentEndTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
const closed = trackData.track.source._closed || isFinalCall;
|
||||
if (!closed && !trackData.packets.some(x => x.timestamp >= endTimestamp)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert(this.output._rootPath !== null);
|
||||
const segmentPath = joinPaths(this.output._rootPath, `segment-${this.nextSegmentId}.ts`);
|
||||
const target = await this.output._getTarget({ path: segmentPath });
|
||||
this.nextSegmentId++;
|
||||
|
||||
const output = new Output({
|
||||
format: new MpegTsOutputFormat(),
|
||||
target,
|
||||
});
|
||||
|
||||
try {
|
||||
const packetSources = new Map<HlsTrackData, MediaSource>();
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
if (trackData.packets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trackData.info.type === 'video') {
|
||||
const outputTrack = trackData.track as OutputVideoTrack;
|
||||
const source = new EncodedVideoPacketSource(outputTrack.source._codec);
|
||||
output.addVideoTrack(source, outputTrack.metadata);
|
||||
|
||||
packetSources.set(trackData, source);
|
||||
} else if (trackData.info.type === 'audio') {
|
||||
const outputTrack = trackData.track as OutputAudioTrack;
|
||||
const source = new EncodedAudioPacketSource(outputTrack.source._codec);
|
||||
output.addAudioTrack(source, outputTrack.metadata);
|
||||
|
||||
packetSources.set(trackData, source);
|
||||
}
|
||||
}
|
||||
|
||||
await output.start();
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
const source = packetSources.get(trackData);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trackData.info.type === 'video') {
|
||||
const videoPacketSource = source as EncodedVideoPacketSource;
|
||||
const meta = { decoderConfig: trackData.info.decoderConfig };
|
||||
|
||||
while (trackData.packets.length > 0) {
|
||||
const nextPacket = trackData.packets[0]!;
|
||||
if (!flushAllVideo && nextPacket.timestamp >= endTimestamp) {
|
||||
break;
|
||||
}
|
||||
|
||||
trackData.packets.shift();
|
||||
await videoPacketSource.add(nextPacket, meta);
|
||||
}
|
||||
|
||||
videoPacketSource.close();
|
||||
} else if (trackData.info.type === 'audio') {
|
||||
const audioPacketSource = source as EncodedAudioPacketSource;
|
||||
const meta = { decoderConfig: trackData.info.decoderConfig };
|
||||
|
||||
while (trackData.packets.length > 0) {
|
||||
const nextPacket = trackData.packets[0]!;
|
||||
if (!flushAllAudio && nextPacket.timestamp >= endTimestamp) {
|
||||
break;
|
||||
}
|
||||
|
||||
trackData.packets.shift();
|
||||
await audioPacketSource.add(nextPacket, meta);
|
||||
}
|
||||
|
||||
audioPacketSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
await output.finalize();
|
||||
} catch (e) {
|
||||
await output.cancel();
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.writtenSegments.push({
|
||||
path: segmentPath,
|
||||
duration: endTimestamp - this.currentSegmentStartTimestamp,
|
||||
});
|
||||
|
||||
this.currentSegmentStartTimestamp = endTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
async finalize() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
await this.ting(true);
|
||||
|
||||
let targetDuration = this.targetSegmentDuration;
|
||||
for (const segment of this.writtenSegments) {
|
||||
targetDuration = Math.max(targetDuration, segment.duration);
|
||||
}
|
||||
|
||||
const playlist = '#EXTM3U\n'
|
||||
+ '#EXT-X-VERSION:3\n'
|
||||
+ '#EXT-X-PLAYLIST-TYPE:VOD\n'
|
||||
+ `#EXT-X-TARGETDURATION:${+targetDuration.toPrecision(13)}\n`
|
||||
+ '\n'
|
||||
+ (this.writtenSegments
|
||||
.map(segment => (
|
||||
`#EXTINF:${+segment.duration.toPrecision(13)}\n`
|
||||
+ `${segment.path}\n`
|
||||
))
|
||||
.join(''))
|
||||
+ '\n'
|
||||
+ '#EXT-X-ENDLIST\n';
|
||||
|
||||
const rootWriter = await this.output._getRootWriter();
|
||||
rootWriter.write(textEncoder.encode(playlist));
|
||||
|
||||
release();
|
||||
}
|
||||
}
|
||||
@@ -409,6 +409,12 @@ export class HlsSegmentedInput extends SegmentedInput {
|
||||
} else if (line === '#EXT-X-ENDLIST') {
|
||||
this.streamHasEnded = true;
|
||||
break; // No need to keep reading after this
|
||||
} else if (line.startsWith('#EXT-X-PLAYLIST-TYPE')) {
|
||||
const type = line.slice(21);
|
||||
if (type.toLowerCase() === 'vod') {
|
||||
// A VOD playlist cannot be updated per spec so we can be sure the stream has ended
|
||||
this.streamHasEnded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
AdtsOutputFormatOptions,
|
||||
FlacOutputFormat,
|
||||
FlacOutputFormatOptions,
|
||||
HlsOutputFormat,
|
||||
IsobmffOutputFormat,
|
||||
IsobmffOutputFormatOptions,
|
||||
MkvOutputFormat,
|
||||
|
||||
@@ -150,10 +150,10 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round =
|
||||
|
||||
export class IsobmffMuxer extends Muxer {
|
||||
format: IsobmffOutputFormat;
|
||||
private writer: Writer;
|
||||
private boxWriter: IsobmffBoxWriter;
|
||||
private fastStart: NonNullable<IsobmffOutputFormatOptions['fastStart']>;
|
||||
isFragmented: boolean;
|
||||
private writer!: Writer;
|
||||
private boxWriter!: IsobmffBoxWriter;
|
||||
private fastStart!: NonNullable<IsobmffOutputFormatOptions['fastStart']>;
|
||||
isFragmented!: boolean;
|
||||
|
||||
isQuickTime: boolean;
|
||||
|
||||
@@ -179,27 +179,26 @@ export class IsobmffMuxer extends Muxer {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
this.boxWriter = new IsobmffBoxWriter(this.writer);
|
||||
|
||||
this.isQuickTime = format instanceof MovOutputFormat;
|
||||
|
||||
// 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 instanceof BufferTargetWriter ? 'in-memory' : false;
|
||||
this.fastStart = format._options.fastStart ?? fastStartDefault;
|
||||
this.isFragmented = this.fastStart === 'fragmented';
|
||||
|
||||
if (this.fastStart === 'in-memory' || this.isFragmented) {
|
||||
this.writer.ensureMonotonicity = true;
|
||||
}
|
||||
|
||||
this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? 1;
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
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 instanceof BufferTargetWriter ? 'in-memory' : false;
|
||||
this.fastStart = this.format._options.fastStart ?? fastStartDefault;
|
||||
this.isFragmented = this.fastStart === 'fragmented';
|
||||
|
||||
if (this.fastStart === 'in-memory' || this.isFragmented) {
|
||||
this.writer.ensureMonotonicity = true;
|
||||
}
|
||||
|
||||
const holdsAvc = this.output._tracks.some(x => x.type === 'video' && x.source._codec === 'avc');
|
||||
|
||||
// Write the header
|
||||
|
||||
@@ -129,8 +129,8 @@ const TRACK_TYPE_MAP: Record<OutputTrack['type'], number> = {
|
||||
};
|
||||
|
||||
export class MatroskaMuxer extends Muxer {
|
||||
private writer: Writer;
|
||||
private ebmlWriter: EBMLWriter;
|
||||
private writer!: Writer;
|
||||
private ebmlWriter!: EBMLWriter;
|
||||
private format: WebMOutputFormat | MkvOutputFormat;
|
||||
|
||||
private trackDatas: MatroskaTrackData[] = [];
|
||||
@@ -158,18 +158,18 @@ export class MatroskaMuxer extends Muxer {
|
||||
constructor(output: Output, format: MkvOutputFormat) {
|
||||
super(output);
|
||||
|
||||
this.writer = output._writer;
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.ebmlWriter = new EBMLWriter(this.writer);
|
||||
|
||||
if (this.format._options.appendOnly) {
|
||||
this.writer.ensureMonotonicity = true;
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writeEBMLHeader();
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import { Id3V2Writer } from '../id3';
|
||||
|
||||
export class Mp3Muxer extends Muxer {
|
||||
private format: Mp3OutputFormat;
|
||||
private writer: Writer;
|
||||
private mp3Writer: Mp3Writer;
|
||||
private writer!: Writer;
|
||||
private mp3Writer!: Mp3Writer;
|
||||
private xingFrameData: XingFrameData | null = null;
|
||||
private frameCount = 0;
|
||||
private framePositions: number[] = [];
|
||||
@@ -30,15 +30,20 @@ export class Mp3Muxer extends Muxer {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
this.mp3Writer = new Mp3Writer(output._writer);
|
||||
}
|
||||
|
||||
async start() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.mp3Writer = new Mp3Writer(this.writer);
|
||||
|
||||
if (!metadataTagsAreEmpty(this.output._metadataTags)) {
|
||||
const id3Writer = new Id3V2Writer(this.writer);
|
||||
id3Writer.writeId3V2Tag(this.output._metadataTags);
|
||||
}
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
|
||||
@@ -71,7 +71,7 @@ type QueuedPacket = {
|
||||
|
||||
export class MpegTsMuxer extends Muxer {
|
||||
private format: MpegTsOutputFormat;
|
||||
private writer: Writer;
|
||||
private writer!: Writer;
|
||||
|
||||
private trackDatas: MpegTsTrackData[] = [];
|
||||
private tablesWritten = false;
|
||||
@@ -90,12 +90,15 @@ export class MpegTsMuxer extends Muxer {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
this.writer.ensureMonotonicity = true;
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Nothing to do here
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.writer.ensureMonotonicity = true;
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
|
||||
@@ -59,7 +59,7 @@ type Packet = {
|
||||
|
||||
export class OggMuxer extends Muxer {
|
||||
private format: OggOutputFormat;
|
||||
private writer: Writer;
|
||||
private writer!: Writer;
|
||||
|
||||
private trackDatas: OggTrackData[] = [];
|
||||
private bosPagesWritten = false;
|
||||
@@ -72,13 +72,15 @@ export class OggMuxer extends Muxer {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
|
||||
this.writer.ensureMonotonicity = true; // Ogg is always monotonically written!
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Nothin'
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.writer.ensureMonotonicity = true; // Ogg is always monotonically written!
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { OggMuxer } from './ogg/ogg-muxer';
|
||||
import { Output, TrackType } from './output';
|
||||
import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer';
|
||||
import { WaveMuxer } from './wave/wave-muxer';
|
||||
import { HlsMuxer } from './hls/hls-muxer';
|
||||
|
||||
/**
|
||||
* Specifies an inclusive range of integers.
|
||||
@@ -1081,3 +1082,46 @@ export class MpegTsOutputFormat extends OutputFormat {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class HlsOutputFormat extends OutputFormat {
|
||||
_createMuxer(output: Output): Muxer {
|
||||
return new HlsMuxer(output);
|
||||
}
|
||||
|
||||
get _name() {
|
||||
return 'HTTP Live Streaming (HLS)';
|
||||
}
|
||||
|
||||
get fileExtension() {
|
||||
return '.m3u8';
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
return 'application/vnd.apple.mpegurl';
|
||||
}
|
||||
|
||||
getSupportedCodecs(): MediaCodec[] {
|
||||
// TODO this should vary based on the HLS "variant"
|
||||
return [
|
||||
...VIDEO_CODECS.filter(codec => ['avc', 'hevc'].includes(codec)),
|
||||
...AUDIO_CODECS.filter(codec => ['aac', 'mp3', 'ac3', 'eac3'].includes(codec)),
|
||||
];
|
||||
}
|
||||
|
||||
getSupportedTrackCounts(): TrackCountLimits {
|
||||
return {
|
||||
video: { min: 0, max: Infinity },
|
||||
audio: { min: 0, max: Infinity },
|
||||
subtitle: { min: 0, max: Infinity },
|
||||
total: { min: 1, max: Infinity },
|
||||
};
|
||||
}
|
||||
|
||||
get supportsVideoRotationMetadata(): boolean {
|
||||
return false; // TODO this is not true with fmp4
|
||||
}
|
||||
|
||||
get supportsTimestampedMediaData(): boolean {
|
||||
return true; // I guess??
|
||||
}
|
||||
}
|
||||
|
||||
+56
-14
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc';
|
||||
import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation } from './misc';
|
||||
import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata';
|
||||
import { Muxer } from './muxer';
|
||||
import { OutputFormat } from './output-format';
|
||||
@@ -14,6 +14,10 @@ import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-s
|
||||
import { Target } from './target';
|
||||
import { Writer } from './writer';
|
||||
|
||||
export type TargetRequest = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The options for creating an Output object.
|
||||
* @group Output files
|
||||
@@ -26,7 +30,8 @@ export type OutputOptions<
|
||||
/** The format of the output file. */
|
||||
format: F;
|
||||
/** The target to which the file will be written. */
|
||||
target: T;
|
||||
target: T | ((request: TargetRequest) => MaybePromise<T>);
|
||||
rootPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -154,14 +159,16 @@ export class Output<
|
||||
/** The format of the output file. */
|
||||
format: F;
|
||||
/** The target to which the file will be written. */
|
||||
target: T;
|
||||
target: T | ((request: TargetRequest) => MaybePromise<T>);
|
||||
/** The current state of the output. */
|
||||
state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending';
|
||||
|
||||
/** @internal */
|
||||
_rootPath: string | null;
|
||||
/** @internal */
|
||||
_muxer: Muxer;
|
||||
/** @internal */
|
||||
_writer: Writer;
|
||||
_rootWriterPromise: Promise<Writer> | null = null;
|
||||
/** @internal */
|
||||
_tracks: OutputTrack[] = [];
|
||||
/** @internal */
|
||||
@@ -186,22 +193,52 @@ export class Output<
|
||||
if (!(options.format instanceof OutputFormat)) {
|
||||
throw new TypeError('options.format must be an OutputFormat.');
|
||||
}
|
||||
if (!(options.target instanceof Target)) {
|
||||
if (!(options.target instanceof Target) && typeof options.target !== 'function') {
|
||||
throw new TypeError('options.target must be a Target.');
|
||||
}
|
||||
|
||||
if (options.target._output) {
|
||||
throw new Error('Target is already used for another output.');
|
||||
if (options.target instanceof Target) {
|
||||
if (options.target._output) {
|
||||
throw new Error('Target is already used for another output.');
|
||||
}
|
||||
options.target._output = this;
|
||||
}
|
||||
if (options.rootPath !== undefined && typeof options.rootPath !== 'string') {
|
||||
throw new TypeError('options.rootPath, when provided, must be a string.');
|
||||
}
|
||||
if (typeof options.target === 'function' && options.rootPath === undefined) {
|
||||
throw new Error('options.rootPath must be provided when options.target is a function.');
|
||||
}
|
||||
options.target._output = this;
|
||||
|
||||
this.format = options.format;
|
||||
this.target = options.target;
|
||||
|
||||
this._writer = options.target._createWriter();
|
||||
this._rootPath = options.rootPath ?? null;
|
||||
this._muxer = options.format._createMuxer(this);
|
||||
}
|
||||
|
||||
_getTarget(request: TargetRequest) {
|
||||
assert(typeof this.target === 'function');
|
||||
|
||||
return this.target(request);
|
||||
}
|
||||
|
||||
_getRootWriter() {
|
||||
return this._rootWriterPromise ??= (async () => {
|
||||
let writer: Writer;
|
||||
|
||||
if (typeof this.target === 'function') {
|
||||
assert(this._rootPath !== null);
|
||||
const rootTarget = await this._getTarget({ path: this._rootPath });
|
||||
writer = rootTarget._createWriter();
|
||||
} else {
|
||||
writer = this.target._createWriter();
|
||||
}
|
||||
|
||||
writer.start();
|
||||
return writer;
|
||||
})();
|
||||
}
|
||||
|
||||
/** Adds a video track to the output with the given source. Can only be called before the output is started. */
|
||||
addVideoTrack(source: VideoSource, metadata: VideoTrackMetadata = {}) {
|
||||
if (!(source instanceof VideoSource)) {
|
||||
@@ -400,7 +437,6 @@ export class Output<
|
||||
|
||||
return this._startPromise = (async () => {
|
||||
this.state = 'started';
|
||||
this._writer.start();
|
||||
|
||||
const release = await this._mutex.acquire();
|
||||
|
||||
@@ -445,7 +481,9 @@ export class Output<
|
||||
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
|
||||
await Promise.all(promises);
|
||||
|
||||
await this._writer.close();
|
||||
if (this._rootWriterPromise) {
|
||||
await (await this._rootWriterPromise).close();
|
||||
}
|
||||
|
||||
release();
|
||||
})();
|
||||
@@ -477,8 +515,12 @@ export class Output<
|
||||
|
||||
await this._muxer.finalize();
|
||||
|
||||
await this._writer.flush();
|
||||
await this._writer.finalize();
|
||||
if (this._rootWriterPromise) {
|
||||
console.log('HERE HERE');
|
||||
const rootWriter = await this._rootWriterPromise;
|
||||
await rootWriter.flush();
|
||||
await rootWriter.finalize();
|
||||
}
|
||||
|
||||
this.state = 'finalized';
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export abstract class Target {
|
||||
* gets called *extremely* often.
|
||||
*/
|
||||
onwrite: ((start: number, end: number) => unknown) | null = null;
|
||||
|
||||
onfinalized: (() => unknown) | null = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-5
@@ -21,8 +21,8 @@ import { Id3V2Writer } from '../id3';
|
||||
export class WaveMuxer extends Muxer {
|
||||
private format: WavOutputFormat;
|
||||
private isRf64: boolean;
|
||||
private writer: Writer;
|
||||
private riffWriter: RiffWriter;
|
||||
private writer!: Writer;
|
||||
private riffWriter!: RiffWriter;
|
||||
private headerWritten = false;
|
||||
private dataSize = 0;
|
||||
private sampleRate: number | null = null;
|
||||
@@ -38,13 +38,18 @@ export class WaveMuxer extends Muxer {
|
||||
super(output);
|
||||
|
||||
this.format = format;
|
||||
this.writer = output._writer;
|
||||
this.riffWriter = new RiffWriter(output._writer);
|
||||
this.isRf64 = !!format._options.large;
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Nothing needed here - we'll write the header with the first sample
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.writer = await this.output._getRootWriter();
|
||||
this.riffWriter = new RiffWriter(this.writer);
|
||||
|
||||
// No writing needed here - we'll write the header with the first sample
|
||||
|
||||
release();
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
|
||||
+9
-2
@@ -175,6 +175,7 @@ export class BufferTargetWriter extends Writer {
|
||||
async finalize() {
|
||||
this.ensureSize(this.pos);
|
||||
this.target.buffer = this.buffer.slice(0, Math.max(this.maxPos, this.pos));
|
||||
this.target.onfinalized?.();
|
||||
}
|
||||
|
||||
async close() {}
|
||||
@@ -470,7 +471,9 @@ export class StreamTargetWriter extends Writer {
|
||||
|
||||
assert(this.writer);
|
||||
await this.writer.ready;
|
||||
return this.writer.close();
|
||||
await this.writer.close();
|
||||
|
||||
this.target.onfinalized?.();
|
||||
}
|
||||
|
||||
async close() {
|
||||
@@ -500,6 +503,10 @@ export class NullTargetWriter extends Writer {
|
||||
}
|
||||
|
||||
async flush() {}
|
||||
async finalize() {}
|
||||
|
||||
async finalize() {
|
||||
this.target.onfinalized?.();
|
||||
}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user