diff --git a/messages/en.json b/messages/en.json index 16cb900..086d0bc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/lib/components/functional/FancyInput.svelte b/src/lib/components/functional/FancyInput.svelte index 4a46298..b5b0313 100644 --- a/src/lib/components/functional/FancyInput.svelte +++ b/src/lib/components/functional/FancyInput.svelte @@ -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 @@ {#if file} - {#await file.getAvailableSettings() then settings} + + {#await file.getAvailableSettings(file) then availableSettings}

{@html sanitize( @@ -78,13 +79,13 @@ )}

- {#if settings.length === 0} + {#if availableSettings.length === 0}

{m["convert.settings.none"]()}

{:else}
- {#each settings as setting (setting.key)} + {#each availableSettings as setting (setting.key)}

{setting.label} @@ -111,6 +112,30 @@ value, )} /> + {#if setting.hasCustomInput} + {@const disabled = + (settings[setting.key] ?? + file.conversionSettings[ + setting.key + ]) !== "custom"} + + handleSettingChange( + setting.customInputKey!, + e.currentTarget.value, + )} + /> + {/if} {:else if setting.type === "boolean"} { + public async getAvailableSettings(input?: VertFile): Promise { return []; } @@ -63,9 +63,9 @@ export class Converter { * Get default settings for a conversion. * @param input The input file. */ - public async getDefaultSettings(): Promise { + public async getDefaultSettings(input?: VertFile): Promise { const defaults: ConversionSettings = {}; - const settings = await this.getAvailableSettings(); + const settings = await this.getAvailableSettings(input); settings.forEach((setting) => { defaults[setting.key] = setting.default; }); diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index 59937da..21289e5 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -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 { + public async getAvailableSettings( + input: VertFile, + ): Promise { // 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 { + public async getDefaultSettings( + input: VertFile, + ): Promise { 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 { + public async convert( + input: VertFile, + to: string, + settings: ConversionSettings, + ): Promise { 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 { + private async setupFFmpeg( + input: VertFile, + temporary = false, + ): Promise { 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 { + 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 { + 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 { 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, diff --git a/src/lib/types/conversion-settings.ts b/src/lib/types/conversion-settings.ts index bb6e640..fff8d4b 100644 --- a/src/lib/types/conversion-settings.ts +++ b/src/lib/types/conversion-settings.ts @@ -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 { diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts index 82d5b81..8e23ccb 100644 --- a/src/lib/types/file.svelte.ts +++ b/src/lib/types/file.svelte.ts @@ -23,7 +23,7 @@ export class VertFile { return this.file.name; } - public conversionSettings = $state({}); + public conversionSettings = $state({}); // empty object = defaults public progress = $state(0); public result = $state(null); @@ -39,10 +39,10 @@ export class VertFile { public isZip = $state(() => this.from === ".zip"); - public getAvailableSettings(): Promise { + public getAvailableSettings(input: VertFile): Promise { 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");