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 || ""),
);
});
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 normalize = (str: string) => str.replace(/^\./, "").toLowerCase();
// if no query, return formats for current category
if (!searchQuery) {
return {
categories: availableCategories,
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) =>
categories[cat].formats.some((format) =>
format.toLowerCase().includes(searchQuery.toLowerCase()),
categories[cat].formats.some(
(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
const filteredFormats =
currentCategory && categories[currentCategory]
? categories[currentCategory].formats.filter((format) =>
format
.toLowerCase()
.includes(searchQuery.toLowerCase()),
// if current category has no matches, switch to first category that does
const currentCategoryHasMatches =
currentCategory &&
matchingCategories.some((cat) => cat === currentCategory);
if (!currentCategoryHasMatches && matchingCategories.length > 0) {
const newCategory = matchingCategories[0];
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 {
categories: matchingCategories,
categories:
matchingCategories.length > 0
? matchingCategories
: availableCategories,
formats: filteredFormats,
};
});
@ -98,6 +142,21 @@
const selectOption = (option: string) => {
selected = option;
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);
};
@ -107,7 +166,39 @@
};
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 = () => {
@ -201,7 +292,6 @@
{#if open}
<div
bind:this={dropdownMenu}
style={hover ? "will-change: opacity, fade, transform" : ""}
transition:fade={{
duration,
easing: quintOut,
@ -219,6 +309,7 @@
class="flex-grow w-full !pl-11 !pr-3 rounded-lg bg-panel text-foreground"
bind:value={searchQuery}
oninput={handleSearch}
onkeydown={onEnter}
onfocus={() => {}}
id="format-search"
autocomplete="off"
@ -228,15 +319,25 @@
>
<SearchIcon class="w-4 h-4" />
</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>
<!-- available categories -->
<div class="flex items-center justify-between">
{#each filteredData.categories as category}
<button
class="flex-grow text-lg hover:text-muted/20 border-b-[1px] pb-2 capitalize {currentCategory ===
category
class="flex-grow text-lg hover:text-muted/20 border-b-[1px] pb-2 capitalize
{currentCategory === category
? 'text-accent border-b-accent'
: 'border-b-separator text-muted'}"
onclick={() => selectCategory(category)}
@ -245,21 +346,26 @@
</button>
{/each}
</div>
<!-- available formats -->
<div class="max-h-80 overflow-y-auto grid grid-cols-3 gap-2 p-2">
{#if filteredData.formats.length > 0}
{#each filteredData.formats as format}
<button
class="w-full p-2 text-center rounded-xl
{format === selected
? 'bg-accent text-black'
: 'hover:bg-panel'}
{format === selected ? 'bg-accent text-black' : 'hover:bg-panel'}
{format === from ? 'bg-separator' : ''}"
onclick={() => selectOption(format)}
>
{format}
</button>
{/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>
{/if}

View File

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

View File

@ -6,6 +6,8 @@ import { error, log } from "$lib/logger";
import { addToast } from "$lib/store/ToastProvider";
import { m } from "$lib/paraglide/messages";
const videoFormats = [".mkv", ".mp4", ".avi", ".mov", ".webm", ".ts", ".mts", ".m2ts", ".wmv"];
export class FFmpegConverter extends Converter {
private ffmpeg: FFmpeg = null!;
public name = "ffmpeg";
@ -23,9 +25,10 @@ export class FFmpegConverter extends Converter {
new FormatInfo("wma", true, true),
new FormatInfo("amr", true, true),
new FormatInfo("ac3", true, true),
new FormatInfo("alac", true, false),
new FormatInfo("alac", true, true),
new FormatInfo("aiff", true, true),
new FormatInfo("aif", true, true),
...videoFormats.map((f) => new FormatInfo(f, true, true, false)),
];
public readonly reportsProgress = true;
@ -61,6 +64,17 @@ export class FFmpegConverter extends Converter {
ffmpeg.on("progress", (progress) => {
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 =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
await ffmpeg.load({
@ -73,7 +87,37 @@ export class FFmpegConverter extends Converter {
["converters", this.name],
`wrote ${input.name} to ffmpeg virtual fs`,
);
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`);
const output = (await ffmpeg.readFile(
"output" + to,
@ -86,3 +130,49 @@ export class FFmpegConverter extends Converter {
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 { Converter } from "./converter.svelte";
import { FFmpegConverter } from "./ffmpeg.svelte";
import { PandocConverter } from "./pandoc.svelte";
import { VertdConverter } from "./vertd.svelte";
@ -22,19 +23,21 @@ export function getConverterByFormat(format: string) {
export const categories: Categories = {
image: { formats: [""], canConvertTo: [] },
video: { formats: [""], canConvertTo: [] }, // add "audio" when "nullptr/experimental-audio-to-video" is implemented
audio: { formats: [""], canConvertTo: [] }, // add "video" when "nullptr/experimental-audio-to-video" is implemented
video: { formats: [""], canConvertTo: ["audio"] },
audio: { formats: [""], canConvertTo: ["video"] },
docs: { formats: [""], canConvertTo: [] },
};
categories.audio.formats =
converters
.find((c) => c.name === "ffmpeg")
?.formatStrings((f) => f.toSupported) || [];
?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];
categories.video.formats =
converters
.find((c) => c.name === "vertd")
?.formatStrings((f) => f.toSupported) || [];
?.supportedFormats.filter((f) => f.toSupported && f.isNative)
.map((f) => f.name) || [];
categories.image.formats =
converters
.find((c) => c.name === "imagemagick")
@ -42,4 +45,18 @@ categories.image.formats =
categories.docs.formats =
converters
.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("nef", true, false),
new FormatInfo("cr2", true, false),
new FormatInfo("hdr", true, true),
new FormatInfo("jpe", true, true),
new FormatInfo("hdr"),
new FormatInfo("jpe"),
new FormatInfo("dng", true, false),
new FormatInfo("mat", true, true),
new FormatInfo("pbm", true, true),

View File

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

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 { error } from "$lib/logger";
import { m } from "$lib/paraglide/messages";
@ -28,18 +28,35 @@ export class VertFile {
public converters: Converter[] = [];
public findConverters(supportedFormats: string[] = [this.from]) {
const converter = this.converters.filter((converter) =>
converter.formatStrings().map((f) => supportedFormats.includes(f)),
);
const converter = this.converters
.filter((converter) =>
converter
.formatStrings()
.map((f) => supportedFormats.includes(f)),
)
.sort(byNative(this.from));
return converter;
}
public findConverter() {
const converter = this.converters.find(
(converter) =>
converter.formatStrings().includes(this.from) &&
converter.formatStrings().includes(this.to),
const converter = this.converters.find((converter) => {
if (
!converter.formatStrings().includes(this.from) ||
!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;
}

View File

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