feat: ffmpeg/audio settings logic

im sleepy this is probably a terrible implementation rn
This commit is contained in:
Maya 2026-02-17 21:37:57 +03:00
parent 7fcbdcd73a
commit 4fca8a9696
No known key found for this signature in database
7 changed files with 273 additions and 62 deletions

View File

@ -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",

View File

@ -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'}

View File

@ -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"

View File

@ -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;
});

View File

@ -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,

View File

@ -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 {

View File

@ -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");