diff --git a/messages/en.json b/messages/en.json index 086d0bc..6009e9c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -84,7 +84,7 @@ "settings": { "settings": "Settings", "title": "File conversion settings", - "description": "Change the conversion settings for {filename}, which is using {converter}. These settings may not be available for all formats.", + "description": "Change the conversion settings for {filename}, which is using {converter}. These settings may not be available for all formats. This is an early beta and may have some issues.", "none": "No settings available for this format.", "image": { "quality": "Quality", @@ -94,30 +94,42 @@ }, "audio": { "bitrate": "Bitrate (kbps)", + "bitrate_placeholder": "Custom bitrate", "sample_rate": "Sample rate (Hz)", + "sample_rate_placeholder": "Custom sample rate", "channels": "Audio channels", - "tracks": "Audio tracks" + "channels_placeholder": "Custom audio channels", + "tracks": "Audio tracks", + "tracks_placeholder": "Custom audio tracks" }, "video": { "quality": "Quality", "metadata": "Metadata", "speed": "Conversion speed", - "speed_very_slow": "Very Slow", - "speed_slower": "Slower", - "speed_slow": "Slow", - "speed_medium": "Medium", - "speed_fast": "Fast", - "speed_ultra_fast": "Ultra Fast", + "speed_description": "This will be overridden if you manually set the bitrate or resolution below - selecting options other than \"auto\".", + "speed_very_slow": "Highest quality (slowest)", + "speed_slower": "Higher quality (slower)", + "speed_slow": "High quality (slow)", + "speed_medium": "Medium quality (average)", + "speed_fast": "Lower quality (faster)", + "speed_ultra_fast": "Lowest quality (fastest)", "fps": "Frame rate (FPS)", - "fps_placeholder": "Auto", + "fps_placeholder": "Custom frame rate", "resolution": "Resolution", - "resolution_placeholder": "Auto (e.g., 1920x1080)" + "resolution_placeholder": "Custom resolution", + "video_bitrate": "Video bitrate (kbps)", + "audio_bitrate": "Audio bitrate (kbps)", + "bitrate_placeholder": "Custom bitrate", + "sample_rate": "Audio sample rate (Hz)", + "sample_rate_placeholder": "Custom sample rate" }, "document": { "something": "Something" }, "common": { - "metadata": "Metadata" + "metadata": "Metadata", + "auto": "auto", + "custom": "custom" } }, "tooltips": { diff --git a/src/lib/components/functional/SettingsModal.svelte b/src/lib/components/functional/SettingsModal.svelte index 904197b..0b90919 100644 --- a/src/lib/components/functional/SettingsModal.svelte +++ b/src/lib/components/functional/SettingsModal.svelte @@ -86,13 +86,17 @@ {:else}
{#each availableSettings as setting (setting.key)} -
+

{setting.label}

{#if setting.description} -

+

{setting.description}

{/if} @@ -102,9 +106,11 @@ options={setting.options?.map( (opt) => opt.value, ) || []} - selected={file.conversionSettings[ - setting.key - ] ?? setting.default} + selected={settings[setting.key] ?? + file.conversionSettings[ + setting.key + ] ?? + setting.default} settingsStyle onselect={(value) => handleSettingChange( @@ -139,9 +145,11 @@ {:else if setting.type === "boolean"} handleSettingChange( @@ -149,12 +157,51 @@ e.currentTarget.checked, )} /> + {:else if setting.type === "range"} + {@const rangeValue = (settings[ + setting.key + ] ?? + file.conversionSettings[ + setting.key + ] ?? + setting.default ?? + setting.min ?? + 0) as number} + {@const rangeLabel = + setting.options?.[rangeValue] + ?.label ?? rangeValue} +
+ { + const nextValue = + e.currentTarget + .valueAsNumber; + handleSettingChange( + setting.key, + nextValue, + ); + }} + /> + + {rangeLabel} + +
{:else} handleSettingChange( diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index fb8fdaa..9d7baad 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -109,21 +109,11 @@ export class FFmpegConverter extends Converter { } } - public async getAvailableSettings( - input: VertFile, - ): Promise { + public async getAvailableSettings(): Promise { // audio - bitrate, sample rate, channels, normalize, trim silence 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"](), @@ -135,10 +125,9 @@ export class FFmpegConverter extends Converter { })), hasCustomInput: true, customInputKey: "customBitrate", - placeholder: detectedBitrate ?? "128" + placeholder: m["convert.settings.audio.bitrate_placeholder"](), }; - const detectedSampleRate = await this.detectAudioSampleRate(ffmpeg); const sampleRate: SettingDefinition = { key: "sampleRate", label: m["convert.settings.audio.sample_rate"](), @@ -153,31 +142,31 @@ export class FFmpegConverter extends Converter { })), hasCustomInput: true, customInputKey: "customSampleRate", - placeholder: detectedSampleRate ?? "44100" + placeholder: m["convert.settings.audio.sample_rate_placeholder"](), }; - const audioTracks = await this.detectAudioTracks(ffmpeg); const tracks: SettingDefinition = { key: "tracks", label: m["convert.settings.audio.tracks"](), type: "number", - default: audioTracks ?? 1, + default: 1, min: 1, - max: audioTracks ? audioTracks : 1, - placeholder: audioTracks ?? 1 + placeholder: m["convert.settings.audio.tracks_placeholder"](), }; - const audioChannels = await this.detectAudioChannels(ffmpeg); const channels: SettingDefinition = { key: "channels", label: m["convert.settings.audio.channels"](), type: "number", - default: audioChannels ?? 2, + default: 2, min: 1, - max: audioChannels ? audioChannels * 2 : 5, - placeholder: audioChannels ?? 2 + max: 8, + placeholder: m["convert.settings.audio.channels_placeholder"](), }; + /* + * common + */ const metadata: SettingDefinition = { key: "metadata", label: m["convert.settings.common.metadata"](), @@ -426,100 +415,6 @@ 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, @@ -962,12 +857,12 @@ export type ConversionBitrate = (typeof CONVERSION_BITRATES)[number]; export const SAMPLE_RATES = [ "auto", "custom", - "48000", - "44100", - "32000", - "22050", - "16000", - "11025", - "8000", + 48000, + 44100, + 32000, + 22050, + 16000, + 11025, + 8000, ] as const; export type SampleRate = (typeof SAMPLE_RATES)[number]; diff --git a/src/lib/converters/vertd.svelte.ts b/src/lib/converters/vertd.svelte.ts index 93279d3..52adcaf 100644 --- a/src/lib/converters/vertd.svelte.ts +++ b/src/lib/converters/vertd.svelte.ts @@ -12,6 +12,7 @@ import type { SettingDefinition, ConversionSettings, } from "$lib/types/conversion-settings"; +import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte"; interface UploadResponse { id: string; @@ -85,6 +86,15 @@ export type ConversionSpeed = | "fast" | "ultraFast"; +const vertdSpeedValues: ConversionSpeed[] = [ + "verySlow", + "slower", + "slow", + "medium", + "fast", + "ultraFast", +]; + interface StartJobMessage { type: "startJob"; data: { @@ -375,30 +385,128 @@ export class VertdConverter extends Converter { }, ]; - const quality: SettingDefinition = { - key: "vertdSpeed", + const qualitySpeedRange: SettingDefinition = { + key: "vertdSpeedSlider", label: m["convert.settings.video.speed"](), - type: "select", - default: "medium", - options: qualityOptions, + description: m["convert.settings.video.speed_description"](), + type: "range", + min: 0, + max: qualityOptions.length - 1, + step: 1, + default: 3, + options: qualityOptions.map((option, index) => ({ + value: index, + label: option.label, + speedValue: option.value, + })), + forceFullWidth: true, }; - // TODO: for fps and resolution, set placeholder to detected values const fps: SettingDefinition = { key: "fps", label: m["convert.settings.video.fps"](), + type: "select", + default: "auto", + options: [ + { value: "auto", label: m["convert.settings.common.auto"]() }, + { + value: "custom", + label: m["convert.settings.common.custom"](), + }, + { value: 24, label: "24" }, + { value: 30, label: "30" }, + { value: 60, label: "60" }, + { value: 120, label: "120" }, + { value: 144, label: "144" }, + { value: 240, label: "240" }, + ], + hasCustomInput: true, + customInputKey: "customFps", placeholder: m["convert.settings.video.fps_placeholder"](), - type: "number", - min: 1, }; const resolution: SettingDefinition = { key: "resolution", label: m["convert.settings.video.resolution"](), + type: "select", + default: "auto", + options: [ + { value: "auto", label: m["convert.settings.common.auto"]() }, + { + value: "custom", + label: m["convert.settings.common.custom"](), + }, + { value: "426x240", label: "426x240" }, + { value: "640x360", label: "640x360" }, + { value: "854x480", label: "854x480" }, + { value: "1280x720", label: "1280x720" }, + { value: "1920x1080", label: "1920x1080" }, + { value: "2560x1440", label: "2560x1440" }, + { value: "3840x2160", label: "3840x2160" }, + ], + hasCustomInput: true, + customInputKey: "customResolution", placeholder: m["convert.settings.video.resolution_placeholder"](), - type: "string", }; + // TODO: allow CRF for consistent quality? + const videoBitrate: SettingDefinition = { + key: "videoBitrate", + label: m["convert.settings.video.video_bitrate"](), + type: "select", + default: "auto", + options: [ + { value: "auto", label: m["convert.settings.common.auto"]() }, + { + value: "custom", + label: m["convert.settings.common.custom"](), + }, + { value: 1000, label: "1000 kbps" }, + { value: 2500, label: "2500 kbps" }, + { value: 5000, label: "5000 kbps" }, + { value: 8000, label: "8000 kbps" }, + { value: 12000, label: "12000 kbps" }, + { value: 18000, label: "18000 kbps" }, + ], + hasCustomInput: true, + customInputKey: "customBitrate", + placeholder: m["convert.settings.video.bitrate_placeholder"](), + }; + + /* + * audio settings + */ + const audioBitrate: SettingDefinition = { + key: "audioBitrate", + label: m["convert.settings.video.audio_bitrate"](), + type: "select", + default: "auto", + options: CONVERSION_BITRATES.map((b) => ({ + value: b, + label: b, + })), + hasCustomInput: true, + customInputKey: "customBitrate", + placeholder: m["convert.settings.audio.bitrate_placeholder"](), + }; + + const sampleRate: SettingDefinition = { + key: "sampleRate", + label: m["convert.settings.audio.sample_rate"](), + type: "select", + default: "auto", + options: SAMPLE_RATES.map((r) => ({ + value: r, + label: r, + })), + hasCustomInput: true, + customInputKey: "customSampleRate", + placeholder: m["convert.settings.audio.sample_rate_placeholder"](), + }; + + /* + * common + */ const metadata: SettingDefinition = { key: "metadata", label: m["convert.settings.common.metadata"](), @@ -408,9 +516,15 @@ export class VertdConverter extends Converter { // trim/crop/rotate - also have another ui for this prob - // import all audio settings? - - return [quality, fps, resolution, metadata]; + return [ + qualitySpeedRange, + videoBitrate, + resolution, + fps, + metadata, + audioBitrate, + sampleRate, + ]; } public async getDefaultSettings(): Promise { @@ -419,10 +533,19 @@ export class VertdConverter extends Converter { settings.forEach((setting) => { defaults[setting.key] = setting.default; }); + + if (defaults.vertdSpeedSlider !== undefined) { + const sliderIndex = defaults.vertdSpeedSlider as number; + defaults.vertdSpeed = vertdSpeedValues[sliderIndex]; + } return defaults; } - public async convert(input: VertFile, to: string, settings: ConversionSettings): Promise { + public async convert( + input: VertFile, + to: string, + settings: ConversionSettings, + ): Promise { if (to.startsWith(".")) to = to.slice(1); let fileUpload = input; @@ -481,7 +604,14 @@ export class VertdConverter extends Converter { }); ws.onopen = () => { - const speed = Settings.instance.settings.vertdSpeed; + let speed = settings.vertdSpeed as ConversionSpeed | undefined; + const sliderIndex = settings.vertdSpeedSlider as + | number + | undefined; + if (sliderIndex !== undefined) { + speed = vertdSpeedValues[sliderIndex] || speed; + } + if (!speed) speed = Settings.instance.settings.vertdSpeed; const keepMetadata = Settings.instance.settings.metadata; this.log( `opened ws connection to vertd for file ${input.name}`, diff --git a/src/lib/css/app.scss b/src/lib/css/app.scss index becfa7a..97e8880 100644 --- a/src/lib/css/app.scss +++ b/src/lib/css/app.scss @@ -413,6 +413,36 @@ body { @apply outline outline-accent outline-2; } + input[type="range"].range-slider { + @apply w-full h-[10px] appearance-none bg-transparent; + } + + input[type="range"].range-slider:focus { + @apply outline-none; + } + + // for some reason, thumb and tracks behave differently in webkit (chromium) and firefox + // so i had to do some manual adjustments to get them similar :sob: -maya + input[type="range"].range-slider::-webkit-slider-runnable-track { + @apply h-[10px] rounded-full bg-button; + appearance: none; + } + + input[type="range"].range-slider::-webkit-slider-thumb { + @apply bg-panel w-[18px] h-[18px] -mt-1 rounded-full cursor-pointer shadow-md; + appearance: none; + border: 2px solid var(--accent); + } + + input[type="range"].range-slider::-moz-range-track { + @apply h-[10px] rounded-full bg-button; + appearance: none; + } + + input[type="range"].range-slider::-moz-range-thumb { + @apply bg-panel border-2 border-accent w-4 h-4 rounded-full cursor-pointer shadow-md; + } + hr { @apply border-separator; } diff --git a/src/lib/types/conversion-settings.ts b/src/lib/types/conversion-settings.ts index fff8d4b..7b34912 100644 --- a/src/lib/types/conversion-settings.ts +++ b/src/lib/types/conversion-settings.ts @@ -2,20 +2,21 @@ export type SettingType = "number" | "select" | "boolean" | "string" | "range"; export interface SettingDefinition { - key: string; - label: string; - type: SettingType; - default?: any; - placeholder?: any; - min?: number; - max?: number; - step?: number; - 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 + key: string; + label: string; + type: SettingType; + default?: any; + placeholder?: any; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: any; label: any; speedValue?: any }>; // for select/range types + description?: string; + hasCustomInput?: boolean; // for select types with a "custom" option + customInputKey?: string; // key to use for custom input value in settings object + forceFullWidth?: boolean; // force setting to take up full width (usually grid 2) } export interface ConversionSettings { - [key: string]: any; -} \ No newline at end of file + [key: string]: any; +}