feat: default conversion format

also improves formatdropdown and thumbnail generation
This commit is contained in:
Maya 2025-09-08 09:00:20 +03:00
parent ab1dd2b507
commit 93faaa4b34
No known key found for this signature in database
9 changed files with 325 additions and 89 deletions

View File

@ -104,6 +104,12 @@
"filename_format": "File name format", "filename_format": "File name format",
"filename_description": "This will determine the name of the file on download, <b>not including the file extension.</b> You can put these following templates in the format, which will be replaced with the relevant information: <b>%name%</b> for the original file name, <b>%extension%</b> for the original file extension, and <b>%date%</b> for a date string of when the file was converted.", "filename_description": "This will determine the name of the file on download, <b>not including the file extension.</b> You can put these following templates in the format, which will be replaced with the relevant information: <b>%name%</b> for the original file name, <b>%extension%</b> for the original file extension, and <b>%date%</b> for a date string of when the file was converted.",
"placeholder": "VERT_%name%", "placeholder": "VERT_%name%",
"default_format": "Default conversion format",
"default_format_description": "This will change the default format selected when you upload a file of this file type.",
"default_format_image": "Images",
"default_format_video": "Videos",
"default_format_audio": "Audio",
"default_format_document": "Documents",
"metadata": "File metadata", "metadata": "File metadata",
"metadata_description": "This changes whether any metadata (EXIF, song info, etc.) on the original file is preserved in converted files.", "metadata_description": "This changes whether any metadata (EXIF, song info, etc.) on the original file is preserved in converted files.",
"keep": "Keep", "keep": "Keep",

View File

