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)}