feat: start vertd settings

removed the settings auto detection (since it made UX a bit worse), added conversion speed slider - need to implement in vertd
This commit is contained in:
Maya 2026-02-19 09:52:24 +03:00
parent f129682474
commit 892db30480
6 changed files with 288 additions and 173 deletions

View File

@ -84,7 +84,7 @@
"settings": {
"settings": "Settings",
"title": "File conversion settings",
"description": "Change the conversion settings for <b>{filename}</b>, which is using <b>{converter}</b>. These settings may not be available for all formats.",
"description": "Change the conversion settings for <b>{filename}</b>, which is using <b>{converter}</b>. 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": {

View File

@ -86,13 +86,17 @@
{:else}
<div class="grid grid-cols-2 gap-4">
{#each availableSettings as setting (setting.key)}
<div class="flex flex-col gap-2">
<div
class={setting.forceFullWidth
? "col-span-2"
: "flex flex-col gap-2"}
>
<p class="text-sm font-bold">
{setting.label}
</p>
<!-- prob unneeded -->
{#if setting.description}
<p class="text-xs text-muted">
<p class="text-xs text-muted mt-1">
{setting.description}
</p>
{/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"}
<FancyInput
type="checkbox"
checked={file.conversionSettings[
setting.key
] ?? setting.default}
checked={settings[setting.key] ??
file.conversionSettings[
setting.key
] ??
setting.default}
placeholder={setting.placeholder}
onchange={(e) =>
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}
<div class="flex items-center mt-2 gap-2">
<input
type="range"
min={setting.min}
max={setting.max}
step={setting.step}
value={rangeValue}
class="range-slider w-full"
oninput={(e) => {
const nextValue =
e.currentTarget
.valueAsNumber;
handleSettingChange(
setting.key,
nextValue,
);
}}
/>
<span
class="text-sm max-w-28 w-full text-right"
>
{rangeLabel}
</span>
</div>
{:else}
<FancyInput
type={setting.type}
value={file.conversionSettings[
setting.key
] ?? setting.default}
value={settings[setting.key] ??
file.conversionSettings[
setting.key
] ??
setting.default}
placeholder={setting.placeholder}
oninput={(e) =>
handleSettingChange(

View File

@ -109,21 +109,11 @@ export class FFmpegConverter extends Converter {
}
}
public async getAvailableSettings(
input: VertFile,
): Promise<SettingDefinition[]> {
public async getAvailableSettings(): Promise<SettingDefinition[]> {
// 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<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,
@ -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];

View File

@ -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<ConversionSettings> {
@ -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<VertFile> {
public async convert(
input: VertFile,
to: string,
settings: ConversionSettings,
): Promise<VertFile> {
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}`,

View File

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

View File

@ -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;
}
[key: string]: any;
}