mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Make Conversion API copy input pairability graph by default, fix HLS muxer rotation bug, remove onfinalized callback
This commit is contained in:
+47
-4
@@ -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<void>[] = [];
|
||||
@@ -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 */
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
+6
-20
@@ -65,13 +65,6 @@ export abstract class Target extends EventEmitter<TargetEvents> {
|
||||
*/
|
||||
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<TargetEvents> {
|
||||
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 */
|
||||
|
||||
@@ -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<string, ArrayBuffer>();
|
||||
|
||||
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<string, ArrayBuffer>();
|
||||
|
||||
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<string, ArrayBuffer>();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user