Merge branch 'merged-video-audio-and-dropdowns' into feat/merge-big-stuff

This commit is contained in:
Maya 2025-07-26 21:08:36 +03:00
commit d543433007
No known key found for this signature in database
8 changed files with 324 additions and 80 deletions

View File

@ -62,35 +62,79 @@
categories[cat].canConvertTo?.includes(currentCategory || ""), categories[cat].canConvertTo?.includes(currentCategory || ""),
); );
}); });
const shouldInclude = (format: string, category: string): boolean => {
// if converting from audio to video, dont show gifs
if (categories["audio"]?.formats.includes(from ?? "") && format === ".gif") {
return false;
}
return true;
};
const filteredData = $derived.by(() => { const filteredData = $derived.by(() => {
const normalize = (str: string) => str.replace(/^\./, "").toLowerCase();
// if no query, return formats for current category
if (!searchQuery) { if (!searchQuery) {
return { return {
categories: availableCategories, categories: availableCategories,
formats: currentCategory formats: currentCategory
? categories[currentCategory].formats ? categories[currentCategory].formats.filter((format) =>
shouldInclude(format, currentCategory!),
)
: [], : [],
}; };
} }
const searchLower = normalize(searchQuery);
// filter categories that have matching formats // find all categories that have formats matching the search query
const matchingCategories = availableCategories.filter((cat) => const matchingCategories = availableCategories.filter((cat) =>
categories[cat].formats.some((format) => categories[cat].formats.some(
format.toLowerCase().includes(searchQuery.toLowerCase()), (format) =>
normalize(format).includes(searchLower) &&
shouldInclude(format, cat),
), ),
); );
if (matchingCategories.length === 0) {
return {
categories: availableCategories,
formats: [],
};
}
// only show formats from the current category that match the search // if current category has no matches, switch to first category that does
const filteredFormats = const currentCategoryHasMatches =
currentCategory && categories[currentCategory] currentCategory &&
? categories[currentCategory].formats.filter((format) => matchingCategories.some((cat) => cat === currentCategory);
format if (!currentCategoryHasMatches && matchingCategories.length > 0) {
.toLowerCase() const newCategory = matchingCategories[0];
.includes(searchQuery.toLowerCase()), currentCategory = newCategory;
) }
: [];
// return formats only from the current category that match the search
let filteredFormats = currentCategory
? categories[currentCategory].formats.filter(
(format) =>
normalize(format).includes(searchLower) &&
shouldInclude(format, currentCategory!),
)
: [];
// sorting exact match first, then others
filteredFormats = filteredFormats.sort((a, b) => {
const aExact = normalize(a) === searchLower;
const bExact = normalize(b) === searchLower;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return 0;
});
return { return {
categories: matchingCategories, categories:
matchingCategories.length > 0
? matchingCategories
: availableCategories,
formats: filteredFormats, formats: filteredFormats,
}; };
}); });
@ -98,6 +142,21 @@
const selectOption = (option: string) => { const selectOption = (option: string) => {
selected = option; selected = option;
open = false; open = false;
// find the category of this option if it's not in the current category
if (
currentCategory &&
!categories[currentCategory].formats.includes(option)
) {
const formatCategory = Object.keys(categories).find((cat) =>
categories[cat].formats.includes(option),
);
if (formatCategory) {
currentCategory = formatCategory;
}
}
onselect?.(option); onselect?.(option);
}; };
@ -107,7 +166,39 @@
}; };
const handleSearch = (event: Event) => { const handleSearch = (event: Event) => {
searchQuery = (event.target as HTMLInputElement).value; const query = (event.target as HTMLInputElement).value;
searchQuery = query;
// find which categories have matching formats & switch
if (query) {
const queryLower = query.toLowerCase();
const categoriesWithMatches = availableCategories.filter((cat) =>
categories[cat].formats.some((format) =>
format.toLowerCase().includes(queryLower),
),
);
if (categoriesWithMatches.length > 0) {
const currentHasMatches =
currentCategory &&
categories[currentCategory].formats.some((format) =>
format.toLowerCase().includes(queryLower),
);
if (!currentHasMatches) {
currentCategory = categoriesWithMatches[0];
}
}
}
};
const onEnter = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
if (filteredData.formats.length > 0) {
selectOption(filteredData.formats[0]);
}
}
}; };
const clickDropdown = () => { const clickDropdown = () => {
@ -201,7 +292,6 @@
{#if open} {#if open}
<div <div
bind:this={dropdownMenu} bind:this={dropdownMenu}
style={hover ? "will-change: opacity, fade, transform" : ""}
transition:fade={{ transition:fade={{
duration, duration,
easing: quintOut, easing: quintOut,
@ -219,6 +309,7 @@
class="flex-grow w-full !pl-11 !pr-3 rounded-lg bg-panel text-foreground" class="flex-grow w-full !pl-11 !pr-3 rounded-lg bg-panel text-foreground"
bind:value={searchQuery} bind:value={searchQuery}
oninput={handleSearch} oninput={handleSearch}
onkeydown={onEnter}
onfocus={() => {}} onfocus={() => {}}
id="format-search" id="format-search"
autocomplete="off" autocomplete="off"
@ -228,15 +319,25 @@
> >
<SearchIcon class="w-4 h-4" /> <SearchIcon class="w-4 h-4" />
</span> </span>
{#if searchQuery}
<span
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted"
style="font-size: 0.7rem;"
>
{filteredData.formats.length}
{filteredData.formats.length === 1
? "result"
: "results"}
</span>
{/if}
</div> </div>
</div> </div>
<!-- available categories --> <!-- available categories -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
{#each filteredData.categories as category} {#each filteredData.categories as category}
<button <button
class="flex-grow text-lg hover:text-muted/20 border-b-[1px] pb-2 capitalize {currentCategory === class="flex-grow text-lg hover:text-muted/20 border-b-[1px] pb-2 capitalize
category {currentCategory === category
? 'text-accent border-b-accent' ? 'text-accent border-b-accent'
: 'border-b-separator text-muted'}" : 'border-b-separator text-muted'}"
onclick={() => selectCategory(category)} onclick={() => selectCategory(category)}
@ -245,21 +346,26 @@
</button> </button>
{/each} {/each}
</div> </div>
<!-- available formats --> <!-- available formats -->
<div class="max-h-80 overflow-y-auto grid grid-cols-3 gap-2 p-2"> <div class="max-h-80 overflow-y-auto grid grid-cols-3 gap-2 p-2">
{#each filteredData.formats as format} {#if filteredData.formats.length > 0}
<button {#each filteredData.formats as format}
class="w-full p-2 text-center rounded-xl <button
{format === selected class="w-full p-2 text-center rounded-xl
? 'bg-accent text-black' {format === selected ? 'bg-accent text-black' : 'hover:bg-panel'}
: 'hover:bg-panel'}
{format === from ? 'bg-separator' : ''}" {format === from ? 'bg-separator' : ''}"
onclick={() => selectOption(format)} onclick={() => selectOption(format)}
> >
{format} {format}
</button> </button>
{/each} {/each}
{:else}
<div class="col-span-3 text-center p-4 text-muted">
{searchQuery
? "No formats match your search"
: "No formats available"}
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -5,8 +5,9 @@ export class FormatInfo {
constructor( constructor(
name: string, name: string,
public fromSupported: boolean, public fromSupported = true,
public toSupported: boolean, public toSupported = true,
public isNative = true,
) { ) {
this.name = name; this.name = name;
if (!this.name.startsWith(".")) { if (!this.name.startsWith(".")) {

View File

@ -6,6 +6,8 @@ import { error, log } from "$lib/logger";
import { addToast } from "$lib/store/ToastProvider"; import { addToast } from "$lib/store/ToastProvider";
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
const videoFormats = [".mkv", ".mp4", ".avi", ".mov", ".webm", ".ts", ".mts", ".m2ts", ".wmv"];
export class FFmpegConverter extends Converter { export class FFmpegConverter extends Converter {
private ffmpeg: FFmpeg = null!; private ffmpeg: FFmpeg = null!;
public name = "ffmpeg"; public name = "ffmpeg";
@ -23,9 +25,10 @@ export class FFmpegConverter extends Converter {
new FormatInfo("wma", true, true), new FormatInfo("wma", true, true),
new FormatInfo("amr", true, true), new FormatInfo("amr", true, true),
new FormatInfo("ac3", true, true), new FormatInfo("ac3", true, true),
new FormatInfo("alac", true, false), new FormatInfo("alac", true, true),
new FormatInfo("aiff", true, true), new FormatInfo("aiff", true, true),
new FormatInfo("aif", true, true), new FormatInfo("aif", true, true),
...videoFormats.map((f) => new FormatInfo(f, true, true, false)),
]; ];
public readonly reportsProgress = true; public readonly reportsProgress = true;
@ -61,6 +64,17 @@ export class FFmpegConverter extends Converter {
ffmpeg.on("progress", (progress) => { ffmpeg.on("progress", (progress) => {
input.progress = progress.progress * 100; input.progress = progress.progress * 100;
}); });
ffmpeg.on("log", (l) => {
log(["converters", this.name], l.message);
if (l.message.includes("Stream map '0:a:0' matches no streams.")) {
error(
["converters", this.name],
`No audio stream found in ${input.name}.`,
);
addToast("error", `No audio stream found in ${input.name}.`);
}
});
const baseURL = const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm"; "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
await ffmpeg.load({ await ffmpeg.load({
@ -73,7 +87,37 @@ export class FFmpegConverter extends Converter {
["converters", this.name], ["converters", this.name],
`wrote ${input.name} to ffmpeg virtual fs`, `wrote ${input.name} to ffmpeg virtual fs`,
); );
await ffmpeg.exec(["-i", "input", "output" + to]); if (videoFormats.includes(input.from.slice(1))) {
// create an audio track from the video
await ffmpeg.exec(["-i", "input", "-map", "0:a:0", "output" + to]);
} else if (videoFormats.includes(to.slice(1))) {
// nab the album art
await ffmpeg.exec([
"-i",
"input",
"-an",
"-vcodec",
"copy",
"cover.png",
]);
const cmd = [
"-i",
"input",
"-i",
"cover.png",
"-loop",
"1",
"-pix_fmt",
"yuv420p",
...toArgs(to),
"output" + to,
];
console.log(cmd);
await ffmpeg.exec(cmd);
} else {
await ffmpeg.exec(["-i", "input", "output" + to]);
}
log(["converters", this.name], `executed ffmpeg command`); log(["converters", this.name], `executed ffmpeg command`);
const output = (await ffmpeg.readFile( const output = (await ffmpeg.readFile(
"output" + to, "output" + to,
@ -86,3 +130,49 @@ export class FFmpegConverter extends Converter {
return new VertFile(new File([output], input.name), to); return new VertFile(new File([output], input.name), to);
} }
} }
// and here i was, thinking i'd be done with ffmpeg after finishing vertd
// but OH NO we just HAD to have someone suggest to allow album art video generation.
//
// i hate you SO much.
// - love, maddie
const toArgs = (ext: string): string[] => {
const encoder = getEncoder(ext);
const args = ["-c:v", encoder];
switch (encoder) {
case "libx264": {
args.push(
"-preset",
"ultrafast",
"-crf",
"18",
"-tune",
"stillimage",
"-c:a",
"aac",
);
break;
}
case "libvpx": {
args.push("-c:v", "libvpx-vp9", "-c:a", "libvorbis");
break;
}
}
return args;
};
const getEncoder = (ext: string): string => {
switch (ext) {
case ".mkv":
case ".mp4":
case ".avi":
case ".mov":
return "libx264";
case ".webm":
return "libvpx";
default:
return "copy";
}
};

View File

@ -1,4 +1,5 @@
import type { Categories } from "$lib/types"; import type { Categories } from "$lib/types";
import type { Converter } from "./converter.svelte";
import { FFmpegConverter } from "./ffmpeg.svelte"; import { FFmpegConverter } from "./ffmpeg.svelte";
import { PandocConverter } from "./pandoc.svelte"; import { PandocConverter } from "./pandoc.svelte";
import { VertdConverter } from "./vertd.svelte"; import { VertdConverter } from "./vertd.svelte";
@ -22,19 +23,21 @@ export function getConverterByFormat(format: string) {
export const categories: Categories = { export const categories: Categories = {
image: { formats: [""], canConvertTo: [] }, image: { formats: [""], canConvertTo: [] },
video: { formats: [""], canConvertTo: [] }, // add "audio" when "nullptr/experimental-audio-to-video" is implemented video: { formats: [""], canConvertTo: ["audio"] },
audio: { formats: [""], canConvertTo: [] }, // add "video" when "nullptr/experimental-audio-to-video" is implemented audio: { formats: [""], canConvertTo: ["video"] },
docs: { formats: [""], canConvertTo: [] }, docs: { formats: [""], canConvertTo: [] },
}; };
categories.audio.formats = categories.audio.formats =
converters converters
.find((c) => c.name === "ffmpeg") .find((c) => c.name === "ffmpeg")
?.formatStrings((f) => f.toSupported) || []; ?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];
categories.video.formats = categories.video.formats =
converters converters
.find((c) => c.name === "vertd") .find((c) => c.name === "vertd")
?.formatStrings((f) => f.toSupported) || []; ?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];
categories.image.formats = categories.image.formats =
converters converters
.find((c) => c.name === "imagemagick") .find((c) => c.name === "imagemagick")
@ -42,4 +45,18 @@ categories.image.formats =
categories.docs.formats = categories.docs.formats =
converters converters
.find((c) => c.name === "pandoc") .find((c) => c.name === "pandoc")
?.formatStrings((f) => f.toSupported) || []; ?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];
export const byNative = (format: string) => {
return (a: Converter, b: Converter) => {
const aFormat = a.supportedFormats.find((f) => f.name === format);
const bFormat = b.supportedFormats.find((f) => f.name === format);
if (aFormat && bFormat) {
return aFormat.isNative ? -1 : 1;
}
return 0;
};
};

View File

@ -38,8 +38,8 @@ export class MagickConverter extends Converter {
new FormatInfo("icns", true, false), new FormatInfo("icns", true, false),
new FormatInfo("nef", true, false), new FormatInfo("nef", true, false),
new FormatInfo("cr2", true, false), new FormatInfo("cr2", true, false),
new FormatInfo("hdr", true, true), new FormatInfo("hdr"),
new FormatInfo("jpe", true, true), new FormatInfo("jpe"),
new FormatInfo("dng", true, false), new FormatInfo("dng", true, false),
new FormatInfo("mat", true, true), new FormatInfo("mat", true, true),
new FormatInfo("pbm", true, true), new FormatInfo("pbm", true, true),

View File

@ -1,5 +1,5 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { converters } from "$lib/converters"; import { byNative, converters } from "$lib/converters";
import { error, log } from "$lib/logger"; import { error, log } from "$lib/logger";
import { VertFile } from "$lib/types"; import { VertFile } from "$lib/types";
import { parseBlob, selectCover } from "music-metadata"; import { parseBlob, selectCover } from "music-metadata";
@ -33,11 +33,13 @@ class Files {
this.thumbnailQueue.add(async () => { this.thumbnailQueue.add(async () => {
const isAudio = converters const isAudio = converters
.find((c) => c.name === "ffmpeg") .find((c) => c.name === "ffmpeg")
?.formatStrings() ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
?.includes(file.from.toLowerCase()); ?.includes(file.from.toLowerCase());
const isVideo = converters const isVideo = converters
.find((c) => c.name === "vertd") .find((c) => c.name === "vertd")
?.formatStrings() ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
?.includes(file.from.toLowerCase()); ?.includes(file.from.toLowerCase());
try { try {
@ -121,11 +123,11 @@ class Files {
log(["files"], `no extension found for ${file.name}`); log(["files"], `no extension found for ${file.name}`);
return; return;
} }
const converter = converters.find((c) => const converter = converters
c .sort(byNative(format))
.formatStrings() .find((converter) =>
.includes(format || ".somenonexistentextension"), converter.formatStrings().includes(format),
); );
if (!converter) { if (!converter) {
log(["files"], `no converter found for ${file.name}`); log(["files"], `no converter found for ${file.name}`);
this.files.push(new VertFile(file, format)); this.files.push(new VertFile(file, format));

View File

@ -1,4 +1,4 @@
import { converters } from "$lib/converters"; import { byNative, converters } from "$lib/converters";
import type { Converter } from "$lib/converters/converter.svelte"; import type { Converter } from "$lib/converters/converter.svelte";
import { error } from "$lib/logger"; import { error } from "$lib/logger";
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
@ -28,18 +28,35 @@ export class VertFile {
public converters: Converter[] = []; public converters: Converter[] = [];
public findConverters(supportedFormats: string[] = [this.from]) { public findConverters(supportedFormats: string[] = [this.from]) {
const converter = this.converters.filter((converter) => const converter = this.converters
converter.formatStrings().map((f) => supportedFormats.includes(f)), .filter((converter) =>
); converter
.formatStrings()
.map((f) => supportedFormats.includes(f)),
)
.sort(byNative(this.from));
return converter; return converter;
} }
public findConverter() { public findConverter() {
const converter = this.converters.find( const converter = this.converters.find((converter) => {
(converter) => if (
converter.formatStrings().includes(this.from) && !converter.formatStrings().includes(this.from) ||
converter.formatStrings().includes(this.to), !converter.formatStrings().includes(this.to)
); ) {
return false;
}
const theirFrom = converter.supportedFormats.find(
(f) => f.name === this.from,
);
const theirTo = converter.supportedFormats.find(
(f) => f.name === this.to,
);
if (!theirFrom || !theirTo) return false;
if (!theirFrom.isNative && !theirTo.isNative) return false;
return true;
});
return converter; return converter;
} }

View File

@ -5,7 +5,7 @@
import Panel from "$lib/components/visual/Panel.svelte"; import Panel from "$lib/components/visual/Panel.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte"; import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import Tooltip from "$lib/components/visual/Tooltip.svelte"; import Tooltip from "$lib/components/visual/Tooltip.svelte";
import { categories, converters } from "$lib/converters"; import { categories, converters, byNative } from "$lib/converters";
import { import {
effects, effects,
files, files,
@ -52,21 +52,30 @@
$effect(() => { $effect(() => {
// Set gradient color depending on the file types // Set gradient color depending on the file types
// TODO: if more file types added, add a "fileType" property to the file object // TODO: if more file types added, add a "fileType" property to the file object
const allAudio = files.files.every( const allAudio = files.files.every((file) => {
(file) => file.findConverter()?.name === "ffmpeg", const converter = file
); .findConverters()
const allImages = files.files.every( .sort(byNative(file.from))[0];
(file) => return converter?.name === "ffmpeg";
file.findConverter()?.name !== "ffmpeg" && });
file.findConverter()?.name !== "vertd", const allImages = files.files.every((file) => {
); const converter = file
const allVideos = files.files.every( .findConverters()
(file) => file.findConverter()?.name === "vertd", .sort(byNative(file.from))[0];
); return converter?.name === "libvips";
});
const allDocuments = files.files.every( const allVideos = files.files.every((file) => {
(file) => file.findConverter()?.name === "pandoc", const converter = file
); .findConverters()
.sort(byNative(file.from))[0];
return converter?.name === "vertd";
});
const allDocuments = files.files.every((file) => {
const converter = file
.findConverters()
.sort(byNative(file.from))[0];
return converter?.name === "pandoc";
});
if (files.files.length === 1 && files.files[0].blobUrl && !allVideos) { if (files.files.length === 1 && files.files[0].blobUrl && !allVideos) {
showGradient.set(false); showGradient.set(false);
@ -76,7 +85,7 @@
if ( if (
files.files.length === 0 || files.files.length === 0 ||
(!allAudio && !allImages && !allVideos) (!allAudio && !allImages && !allVideos && !allDocuments)
) { ) {
gradientColor.set(""); gradientColor.set("");
} else { } else {
@ -96,7 +105,6 @@
</script> </script>
{#snippet fileItem(file: VertFile, index: number)} {#snippet fileItem(file: VertFile, index: number)}
{@const availableConverters = file.findConverters()}
{@const currentConverter = converters.find( {@const currentConverter = converters.find(
(c) => (c) =>
c.formatStrings((f) => f.fromSupported).includes(file.from) && c.formatStrings((f) => f.fromSupported).includes(file.from) &&
@ -104,11 +112,13 @@
)} )}
{@const isAudio = converters {@const isAudio = converters
.find((c) => c.name === "ffmpeg") .find((c) => c.name === "ffmpeg")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
{@const isVideo = converters {@const isVideo = converters
.find((c) => c.name === "vertd") .find((c) => c.name === "vertd")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
{@const isImage = converters {@const isImage = converters
.find((c) => c.name === "imagemagick") .find((c) => c.name === "imagemagick")
@ -116,7 +126,8 @@
.includes(file.from)} .includes(file.from)}
{@const isDocument = converters {@const isDocument = converters
.find((c) => c.name === "pandoc") .find((c) => c.name === "pandoc")
?.formatStrings((f) => f.fromSupported) ?.supportedFormats.filter((f) => f.isNative)
.map((f) => f.name)
.includes(file.from)} .includes(file.from)}
<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">