feat: ffmpeg support (+ many bugfixes and refactors) (#5)

* Separate menu into custom component

* feat: audio conversion support via ffmpeg

---------

Co-authored-by: Realmy <163438634+RealmyTheMan@users.noreply.github.com>
This commit is contained in:
nullptr 2024-11-13 16:30:13 +00:00 committed by GitHub
parent b724f066ba
commit 00a855e590
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 307 additions and 203 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -32,6 +32,8 @@
"vite": "^5.0.3"
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@fontsource/azeret-mono": "^5.1.0",
"@fontsource/lexend": "^5.1.1",
"@imagemagick/magick-wasm": "^0.0.31",

View File

@ -67,7 +67,6 @@ export const blur = (
).matches;
if (typeof config?.opacity === "undefined" && config) config.opacity = true;
const isUsingTranslate = !!config?.x || !!config?.y || !!config?.scale;
console.log(isUsingTranslate);
return {
delay: config?.delay || 0,
duration: prefersReducedMotion ? 0 : config?.duration || 300,
@ -155,7 +154,6 @@ export function flip(
const [ox, oy] = style.transformOrigin.split(" ").map(parseFloat);
const dx = from.left + (from.width * ox) / to.width - (to.left + ox);
const dy = from.top + (from.height * oy) / to.height - (to.top + oy);
const {
delay = 0,
duration = (d) => Math.sqrt(d) * 120,

View File

@ -0,0 +1,76 @@
<script lang="ts">
import { page } from "$app/stores";
import { fly } from "svelte/transition";
import { duration } from "$lib/animation";
import { quintOut } from "svelte/easing";
import type { Writable } from "svelte/store";
interface Props {
links: {
name: string;
url: string;
activeMatch: (pathname: string) => boolean;
}[];
shouldGoBack: Writable<boolean> | null;
}
let { links, shouldGoBack = null }: Props = $props();
let navWidth = $state(1);
let linkCount = $derived(links.length);
let activeLinkIndex = $derived(
links.findIndex((i) => i.activeMatch($page.url.pathname)),
);
</script>
<div
bind:clientWidth={navWidth}
class="w-full flex bg-background relative h-16"
>
{#if activeLinkIndex !== -1}
<div
class="absolute pointer-events-none top-1 bg-foreground h-[calc(100%-8px)] rounded-xl"
style="width: {navWidth / linkCount - 8}px; left: {(navWidth /
linkCount) *
activeLinkIndex +
4}px; transition: {duration - 200}ms ease left;"
></div>
{/if}
{#each links as { name, url } (url)}
<a
class="w-1/2 px-2 h-[calc(100%-16px)] mt-2 flex items-center justify-center rounded-xl relative font-display overflow-hidden"
href={url}
onclick={() => {
if (shouldGoBack) {
const currentIndex = links.findIndex((i) =>
i.activeMatch($page.url.pathname),
);
const nextIndex = links.findIndex((i) =>
i.activeMatch(url),
);
$shouldGoBack = nextIndex < currentIndex;
}
}}
>
<div class="grid grid-cols-1 grid-rows-1">
{#key name}
<span
class="mix-blend-difference invert col-start-1 row-start-1 text-center"
in:fly={{
duration,
easing: quintOut,
y: -50,
}}
out:fly={{
duration,
easing: quintOut,
y: 50,
}}
>
{name}
</span>
{/key}
</div>
</a>
{/each}
</div>

View File

@ -0,0 +1,61 @@
import type { IFile } from "$lib/types";
import { Converter } from "./converter.svelte";
import type { OmitBetterStrict } from "$lib/types";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { browser } from "$app/environment";
export class FFmpegConverter extends Converter {
private ffmpeg: FFmpeg = null!;
public name = "ffmpeg";
public ready = $state(false);
public supportedFormats = [
".mp3",
".wav",
".flac",
".ogg",
".aac",
".m4a",
".opus",
".wma",
".m4a",
".amr",
".ac3",
"alac",
".aiff",
];
constructor() {
super();
if (!browser) return;
this.ffmpeg = new FFmpeg();
(async () => {
const baseURL = "https://unpkg.com/@ffmpeg/core@latest/dist/esm";
await this.ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
});
this.ready = true;
})();
}
public async convert(
input: OmitBetterStrict<IFile, "extension">,
to: string,
): Promise<IFile> {
if (!to.startsWith(".")) to = `.${to}`;
// clone input.buffer
const buf = new Uint8Array(input.buffer);
await this.ffmpeg.writeFile("input", buf);
await this.ffmpeg.exec(["-i", "input", "output" + to]);
const output = (await this.ffmpeg.readFile(
"output" + to,
)) as unknown as Uint8Array;
return {
...input,
buffer: output.buffer,
extension: to,
};
}
}

View File

@ -1,3 +1,4 @@
import { FFmpegConverter } from "./ffmpeg.svelte";
import { VipsConverter } from "./vips.svelte";
export const converters = [new VipsConverter()];
export const converters = [new VipsConverter(), new FFmpegConverter()];

View File

@ -11,8 +11,6 @@ class Files {
result?: (IFile & { blobUrl: string; animating: boolean }) | null;
}[]
>([]);
public conversionTypes = $state<string[]>([]);
public conversionTypesReverse = $derived(this.conversionTypes.reverse());
public beenToConverterPage = $state(false);
public shouldShowAlert = $derived(
!this.beenToConverterPage && this.files.length > 0,

View File

@ -6,8 +6,6 @@ import {
} from "@imagemagick/magick-wasm";
import wasmUrl from "@imagemagick/magick-wasm/magick.wasm?url";
console.log(wasmUrl);
const magickPromise = fetch(wasmUrl)
.then((r) => r.arrayBuffer())
.then((r) => initializeImageMagick(r));

View File

@ -5,28 +5,40 @@
import { quintOut } from "svelte/easing";
import { files } from "$lib/store/index.svelte";
import Logo from "$lib/components/visual/svg/Logo.svelte";
import { fly } from "svelte/transition";
import featuredImage from "$lib/assets/VERT_Feature.webp";
import { PUB_HOSTNAME, PUB_PLAUSIBLE_URL } from "$env/static/public";
import FancyMenu from "$lib/components/functional/FancyMenu.svelte";
import { writable } from "svelte/store";
let { children, data } = $props();
let navWidth = $state(1);
let shouldGoBack = $state(false);
let shouldGoBack = writable(false);
const links = $derived<{
[key: string]: string;
}>({
Upload: "/",
[files.files.length > 0
? `Convert (${files.files.length})`
: `Convert`]: "/convert",
About: "/about",
});
const linkCount = $derived(Object.keys(links).length);
const linkIndex = $derived(
Object.keys(links).findIndex((link) => links[link] === data.pathname),
);
const links = $derived<
{
name: string;
url: string;
activeMatch: (pathname: string) => boolean;
}[]
>([
{
name: "Upload",
url: "/",
activeMatch: (pathname) => pathname === "/",
},
{
name:
files.files.length > 0
? `Convert (${files.files.length})`
: `Convert`,
url: "/convert",
activeMatch: (pathname) => pathname === "/convert",
},
{
name: "About",
url: "/about",
activeMatch: (pathname) => pathname.startsWith("/about"),
},
]);
const maybeNavToHome = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) {
@ -78,54 +90,7 @@
</a>
</div>
<div
bind:clientWidth={navWidth}
class="w-full flex bg-background relative h-16"
>
<div
class="absolute pointer-events-none top-1 bg-foreground h-[calc(100%-8px)] rounded-xl"
style="width: {navWidth / linkCount - 8}px; left: {(navWidth /
linkCount) *
linkIndex +
4}px; transition: {duration - 200}ms ease left;"
></div>
{#each Object.entries(links) as [name, link] (link)}
<a
class="w-1/2 px-2 h-[calc(100%-16px)] mt-2 flex items-center justify-center rounded-xl relative font-display overflow-hidden"
href={link}
onclick={() => {
const keys = Object.keys(links);
const currentIndex = keys.findIndex(
(key) => links[key] === data.pathname,
);
const nextIndex = keys.findIndex(
(key) => links[key] === link,
);
shouldGoBack = nextIndex < currentIndex;
}}
>
<div class="grid grid-cols-1 grid-rows-1">
{#key name}
<span
class="mix-blend-difference invert col-start-1 row-start-1 text-center"
in:fly={{
duration,
easing: quintOut,
y: -50,
}}
out:fly={{
duration,
easing: quintOut,
y: 50,
}}
>
{name}
</span>
{/key}
</div>
</a>
{/each}
</div>
<FancyMenu {links} {shouldGoBack} />
</div>
<div class="w-full max-w-screen-lg grid grid-cols-1 grid-rows-1 relative">
{#key data.pathname}
@ -137,7 +102,7 @@
easing: quintOut,
blurMultiplier: 12,
x: {
start: !shouldGoBack ? 250 : -250,
start: !$shouldGoBack ? 250 : -250,
end: 0,
},
scale: {
@ -151,7 +116,7 @@
blurMultiplier: 12,
x: {
start: 0,
end: !shouldGoBack ? -250 : 250,
end: !$shouldGoBack ? -250 : 250,
},
scale: {
start: 1,

View File

@ -12,13 +12,22 @@
const runUpload = () => {
files.files = [
...files.files,
...(ourFiles || []).map((f) => ({
file: f,
from: "." + f.name.split(".").slice(-1),
to: converters[0].supportedFormats[0],
blobUrl: URL.createObjectURL(f),
id: Math.random().toString(36).substring(2),
})),
...(ourFiles || []).map((f, i) => {
const from = "." + f.name.toLowerCase().split(".").slice(-1);
const converter = converters.find((c) =>
c.supportedFormats.includes(from),
);
const to =
converter?.supportedFormats.find((f) => f !== from) ||
converters[0].supportedFormats[0];
return {
file: f,
from,
to,
blobUrl: URL.createObjectURL(f),
id: Math.random().toString(36).substring(2),
};
}),
];
ourFiles = [];

View File

@ -1,15 +1,17 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { blur, duration, flip } from "$lib/animation";
import Dropdown from "$lib/components/functional/Dropdown.svelte";
import ProgressiveBlur from "$lib/components/visual/effects/ProgressiveBlur.svelte";
import { converters } from "$lib/converters";
import type { Converter } from "$lib/converters/converter.svelte";
import { files } from "$lib/store/index.svelte";
import clsx from "clsx";
import { ArrowRight, XIcon } from "lucide-svelte";
import { onMount } from "svelte";
import { quintOut } from "svelte/easing";
const reversed = $derived(files.files.slice().reverse());
const reversedFiles = $derived(files.files.slice().reverse());
let finisheds = $state(
Array.from({ length: files.files.length }, () => false),
@ -17,6 +19,33 @@
let isSm = $state(false);
let processings = $state<boolean[]>([]);
const convertersRequired = $derived.by(() => {
const required: Converter[] = [];
for (let i = 0; i < files.files.length; i++) {
const file = files.files[i];
const converter = converters.find(
(c) =>
c.supportedFormats.includes(file.from) &&
c.supportedFormats.includes(file.to),
);
if (!converter) throw new Error("No converter found");
required.push(converter);
}
return Array.from(new Set(required));
});
const multipleConverters = $derived(convertersRequired.length > 1);
const noMultConverter = $derived(
multipleConverters ? null : convertersRequired[0],
);
const allConvertersReady = $derived(
convertersRequired.every((c) => c.ready),
);
onMount(() => {
isSm = window.innerWidth < 640;
window.addEventListener("resize", () => {
@ -24,10 +53,6 @@
});
});
let converterName = $state(converters[0].name);
let converter = $derived(converters.find((c) => c.name === converterName))!;
let disabled = $derived(files.files.some((f) => !f.result));
onMount(() => {
@ -35,46 +60,25 @@
const duration = 575 + i * 50 - 32;
setTimeout(() => {
finisheds[i] = true;
console.log(`finished ${i}`);
}, duration);
});
});
const convertAll = async () => {
// if (!converter.ready) return;
// const workingFormats: string[] = [];
// try {
// await Promise.all(
// converter.supportedFormats.map(async (format) => {
// try {
// const img = files.files[0];
// if (!img) return;
// console.log(`Converting to ${format}`);
// await converter.convert(
// {
// name: img.file.name,
// buffer: await img.file.arrayBuffer(),
// },
// format,
// );
// console.log(`Converted to ${format}`);
// workingFormats.push(format);
// } catch (e: any) {
// console.error(e);
// }
// }),
// );
// } catch {
// console.error("Failed to convert to any format");
// }
// console.log(workingFormats);
// return;
files.files.forEach((f) => (f.result = null));
const promises: Promise<void>[] = [];
for (let i = 0; i < files.files.length; i++) {
const file = files.files[i];
const to = files.conversionTypes[i];
promises.push(
(async () => {
const file = files.files[i];
const converter = converters.find(
(c) =>
c.supportedFormats.includes(file.from) &&
c.supportedFormats.includes(file.to),
);
if (!converter) throw new Error("No converter found");
const to = file.to;
processings[i] = true;
const converted = await converter.convert(
{
name: file.file.name,
@ -94,18 +98,12 @@
animating: true,
},
};
await new Promise((r) => setTimeout(r, 750));
if (
files.files[i].result !== null &&
files.files[i].result !== undefined
)
files.files[i].result!.animating = false;
processings[i] = false;
})(),
);
}
await Promise.all(promises);
console.log("done");
};
const downloadAll = async () => {
@ -118,9 +116,7 @@
continue;
}
dlFiles.push({
name:
file.file.name.replace(/\.[^/.]+$/, "") +
files.conversionTypes[i],
name: file.file.name.replace(/\.[^/.]+$/, "") + file.to,
lastModified: Date.now(),
input: result.buffer,
});
@ -136,7 +132,7 @@
const a = document.createElement("a");
a.href = blob;
a.download = `VERT-Converted_${new Date().toISOString()}${
files.conversionTypes[0]
files.files[0].to
}`;
a.click();
URL.revokeObjectURL(blob);
@ -180,56 +176,60 @@
class="w-full p-4 max-w-screen-lg border-solid flex-col border-2 rounded-2xl border-foreground-muted-alt flex flex-shrink-0"
>
<h2 class="font-bold text-xl mb-1">Options</h2>
<div class="flex flex-col mb-1 w-full gap-4 mt-2">
<div class="flex flex-col w-full gap-4 mt-2">
<div class="flex flex-col gap-3 w-fit">
<h3>Set all target formats</h3>
<Dropdown
options={converter.supportedFormats}
onselect={(o) => {
files.conversionTypes = Array.from(
{ length: files.files.length },
() => o,
);
<div class="grid grid-rows-1 grid-cols-1">
{#if !multipleConverters && noMultConverter}
<div
transition:blur={{
blurMultiplier: 8,
duration,
easing: quintOut,
}}
class="row-start-1 col-start-1 w-fit"
>
<Dropdown
options={noMultConverter.supportedFormats}
onselect={(o) => {
// files.conversionTypes = Array.from(
// { length: files.files.length },
// () => o,
// );
files.files.forEach((file) => {
file.result = null;
});
}}
/>
files.files.forEach((file) => {
file.result = null;
file.to = o;
});
}}
/>
</div>
{:else}
<div
class="italic w-fit text-foreground-muted-alt h-11 flex items-center row-start-1 col-start-1"
transition:blur={{
blurMultiplier: 8,
duration,
easing: quintOut,
}}
>
The listed files require different
converters, so you can't set them in bulk.
</div>
{/if}
</div>
</div>
</div>
<h2 class="font-bold text-base mb-1 mt-6">Advanced</h2>
<div class="flex flex-col gap-4 mt-2">
<div class="flex flex-col gap-3 w-fit">
<h3>Converter backend</h3>
<Dropdown
options={converters.map(
(converter) => converter.name,
)}
bind:selected={converterName}
onselect={() => {
files.files.forEach((file) => {
file.result = null;
});
files.conversionTypes = Array.from(
{ length: files.files.length },
() => converter.supportedFormats[0],
);
}}
/>
</div>
</div>
<div class="grid md:grid-cols-2 gap-3 mt-8">
<div class="grid md:grid-cols-2 gap-3 mt-4">
<button
onclick={convertAll}
class={clsx("btn flex-grow", {
"btn-highlight": disabled,
})}
disabled={!converter.ready}
disabled={!allConvertersReady}
>
{#if converter.ready}
{#if allConvertersReady}
Convert {files.files.length > 1 ? "All" : ""}
{:else}
Loading...
@ -245,11 +245,14 @@
>
</div>
</div>
{#each reversed as file, i (file.id)}
{#each reversedFiles as file, i (file.id)}
{@const converter = (() => {
return converters.find((c) =>
c.supportedFormats.includes(file.from),
);
})()}
<div
class={clsx("w-full rounded-xl", {
"finished-anim": file.result?.animating,
})}
class="w-full rounded-xl"
animate:flip={{ duration, easing: quintOut }}
out:blur={{
duration,
@ -262,12 +265,14 @@
"sm:h-16 sm:py-0 py-4 px-3 flex relative flex-shrink-0 items-center w-full rounded-xl",
{
"initial-fade": !finisheds[i],
processing:
processings[files.files.length - i - 1],
},
)}
style="--delay: {i * 50}ms; z-index: {files.files
.length - i}; border: solid 3px {file.result
? 'var(--accent-bg)'
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease;"
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
>
<div
class="flex gap-8 sm:gap-0 sm:flex-row flex-col items-center justify-between w-full z-50 relative sm:h-fit h-full"
@ -288,7 +293,7 @@
<div
class="flex items-center gap-3 sm:justify-normal w-full sm:w-fit flex-shrink-0"
>
{#if converter.supportedFormats.includes(file.from)}
{#if converter && converter.supportedFormats.includes(file.from)}
<span class="sm:block hidden">from</span>
<span
class="py-2 px-3 font-display bg-foreground text-background rounded-xl sm:block hidden"
@ -298,10 +303,9 @@
<div class="sm:block hidden">
<Dropdown
options={converter.supportedFormats}
bind:selected={files
.conversionTypes[
bind:selected={files.files[
files.files.length - i - 1
]}
].to}
onselect={() => {
file.result = null;
}}
@ -324,10 +328,9 @@
<div class="w-full sm:hidden block h-full">
<Dropdown
options={converter.supportedFormats}
bind:selected={files
.conversionTypes[
bind:selected={files.files[
files.files.length - 1 - i
]}
].to}
onselect={() => {
file.result = null;
}}
@ -349,10 +352,7 @@
files.files = files.files.filter(
(f) => f !== file,
);
files.conversionTypes =
files.conversionTypes.filter(
(_, j) => j !== i,
);
if (files.files.length === 0) goto("/");
}}
class="ml-2 mr-1 sm:block hidden"
>
@ -360,7 +360,7 @@
</button>
</div>
</div>
{#if converter.supportedFormats.includes(file.from)}
{#if converter && converter.supportedFormats.includes(file.from)}
<!-- god knows why, but setting opacity > 0.98 causes a z-ordering issue in firefox ??? -->
<div
class="absolute top-0 -z-50 left-0 w-full h-full rounded-[10px] overflow-hidden opacity-[0.98]"
@ -404,23 +404,6 @@
}
}
@keyframes finished-animation {
0% {
transform: scale(1);
filter: blur(0);
}
50% {
transform: scale(1.02);
filter: blur(4px);
}
100% {
transform: scale(1);
filter: blur(0);
}
}
.initial-fade {
animation: initial-transition 600ms var(--delay) var(--transition);
opacity: 0;
@ -431,7 +414,15 @@
opacity: 1 !important;
}
.finished-anim {
animation: finished-animation 750ms var(--transition);
.processing {
transform: scale(1.05);
filter: blur(4px);
pointer-events: none;
}
.file-list {
transition:
filter 500ms var(--transition),
transform 500ms var(--transition);
}
</style>

View File

@ -19,6 +19,11 @@ export default defineConfig({
},
],
optimizeDeps: {
exclude: ["wasm-vips"],
exclude: [
"wasm-vips",
"@ffmpeg/core-mt",
"@ffmpeg/ffmpeg",
"@ffmpeg/util",
],
},
});