mirror of https://github.com/VERT-sh/VERT.git
feat: ffmpeg/audio settings logic
im sleepy this is probably a terrible implementation rn
This commit is contained in:
parent
7fcbdcd73a
commit
4fca8a9696
|
|
@ -93,9 +93,10 @@
|
|||
"transparency": "Transparency"
|
||||
},
|
||||
"audio": {
|
||||
"bitrate": "Bitrate",
|
||||
"sample_rate": "Sample rate",
|
||||
"channels": "Audio channels"
|
||||
"bitrate": "Bitrate (kbps)",
|
||||
"sample_rate": "Sample rate (Hz)",
|
||||
"channels": "Audio channels",
|
||||
"tracks": "Audio tracks"
|
||||
},
|
||||
"video": {
|
||||
"quality": "Quality",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
{...rest}
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
{disabled}
|
||||
class="w-full p-3 rounded-lg bg-panel border-2 border-button
|
||||
{prefix ? 'pl-[2rem]' : 'pl-3'}
|
||||
{extension ? 'pr-[4rem]' : 'pr-3'}
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
<input
|
||||
{...rest}
|
||||
bind:value
|
||||
{disabled}
|
||||
class="w-full p-3 rounded-lg bg-panel border-2 border-button
|
||||
{prefix ? 'pl-[2rem]' : 'pl-3'}
|
||||
{extension ? 'pr-[4rem]' : 'pr-3'}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
}
|
||||
// apply defaults, then existing settings, then new settings on top
|
||||
file.conversionSettings = {
|
||||
...(await converter.getDefaultSettings()),
|
||||
...(await converter.getDefaultSettings(file)),
|
||||
...file.conversionSettings,
|
||||
...settings,
|
||||
};
|
||||
|
|
@ -66,7 +66,8 @@
|
|||
>
|
||||
<div class="flex flex-col gap-8">
|
||||
{#if file}
|
||||
{#await file.getAvailableSettings() then settings}
|
||||
<!-- FIXME: modal loads before settings is finished for some reason -->
|
||||
{#await file.getAvailableSettings(file) then availableSettings}
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-base">
|
||||
{@html sanitize(
|
||||
|
|
@ -78,13 +79,13 @@
|
|||
)}
|
||||
</p>
|
||||
|
||||
{#if settings.length === 0}
|
||||
{#if availableSettings.length === 0}
|
||||
<p class="text-sm text-muted">
|
||||
{m["convert.settings.none"]()}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{#each settings as setting (setting.key)}
|
||||
{#each availableSettings as setting (setting.key)}
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm font-bold">
|
||||
{setting.label}
|
||||
|
|
@ -111,6 +112,30 @@
|
|||
value,
|
||||
)}
|
||||
/>
|
||||
{#if setting.hasCustomInput}
|
||||
{@const disabled =
|
||||
(settings[setting.key] ??
|
||||
file.conversionSettings[
|
||||
setting.key
|
||||
]) !== "custom"}
|
||||
<FancyInput
|
||||
type="text"
|
||||
value={settings[
|
||||
setting.customInputKey!
|
||||
] ??
|
||||
file.conversionSettings[
|
||||
setting.customInputKey!
|
||||
] ??
|
||||
""}
|
||||
placeholder={setting.placeholder}
|
||||
{disabled}
|
||||
oninput={(e) =>
|
||||
handleSettingChange(
|
||||
setting.customInputKey!,
|
||||
e.currentTarget.value,
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if setting.type === "boolean"}
|
||||
<FancyInput
|
||||
type="checkbox"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export class Converter {
|
|||
* Can be overridden per converter for format-specific settings.
|
||||
* @param input The input file.
|
||||
*/
|
||||
public async getAvailableSettings(): Promise<SettingDefinition[]> {
|
||||
public async getAvailableSettings(input?: VertFile): Promise<SettingDefinition[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -63,9 +63,9 @@ export class Converter {
|
|||
* Get default settings for a conversion.
|
||||
* @param input The input file.
|
||||
*/
|
||||
public async getDefaultSettings(): Promise<ConversionSettings> {
|
||||
public async getDefaultSettings(input?: VertFile): Promise<ConversionSettings> {
|
||||
const defaults: ConversionSettings = {};
|
||||
const settings = await this.getAvailableSettings();
|
||||
const settings = await this.getAvailableSettings(input);
|
||||
settings.forEach((setting) => {
|
||||
defaults[setting.key] = setting.default;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { error, log } from "$lib/util/logger";
|
|||
import { m } from "$lib/paraglide/messages";
|
||||
import { Settings } from "$lib/sections/settings/index.svelte";
|
||||
import { ToastManager } from "$lib/util/toast.svelte";
|
||||
import type { SettingDefinition, ConversionSettings } from "$lib/types/conversion-settings";
|
||||
import type {
|
||||
SettingDefinition,
|
||||
ConversionSettings,
|
||||
} from "$lib/types/conversion-settings";
|
||||
|
||||
// TODO: differentiate in UI? (not native formats)
|
||||
const videoFormats = [
|
||||
|
|
@ -106,66 +109,108 @@ export class FFmpegConverter extends Converter {
|
|||
}
|
||||
}
|
||||
|
||||
public async getAvailableSettings(): Promise<SettingDefinition[]> {
|
||||
public async getAvailableSettings(
|
||||
input: VertFile,
|
||||
): Promise<SettingDefinition[]> {
|
||||
// audio - bitrate, sample rate, channels, normalize, trim silence
|
||||
|
||||
// TODO: detect bitrate, sample rate, audio channels and set default/max accordingly
|
||||
const global = Settings.instance.settings;
|
||||
|
||||
const ffmpeg = await this.setupFFmpeg(input, true);
|
||||
const buf = new Uint8Array(await input.file.arrayBuffer());
|
||||
await ffmpeg.writeFile("input", buf);
|
||||
|
||||
// TODO: should we really be doing all this detection here? it adds a lot of time before the settings even show up.
|
||||
// which isn't very nice for the UX guh
|
||||
|
||||
const detectedBitrate = await this.detectAudioBitrate(ffmpeg);
|
||||
const bitrate: SettingDefinition = {
|
||||
key: "bitrate",
|
||||
label: m["convert.settings.audio.bitrate"](),
|
||||
type: "select",
|
||||
default: "auto",
|
||||
default: global.ffmpegQuality,
|
||||
options: CONVERSION_BITRATES.map((b) => ({
|
||||
value: b.toString(),
|
||||
label: b.toString(),
|
||||
value: b,
|
||||
label: b,
|
||||
})),
|
||||
hasCustomInput: true,
|
||||
customInputKey: "customBitrate",
|
||||
placeholder: detectedBitrate ?? "128"
|
||||
};
|
||||
|
||||
const detectedSampleRate = await this.detectAudioSampleRate(ffmpeg);
|
||||
const sampleRate: SettingDefinition = {
|
||||
key: "sampleRate",
|
||||
label: m["convert.settings.audio.sample_rate"](),
|
||||
type: "select",
|
||||
default: "auto",
|
||||
default:
|
||||
global.ffmpegSampleRate === "custom"
|
||||
? global.ffmpegCustomSampleRate
|
||||
: global.ffmpegSampleRate,
|
||||
options: SAMPLE_RATES.map((r) => ({
|
||||
value: r.toString(),
|
||||
label: r.toString(),
|
||||
value: r,
|
||||
label: r,
|
||||
})),
|
||||
hasCustomInput: true,
|
||||
customInputKey: "customSampleRate",
|
||||
placeholder: detectedSampleRate ?? "44100"
|
||||
};
|
||||
|
||||
const audioTracks = await this.detectAudioTracks(ffmpeg);
|
||||
const tracks: SettingDefinition = {
|
||||
key: "tracks",
|
||||
label: m["convert.settings.audio.tracks"](),
|
||||
type: "number",
|
||||
default: audioTracks ?? 1,
|
||||
min: 1,
|
||||
max: audioTracks ? audioTracks : 1,
|
||||
};
|
||||
|
||||
const audioChannels = await this.detectAudioChannels(ffmpeg);
|
||||
const channels: SettingDefinition = {
|
||||
key: "channels",
|
||||
label: m["convert.settings.audio.channels"](),
|
||||
type: "number",
|
||||
default: 2,
|
||||
default: audioChannels ?? 2,
|
||||
min: 1,
|
||||
max: 8,
|
||||
max: audioChannels ? audioChannels * 2 : 5,
|
||||
};
|
||||
|
||||
const metadata: SettingDefinition = {
|
||||
key: "metadata",
|
||||
label: m["convert.settings.common.metadata"](),
|
||||
type: "boolean",
|
||||
default: true,
|
||||
default: global.metadata ?? true,
|
||||
};
|
||||
|
||||
// resize, crop, rotate - prob want a ui
|
||||
|
||||
return [bitrate, sampleRate, channels, metadata];
|
||||
return [bitrate, sampleRate, tracks, channels, metadata];
|
||||
}
|
||||
|
||||
public async getDefaultSettings(): Promise<ConversionSettings> {
|
||||
public async getDefaultSettings(
|
||||
input: VertFile,
|
||||
): Promise<ConversionSettings> {
|
||||
const defaults: ConversionSettings = {};
|
||||
const settings = await this.getAvailableSettings();
|
||||
const settings = await this.getAvailableSettings(input);
|
||||
settings.forEach((setting) => {
|
||||
defaults[setting.key] = setting.default;
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public async convert(input: VertFile, to: string): Promise<VertFile> {
|
||||
public async convert(
|
||||
input: VertFile,
|
||||
to: string,
|
||||
settings: ConversionSettings,
|
||||
): Promise<VertFile> {
|
||||
if (!to.startsWith(".")) to = `.${to}`;
|
||||
|
||||
const conversionSettings =
|
||||
Object.keys(settings).length > 0
|
||||
? settings
|
||||
: await this.getDefaultSettings(input); // use defaults if not provided
|
||||
|
||||
const isAlac = to === ".alac";
|
||||
if (isAlac) to = ".m4a";
|
||||
|
||||
|
|
@ -181,7 +226,10 @@ export class FFmpegConverter extends Converter {
|
|||
msg.includes("Specified sample rate") &&
|
||||
msg.includes("is not supported")
|
||||
) {
|
||||
const rate = Settings.instance.settings.ffmpegCustomSampleRate;
|
||||
const rate =
|
||||
conversionSettings.sampleRate === "custom"
|
||||
? conversionSettings.customSampleRate
|
||||
: conversionSettings.sampleRate;
|
||||
conversionError = m["workers.errors.invalid_rate"]({
|
||||
rate,
|
||||
});
|
||||
|
|
@ -212,6 +260,7 @@ export class FFmpegConverter extends Converter {
|
|||
ffmpeg,
|
||||
input,
|
||||
to,
|
||||
conversionSettings,
|
||||
isAlac,
|
||||
);
|
||||
log(["converters", this.name], `FFmpeg command: ${command.join(" ")}`);
|
||||
|
|
@ -267,16 +316,21 @@ export class FFmpegConverter extends Converter {
|
|||
this.activeConversions.delete(input.id);
|
||||
}
|
||||
|
||||
private async setupFFmpeg(input: VertFile): Promise<FFmpeg> {
|
||||
private async setupFFmpeg(
|
||||
input: VertFile,
|
||||
temporary = false,
|
||||
): Promise<FFmpeg> {
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
ffmpeg.on("progress", (progress) => {
|
||||
input.progress = progress.progress * 100;
|
||||
});
|
||||
if (!temporary) {
|
||||
ffmpeg.on("progress", (progress) => {
|
||||
input.progress = progress.progress * 100;
|
||||
});
|
||||
|
||||
ffmpeg.on("log", (l) => {
|
||||
log(["converters", this.name], l.message);
|
||||
});
|
||||
ffmpeg.on("log", (l) => {
|
||||
log(["converters", this.name], l.message);
|
||||
});
|
||||
}
|
||||
|
||||
const baseURL =
|
||||
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm";
|
||||
|
|
@ -370,10 +424,105 @@ export class FFmpegConverter extends Converter {
|
|||
}
|
||||
}
|
||||
|
||||
private async detectAudioTracks(ffmpeg: FFmpeg): Promise<number | null> {
|
||||
const args = [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-show_entries",
|
||||
"stream=index",
|
||||
"-of",
|
||||
"json",
|
||||
"input",
|
||||
];
|
||||
|
||||
try {
|
||||
let output = "";
|
||||
|
||||
const tracksListener = (event: { message: string }) => {
|
||||
output += `${event.message}\n`;
|
||||
};
|
||||
|
||||
ffmpeg.on("log", tracksListener);
|
||||
|
||||
try {
|
||||
log(
|
||||
["converters", this.name],
|
||||
`Running ffprobe to detect audio tracks with args: ${args.join(" ")}`,
|
||||
);
|
||||
await ffmpeg.ffprobe.call(ffmpeg, args);
|
||||
} finally {
|
||||
ffmpeg.off("log", tracksListener);
|
||||
}
|
||||
|
||||
if (!output.trim()) return null;
|
||||
|
||||
const parsed = JSON.parse(output);
|
||||
const tracks = Array.isArray(parsed?.streams)
|
||||
? parsed.streams.length
|
||||
: null;
|
||||
|
||||
log(
|
||||
["converters", this.name],
|
||||
`Detected stream audio tracks: ${tracks}`,
|
||||
);
|
||||
|
||||
return tracks;
|
||||
} catch (err) {
|
||||
error(
|
||||
["converters", this.name],
|
||||
`Error detecting audio tracks: ${err}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async detectAudioChannels(ffmpeg: FFmpeg): Promise<number | null> {
|
||||
const args = [
|
||||
"-v",
|
||||
"0",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-show_entries",
|
||||
"stream=channels",
|
||||
"-of",
|
||||
"compact=p=0:nk=1",
|
||||
"input",
|
||||
];
|
||||
|
||||
try {
|
||||
let channels: number | null = null;
|
||||
|
||||
const channelsListener = (event: { message: string }) => {
|
||||
if (channels !== null) return;
|
||||
const n = parseInt(event.message.trim(), 10);
|
||||
if (!n) return;
|
||||
channels = n;
|
||||
log(
|
||||
["converters", this.name],
|
||||
`Detected stream audio channels: ${channels}`,
|
||||
);
|
||||
};
|
||||
|
||||
ffmpeg.on("log", channelsListener);
|
||||
|
||||
try {
|
||||
await ffmpeg.ffprobe.call(ffmpeg, args);
|
||||
return channels;
|
||||
} finally {
|
||||
ffmpeg.off("log", channelsListener);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async buildConversionCommand(
|
||||
ffmpeg: FFmpeg,
|
||||
input: VertFile,
|
||||
to: string,
|
||||
settings: ConversionSettings,
|
||||
isAlac: boolean = false,
|
||||
): Promise<string[]> {
|
||||
const inputFormat = input.from.slice(1);
|
||||
|
|
@ -390,14 +539,16 @@ export class FFmpegConverter extends Converter {
|
|||
"dsf",
|
||||
"dff",
|
||||
];
|
||||
const userSetting = Settings.instance.settings.ffmpegQuality;
|
||||
const userSampleRate = Settings.instance.settings.ffmpegSampleRate;
|
||||
const customSampleRate =
|
||||
Settings.instance.settings.ffmpegCustomSampleRate ?? 44100;
|
||||
const keepMetadata = Settings.instance.settings.metadata;
|
||||
const userBitrate = settings.bitrate;
|
||||
const customBitrate = settings.customBitrate;
|
||||
const userSampleRate = settings.sampleRate;
|
||||
const customSampleRate = settings.customSampleRate;
|
||||
const keepMetadata = settings.metadata;
|
||||
|
||||
let audioBitrateArgs: string[] = [];
|
||||
let sampleRateArgs: string[] = [];
|
||||
let channelsArgs: string[] = [];
|
||||
let tracksArgs: string[] = [];
|
||||
let metadataArgs: string[] = [];
|
||||
let m4aArgs: string[] = [];
|
||||
|
||||
|
|
@ -415,12 +566,15 @@ export class FFmpegConverter extends Converter {
|
|||
|
||||
const isLosslessToLossy =
|
||||
lossless.includes(inputFormat) && !lossless.includes(outputFormat);
|
||||
if (userSetting !== "auto") {
|
||||
if (userBitrate !== "auto") {
|
||||
// user's setting
|
||||
audioBitrateArgs = ["-b:a", `${userSetting}k`];
|
||||
audioBitrateArgs = [
|
||||
"-b:a",
|
||||
`${userBitrate === "custom" ? customBitrate : userBitrate}k`,
|
||||
];
|
||||
log(
|
||||
["converters", this.name],
|
||||
`using user setting for audio bitrate: ${userSetting}`,
|
||||
`using user setting for audio bitrate: ${userBitrate}`,
|
||||
);
|
||||
} else {
|
||||
// detect bitrate of original file and use
|
||||
|
|
@ -445,14 +599,13 @@ export class FFmpegConverter extends Converter {
|
|||
|
||||
// sample rate setting
|
||||
if (userSampleRate !== "auto") {
|
||||
const rate =
|
||||
userSampleRate === "custom"
|
||||
? customSampleRate.toString()
|
||||
: userSampleRate;
|
||||
sampleRateArgs = ["-ar", rate];
|
||||
sampleRateArgs = [
|
||||
"-ar",
|
||||
userSampleRate === "custom" ? customSampleRate : userSampleRate,
|
||||
];
|
||||
log(
|
||||
["converters", this.name],
|
||||
`using user setting for sample rate: ${rate}`,
|
||||
`using user setting for sample rate: ${userSampleRate}Hz`,
|
||||
);
|
||||
} else {
|
||||
// detect sample rate of original file and use
|
||||
|
|
@ -476,7 +629,7 @@ export class FFmpegConverter extends Converter {
|
|||
}
|
||||
|
||||
sampleRateArgs = inputSampleRate
|
||||
? ["-ar", inputSampleRate.toString()]
|
||||
? ["-ar", `${inputSampleRate}`]
|
||||
: [];
|
||||
log(
|
||||
["converters", this.name],
|
||||
|
|
@ -485,6 +638,33 @@ export class FFmpegConverter extends Converter {
|
|||
}
|
||||
}
|
||||
|
||||
// channels setting
|
||||
if (settings.channels !== "auto") {
|
||||
channelsArgs = ["-ac", settings.channels];
|
||||
log(
|
||||
["converters", this.name],
|
||||
`using user setting for audio channels: ${settings.channels}`,
|
||||
);
|
||||
}
|
||||
|
||||
// tracks setting
|
||||
// TODO: select specific tracks? (prob should be for the other settings that need extra ui stuff)
|
||||
if (settings.tracks !== "auto") {
|
||||
// -map for each audio track
|
||||
if (settings.tracks > 1) {
|
||||
for (let i = 0; i < settings.tracks; i++) {
|
||||
tracksArgs.push("-map", `0:a:${i - 1}`);
|
||||
}
|
||||
} else {
|
||||
tracksArgs = ["-map", "0:a:0"]; // default to first audio track if not specified
|
||||
}
|
||||
|
||||
log(
|
||||
["converters", this.name],
|
||||
`using user setting for audio tracks: ${settings.tracks}`,
|
||||
);
|
||||
}
|
||||
|
||||
// video to audio
|
||||
if (videoFormats.includes(inputFormat)) {
|
||||
log(
|
||||
|
|
@ -499,6 +679,8 @@ export class FFmpegConverter extends Converter {
|
|||
...metadataArgs,
|
||||
...audioBitrateArgs,
|
||||
...sampleRateArgs,
|
||||
...channelsArgs,
|
||||
...tracksArgs,
|
||||
"output" + to,
|
||||
];
|
||||
}
|
||||
|
|
@ -538,6 +720,8 @@ export class FFmpegConverter extends Converter {
|
|||
...metadataArgs,
|
||||
...audioBitrateArgs,
|
||||
...sampleRateArgs,
|
||||
...channelsArgs,
|
||||
...tracksArgs,
|
||||
"output" + to,
|
||||
];
|
||||
} else {
|
||||
|
|
@ -558,6 +742,8 @@ export class FFmpegConverter extends Converter {
|
|||
...metadataArgs,
|
||||
...audioBitrateArgs,
|
||||
...sampleRateArgs,
|
||||
...channelsArgs,
|
||||
...tracksArgs,
|
||||
"output" + to,
|
||||
];
|
||||
}
|
||||
|
|
@ -580,6 +766,8 @@ export class FFmpegConverter extends Converter {
|
|||
...metadataArgs,
|
||||
...audioBitrateArgs,
|
||||
...sampleRateArgs,
|
||||
...channelsArgs,
|
||||
...tracksArgs,
|
||||
"output" + to,
|
||||
];
|
||||
}
|
||||
|
|
@ -758,6 +946,7 @@ const getCodecs = (
|
|||
|
||||
export const CONVERSION_BITRATES = [
|
||||
"auto",
|
||||
"custom",
|
||||
320,
|
||||
256,
|
||||
192,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ export interface SettingDefinition {
|
|||
label: string;
|
||||
type: SettingType;
|
||||
default?: any;
|
||||
placeholder?: string;
|
||||
placeholder?: any;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: Array<{ value: any; label: string }>; // for select types
|
||||
options?: Array<{ value: any; label: any }>; // for select types
|
||||
description?: string;
|
||||
hasCustomInput?: boolean; // for select types with a "custom" option
|
||||
customInputKey?: string; // key to use for custom input value in settings object
|
||||
}
|
||||
|
||||
export interface ConversionSettings {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export class VertFile {
|
|||
return this.file.name;
|
||||
}
|
||||
|
||||
public conversionSettings = $state<ConversionSettings>({});
|
||||
public conversionSettings = $state<ConversionSettings>({}); // empty object = defaults
|
||||
public progress = $state(0);
|
||||
public result = $state<VertFile | null>(null);
|
||||
|
||||
|
|
@ -39,10 +39,10 @@ export class VertFile {
|
|||
|
||||
public isZip = $state(() => this.from === ".zip");
|
||||
|
||||
public getAvailableSettings(): Promise<SettingDefinition[]> {
|
||||
public getAvailableSettings(input: VertFile): Promise<SettingDefinition[]> {
|
||||
const converter = this.findConverter();
|
||||
if (!converter) return Promise.resolve([]);
|
||||
return converter.getAvailableSettings();
|
||||
return converter.getAvailableSettings(input);
|
||||
}
|
||||
|
||||
public findConverters(supportedFormats: string[] = [this.from]) {
|
||||
|
|
@ -108,14 +108,6 @@ export class VertFile {
|
|||
this.blobUrl = blobUrl;
|
||||
}
|
||||
|
||||
public settings() {
|
||||
// settings modal
|
||||
// images - quality/compression/quantize/interlace/depth-DPI, resize, crop, rotate, flip/flop, autoOrient?, color space/bit depth, transparency settings
|
||||
// audio - bitrate, sample rate, channels, normalize, trim silence
|
||||
// video - bitrate, fps, resolution, trim, crop, rotate, flip/flop, audio settings?
|
||||
// common - metadata?
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public async convert(...args: any[]) {
|
||||
if (!this.converters.length) throw new Error("No converters found");
|
||||
|
|
|
|||
Loading…
Reference in New Issue