@ -31,6 +31,7 @@
let searchQuery = $state(""); let searchQuery = $state("");
let dropdownMenu: HTMLElement | undefined = $state(); let dropdownMenu: HTMLElement | undefined = $state();
let rootCategory: string | null = null; let rootCategory: string | null = null;
let dropdownPosition = $state<"left" | "center" | "right">("center");
// initialize current category // initialize current category
$effect(() => { $effect(() => {
@ -86,13 +87,15 @@
// if no query, return formats for current category // if no query, return formats for current category
if (!searchQuery) { if (!searchQuery) {
let formats = currentCategory
? categories[currentCategory].formats.filter((format) =>
shouldInclude(format, currentCategory!),
)
: [];
return { return {
categories: availableCategories, categories: availableCategories,
formats: currentCategory formats,
? categories[currentCategory].formats.filter((format) =>
shouldInclude(format, currentCategory!),
)
: [],
}; };
} }
const searchLower = normalize(searchQuery); const searchLower = normalize(searchQuery);
@ -213,6 +216,34 @@
const clickDropdown = () => { const clickDropdown = () => {
open = !open; open = !open;
if (!open) return; if (!open) return;
// keep within viewport
if (dropdown) {
const rect = dropdown.getBoundingClientRect();
const viewportWidth = window.innerWidth;
let dropdownWidth: number;
if (dropdownSize === "large") {
dropdownWidth = rect.width * 3.2;
} else if (dropdownSize === "default") {
dropdownWidth = rect.width * 2.5;
} else {
dropdownWidth = rect.width * 1.5;
}
const centerX = rect.left + rect.width / 2;
const leftEdge = centerX - dropdownWidth / 2;
const rightEdge = centerX + dropdownWidth / 2;
if (leftEdge < 0) {
dropdownPosition = "left";
} else if (rightEdge > viewportWidth) {
dropdownPosition = "right";
} else {
dropdownPosition = "center";
}
}
setTimeout(() => { setTimeout(() => {
if (!dropdownMenu) return; if (!dropdownMenu) return;
const searchInput = dropdownMenu.querySelector( const searchInput = dropdownMenu.querySelector(
@ -232,23 +263,20 @@
} }
}; };
window.addEventListener("click", handleClickOutside); const handleResize = () => {
return () => window.removeEventListener("click", handleClickOutside); if (open) {
}); // recalculate dropdown position on resize
clickDropdown();
open = true;
}
};
// initialize selected format if none chosen window.addEventListener("click", handleClickOutside);
$effect(() => { window.addEventListener("resize", handleResize);
if ( return () => {
!selected && window.removeEventListener("click", handleClickOutside);
currentCategory && window.removeEventListener("resize", handleResize);
categories[currentCategory]?.formats?.length > 0 };
) {
const from = files.files[0]?.from;
const firstDiff = categories[currentCategory].formats.find(
(f) => f !== from,
);
selected = firstDiff || categories[currentCategory].formats[0];
}
}); });
</script> </script>
@ -258,7 +286,7 @@
> >
<button <button
class="relative flex items-center justify-center w-full font-display px-3 py-3.5 bg-button rounded-full overflow-hidden cursor-pointer focus:!outline-none class="relative flex items-center justify-center w-full font-display px-3 py-3.5 bg-button rounded-full overflow-hidden cursor-pointer focus:!outline-none
{disabled ? 'opacity-50 cursor-auto' : 'cursor-pointer'}" {disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
onclick={() => clickDropdown()} onclick={() => clickDropdown()}
{disabled} {disabled}
> >
@ -278,7 +306,7 @@
}} }}
class="col-start-1 row-start-1 text-center font-body font-medium truncate max-w-[4rem]" class="col-start-1 row-start-1 text-center font-body font-medium truncate max-w-[4rem]"
> >
{selected} {selected || "N/A"}
</p> </p>
{/key} {/key}
{#if currentCategory} {#if currentCategory}
@ -308,12 +336,17 @@
class={clsx( class={clsx(
$isMobile $isMobile
? "fixed inset-x-0 bottom-0 w-full z-[200] shadow-xl bg-panel-alt shadow-black/25 rounded-t-2xl overflow-hidden" ? "fixed inset-x-0 bottom-0 w-full z-[200] shadow-xl bg-panel-alt shadow-black/25 rounded-t-2xl overflow-hidden"
: "min-w-full shadow-xl bg-panel-alt shadow-black/25 absolute -translate-x-1/2 top-full mt-2 z-50 rounded-2xl overflow-hidden", : "min-w-full shadow-xl bg-panel-alt shadow-black/25 absolute top-full mt-2 z-50 rounded-2xl overflow-hidden",
!$isMobile && { !$isMobile && {
"w-[320%]": dropdownSize === "large", "w-[320%]": dropdownSize === "large",
"w-[250%]": dropdownSize === "default", "w-[250%]": dropdownSize === "default",
"w-[150%]": dropdownSize === "small", "w-[150%]": dropdownSize === "small",
}, },
!$isMobile && {
"-translate-x-1/2 left-1/2": dropdownPosition === "center",
"left-0": dropdownPosition === "left",
"right-0": dropdownPosition === "right",
},
)} )}
> >
<!-- search box --> <!-- search box -->

View File

@ -13,8 +13,10 @@
import Dropdown from "$lib/components/functional/Dropdown.svelte"; import Dropdown from "$lib/components/functional/Dropdown.svelte";
import FancyInput from "$lib/components/functional/FancyInput.svelte"; import FancyInput from "$lib/components/functional/FancyInput.svelte";
import { effects } from "$lib/store/index.svelte"; import { effects } from "$lib/store/index.svelte";
import FormatDropdown from "$lib/components/functional/FormatDropdown.svelte";
import { categories } from "$lib/converters";
const { settings }: { settings: ISettings } = $props(); const { settings = $bindable() }: { settings: ISettings } = $props();
</script> </script>
<Panel class="flex flex-col gap-8 p-6"> <Panel class="flex flex-col gap-8 p-6">
@ -44,6 +46,89 @@
type="text" type="text"
/> />
</div> </div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">
{m["settings.conversion.default_format"]()}
</p>
<p class="text-sm text-muted font-normal">
{m["settings.conversion.default_format_description"]()}
</p>
</div>
<div class="flex flex-col gap-3 w-full">
<div class="flex gap-3 w-full">
<button
onclick={() => (settings.useDefaultFormat = true)}
class="btn {$effects
? ''
: '!scale-100'} {settings.useDefaultFormat
? 'selected'
: ''} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
>
<PlayIcon size="24" class="inline-block mr-2" />
Enable
</button>
<button
onclick={() => (settings.useDefaultFormat = false)}
class="btn {$effects
? ''
: '!scale-100'} {settings.useDefaultFormat
? ''
: 'selected'} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
>
<PauseIcon size="24" class="inline-block mr-2" />
Disable
</button>
</div>
</div>
<div class="grid gap-3 grid-cols-2 md:grid-cols-4" class:opacity-50={!settings.useDefaultFormat}>
<div class="flex flex-col gap-2">
<p class="text-sm font-bold">
{m["settings.conversion.default_format_image"]()}
</p>
<FormatDropdown
categories={{image: categories.image}}
from={".png"}
bind:selected={settings.defaultFormat.image}
disabled={!settings.useDefaultFormat}
/>
</div>
<div class="flex flex-col gap-2">
<p class="text-sm font-bold">
{m["settings.conversion.default_format_audio"]()}
</p>
<FormatDropdown
categories={{audio: categories.audio}}
from={".mp3"}
bind:selected={settings.defaultFormat.audio}
disabled={!settings.useDefaultFormat}
/>
</div>
<div class="flex flex-col gap-2">
<p class="text-sm font-bold">
{m["settings.conversion.default_format_video"]()}
</p>
<FormatDropdown
categories={{video: categories.video}}
from={".mp4"}
bind:selected={settings.defaultFormat.video}
disabled={!settings.useDefaultFormat}
/>
</div>
<div class="flex flex-col gap-2">
<p class="text-sm font-bold">
{m["settings.conversion.default_format_document"]()}
</p>
<FormatDropdown
categories={{doc: categories.doc}}
from={".docx"}
bind:selected={settings.defaultFormat.document}
disabled={!settings.useDefaultFormat}
/>
</div>
</div>
</div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-base font-bold"> <p class="text-base font-bold">

