diff --git a/dev/live.html b/dev/live.html index d21faec..62e587c 100644 --- a/dev/live.html +++ b/dev/live.html @@ -19,7 +19,7 @@ button.addEventListener('click', async () => { const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); const videoTrack = stream.getVideoTracks()[0]; - const audioTrack = stream.getAudioTracks()[0]; + const audioTrack = null && stream.getAudioTracks()[0]; const output = new Mediabunny.Output({ target: new Mediabunny.BufferTarget(), @@ -32,7 +32,7 @@ codec: 'vp9', bitrate: Mediabunny.QUALITY_MEDIUM, sizeChangeBehavior: 'passThrough', - }); + }, { timestampBase: 'unix' }); videoSource.errorPromise.catch((d) => console.log("Hello?????", d)); @@ -67,9 +67,9 @@ await output.finalize(); console.log(output.target.buffer); - download(new Blob([output.target.buffer]), 'livetest' + output.format.fileExtension); + //download(new Blob([output.target.buffer]), 'livetest' + output.format.fileExtension); videoTrack?.stop(); audioTrack?.stop(); }); - \ No newline at end of file + diff --git a/docs/blog/mediabunny-now-supports-hls.md b/docs/blog/mediabunny-now-supports-hls.md index 460574d..048de18 100644 --- a/docs/blog/mediabunny-now-supports-hls.md +++ b/docs/blog/mediabunny-now-supports-hls.md @@ -194,22 +194,22 @@ const output = new Output({ const videoSourceFull = new MediaStreamVideoTrackSource(displayTrack, { codec: 'avc', bitrate: QUALITY_HIGH, -}); +}, { timestampBase: 'unix' }); // 480p video const videoSource480p = new MediaStreamVideoTrackSource(displayTrack, { codec: 'avc', bitrate: QUALITY_MEDIUM, transform: { height: 480 }, -}); +}, { timestampBase: 'unix' }); // Audio const audioSource = new MediaStreamAudioTrackSource(micTrack, { codec: 'aac', bitrate: QUALITY_HIGH, -}); +}, { timestampBase: 'unix' }); -output.addVideoTrack(videoSourceFull); -output.addVideoTrack(videoSource480p); -output.addAudioTrack(audioSource); +output.addVideoTrack(videoSourceFull, { isRelativeToUnixEpoch: true }); +output.addVideoTrack(videoSource480p, { isRelativeToUnixEpoch: true }); +output.addAudioTrack(audioSource, { isRelativeToUnixEpoch: true }); await output.start(); diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index f3ee21c..ffc50e4 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -338,11 +338,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) if (linkText) { // If custom link text is provided, always use it. displayText = linkText.trim(); - } else if (memberName) { - // If it's a member link, default the text to just the member name. + } else if (memberName && typeName === currentTypeName) { + // Member link on the current type: just the member name. displayText = `\`${memberName}\``; } else { - // Otherwise, it's a type link, so use the full type name. + // Type link, or member link on another type: use the full target. displayText = `\`${cleanTarget}\``; } diff --git a/src/index.ts b/src/index.ts index 612116a..e919542 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,8 +76,9 @@ export { EncodedAudioPacketSource, EncodedVideoPacketSource, MediaStreamAudioTrackSource, - MediaStreamVideoTrackSourceOptions, + MediaStreamAudioTrackSourceOptions, MediaStreamVideoTrackSource, + MediaStreamVideoTrackSourceOptions, TextSubtitleSource, VideoSampleSource, } from './media-source'; diff --git a/src/media-source.ts b/src/media-source.ts index 832cb6a..a40b28a 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -1332,7 +1332,7 @@ export class CanvasSource extends VideoSource { } /** - * Options for MediaStreamVideoTrackSource. + * Options for {@link MediaStreamVideoTrackSource}. * @group Media sources * @public */ @@ -1344,6 +1344,21 @@ export type MediaStreamVideoTrackSourceOptions = { * lead to wildly irregular FPS. */ frameRate?: number | null; + /** + * Controls the basis (zero point) for video frame timestamps. + * + * When set to `'synced-zero'`, timestamps will be relative to the first chunk of media from a `MediaStreamTrack` + * added to the {@link Output}. + * + * When set to `'zero'`, timestamps will be relative to the first video frame emitted by this source. + * + * When set to `'unix'`, timestamps will be relative to the Unix epoch, so clearly associated with a distinct point + * in time. Here, pausing via {@link MediaStreamVideoTrackSource.pause} will also create gaps in timestamps. Be sure + * to pair this mode with {@link BaseTrackMetadata.isRelativeToUnixEpoch}. + * + * Defaults to `'synced-zero'`. + */ + timestampBase?: 'synced-zero' | 'zero' | 'unix'; }; /** @@ -1412,6 +1427,16 @@ export class MediaStreamVideoTrackSource extends VideoSource { if (options.frameRate != null && (typeof options.frameRate !== 'number' || options.frameRate <= 0)) { throw new TypeError('options.frameRate, when provided, must be either a positive number or null.'); } + if ( + options.timestampBase !== undefined + && options.timestampBase !== 'synced-zero' + && options.timestampBase !== 'zero' + && options.timestampBase !== 'unix' + ) { + throw new TypeError( + 'options.timestampBase, when provided, must be one of \'synced-zero\', \'zero\', or \'unix\'.', + ); + } encodingConfig = { ...encodingConfig, @@ -1508,7 +1533,7 @@ export class MediaStreamVideoTrackSource extends VideoSource { if (this._paused) { const frameSeen = firstVideoFrameTimestamp !== null; if (frameSeen) { - if (lastSampleTimestamp !== null) { + if (lastSampleTimestamp !== null && this._options.timestampBase !== 'unix') { // In addition to dropping this frame, let's also keep track of the time we have lost due to the // pause. Doing it like this instead of simply keeping track of the paused time is better since // it retains the frame rate of the underlying source. @@ -1527,14 +1552,23 @@ export class MediaStreamVideoTrackSource extends VideoSource { if (firstVideoFrameTimestamp === null) { firstVideoFrameTimestamp = currentTimestamp; - const muxer = this._connectedTrack!.output._muxer; - if (muxer.firstMediaStreamTimestamp === null) { - muxer.firstMediaStreamTimestamp = now / 1000; - this._timestampOffset = -firstVideoFrameTimestamp; + let target: number; + const timestampBase = this._options.timestampBase ?? 'synced-zero'; + if (timestampBase === 'unix') { + target = Date.now() / 1000; + } else if (timestampBase === 'zero') { + target = 0; } else { - this._timestampOffset = (now / 1000 - muxer.firstMediaStreamTimestamp) - - firstVideoFrameTimestamp; + const output = this._connectedTrack!.output; + if (output._firstMediaStreamTimestamp === null) { + output._firstMediaStreamTimestamp = now / 1000; + target = 0; + } else { + target = now / 1000 - output._firstMediaStreamTimestamp; + } } + + this._timestampOffset = target - firstVideoFrameTimestamp; } lastSampleTimestamp = currentTimestamp; @@ -2405,6 +2439,29 @@ export class AudioBufferSource extends AudioSource { } } +/** + * Options for {@link MediaStreamAudioTrackSource}. + * @group Media sources + * @public + */ +export type MediaStreamAudioTrackSourceOptions = { + /** + * Controls the basis (zero point) for audio sample timestamps. + * + * When set to `'synced-zero'`, timestamps will be relative to the first chunk of media from a `MediaStreamTrack` + * added to the {@link Output}. + * + * When set to `'zero'`, timestamps will be relative to the first audio sample emitted by this source. + * + * When set to `'unix'`, timestamps will be relative to the Unix epoch, so clearly associated with a distinct point + * in time. Here, pausing via {@link MediaStreamAudioTrackSource.pause} will also create gaps in timestamps. Be sure + * to pair this mode with {@link BaseTrackMetadata.isRelativeToUnixEpoch}. + * + * Defaults to `'synced-zero'`. + */ + timestampBase?: 'synced-zero' | 'zero' | 'unix'; +}; + /** * Audio source that encodes the data of a * [`MediaStreamAudioTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) and pipes it into the @@ -2415,6 +2472,8 @@ export class AudioBufferSource extends AudioSource { * @public */ export class MediaStreamAudioTrackSource extends AudioSource { + /** @internal */ + private _options: MediaStreamAudioTrackSourceOptions; /** @internal */ private _encoder: AudioEncoderWrapper; /** @internal */ @@ -2448,13 +2507,31 @@ export class MediaStreamAudioTrackSource extends AudioSource { * Creates a new {@link MediaStreamAudioTrackSource} from a `MediaStreamAudioTrack`, which will pull audio samples * from the stream in real time and encode them according to {@link AudioEncodingConfig}. */ - constructor(track: MediaStreamAudioTrack, encodingConfig: AudioEncodingConfig) { + constructor( + track: MediaStreamAudioTrack, + encodingConfig: AudioEncodingConfig, + options: MediaStreamAudioTrackSourceOptions = {}, + ) { if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') { throw new TypeError('track must be an audio MediaStreamTrack.'); } validateAudioEncodingConfig(encodingConfig); + if (typeof options !== 'object' || !options) { + throw new TypeError('options must be an object.'); + } + if ( + options.timestampBase !== undefined + && options.timestampBase !== 'synced-zero' + && options.timestampBase !== 'zero' + && options.timestampBase !== 'unix' + ) { + throw new TypeError( + 'options.timestampBase, when provided, must be one of \'synced-zero\', \'zero\', or \'unix\'.', + ); + } super(encodingConfig.codec); + this._options = options; this._encoder = new AudioEncoderWrapper(this, encodingConfig); this._track = track; } @@ -2486,7 +2563,7 @@ export class MediaStreamAudioTrackSource extends AudioSource { if (this._paused) { const dataSeen = firstAudioDataTimestamp !== null; if (dataSeen) { - if (lastSampleTimestamp !== null) { + if (lastSampleTimestamp !== null && this._options.timestampBase !== 'unix') { // In addition to dropping this sample, let's also keep track of the time we have lost due to // the pause. Doing it like this instead of simply keeping track of the paused time is better // since it retains the sample rate of the underlying source. @@ -2505,14 +2582,23 @@ export class MediaStreamAudioTrackSource extends AudioSource { if (firstAudioDataTimestamp === null) { firstAudioDataTimestamp = audioSample.timestamp; - const muxer = this._connectedTrack!.output._muxer; - if (muxer.firstMediaStreamTimestamp === null) { - muxer.firstMediaStreamTimestamp = performance.now() / 1000; - this._timestampOffset = -firstAudioDataTimestamp; + let target: number; + const timestampBase = this._options.timestampBase ?? 'synced-zero'; + if (timestampBase === 'unix') { + target = Date.now() / 1000; + } else if (timestampBase === 'zero') { + target = 0; } else { - this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp) - - firstAudioDataTimestamp; + const output = this._connectedTrack!.output; + if (output._firstMediaStreamTimestamp === null) { + output._firstMediaStreamTimestamp = performance.now() / 1000; + target = 0; + } else { + target = performance.now() / 1000 - output._firstMediaStreamTimestamp; + } } + + this._timestampOffset = target - firstAudioDataTimestamp; } lastSampleTimestamp = currentTimestamp; diff --git a/src/muxer.ts b/src/muxer.ts index 83def00..fc9df5c 100644 --- a/src/muxer.ts +++ b/src/muxer.ts @@ -15,13 +15,6 @@ export abstract class Muxer { output: Output; mutex = new AsyncMutex(); - /** - * This field is used to synchronize multiple MediaStreamTracks. They use the same time coordinate system across - * tracks, and to ensure correct audio-video sync, we must use the same offset for all of them. The reason an offset - * is needed at all is because the timestamps typically don't start at zero. - */ - firstMediaStreamTimestamp: number | null = null; - constructor(output: Output) { this.output = output; } diff --git a/src/output.ts b/src/output.ts index 5f4624a..ecd3862 100644 --- a/src/output.ts +++ b/src/output.ts @@ -397,6 +397,13 @@ export class Output< _rootTarget: T | null = null; /** @internal */ _rootTargetPromise: Promise | null = null; + /** + * This field is used to synchronize multiple MediaStreamTracks. They use the same time coordinate system across + * tracks, and to ensure correct audio-video sync, we must use the same offset for all of them. The reason an offset + * is needed at all is because the timestamps typically don't start at zero. + * @internal + */ + _firstMediaStreamTimestamp: number | null = null; /** * The target to which the root file will be written. Throws when using {@link PathedTarget} with an async callback; diff --git a/todo.txt b/todo.txt index ae966ed..44519cc 100644 --- a/todo.txt +++ b/todo.txt @@ -1,3 +1,2 @@ Thoughts: So, a certain "lookahead" logic is definitely needed. The question is if this is a per-demuxer thing or a general thing instead. The demuxer could get in a "packet query" that specifies things like "I am interested in the next 20 seconds guaranteed", allowing the demuxer to pre-fetch more intelligently. The alternative would be some sort of demuxer-agnostic approach where there is a magical "packet requester" that has to be segment-aware. I'm actually not sure if that's good. -