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": "Settings", "settings": "Settings",
"title": "File conversion 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.", "none": "No settings available for this format.",
"image": { "image": {
"quality": "Quality", "quality": "Quality",
@ -94,30 +94,42 @@
}, },
"audio": { "audio": {
"bitrate": "Bitrate (kbps)", "bitrate": "Bitrate (kbps)",
"bitrate_placeholder": "Custom bitrate",
"sample_rate": "Sample rate (Hz)", "sample_rate": "Sample rate (Hz)",
"sample_rate_placeholder": "Custom sample rate",
"channels": "Audio channels", "channels": "Audio channels",
"tracks": "Audio tracks" "channels_placeholder": "Custom audio channels",
"tracks": "Audio tracks",
"tracks_placeholder": "Custom audio tracks"
}, },
"video": { "video": {
"quality": "Quality", "quality": "Quality",
"metadata": "Metadata", "metadata": "Metadata",
"speed": "Conversion speed", "speed": "Conversion speed",
"speed_very_slow": "Very Slow", "speed_description": "This will be overridden if you manually set the bitrate or resolution below - selecting options other than \"auto\".",
"speed_slower": "Slower", "speed_very_slow": "Highest quality (slowest)",
"speed_slow": "Slow", "speed_slower": "Higher quality (slower)",
"speed_medium": "Medium", "speed_slow": "High quality (slow)",
"speed_fast": "Fast", "speed_medium": "Medium quality (average)",
"speed_ultra_fast": "Ultra Fast", "speed_fast": "Lower quality (faster)",
"speed_ultra_fast": "Lowest quality (fastest)",
"fps": "Frame rate (FPS)", "fps": "Frame rate (FPS)",
"fps_placeholder": "Auto", "fps_placeholder": "Custom frame rate",
"resolution": "Resolution", "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": { "document": {
"something": "Something" "something": "Something"
}, },
"common": { "common": {
"metadata": "Metadata" "metadata": "Metadata",
"auto": "auto",
"custom": "custom"
} }
}, },
"tooltips": { "tooltips": {

View File

@ -86,13 +86,17 @@
{:else} {:else}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
{#each availableSettings as setting (setting.key)} {#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"> <p class="text-sm font-bold">
{setting.label} {setting.label}
</p> </p>
<!-- prob unneeded --> <!-- prob unneeded -->
{#if setting.description} {#if setting.description}
<p class="text-xs text-muted"> <p class="text-xs text-muted mt-1">
{setting.description} {setting.description}
</p> </p>
{/if} {/if}
@ -102,9 +106,11 @@
options={setting.options?.map( options={setting.options?.map(
(opt) => opt.value, (opt) => opt.value,
) || []} ) || []}
selected={file.conversionSettings[ selected={settings[setting.key] ??
setting.key file.conversionSettings[
] ?? setting.default} setting.key
] ??
setting.default}
settingsStyle settingsStyle
onselect={(value) => onselect={(value) =>
handleSettingChange( handleSettingChange(
@ -139,9 +145,11 @@
{:else if setting.type === "boolean"} {:else if setting.type === "boolean"}
<FancyInput <FancyInput
type="checkbox" type="checkbox"
checked={file.conversionSettings[ checked={settings[setting.key] ??
setting.key file.conversionSettings[
] ?? setting.default} setting.key
] ??
setting.default}
placeholder={setting.placeholder} placeholder={setting.placeholder}
onchange={(e) => onchange={(e) =>
handleSettingChange( handleSettingChange(
@ -149,12 +157,51 @@
e.currentTarget.checked, 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} {:else}
<FancyInput <FancyInput
type={setting.type} type={setting.type}
value={file.conversionSettings[ value={settings[setting.key] ??
setting.key file.conversionSettings[
] ?? setting.default} setting.key
] ??
setting.default}
placeholder={setting.placeholder} placeholder={setting.placeholder}
oninput={(e) => oninput={(e) =>
handleSettingChange( handleSettingChange(

View File

@ -109,21 +109,11 @@ export class FFmpegConverter extends Converter {
} }
} }
public async getAvailableSettings( public async getAvailableSettings(): Promise<SettingDefinition[]> {
input: VertFile,
): Promise<SettingDefinition[]> {
// audio - bitrate, sample rate, channels, normalize, trim silence // audio - bitrate, sample rate, channels, normalize, trim silence
const global = Settings.instance.settings; 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"](),
@ -135,10 +125,9 @@ export class FFmpegConverter extends Converter {
})), })),
hasCustomInput: true, hasCustomInput: true,
customInputKey: "customBitrate", customInputKey: "customBitrate",
placeholder: detectedBitrate ?? "128" placeholder: m["convert.settings.audio.bitrate_placeholder"](),
}; };
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"](),
@ -153,31 +142,31 @@ export class FFmpegConverter extends Converter {
})), })),
hasCustomInput: true, hasCustomInput: true,
customInputKey: "customSampleRate", customInputKey: "customSampleRate",
placeholder: detectedSampleRate ?? "44100" placeholder: m["convert.settings.audio.sample_rate_placeholder"](),
}; };
const audioTracks = await this.detectAudioTracks(ffmpeg);
const tracks: SettingDefinition = { const tracks: SettingDefinition = {
key: "tracks", key: "tracks",
label: m["convert.settings.audio.tracks"](), label: m["convert.settings.audio.tracks"](),
type: "number", type: "number",
default: audioTracks ?? 1, default: 1,
min: 1, min: 1,
max: audioTracks ? audioTracks : 1, placeholder: m["convert.settings.audio.tracks_placeholder"](),
placeholder: 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: audioChannels ?? 2, default: 2,
min: 1, min: 1,
max: audioChannels ? audioChannels * 2 : 5, max: 8,
placeholder: audioChannels ?? 2 placeholder: m["convert.settings.audio.channels_placeholder"](),
}; };
/*
* common
*/
const metadata: SettingDefinition = { const metadata: SettingDefinition = {
key: "metadata", key: "metadata",
label: m["convert.settings.common.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( private async buildConversionCommand(
ffmpeg: FFmpeg, ffmpeg: FFmpeg,
input: VertFile, input: VertFile,
@ -962,12 +857,12 @@ export type ConversionBitrate = (typeof CONVERSION_BITRATES)[number];
export const SAMPLE_RATES = [ export const SAMPLE_RATES = [
"auto", "auto",
"custom", "custom",
"48000", 48000,
"44100", 44100,
"32000", 32000,
"22050", 22050,
"16000", 16000,
"11025", 11025,
"8000", 8000,
] as const; ] as const;
export type SampleRate = (typeof SAMPLE_RATES)[number]; export type SampleRate = (typeof SAMPLE_RATES)[number];

View File

@ -12,6 +12,7 @@ import type {
SettingDefinition, SettingDefinition,
ConversionSettings, ConversionSettings,
} from "$lib/types/conversion-settings"; } from "$lib/types/conversion-settings";
import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte";
interface UploadResponse { interface UploadResponse {
id: string; id: string;
@ -85,6 +86,15 @@ export type ConversionSpeed =
| "fast" | "fast"
| "ultraFast"; | "ultraFast";
const vertdSpeedValues: ConversionSpeed[] = [
"verySlow",
"slower",
"slow",
"medium",
"fast",
"ultraFast",
];
interface StartJobMessage { interface StartJobMessage {
type: "startJob"; type: "startJob";
data: { data: {
@ -375,30 +385,128 @@ export class VertdConverter extends Converter {
}, },
]; ];
const quality: SettingDefinition = { const qualitySpeedRange: SettingDefinition = {
key: "vertdSpeed", key: "vertdSpeedSlider",
label: m["convert.settings.video.speed"](), label: m["convert.settings.video.speed"](),
type: "select", description: m["convert.settings.video.speed_description"](),
default: "medium", type: "range",
options: qualityOptions, 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 = { const fps: SettingDefinition = {
key: "fps", key: "fps",
label: m["convert.settings.video.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"](), placeholder: m["convert.settings.video.fps_placeholder"](),
type: "number",
min: 1,
}; };
const resolution: SettingDefinition = { const resolution: SettingDefinition = {
key: "resolution", key: "resolution",
label: m["convert.settings.video.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"](), 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 = { const metadata: SettingDefinition = {
key: "metadata", key: "metadata",
label: m["convert.settings.common.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 // trim/crop/rotate - also have another ui for this prob
// import all audio settings? return [
qualitySpeedRange,
return [quality, fps, resolution, metadata]; videoBitrate,
resolution,
fps,
metadata,
audioBitrate,
sampleRate,
];
} }
public async getDefaultSettings(): Promise<ConversionSettings> { public async getDefaultSettings(): Promise<ConversionSettings> {
@ -419,10 +533,19 @@ export class VertdConverter extends Converter {
settings.forEach((setting) => { settings.forEach((setting) => {
defaults[setting.key] = setting.default; defaults[setting.key] = setting.default;
}); });
if (defaults.vertdSpeedSlider !== undefined) {
const sliderIndex = defaults.vertdSpeedSlider as number;
defaults.vertdSpeed = vertdSpeedValues[sliderIndex];
}
return defaults; 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); if (to.startsWith(".")) to = to.slice(1);
let fileUpload = input; let fileUpload = input;
@ -481,7 +604,14 @@ export class VertdConverter extends Converter {
}); });
ws.onopen = () => { 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; const keepMetadata = Settings.instance.settings.metadata;
this.log( this.log(
`opened ws connection to vertd for file ${input.name}`, `opened ws connection to vertd for file ${input.name}`,

View File

@ -413,6 +413,36 @@ body {
@apply outline outline-accent outline-2; @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 { hr {
@apply border-separator; @apply border-separator;
} }

View File

@ -2,20 +2,21 @@
export type SettingType = "number" | "select" | "boolean" | "string" | "range"; export type SettingType = "number" | "select" | "boolean" | "string" | "range";
export interface SettingDefinition { export interface SettingDefinition {
key: string; key: string;
label: string; label: string;
type: SettingType; type: SettingType;
default?: any; default?: any;
placeholder?: any; placeholder?: any;
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
options?: Array<{ value: any; label: any }>; // for select types options?: Array<{ value: any; label: any; speedValue?: any }>; // for select/range types
description?: string; description?: string;
hasCustomInput?: boolean; // for select types with a "custom" option hasCustomInput?: boolean; // for select types with a "custom" option
customInputKey?: string; // key to use for custom input value in settings object 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 { export interface ConversionSettings {
[key: string]: any; [key: string]: any;
} }