diff --git a/src/lib/components/functional/FormatDropdown.svelte b/src/lib/components/functional/FormatDropdown.svelte
index da14d79..38bad18 100644
--- a/src/lib/components/functional/FormatDropdown.svelte
+++ b/src/lib/components/functional/FormatDropdown.svelte
@@ -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}
{}}
id="format-search"
autocomplete="off"
@@ -228,15 +319,25 @@
>
+ {#if searchQuery}
+
+ {filteredData.formats.length}
+ {filteredData.formats.length === 1
+ ? "result"
+ : "results"}
+
+ {/if}
-
{#each filteredData.categories as category}
{/each}
-
- {#each filteredData.formats as format}
-
- {/each}
+ onclick={() => selectOption(format)}
+ >
+ {format}
+
+ {/each}
+ {:else}
+
+ {searchQuery
+ ? "No formats match your search"
+ : "No formats available"}
+
+ {/if}
{/if}
diff --git a/src/lib/converters/converter.svelte.ts b/src/lib/converters/converter.svelte.ts
index 4c0fc85..f321b56 100644
--- a/src/lib/converters/converter.svelte.ts
+++ b/src/lib/converters/converter.svelte.ts
@@ -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(".")) {
diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts
index f7e9a61..cf943c5 100644
--- a/src/lib/converters/ffmpeg.svelte.ts
+++ b/src/lib/converters/ffmpeg.svelte.ts
@@ -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`,
);
- 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`);
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";
+ }
+};
diff --git a/src/lib/converters/index.ts b/src/lib/converters/index.ts
index 83c5fe8..6b9433d 100644
--- a/src/lib/converters/index.ts
+++ b/src/lib/converters/index.ts
@@ -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;
+ };
+};
diff --git a/src/lib/converters/magick.svelte.ts b/src/lib/converters/magick.svelte.ts
index a681a11..3a1ac0c 100644
--- a/src/lib/converters/magick.svelte.ts
+++ b/src/lib/converters/magick.svelte.ts
@@ -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),
diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts
index 66a0c94..8368335 100644
--- a/src/lib/store/index.svelte.ts
+++ b/src/lib/store/index.svelte.ts
@@ -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,11 +123,11 @@ 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}`);
this.files.push(new VertFile(file, format));
diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts
index 345d289..7983e94 100644
--- a/src/lib/types/file.svelte.ts
+++ b/src/lib/types/file.svelte.ts
@@ -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;
}
diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte
index f4b8a79..e5d0101 100644
--- a/src/routes/convert/+page.svelte
+++ b/src/routes/convert/+page.svelte
@@ -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 @@
{#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)}