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" "transparency": "Transparency"
}, },
"audio": { "audio": {
"bitrate": "Bitrate", "bitrate": "Bitrate (kbps)",
"sample_rate": "Sample rate", "sample_rate": "Sample rate (Hz)",
"channels": "Audio channels" "channels": "Audio channels",
"tracks": "Audio tracks"
}, },
"video": { "video": {
"quality": "Quality", "quality": "Quality",

View File

@ -25,6 +25,7 @@
{...rest} {...rest}
type="checkbox" type="checkbox"
bind:checked bind:checked
{disabled}
class="w-full p-3 rounded-lg bg-panel border-2 border-button class="w-full p-3 rounded-lg bg-panel border-2 border-button
{prefix ? 'pl-[2rem]' : 'pl-3'} {prefix ? 'pl-[2rem]' : 'pl-3'}
{extension ? 'pr-[4rem]' : 'pr-3'} {extension ? 'pr-[4rem]' : 'pr-3'}
@ -42,6 +43,7 @@
<input <input
{...rest} {...rest}
bind:value bind:value
{disabled}
class="w-full p-3 rounded-lg bg-panel border-2 border-button class="w-full p-3 rounded-lg bg-panel border-2 border-button
{prefix ? 'pl-[2rem]' : 'pl-3'} {prefix ? 'pl-[2rem]' : 'pl-3'}
{extension ? 'pr-[4rem]' : 'pr-3'} {extension ? 'pr-[4rem]' : 'pr-3'}

View File

@ -36,7 +36,7 @@
} }
// apply defaults, then existing settings, then new settings on top // apply defaults, then existing settings, then new settings on top
file.conversionSettings = { file.conversionSettings = {
...(await converter.getDefaultSettings()), ...(await converter.getDefaultSettings(file)),
...file.conversionSettings, ...file.conversionSettings,
...settings, ...settings,
}; };
@ -66,7 +66,8 @@
> >
<div class="flex flex-col gap-8"> <div class="flex flex-col gap-8">
{#if file} {#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"> <div class="flex flex-col gap-4">
<p class="text-base"> <p class="text-base">
{@html sanitize( {@html sanitize(
@ -78,13 +79,13 @@
)} )}
</p> </p>
{#if settings.length === 0} {#if availableSettings.length === 0}
<p class="text-sm text-muted"> <p class="text-sm text-muted">
{m["convert.settings.none"]()} {m["convert.settings.none"]()}
</p> </p>
{:else} {:else}
<div class="grid grid-cols-2 gap-4"> <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"> <div class="flex flex-col gap-2">
<p class="text-sm font-bold"> <p class="text-sm font-bold">
{setting.label} {setting.label}
@ -111,6 +112,30 @@
value, 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"} {:else if setting.type === "boolean"}
<FancyInput <FancyInput
type="checkbox" type="checkbox"

View File

@ -55,7 +55,7 @@ export class Converter {
* Can be overridden per converter for format-specific settings. * Can be overridden per converter for format-specific settings.
* @param input The input file. * @param input The input file.
*/ */
public async getAvailableSettings(): Promise<SettingDefinition[]> { public async getAvailableSettings(input?: VertFile): Promise<SettingDefinition[]> {
return []; return [];
} }
@ -63,9 +63,9 @@ export class Converter {
* Get default settings for a conversion. * Get default settings for a conversion.
* @param input The input file. * @param input The input file.
*/ */
public async getDefaultSettings(): Promise<ConversionSettings> { public async getDefaultSettings(input?: VertFile): Promise<ConversionSettings> {
const defaults: ConversionSettings = {}; const defaults: ConversionSettings = {};
const settings = await this.getAvailableSettings(); const settings = await this.getAvailableSettings(input);
settings.forEach((setting) => { settings.forEach((setting) => {
defaults[setting.key] = setting.default; 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 { m } from "$lib/paraglide/messages";
import { Settings } from "$lib/sections/settings/index.svelte"; import { Settings } from "$lib/sections/settings/index.svelte";
import { ToastManager } from "$lib/util/toast.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) // TODO: differentiate in UI? (not native formats)
const videoFormats = [ 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 // 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 = { const bitrate: SettingDefinition = {
key: "bitrate", key: "bitrate",
label: m["convert.settings.audio.bitrate"](), label: m["convert.settings.audio.bitrate"](),
type: "select", type: "select",
default: "auto", default: global.ffmpegQuality,
options: CONVERSION_BITRATES.map((b) => ({ options: CONVERSION_BITRATES.map((b) => ({
value: b.toString(), value: b,
label: b.toString(), label: b,
})), })),
hasCustomInput: true,
customInputKey: "customBitrate",
placeholder: detectedBitrate ?? "128"
}; };
const detectedSampleRate = await this.detectAudioSampleRate(ffmpeg);
const sampleRate: SettingDefinition = { const sampleRate: SettingDefinition = {
key: "sampleRate", key: "sampleRate",
label: m["convert.settings.audio.sample_rate"](), label: m["convert.settings.audio.sample_rate"](),
type: "select", type: "select",
default: "auto", default:
global.ffmpegSampleRate === "custom"
? global.ffmpegCustomSampleRate
: global.ffmpegSampleRate,
options: SAMPLE_RATES.map((r) => ({ options: SAMPLE_RATES.map((r) => ({
value: r.toString(), value: r,
label: r.toString(), 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 = { const channels: SettingDefinition = {
key: "channels", key: "channels",
label: m["convert.settings.audio.channels"](), label: m["convert.settings.audio.channels"](),
type: "number", type: "number",
default: 2, default: audioChannels ?? 2,
min: 1, min: 1,
max: 8, max: audioChannels ? audioChannels * 2 : 5,
}; };
const metadata: SettingDefinition = { const metadata: SettingDefinition = {
key: "metadata", key: "metadata",
label: m["convert.settings.common.metadata"](), label: m["convert.settings.common.metadata"](),
type: "boolean", type: "boolean",
default: true, default: global.metadata ?? true,
}; };
// resize, crop, rotate - prob want a ui // 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 defaults: ConversionSettings = {};
const settings = await this.getAvailableSettings(); const settings = await this.getAvailableSettings(input);
settings.forEach((setting) => { settings.forEach((setting) => {
defaults[setting.key] = setting.default; defaults[setting.key] = setting.default;
}); });
return defaults; 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}`; 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"; const isAlac = to === ".alac";
if (isAlac) to = ".m4a"; if (isAlac) to = ".m4a";
@ -181,7 +226,10 @@ export class FFmpegConverter extends Converter {
msg.includes("Specified sample rate") && msg.includes("Specified sample rate") &&
msg.includes("is not supported") 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"]({ conversionError = m["workers.errors.invalid_rate"]({
rate, rate,
}); });
@ -212,6 +260,7 @@ export class FFmpegConverter extends Converter {
ffmpeg, ffmpeg,
input, input,
to, to,
conversionSettings,
isAlac, isAlac,
); );
log(["converters", this.name], `FFmpeg command: ${command.join(" ")}`); log(["converters", this.name], `FFmpeg command: ${command.join(" ")}`);
@ -267,16 +316,21 @@ export class FFmpegConverter extends Converter {
this.activeConversions.delete(input.id); 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(); const ffmpeg = new FFmpeg();
ffmpeg.on("progress", (progress) => { if (!temporary) {
input.progress = progress.progress * 100; ffmpeg.on("progress", (progress) => {
}); input.progress = progress.progress * 100;
});
ffmpeg.on("log", (l) => { ffmpeg.on("log", (l) => {
log(["converters", this.name], l.message); log(["converters", this.name], l.message);
}); });
}
const baseURL = const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm"; "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( private async buildConversionCommand(
ffmpeg: FFmpeg, ffmpeg: FFmpeg,
input: VertFile, input: VertFile,
to: string, to: string,
settings: ConversionSettings,
isAlac: boolean = false, isAlac: boolean = false,
): Promise<string[]> { ): Promise<string[]> {
const inputFormat = input.from.slice(1); const inputFormat = input.from.slice(1);
@ -390,14 +539,16 @@ export class FFmpegConverter extends Converter {
"dsf", "dsf",
"dff", "dff",
]; ];
const userSetting = Settings.instance.settings.ffmpegQuality; const userBitrate = settings.bitrate;
const userSampleRate = Settings.instance.settings.ffmpegSampleRate; const customBitrate = settings.customBitrate;
const customSampleRate = const userSampleRate = settings.sampleRate;
Settings.instance.settings.ffmpegCustomSampleRate ?? 44100; const customSampleRate = settings.customSampleRate;
const keepMetadata = Settings.instance.settings.metadata; const keepMetadata = settings.metadata;
let audioBitrateArgs: string[] = []; let audioBitrateArgs: string[] = [];
let sampleRateArgs: string[] = []; let sampleRateArgs: string[] = [];
let channelsArgs: string[] = [];
let tracksArgs: string[] = [];
let metadataArgs: string[] = []; let metadataArgs: string[] = [];
let m4aArgs: string[] = []; let m4aArgs: string[] = [];
@ -415,12 +566,15 @@ export class FFmpegConverter extends Converter {
const isLosslessToLossy = const isLosslessToLossy =
lossless.includes(inputFormat) && !lossless.includes(outputFormat); lossless.includes(inputFormat) && !lossless.includes(outputFormat);
if (userSetting !== "auto") { if (userBitrate !== "auto") {
// user's setting // user's setting
audioBitrateArgs = ["-b:a", `${userSetting}k`]; audioBitrateArgs = [
"-b:a",
`${userBitrate === "custom" ? customBitrate : userBitrate}k`,
];
log( log(
["converters", this.name], ["converters", this.name],
`using user setting for audio bitrate: ${userSetting}`, `using user setting for audio bitrate: ${userBitrate}`,
); );
} else { } else {
// detect bitrate of original file and use // detect bitrate of original file and use
@ -445,14 +599,13 @@ export class FFmpegConverter extends Converter {
// sample rate setting // sample rate setting
if (userSampleRate !== "auto") { if (userSampleRate !== "auto") {
const rate = sampleRateArgs = [
userSampleRate === "custom" "-ar",
? customSampleRate.toString() userSampleRate === "custom" ? customSampleRate : userSampleRate,
: userSampleRate; ];
sampleRateArgs = ["-ar", rate];
log( log(
["converters", this.name], ["converters", this.name],
`using user setting for sample rate: ${rate}`, `using user setting for sample rate: ${userSampleRate}Hz`,
); );
} else { } else {
// detect sample rate of original file and use // detect sample rate of original file and use
@ -476,7 +629,7 @@ export class FFmpegConverter extends Converter {
} }
sampleRateArgs = inputSampleRate sampleRateArgs = inputSampleRate
? ["-ar", inputSampleRate.toString()] ? ["-ar", `${inputSampleRate}`]
: []; : [];
log( log(
["converters", this.name], ["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 // video to audio
if (videoFormats.includes(inputFormat)) { if (videoFormats.includes(inputFormat)) {
log( log(
@ -499,6 +679,8 @@ export class FFmpegConverter extends Converter {
...metadataArgs, ...metadataArgs,
...audioBitrateArgs, ...audioBitrateArgs,
...sampleRateArgs, ...sampleRateArgs,
...channelsArgs,
...tracksArgs,
"output" + to, "output" + to,
]; ];
} }
@ -538,6 +720,8 @@ export class FFmpegConverter extends Converter {
...metadataArgs, ...metadataArgs,
...audioBitrateArgs, ...audioBitrateArgs,
...sampleRateArgs, ...sampleRateArgs,
...channelsArgs,
...tracksArgs,
"output" + to, "output" + to,
]; ];
} else { } else {
@ -558,6 +742,8 @@ export class FFmpegConverter extends Converter {
...metadataArgs, ...metadataArgs,
...audioBitrateArgs, ...audioBitrateArgs,
...sampleRateArgs, ...sampleRateArgs,
...channelsArgs,
...tracksArgs,
"output" + to, "output" + to,
]; ];
} }
@ -580,6 +766,8 @@ export class FFmpegConverter extends Converter {
...metadataArgs, ...metadataArgs,
...audioBitrateArgs, ...audioBitrateArgs,
...sampleRateArgs, ...sampleRateArgs,
...channelsArgs,
...tracksArgs,
"output" + to, "output" + to,
]; ];
} }
@ -758,6 +946,7 @@ const getCodecs = (
export const CONVERSION_BITRATES = [ export const CONVERSION_BITRATES = [
"auto", "auto",
"custom",
320, 320,
256, 256,
192, 192,

View File

@ -6,12 +6,14 @@ export interface SettingDefinition {
label: string; label: string;
type: SettingType; type: SettingType;
default?: any; default?: any;
placeholder?: string; placeholder?: any;
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
options?: Array<{ value: any; label: string }>; // for select types options?: Array<{ value: any; label: any }>; // for select types
description?: string; 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 { export interface ConversionSettings {

View File

@ -23,7 +23,7 @@ export class VertFile {
return this.file.name; return this.file.name;
} }
public conversionSettings = $state<ConversionSettings>({}); public conversionSettings = $state<ConversionSettings>({}); // empty object = defaults
public progress = $state(0); public progress = $state(0);
public result = $state<VertFile | null>(null); public result = $state<VertFile | null>(null);
@ -39,10 +39,10 @@ export class VertFile {
public isZip = $state(() => this.from === ".zip"); public isZip = $state(() => this.from === ".zip");
public getAvailableSettings(): Promise<SettingDefinition[]> { public getAvailableSettings(input: VertFile): Promise<SettingDefinition[]> {
const converter = this.findConverter(); const converter = this.findConverter();
if (!converter) return Promise.resolve([]); if (!converter) return Promise.resolve([]);
return converter.getAvailableSettings(); return converter.getAvailableSettings(input);
} }
public findConverters(supportedFormats: string[] = [this.from]) { public findConverters(supportedFormats: string[] = [this.from]) {
@ -108,14 +108,6 @@ export class VertFile {
this.blobUrl = blobUrl; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
public async convert(...args: any[]) { public async convert(...args: any[]) {
if (!this.converters.length) throw new Error("No converters found"); if (!this.converters.length) throw new Error("No converters found");