diff --git a/src/conversion.ts b/src/conversion.ts index 24b163e..11d9760 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -251,7 +251,12 @@ export type ConversionVideoOptions = { * encoder configuration. */ processedHeight?: number; - /** Defines the group(s) the output track is a part of. Same semantics as {@link BaseTrackMetadata.group}. */ + /** + * Defines the group(s) the output track is a part of. For more, see {@link BaseTrackMetadata.group}. + * + * If left blank, tracks will internally be assigned to groups such that the output track pairability graph exactly + * matches the input track pairability graph. + */ group?: OutputTrackGroup | OutputTrackGroup[]; }; @@ -297,7 +302,12 @@ export type ConversionAudioOptions = { * encoder configuration. */ processedSampleRate?: number; - /** Defines the group(s) the output track is a part of. Same semantics as {@link BaseTrackMetadata.group}. */ + /** + * Defines the group(s) the output track is a part of. For more, see {@link BaseTrackMetadata.group}. + * + * If left blank, tracks will internally be assigned to groups such that the output track pairability graph exactly + * matches the input track pairability graph. + */ group?: OutputTrackGroup | OutputTrackGroup[]; }; @@ -535,6 +545,8 @@ export class Conversion { _nextOutputTrackId = 0; /** @internal */ _outputTrackIds: number[] = []; + /** @internal */ + _outputOwnTrackGroups: (OutputTrackGroup | null)[] = []; /** @internal */ _trackPromises: Promise[] = []; @@ -869,6 +881,25 @@ export class Conversion { } } + // When no track groups are set by the user, then the output track pairability should be *identical* to the + // input's. We do the naive algorithm to achieve this: assign each track to its own group, and pair groups with + // each other based on input track pairability. + for (let i = 0; i < this.utilizedTracks.length - 1; i++) { + for (let j = i + 1; j < this.utilizedTracks.length; j++) { + const trackA = this.utilizedTracks[i]!; + const trackB = this.utilizedTracks[j]!; + const ownGroupA = this._outputOwnTrackGroups[i]; + const ownGroupB = this._outputOwnTrackGroups[j]; + + assert(ownGroupA !== undefined); + assert(ownGroupB !== undefined); + + if (ownGroupA && ownGroupB && trackA.canBePairedWith(trackB)) { + ownGroupA.pairWith(ownGroupB); + } + } + } + // Now, let's deal with metadata tags const inputTags = await this.input.getMetadataTags(); @@ -1467,6 +1498,11 @@ export class Conversion { } } + let ownGroup: OutputTrackGroup | null = null; + if (!trackOptions.group) { + ownGroup = new OutputTrackGroup(); + } + const videoTrackLanguageCode = await track.getLanguageCode(); this.output.addVideoTrack(videoSource, { frameRate: trackOptions.frameRate, @@ -1475,13 +1511,14 @@ export class Conversion { name: await track.getName() ?? undefined, disposition: await track.getDisposition(), rotation: outputTrackRotation, - group: trackOptions.group, + group: ownGroup ?? trackOptions.group, }); this._addedCounts.video++; this._totalTrackCount++; this.utilizedTracks.push(track); this._outputTrackIds.push(outputTrackId); + this._outputOwnTrackGroups.push(ownGroup); } /** @internal */ @@ -1726,19 +1763,25 @@ export class Conversion { } } + let ownGroup: OutputTrackGroup | null = null; + if (!trackOptions.group) { + ownGroup = new OutputTrackGroup(); + } + const audioTrackLanguageCode = await track.getLanguageCode(); this.output.addAudioTrack(audioSource, { // TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2 languageCode: isIso639Dash2LanguageCode(audioTrackLanguageCode) ? audioTrackLanguageCode : undefined, name: await track.getName() ?? undefined, disposition: await track.getDisposition(), - group: trackOptions.group, + group: ownGroup ?? trackOptions.group, }); this._addedCounts.audio++; this._totalTrackCount++; this.utilizedTracks.push(track); this._outputTrackIds.push(outputTrackId); + this._outputOwnTrackGroups.push(ownGroup); } /** @internal */ diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index b3f078c..9046704 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -1261,7 +1261,7 @@ export class HlsMuxer extends Muxer { if (width !== undefined && height !== undefined) { if ( videoTrack.metadata.rotation !== undefined - && videoTrack.metadata.rotation % 180 !== 90 + && videoTrack.metadata.rotation % 180 === 90 ) { [width, height] = [height, width]; } diff --git a/src/target.ts b/src/target.ts index 14b83bf..01ff9cb 100644 --- a/src/target.ts +++ b/src/target.ts @@ -65,13 +65,6 @@ export abstract class Target extends EventEmitter { */ onwrite: ((start: number, end: number) => unknown) | null = null; - /** - * Called when the target is finalized. - * - * @deprecated Use `target.on('finalized', () => ...)` instead. - */ - onfinalized: (() => unknown) | null = null; - /** @internal */ _dispatchWrite(start: number, end: number) { // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -79,14 +72,7 @@ export abstract class Target extends EventEmitter { this._emit('write', { start, end }); } - /** @internal */ - _dispatchFinalized() { - // eslint-disable-next-line @typescript-eslint/no-deprecated - this.onfinalized?.(); - this._emit('finalized'); - } - - /** +/** * Returns a new {@link RangedTarget} that writes data to this target using the given offset. * * Useful for writing a file into a section of a larger file. @@ -192,7 +178,7 @@ export class BufferTarget extends Target { /** @internal */ async _finalize() { this.buffer = this._buffer.slice(0, this._maxPos); - this._dispatchFinalized(); + this._emit('finalized'); } /** @internal */ @@ -546,7 +532,7 @@ export class StreamTarget extends Target { await this._streamWriter.ready; await this._streamWriter.close(); - this._dispatchFinalized(); + this._emit('finalized'); } /** @internal */ @@ -630,7 +616,7 @@ export class FilePathTarget extends Target { /** @internal */ async _finalize() { await this._streamTarget._finalize(); - this._dispatchFinalized(); + this._emit('finalized'); } /** @internal */ @@ -660,7 +646,7 @@ export class NullTarget extends Target { /** @internal */ async _finalize() { - this._dispatchFinalized(); + this._emit('finalized'); } /** @internal */ @@ -704,7 +690,7 @@ export class RangedTarget extends Target { /** @internal */ async _finalize() { - this._dispatchFinalized(); + this._emit('finalized'); } /** @internal */ diff --git a/test/browser/conversion.test.ts b/test/browser/conversion.test.ts index 218b501..fe9a2ad 100644 --- a/test/browser/conversion.test.ts +++ b/test/browser/conversion.test.ts @@ -1,13 +1,15 @@ import { ALL_FORMATS } from '../../src/input-format.js'; import { Input } from '../../src/input.js'; -import { AdtsOutputFormat, Mp4OutputFormat } from '../../src/output-format.js'; -import { Output } from '../../src/output.js'; -import { BufferSource, UrlSource } from '../../src/source.js'; +import { AdtsOutputFormat, HlsOutputFormat, Mp4OutputFormat, MpegTsOutputFormat } from '../../src/output-format.js'; +import { Output, OutputTrackGroup } from '../../src/output.js'; +import { BufferSource, PathedSource, UrlSource } from '../../src/source.js'; import { expect, test } from 'vitest'; -import { BufferTarget } from '../../src/target.js'; +import { BufferTarget, PathedTarget } from '../../src/target.js'; import { Conversion } from '../../src/conversion.js'; import { assert } from '../../src/misc.js'; import { InputVideoTrack } from '../../src/input-track.js'; +import { AudioBufferSource, CanvasSource } from '../../src/media-source.js'; +import { QUALITY_HIGH } from '../../src/encode.js'; test('Rotation is baked-in when rerendering', async () => { using input = new Input({ @@ -100,3 +102,260 @@ test('Fan-out', async () => { expect(await tracks[0]!.getDisplayHeight()).toBe(480); expect(await tracks[1]!.getDisplayHeight()).toBe(360); }); + +const createSineWave = (sampleRate: number, channels: number, durationSeconds: number) => { + const buffer = new AudioBuffer({ + sampleRate, + numberOfChannels: channels, + length: sampleRate * durationSeconds, + }); + + for (let ch = 0; ch < channels; ch++) { + const data = buffer.getChannelData(ch); + for (let i = 0; i < data.length; i++) { + data[i] = Math.sin(2 * Math.PI * 440 * i / sampleRate); + } + } + + return buffer; +}; + +test('HLS track assignability is kept #1', async () => { + const files = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: new PathedTarget( + 'master.m3u8', + ({ path }) => { + const target = new BufferTarget(); + target.on('finalized', () => { + files.set(path, target.buffer!); + }); + + return target; + }, + ), + }); + + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'red'; + ctx.fillRect(100, 100, 200, 200); + + const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH }); + output.addVideoTrack(videoSource); + + const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: QUALITY_HIGH }); + output.addAudioTrack(audioSource); + + await output.start(); + + for (let i = 0; i < 4; i++) { + await videoSource.add(i / 2, 1 / 2); + } + + await audioSource.add(createSineWave(48000, 2, 2)); + + await output.finalize(); + + const masterPlayist = new TextDecoder().decode(files.get('master.m3u8')); + expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(1); + + using input = new Input({ + formats: ALL_FORMATS, + source: new PathedSource( + 'master.m3u8', + ({ path }) => new BufferSource(files.get(path)!), + ), + }); + + const newOutput = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: new PathedTarget( + 'new/master.m3u8', + ({ path }) => { + const target = new BufferTarget(); + target.on('finalized', () => { + files.set(path, target.buffer!); + }); + + return target; + }, + ), + }); + + const conversion = await Conversion.init({ input, output: newOutput }); + await conversion.execute(); + + const newMasterPlayist = new TextDecoder().decode(files.get('new/master.m3u8')); + expect(newMasterPlayist).toBe(masterPlayist); +}); + +test('HLS track assignability is kept #2', async () => { + const files = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: new PathedTarget( + 'master.m3u8', + ({ path }) => { + const target = new BufferTarget(); + target.on('finalized', () => { + files.set(path, target.buffer!); + }); + + return target; + }, + ), + }); + + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'red'; + ctx.fillRect(100, 100, 200, 200); + + const a = new OutputTrackGroup(); + const b = new OutputTrackGroup(); + + const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH }); + output.addVideoTrack(videoSource, { group: a }); + + const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: QUALITY_HIGH }); + output.addAudioTrack(audioSource, { group: b }); + + await output.start(); + + for (let i = 0; i < 4; i++) { + await videoSource.add(i / 2, 1 / 2); + } + + await audioSource.add(createSineWave(48000, 2, 2)); + + await output.finalize(); + + const masterPlayist = new TextDecoder().decode(files.get('master.m3u8')); + expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(2); + + using input = new Input({ + formats: ALL_FORMATS, + source: new PathedSource( + 'master.m3u8', + ({ path }) => new BufferSource(files.get(path)!), + ), + }); + + const newOutput = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: new PathedTarget( + 'new/master.m3u8', + ({ path }) => { + const target = new BufferTarget(); + target.on('finalized', () => { + files.set(path, target.buffer!); + }); + + return target; + }, + ), + }); + + const conversion = await Conversion.init({ input, output: newOutput }); + await conversion.execute(); + + const newMasterPlayist = new TextDecoder().decode(files.get('new/master.m3u8')); + expect(newMasterPlayist).toBe(masterPlayist); +}); + +test('HLS track assignability can be overridden', async () => { + const files = new Map(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: new PathedTarget( + 'master.m3u8', + ({ path }) => { + const target = new BufferTarget(); + target.on('finalized', () => { + files.set(path, target.buffer!); + }); + + return target; + }, + ), + }); + + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = 'red'; + ctx.fillRect(100, 100, 200, 200); + + const a = new OutputTrackGroup(); + const b = new OutputTrackGroup(); + + const videoSource = new CanvasSource(canvas, { codec: 'avc', bitrate: QUALITY_HIGH }); + output.addVideoTrack(videoSource, { group: a }); + + const audioSource = new AudioBufferSource({ codec: 'aac', bitrate: QUALITY_HIGH }); + output.addAudioTrack(audioSource, { group: b }); + + await output.start(); + + for (let i = 0; i < 4; i++) { + await videoSource.add(i / 2, 1 / 2); + } + + await audioSource.add(createSineWave(48000, 2, 2)); + + await output.finalize(); + + const masterPlayist = new TextDecoder().decode(files.get('master.m3u8')); + expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(2); + + using input = new Input({ + formats: ALL_FORMATS, + source: new PathedSource( + 'master.m3u8', + ({ path }) => new BufferSource(files.get(path)!), + ), + }); + + const newOutput = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new MpegTsOutputFormat(), + }), + target: new PathedTarget( + 'new/master.m3u8', + ({ path }) => { + const target = new BufferTarget(); + target.on('finalized', () => { + files.set(path, target.buffer!); + }); + + return target; + }, + ), + }); + + const conversion = await Conversion.init({ + input, + output: newOutput, + video: { group: newOutput.defaultTrackGroup }, + audio: { group: newOutput.defaultTrackGroup }, + }); + await conversion.execute(); + + const newMasterPlayist = new TextDecoder().decode(files.get('new/master.m3u8')); + expect(newMasterPlayist).not.toBe(masterPlayist); + expect(newMasterPlayist.match(/\.m3u8/g)?.length).toBe(1); +});