feat: choose converter, clean up logic

This commit is contained in:
Maya 2026-03-08 13:46:32 +03:00
parent 9164dc8824
commit 9b0470e4de
No known key found for this signature in database
8 changed files with 449 additions and 196 deletions

View File

@ -31,9 +31,9 @@ export default ts.config(
ignores: ["build/", ".svelte-kit/", "dist/"],
},
{
files: ["**/*.ts", "**/*.svelte.ts"],
files: ["**/*.ts", "**/*.svelte.ts", "**/*.svelte"],
rules: {
"no-at-html-tags": "off",
"svelte/no-at-html-tags": "off",
},
},
);

View File

@ -85,8 +85,9 @@
"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. This is an early beta and may have some issues.",
"description": "Change the conversion settings for <b>{filename}</b> with the selected converter. These settings may not be available for all formats. <b>This is an early beta and may have some issues.</b>",
"none": "No settings available for this format.",
"converter": "Converter",
"image": {
"quality": "Quality",
"depth": "Color depth",

View File

@ -17,19 +17,40 @@
let { file, onclose }: Props = $props();
let settings = $state<ConversionSettings>({});
const getCurrentConverter = (
vertFile: VertFile,
converterOverride?: string,
) => {
const converterName =
converterOverride || vertFile.conversionSettings.converter;
const availableConverters = vertFile.isZip()
? vertFile.converters
: vertFile.findConverters();
if (converterName) {
const selectedConverter =
availableConverters.find((c) => c.name === converterName) ||
vertFile.converters.find((c) => c.name === converterName);
if (selectedConverter) return selectedConverter;
}
return vertFile.isZip()
? vertFile.converters[0]
: vertFile.findConverters()[0];
};
let settings = $derived<ConversionSettings>({
converter: file ? getCurrentConverter(file)?.name : undefined,
});
const handleSettingChange = (key: string, value: any) => {
if (!file) return;
settings[key] = value;
};
const applySettings = async () => {
onclose?.();
const applySettings = async (converterName: string) => {
if (!file) return;
const converter = file.isZip()
? file.converters[0]
: file.findConverters()[0];
const converter = getCurrentConverter(file, converterName);
if (!converter) {
log(
["settings", "modal"],
@ -61,7 +82,10 @@
},
{
text: "Apply",
action: applySettings,
action: () => {
applySettings(settings.converter!);
onclose?.();
},
primary: true,
},
]}
@ -69,171 +93,196 @@
>
<div class="flex flex-col gap-8 max-h-[calc(100vh-225px)] overflow-y-auto">
{#if file}
{@const currentConverter = getCurrentConverter(file)}
{@const availableConverters = file.isZip()
? file.converters
: file.findConverters()}
<p class="text-base">
{@html sanitize(
m["convert.settings.description"]({
converter: currentConverter?.name || "unknown",
filename: file.name,
}),
)}
</p>
<div class="flex flex-col gap-2">
<p class="text-sm font-bold mb-1">
{m["convert.settings.converter"]()}
</p>
<Dropdown
options={availableConverters.map((converter) => ({
value: converter.name,
label: converter.name,
}))}
selected={settings.converter || currentConverter?.name}
settingsStyle
onselect={(value) => {
settings = { converter: value }; // TODO: dont think i need to add the converter here
}}
/>
</div>
<!-- FIXME: modal loads before settings is finished for some reason -->
{#await file.getAvailableSettings(file) then availableSettings}
<div class="flex flex-col gap-4">
<p class="text-base">
{@html sanitize(
m["convert.settings.description"]({
converter: file.isZip()
? file.converters[0].name
: file.findConverters()[0].name ||
"unknown",
filename: file.name,
}),
)}
</p>
{#if availableSettings.length === 0}
<p class="text-sm text-muted">
{m["convert.settings.none"]()}
</p>
{:else}
<div class="grid grid-cols-2 gap-4">
{#each availableSettings as setting (setting.key)}
<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 mt-1">
{setting.description}
{#key settings}
{#await file.getAvailableSettings(file, settings.converter) then availableSettings}
<div class="flex flex-col gap-4">
{#if availableSettings.length === 0}
<p class="text-sm text-muted">
{m["convert.settings.none"]()}
</p>
{:else}
<div class="grid grid-cols-2 gap-4">
{#each availableSettings as setting (setting.key)}
<div
class={setting.forceFullWidth
? "col-span-2"
: "flex flex-col gap-2"}
>
<p class="text-sm font-bold">
{setting.label}
</p>
{/if}
<!-- prob unneeded -->
{#if setting.description}
<p class="text-xs text-muted mt-1">
{setting.description}
</p>
{/if}
{#if setting.type === "select"}
<Dropdown
options={setting.options?.map(
(opt) =>
typeof opt === "string"
? {
value: opt,
label: opt,
}
: opt,
) || []}
selected={settings[setting.key] ??
file.conversionSettings[
{#if setting.type === "select"}
<Dropdown
options={setting.options?.map(
(opt) =>
typeof opt === "string"
? {
value: opt,
label: opt,
}
: opt,
) || []}
selected={settings[
setting.key
] ??
setting.default}
settingsStyle
onselect={(value) =>
handleSettingChange(
setting.key,
value,
)}
disabled={setting.disabled}
/>
{#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={disabled ||
setting.disabled}
oninput={(e) =>
handleSettingChange(
setting.customInputKey!,
e.currentTarget.value,
)}
/>
{/if}
{:else if setting.type === "boolean"}
<FancyInput
type="checkbox"
checked={settings[setting.key] ??
file.conversionSettings[
setting.key
] ??
setting.default}
placeholder={setting.placeholder}
onchange={(e) =>
handleSettingChange(
setting.key,
e.currentTarget.checked,
)}
disabled={setting.disabled}
/>
{: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;
setting.default}
settingsStyle
onselect={(value) =>
handleSettingChange(
setting.key,
nextValue,
);
}}
value,
)}
disabled={setting.disabled}
/>
<span
class="text-sm max-w-28 w-full text-right"
>
{rangeLabel}
</span>
</div>
{:else}
<FancyInput
type={setting.type}
value={settings[setting.key] ??
{#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={disabled ||
setting.disabled}
oninput={(e) =>
handleSettingChange(
setting.customInputKey!,
e.currentTarget
.value,
)}
/>
{/if}
{:else if setting.type === "boolean"}
<FancyInput
type="checkbox"
checked={settings[
setting.key
] ??
file.conversionSettings[
setting.key
] ??
setting.default}
placeholder={setting.placeholder}
onchange={(e) =>
handleSettingChange(
setting.key,
e.currentTarget.checked,
)}
disabled={setting.disabled}
/>
{:else if setting.type === "range"}
{@const rangeValue = (settings[
setting.key
] ??
file.conversionSettings[
setting.key
] ??
setting.default}
placeholder={setting.placeholder}
oninput={(e) =>
handleSettingChange(
setting.key,
e.currentTarget.value,
)}
disabled={setting.disabled}
/>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/await}
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,
);
}}
disabled={setting.disabled}
/>
<span
class="text-sm max-w-28 w-full text-right"
>
{rangeLabel}
</span>
</div>
{:else}
<FancyInput
type={setting.type}
value={settings[setting.key] ??
file.conversionSettings[
setting.key
] ??
setting.default}
placeholder={setting.placeholder}
oninput={(e) =>
handleSettingChange(
setting.key,
e.currentTarget.value,
)}
disabled={setting.disabled}
/>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/await}
{/key}
{/if}
</div>
</Modal>

View File

@ -7,6 +7,7 @@ import { MagickConverter } from "./magick.svelte";
import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts";
import { MediabunnyConverter } from "./mediabunny.svelte";
// TODO: change this to include category with initialization to replace converterCategories and maybe categories as well
const getConverters = (): Converter[] => {
const converters: Converter[] = [
new MagickConverter(),
@ -21,6 +22,12 @@ const getConverters = (): Converter[] => {
};
export const converters = getConverters();
export const converterCategories = {
image: ["imagemagick"],
video: ["mediabunny", "vertd"],
audio: ["ffmpeg"],
doc: ["pandoc"],
}
export function getConverterByFormat(format: string) {
for (const converter of converters) {
@ -40,13 +47,13 @@ export const categories: Categories = {
categories.audio.formats =
converters
.find((c) => c.name === "ffmpeg")
.find((c) => converterCategories.audio.includes(c.name))
?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];
categories.video.formats = [
...new Set(
converters
.filter((c) => c.name === "mediabunny" || c.name === "vertd")
.filter((c) => converterCategories.video.includes(c.name))
.flatMap((c) =>
c.supportedFormats
.filter((f) => f.toSupported && f.isNative)
@ -56,11 +63,11 @@ categories.video.formats = [
];
categories.image.formats =
converters
.find((c) => c.name === "imagemagick")
.find((c) => converterCategories.image.includes(c.name))
?.formatStrings((f) => f.toSupported) || [];
categories.doc.formats =
converters
.find((c) => c.name === "pandoc")
.find((c) => converterCategories.doc.includes(c.name))
?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];

View File

@ -23,6 +23,12 @@ import { Converter, FormatInfo, type WorkerStatus } from "./converter.svelte";
import { ToastManager } from "$lib/util/toast.svelte";
import { error, log } from "$lib/util/logger";
import { registerFlacEncoder } from "@mediabunny/flac-encoder";
import { m } from "$lib/paraglide/messages";
import type {
SettingDefinition,
ConversionSettings,
} from "$lib/types/conversion-settings";
import { CONVERSION_BITRATES, SAMPLE_RATES } from "./ffmpeg.svelte";
// codec compatibility object, based on docs
// https://mediabunny.dev/guide/supported-formats-and-codecs#compatibility-table
@ -209,6 +215,152 @@ export class MediabunnyConverter extends Converter {
registerAc3Encoder();
}
public async getAvailableSettings(): Promise<SettingDefinition[]> {
// TODO: maybe have a slider for conversion speed/quality like vertd
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"](),
};
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"](),
};
// 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: "customVideoBitrate",
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.toString(),
label:
b === "auto"
? m["convert.settings.common.auto"]()
: b === "custom"
? m["convert.settings.common.custom"]()
: `${b} kbps`,
})),
hasCustomInput: true,
customInputKey: "customAudioBitrate",
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.toString(),
label:
r === "auto"
? m["convert.settings.common.auto"]()
: r === "custom"
? m["convert.settings.common.custom"]()
: `${r} Hz`,
})),
hasCustomInput: true,
customInputKey: "customSampleRate",
placeholder: m["convert.settings.audio.sample_rate_placeholder"](),
};
/*
* common
*/
const metadata: SettingDefinition = {
key: "metadata",
label: m["convert.settings.common.metadata"](),
type: "boolean",
default: true,
};
// trim/crop/rotate - also have another ui for this prob
return [
videoBitrate,
resolution,
fps,
metadata,
audioBitrate,
sampleRate,
];
}
public async getDefaultSettings(): Promise<ConversionSettings> {
const defaults: ConversionSettings = {};
const settings = await this.getAvailableSettings();
settings.forEach((setting) => {
defaults[setting.key] = setting.default;
});
return defaults;
}
public async convert(file: VertFile, to: string): Promise<VertFile> {
const input = new Input({
// TODO: add settings & special handling for certain formats & codecs

View File

@ -1,5 +1,5 @@
import { browser } from "$app/environment";
import { byNative, converters } from "$lib/converters";
import { byNative, converterCategories, converters } from "$lib/converters";
import { error, log } from "$lib/util/logger";
import { VertFile } from "$lib/types";
import { parseBlob, selectCover } from "music-metadata";
@ -36,12 +36,12 @@ class Files {
private _addThumbnail = async (file: VertFile) => {
this.thumbnailQueue.add(async () => {
const isAudio = converters
.find((c) => c.name === "ffmpeg")
.find((c) => converterCategories.audio.includes(c.name))
?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
?.includes(file.from.toLowerCase());
const isVideo = converters
.find((c) => c.name === "vertd")
.find((c) => converterCategories.video.includes(c.name))
?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
?.includes(file.from.toLowerCase());
@ -291,7 +291,7 @@ class Files {
this._addThumbnail(vf);
const convName = converter.name;
if (file.size > MAX_ARRAY_BUFFER_SIZE && convName === "vertd") {
if (file.size > MAX_ARRAY_BUFFER_SIZE && (converterCategories.video.includes(convName))) {
ToastManager.add({
type: "warning",
message: m["convert.large_file_warning"]({
@ -303,10 +303,11 @@ class Files {
});
}
const isVideo = convName === "vertd";
// TODO: only show if vertd is needed/requested
const isServerVideo = convName === "vertd";
const acceptedExternalWarning =
localStorage.getItem("acceptedExternalWarning") === "true";
if (isVideo && !acceptedExternalWarning && !this._warningShown) {
if (isServerVideo && !acceptedExternalWarning && !this._warningShown) {
this._warningShown = true;
const title = m["convert.external_warning.title"]();
const message = m["convert.external_warning.text"]();

View File

@ -40,10 +40,15 @@ export class VertFile {
public isZip = $state(() => this.from === ".zip");
public getAvailableSettings(input: VertFile): Promise<SettingDefinition[]> {
const converter = this.findConverters()[0];
if (!converter) return Promise.resolve([]);
return converter.getAvailableSettings(input);
public getAvailableSettings(
input: VertFile,
converter: string | undefined = this.conversionSettings.converter,
): Promise<SettingDefinition[]> {
const converterInstance = this.converters.find(
(c) => c.name === converter,
);
if (!converterInstance) return Promise.resolve([]);
return converterInstance.getAvailableSettings(input);
}
public findConverters(supportedFormats: string[] = [this.from]) {
@ -133,10 +138,19 @@ export class VertFile {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public async convert(...args: any[]) {
if (!this.converters.length) throw new Error("No converters found");
const converter = this.isZip()
? this.converters[0]
: this.findConverters()[0];
const customConverter = this.converters.find(
(c) => c.name === this.conversionSettings.converter,
);
const converter =
customConverter ||
(this.isZip() // TODO: not sure if the zip needs to be changed now
? this.converters[0]
: this.findConverters([this.from, this.to])[0]);
log(["file", "convert"], `using converter: ${converter.name}`);
if (!converter) throw new Error("No converter found");
this.result = null;
this.progress = 0;
this.processing = true;
@ -255,9 +269,9 @@ export class VertFile {
public async cancel() {
if (!this.processing) return;
const converter = this.isZip()
? this.converters[0]
: this.findConverters()[0];
const converter = this.converters.find(
(c) => c.name === this.conversionSettings.converter,
);
if (!converter) throw new Error("No converter found");
this.cancelled = true;
try {

View File

@ -5,7 +5,11 @@
import Panel from "$lib/components/visual/Panel.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import Tooltip from "$lib/components/visual/Tooltip.svelte";
import { categories, converters } from "$lib/converters";
import {
categories,
converterCategories,
converters,
} from "$lib/converters";
import {
effects,
files,
@ -35,6 +39,22 @@
let processedFileIds = $state(new Set<string>());
const getCurrentConverter = (file: VertFile) => {
const converterName = file.conversionSettings.converter;
const availableConverters = file.isZip()
? file.converters
: file.findConverters();
if (converterName) {
const selectedConverter =
availableConverters.find((c) => c.name === converterName) ||
file.converters.find((c) => c.name === converterName);
if (selectedConverter) return selectedConverter;
}
return file.isZip() ? file.converters[0] : file.findConverters()[0];
};
$effect(() => {
if (!Settings.instance.settings || files.files.length === 0) return;
@ -42,14 +62,19 @@
const settings = Settings.instance.settings;
if (processedFileIds.has(file.id)) return;
const converter = file.isZip() ? file.converters[0] : file.findConverters()[0];
const converter = getCurrentConverter(file);
if (!converter) return;
// Initialize converter in settings if not already set
if (!file.conversionSettings.converter)
file.conversionSettings.converter = converter.name;
let category: string | undefined;
const isImage = converter.name === "imagemagick";
const isAudio = converter.name === "ffmpeg";
const isVideo = converter.name === "vertd";
const isDocument = converter.name === "pandoc";
const isImage = converterCategories.image.includes(converter.name);
const isAudio = converterCategories.audio.includes(converter.name);
const isVideo = converterCategories.video.includes(converter.name);
const isDocument = converterCategories.doc.includes(converter.name);
if (isImage) category = "image";
else if (isAudio) category = "audio";
@ -108,16 +133,19 @@
let type = "";
if (files.files.length) {
const converters = files.files.map(
(file) => (file.isZip() ? file.converters[0] : file.findConverters()[0])?.name,
(file) => getCurrentConverter(file)?.name,
);
const uniqueTypes = new Set(converters);
if (uniqueTypes.size === 1) {
const onlyType = converters[0];
if (onlyType === "imagemagick") type = "blue";
else if (onlyType === "ffmpeg") type = "purple";
else if (onlyType === "vertd") type = "red";
else if (onlyType === "pandoc") type = "green";
if (converterCategories.image.includes(onlyType)) type = "blue";
else if (converterCategories.audio.includes(onlyType))
type = "purple";
else if (converterCategories.video.includes(onlyType))
type = "red";
else if (converterCategories.doc.includes(onlyType))
type = "green";
}
}
@ -130,11 +158,12 @@
</script>
{#snippet fileItem(file: VertFile, index: number)}
{@const currentConverter = file.isZip() ? file.converters[0] : file.findConverters()[0]}
{@const isImage = currentConverter?.name === "imagemagick"}
{@const isAudio = currentConverter?.name === "ffmpeg"}
{@const isVideo = currentConverter?.name === "vertd"}
{@const isDocument = currentConverter?.name === "pandoc"}
{@const currentConverter = getCurrentConverter(file)}
{@const name = currentConverter?.name || "unknown"}
{@const isImage = converterCategories.image.includes(name)}
{@const isAudio = converterCategories.audio.includes(name)}
{@const isVideo = converterCategories.video.includes(name)}
{@const isDocument = converterCategories.doc.includes(name)}
<Panel class="p-5 flex flex-col min-w-0 gap-4 relative">
<div class="flex-shrink-0 h-8 w-full flex items-center gap-2">
{#if !converters.length}