mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 10:53:50 +02:00
Add composable conversions
* Add non-owning conversions via ConversionOptions.ownsOutput A conversion with ownsOutput: false only adds tracks to the output and drives their media data; starting, finalizing, and metadata tags remain the caller's responsibility. This lets multiple conversions and directly-added user tracks compose on a single Output (see upstream issue #436). - ownsOutput: false allows a pre-populated output (state must still be 'pending') and seeds track-capacity accounting from existing tracks - execute() requires the output to be started and never finalizes it - cancel() closes only the conversion's own sources, releasing internal synchronizer waiters, and leaves the output usable - tags cannot be combined with ownsOutput: false - isValid requires at least one contributed track instead of the format's minimum track counts Prototype for API discussion; default (owning) behavior is unchanged. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AJdfnbY9AFh9i9dKgtrj6E * Add external-audio example using a non-owning conversion Demonstrates composing a user-owned audio track (synthesized voiceover via OfflineAudioContext + AudioBufferSource) onto a picked video with Conversion.init({ ownsOutput: false }), including progress reporting and playback/download of the result. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AJdfnbY9AFh9i9dKgtrj6E * Release synchronizer waiters when canceling during output finalization A non-owning conversion's cancel() previously no-oped entirely when the output (owned by someone else) was already finalizing or finalized, leaving pump loops parked in the track synchronizer and hanging execute() forever. Now it still marks the conversion canceled and releases parked waiters in that state, without force-closing sources (finalization owns flushing them at that point). Also adds coverage: non-owning onProgress monotonicity, canceling one of two sibling conversions, capacity seeding across sequential inits, exact metadata exclusivity, and cancel-before-execute. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AJdfnbY9AFh9i9dKgtrj6E * Document non-owning conversions on the converting-media-files guide page Adds the doc section requested in #436: what ownsOutput: false does, the required choreography (add tracks -> output.start() before execute() -> run the conversion concurrently with your own sources -> finalize), the cancellation split (conversion.cancel() leaves the output alive; cancel both for a full abort and tear the output down on error paths), isValid semantics in this mode, and the tags restriction with the setMetadataTags() alternative. Also cross-links the fresh-output rule to the new section. VitePress build passes with dead-link checking on. Co-Authored-By: Claude Opus 4.8 <[email protected]> * Clean up conversion logic, add Output.tracks and .hasEnoughTracks(), move new conversion tests around, remove external audio example * non-owning -> composable, and update docs * Update --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Vanilagy <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
Vanilagy
parent
794b84884f
commit
f41eef0937
@@ -11,7 +11,7 @@ import { Output, OutputTrackGroup } from '../../src/output.js';
|
||||
import { BufferSource, CustomPathedSource, UrlSource } from '../../src/source.js';
|
||||
import { expect, test } from 'vitest';
|
||||
import { BufferTarget, PathedTarget } from '../../src/target.js';
|
||||
import { Conversion } from '../../src/conversion.js';
|
||||
import { Conversion, ConversionCanceledError } from '../../src/conversion.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
import { InputVideoTrack } from '../../src/input-track.js';
|
||||
import { CanvasSource, EncodedAudioPacketSource } from '../../src/media-source.js';
|
||||
@@ -402,3 +402,261 @@ test('Fractional audio sample boundary', async () => {
|
||||
});
|
||||
await conversion.execute();
|
||||
});
|
||||
|
||||
test('Non-composable conversion requires a fresh output', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac')); // Makes the output non-fresh
|
||||
|
||||
await expect(Conversion.init({ input, output })).rejects.toThrow(/must be fresh/);
|
||||
});
|
||||
|
||||
test('Composable init works on an output that already has a track, but not on a started one', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('aac')); // A user-added track
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true }, // Only contribute the video track
|
||||
showWarnings: false,
|
||||
});
|
||||
expect(conversion.isValid).toBe(true);
|
||||
expect(conversion.utilizedTracks).toHaveLength(1);
|
||||
expect(conversion.utilizedTracks[0]!.type).toBe('video');
|
||||
|
||||
const startedOutput = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
startedOutput.addAudioTrack(new EncodedAudioPacketSource('aac'));
|
||||
await startedOutput.start();
|
||||
|
||||
await expect(Conversion.init({ input, output: startedOutput, composable: true }))
|
||||
.rejects.toThrow(/not have been started/);
|
||||
|
||||
await startedOutput.cancel();
|
||||
});
|
||||
|
||||
test('Composable conversion rejects tags', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const makeOutput = () => new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
await expect(Conversion.init({
|
||||
input,
|
||||
output: makeOutput(),
|
||||
composable: true,
|
||||
tags: { title: 'Not allowed' },
|
||||
})).rejects.toThrow(/tags cannot be set by a composable conversion/);
|
||||
});
|
||||
|
||||
test('Composable conversion composes with a user-added track', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true }, // The user provides their own audio track
|
||||
showWarnings: false,
|
||||
});
|
||||
expect(conversion.utilizedTracks).toHaveLength(1);
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('aac');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
|
||||
await Promise.all([
|
||||
conversion.execute(),
|
||||
(async () => {
|
||||
await addAacPackets(audioSource, 5);
|
||||
audioSource.close();
|
||||
})(),
|
||||
]);
|
||||
|
||||
// The composable conversion must not have finalized the output
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
await output.finalize();
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const tracks = await result.getTracks();
|
||||
expect(tracks.map(t => t.type).sort()).toEqual(['audio', 'video']);
|
||||
|
||||
const videoTrack = await result.getPrimaryVideoTrack();
|
||||
const audioTrack = await result.getPrimaryAudioTrack();
|
||||
expect(videoTrack).not.toBeNull();
|
||||
expect(audioTrack).not.toBeNull();
|
||||
expect(await videoTrack!.getCodec()).toBe('avc');
|
||||
expect(await audioTrack!.getCodec()).toBe('aac');
|
||||
expect(await videoTrack!.computeDuration()).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test('Two composable conversions compose into one output', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const videoConversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
const audioConversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
video: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
expect(videoConversion.utilizedTracks).toHaveLength(1);
|
||||
expect(videoConversion.utilizedTracks[0]!.type).toBe('video');
|
||||
expect(audioConversion.utilizedTracks).toHaveLength(1);
|
||||
expect(audioConversion.utilizedTracks[0]!.type).toBe('audio');
|
||||
|
||||
await output.start();
|
||||
await Promise.all([videoConversion.execute(), audioConversion.execute()]);
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
await output.finalize();
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const tracks = await result.getTracks();
|
||||
expect(tracks.map(t => t.type).sort()).toEqual(['audio', 'video']);
|
||||
expect(await (await result.getPrimaryVideoTrack())!.getCodec()).toBe('avc');
|
||||
expect(await (await result.getPrimaryAudioTrack())!.getCodec()).toBe('aac');
|
||||
});
|
||||
|
||||
test('Composable conversion does not write metadata tags', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
// Sanity check: this input carries metadata tags that a non-composable conversion would copy over
|
||||
const inputTags = await input.getMetadataTags();
|
||||
expect(inputTags.comment).toBeDefined();
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
|
||||
// The conversion must not have touched the output's metadata tags
|
||||
expect(Object.keys(output._metadataTags)).toHaveLength(0);
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('aac');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
// The user sets their own tags; these must survive
|
||||
output.setMetadataTags({ comment: 'User-owned' });
|
||||
|
||||
await output.start();
|
||||
await Promise.all([
|
||||
conversion.execute(),
|
||||
(async () => {
|
||||
await addAacPackets(audioSource, 5);
|
||||
audioSource.close();
|
||||
})(),
|
||||
]);
|
||||
await output.finalize();
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const outTags = await result.getMetadataTags();
|
||||
// Only the user's tag is present; the input's tags were not copied
|
||||
expect(outTags.comment).toBe('User-owned');
|
||||
});
|
||||
|
||||
test('Canceling a composable conversion leaves the output usable', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
audio: { discard: true },
|
||||
showWarnings: false,
|
||||
});
|
||||
|
||||
const audioSource = new EncodedAudioPacketSource('aac');
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
|
||||
const executePromise = conversion.execute();
|
||||
void conversion.cancel();
|
||||
|
||||
await expect(executePromise).rejects.toBeInstanceOf(ConversionCanceledError);
|
||||
|
||||
// The output must not have been canceled by the composable conversion
|
||||
expect(output.state).toBe('started');
|
||||
|
||||
// The user's own track can still finish, and the output can still be finalized
|
||||
await addAacPackets(audioSource, 2);
|
||||
audioSource.close();
|
||||
await output.finalize();
|
||||
expect(output.state).toBe('finalized');
|
||||
|
||||
using result = new Input({ source: new BufferSource(output.target.buffer!), formats: ALL_FORMATS });
|
||||
const audioTrack = await result.getPrimaryAudioTrack();
|
||||
expect(audioTrack).not.toBeNull();
|
||||
expect(await audioTrack!.getCodec()).toBe('aac');
|
||||
});
|
||||
|
||||
test('Track capacity works correctly with composable conversions', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/video.mp4'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new Output({ format: new WavOutputFormat(), target: new BufferTarget() });
|
||||
// The user already occupies the single audio slot that WAVE allows
|
||||
output.addAudioTrack(new EncodedAudioPacketSource('pcm-s16'));
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
composable: true,
|
||||
showWarnings: false,
|
||||
});
|
||||
|
||||
// The conversion's audio track has no room left, so it gets discarded
|
||||
expect(conversion.isValid).toBe(true);
|
||||
expect(conversion.utilizedTracks).toHaveLength(0);
|
||||
expect(conversion.discardedTracks).toHaveLength(2);
|
||||
// WAVE allows only one track in total, so the total-count check fires before the per-type one
|
||||
expect(conversion.discardedTracks[0]!.reason).toBe('max_track_count_reached');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user