View File

@ -6,7 +6,7 @@
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
import { link } from "$lib/store/index.svelte"; import { link } from "$lib/store/index.svelte";
const { settings }: { settings: ISettings } = $props(); const { settings = $bindable() }: { settings: ISettings } = $props();
</script> </script>
<Panel class="flex flex-col gap-8 p-6"> <Panel class="flex flex-col gap-8 p-6">

View File

@ -13,7 +13,7 @@
let vertdCommit = $state<string | null>(null); let vertdCommit = $state<string | null>(null);
let abortController: AbortController | null = null; let abortController: AbortController | null = null;
const { settings }: { settings: ISettings } = $props(); const { settings = $bindable() }: { settings: ISettings } = $props();
$effect(() => { $effect(() => {
if (settings.vertdURL) { if (settings.vertdURL) {

View File

@ -8,8 +8,17 @@ export { default as Vertd } from "./Vertd.svelte";
export { default as Privacy } from "./Privacy.svelte"; export { default as Privacy } from "./Privacy.svelte";
// TODO: clean up settings & button code (componetize) // TODO: clean up settings & button code (componetize)
export interface DefaultFormats {
image: string;
video: string;
audio: string;
document: string;
}
export interface ISettings { export interface ISettings {
filenameFormat: string; filenameFormat: string;
defaultFormat: DefaultFormats;
useDefaultFormat: boolean;
metadata: boolean; metadata: boolean;
plausible: boolean; plausible: boolean;
vertdURL: string; vertdURL: string;
@ -25,6 +34,13 @@ export class Settings {
public settings: ISettings = $state({ public settings: ISettings = $state({
filenameFormat: "VERT_%name%", filenameFormat: "VERT_%name%",
defaultFormat: {
image: ".png",
video: ".mp4",
audio: ".mp3",
document: ".docx",
},
useDefaultFormat: false,
metadata: true, metadata: true,
plausible: true, plausible: true,
vertdURL: PUB_VERTD_URL, vertdURL: PUB_VERTD_URL,

View File

@ -50,9 +50,10 @@ class Files {
}); });
const cover = selectCover(common.picture); const cover = selectCover(common.picture);
if (cover) { if (cover) {
const arrayBuffer = cover.data.buffer instanceof ArrayBuffer const arrayBuffer =
? cover.data.buffer cover.data.buffer instanceof ArrayBuffer
: new Uint8Array(cover.data).buffer; ? cover.data.buffer
: new Uint8Array(cover.data).buffer;
const blob = new Blob([new Uint8Array(arrayBuffer)], { const blob = new Blob([new Uint8Array(arrayBuffer)], {
type: cover.format, type: cover.format,
}); });
@ -114,15 +115,23 @@ class Files {
? (mediaElement as HTMLVideoElement).videoHeight ? (mediaElement as HTMLVideoElement).videoHeight
: (mediaElement as HTMLImageElement).height; : (mediaElement as HTMLImageElement).height;
if (width === 0 || height === 0) {
URL.revokeObjectURL(mediaElement.src);
return undefined;
}
const scale = Math.max(maxSize / width, maxSize / height); const scale = Math.max(maxSize / width, maxSize / height);
canvas.width = width * scale; canvas.width = width * scale;
canvas.height = height * scale; canvas.height = height * scale;
ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height);
// check if completely transparent
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const isTransparent = Array.from(imageData.data).every(
(value, index) => {
return (index + 1) % 4 !== 0 || value === 0;
},
);
if (isTransparent) {
canvas.remove();
return undefined;
}
const url = canvas.toDataURL(); const url = canvas.toDataURL();
canvas.remove(); canvas.remove();
return url; return url;
@ -313,9 +322,9 @@ export const effects = writable(true);
export const theme = writable<"light" | "dark">("light"); export const theme = writable<"light" | "dark">("light");
export const locale = writable(getLocale()); export const locale = writable(getLocale());
export const availableLocales = { export const availableLocales = {
"en": "English", en: "English",
"es": "Español", es: "Español",
} };
export function updateLocale(newLocale: string) { export function updateLocale(newLocale: string) {
log(["locale"], `set to ${newLocale}`); log(["locale"], `set to ${newLocale}`);
@ -331,7 +340,7 @@ export function link(
text: string, text: string,
links: string | string[], links: string | string[],
newTab?: boolean | boolean[], newTab?: boolean | boolean[],
className?: string | string[] className?: string | string[],
) { ) {
if (!text) return ""; if (!text) return "";
@ -344,12 +353,15 @@ export function link(
tags.forEach((t, i) => { tags.forEach((t, i) => {
const link = linksArr[i] ?? "#"; const link = linksArr[i] ?? "#";
const target = newTabArr[i] ? 'target="_blank" rel="noopener noreferrer"' : ""; const target = newTabArr[i]
? 'target="_blank" rel="noopener noreferrer"'
: "";
const cls = classArr[i] ? `class="${classArr[i]}"` : ""; const cls = classArr[i] ? `class="${classArr[i]}"` : "";
const regex = new RegExp(`\\[${t}\\](.*?)\\[\\/${t}\\]`, "g"); const regex = new RegExp(`\\[${t}\\](.*?)\\[\\/${t}\\]`, "g");
result = result.replace(regex, (_, inner) => result = result.replace(
`<a href="${link}" ${target} ${cls} >${inner}</a>` regex,
(_, inner) => `<a href="${link}" ${target} ${cls} >${inner}</a>`,
); );
}); });

View File

@ -27,21 +27,79 @@
RotateCwIcon, RotateCwIcon,
XIcon, XIcon,
} from "lucide-svelte"; } from "lucide-svelte";
import { onMount } from "svelte";
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
import { Settings } from "$lib/sections/settings/index.svelte";
let processedFileIds = $state(new Set<string>());
$effect(() => {
if (!Settings.instance.settings || files.files.length === 0) return;
onMount(() => {
// depending on format, select right category and format
files.files.forEach((file) => { files.files.forEach((file) => {
const settings = Settings.instance.settings;
if (processedFileIds.has(file.id)) return;
const converter = file.findConverter(); const converter = file.findConverter();
if (converter) { if (!converter) return;
const category = Object.keys(categories).find((cat) =>
categories[cat].formats.includes(file.to), let category: string | undefined;
); const isImage = converters
if (category) { .find((c) => c.name === "imagemagick")
file.to = file.to || categories[category].formats[0]; ?.formatStrings((f) => f.fromSupported)
.includes(file.from);
const isAudio = converters
.find((c) => c.name === "ffmpeg")
?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from);
const isVideo = converters
.find((c) => c.name === "vertd")
?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from);
const isDocument = converters
.find((c) => c.name === "pandoc")
?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from);
if (isImage) category = "image";
else if (isAudio) category = "audio";
else if (isVideo) category = "video";
else if (isDocument) category = "doc";
if (!category) return;
let targetFormat: string | undefined;
// use default format if enabled
if (settings.useDefaultFormat) {
let defaultFormat: string | undefined;
const df = settings.defaultFormat;
if (category === "image") defaultFormat = df.image;
else if (category === "audio") defaultFormat = df.audio;
else if (category === "video") defaultFormat = df.video;
else if (category === "doc") defaultFormat = df.document;
if (
defaultFormat &&
defaultFormat !== file.from &&
categories[category]?.formats.includes(defaultFormat)
) {
targetFormat = defaultFormat;
} }
} }
// else use first available format (or if default format is same as input)
if (!targetFormat) {
const firstDiff = categories[category]?.formats.find(
(f) => f !== file.from,
);
targetFormat =
firstDiff || categories[category]?.formats[0] || "";
}
file.to = targetFormat;
processedFileIds.add(file.id);
}); });
}); });
@ -132,23 +190,38 @@
<Panel class="p-5 flex flex-col min-w-0 gap-4 relative"> <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"> <div class="flex-shrink-0 h-8 w-full flex items-center gap-2">
{#if !converters.length} {#if !converters.length}
<Tooltip text={m["convert.tooltips.unknown_file"]()} position="bottom"> <Tooltip
text={m["convert.tooltips.unknown_file"]()}
position="bottom"
>
<FileQuestionIcon size="24" class="flex-shrink-0" /> <FileQuestionIcon size="24" class="flex-shrink-0" />
</Tooltip> </Tooltip>
{:else if isAudio} {:else if isAudio}
<Tooltip text={m["convert.tooltips.audio_file"]()} position="bottom"> <Tooltip
text={m["convert.tooltips.audio_file"]()}
position="bottom"
>
<AudioLines size="24" class="flex-shrink-0" /> <AudioLines size="24" class="flex-shrink-0" />
</Tooltip> </Tooltip>
{:else if isVideo} {:else if isVideo}
<Tooltip text={m["convert.tooltips.video_file"]()} position="bottom"> <Tooltip
text={m["convert.tooltips.video_file"]()}
position="bottom"
>
<FilmIcon size="24" class="flex-shrink-0" /> <FilmIcon size="24" class="flex-shrink-0" />
</Tooltip> </Tooltip>
{:else if isDocument} {:else if isDocument}
<Tooltip text={m["convert.tooltips.document_file"]()} position="bottom"> <Tooltip
text={m["convert.tooltips.document_file"]()}
position="bottom"
>
<BookText size="24" class="flex-shrink-0" /> <BookText size="24" class="flex-shrink-0" />
</Tooltip> </Tooltip>
{:else} {:else}
<Tooltip text={m["convert.tooltips.image_file"]()} position="bottom"> <Tooltip
text={m["convert.tooltips.image_file"]()}
position="bottom"
>
<ImageIcon size="24" class="flex-shrink-0" /> <ImageIcon size="24" class="flex-shrink-0" />
</Tooltip> </Tooltip>
{/if} {/if}
@ -206,7 +279,9 @@
<div <div
class="h-full flex flex-col text-center justify-center text-failure" class="h-full flex flex-col text-center justify-center text-failure"
> >
<p class="font-body font-bold">{m["convert.errors.cant_convert"]()}</p> <p class="font-body font-bold">
{m["convert.errors.cant_convert"]()}
</p>
<p class="font-normal"> <p class="font-normal">
{m["convert.errors.worker_downloading"]({ {m["convert.errors.worker_downloading"]({
type: isAudio type: isAudio
@ -215,7 +290,7 @@
? "Video" ? "Video"
: isDocument : isDocument
? m["convert.errors.doc"]() ? m["convert.errors.doc"]()
: m["convert.errors.image"]() : m["convert.errors.image"](),
})} })}
</p> </p>
</div> </div>
@ -223,7 +298,9 @@
<div <div
class="h-full flex flex-col text-center justify-center text-failure" class="h-full flex flex-col text-center justify-center text-failure"
> >
<p class="font-body font-bold">{m["convert.errors.cant_convert"]()}</p> <p class="font-body font-bold">
{m["convert.errors.cant_convert"]()}
</p>
<p class="font-normal"> <p class="font-normal">
{m["convert.errors.worker_error"]({ {m["convert.errors.worker_error"]({
type: isAudio type: isAudio
@ -232,7 +309,7 @@
? "Video" ? "Video"
: isDocument : isDocument
? m["convert.errors.doc"]() ? m["convert.errors.doc"]()
: m["convert.errors.image"]() : m["convert.errors.image"](),
})} })}
</p> </p>
</div> </div>
@ -240,7 +317,9 @@
<div <div
class="h-full flex flex-col text-center justify-center text-failure" class="h-full flex flex-col text-center justify-center text-failure"
> >
<p class="font-body font-bold">{m["convert.errors.cant_convert"]()}</p> <p class="font-body font-bold">
{m["convert.errors.cant_convert"]()}
</p>
<p class="font-normal"> <p class="font-normal">
{m["convert.errors.worker_timeout"]({ {m["convert.errors.worker_timeout"]({
type: isAudio type: isAudio
@ -249,7 +328,7 @@
? "Video" ? "Video"
: isDocument : isDocument
? m["convert.errors.doc"]() ? m["convert.errors.doc"]()
: m["convert.errors.image"]() : m["convert.errors.image"](),
})} })}
</p> </p>
</div> </div>
@ -257,7 +336,9 @@
<div <div
class="h-full flex flex-col text-center justify-center text-failure" class="h-full flex flex-col text-center justify-center text-failure"
> >
<p class="font-body font-bold">{m["convert.errors.cant_convert"]()}</p> <p class="font-body font-bold">
{m["convert.errors.cant_convert"]()}
</p>
<p class="font-normal"> <p class="font-normal">
{m["convert.errors.vertd_not_found"]()} {m["convert.errors.vertd_not_found"]()}
</p> </p>
@ -311,7 +392,10 @@
onselect={(option) => handleSelect(option, file)} onselect={(option) => handleSelect(option, file)}
/> />
<div class="w-full flex items-center justify-between"> <div class="w-full flex items-center justify-between">
<Tooltip text={m["convert.tooltips.convert_file"]()} position="bottom"> <Tooltip
text={m["convert.tooltips.convert_file"]()}
position="bottom"
>
<button <button
class="btn {$effects class="btn {$effects
? '' ? ''

View File

@ -59,14 +59,14 @@
class="w-full max-w-[1280px] flex flex-col md:flex-row gap-4 p-4 md:px-4 md:py-0" class="w-full max-w-[1280px] flex flex-col md:flex-row gap-4 p-4 md:px-4 md:py-0"
> >
<div class="flex flex-col gap-4 flex-1"> <div class="flex flex-col gap-4 flex-1">
<Settings.Conversion {settings} /> <Settings.Conversion bind:settings />
<Settings.Vertd {settings} /> <Settings.Vertd bind:settings />
</div> </div>
<div class="flex flex-col gap-4 flex-1"> <div class="flex flex-col gap-4 flex-1">
<Settings.Appearance /> <Settings.Appearance />
{#if PUB_PLAUSIBLE_URL} {#if PUB_PLAUSIBLE_URL}
<Settings.Privacy {settings} /> <Settings.Privacy bind:settings />
{/if} {/if}
</div> </div>
</div> </div>