feat: audio conversion support via ffmpeg

This commit is contained in:
not-nullptr 2024-11-13 16:18:16 +00:00
parent df4c009ac8
commit 61b43275ed
10 changed files with 199 additions and 136 deletions

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

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"; 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; result?: (IFile & { blobUrl: string; animating: boolean }) | null;
}[] }[]
>([]); >([]);
public conversionTypes = $state<string[]>([]);
public conversionTypesReverse = $derived(this.conversionTypes.reverse());
public beenToConverterPage = $state(false); public beenToConverterPage = $state(false);
public shouldShowAlert = $derived( public shouldShowAlert = $derived(
!this.beenToConverterPage && this.files.length > 0, !this.beenToConverterPage && this.files.length > 0,

View File

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

View File

@ -12,13 +12,22 @@
const runUpload = () => { const runUpload = () => {
files.files = [ files.files = [
...files.files, ...files.files,
...(ourFiles || []).map((f) => ({ ...(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, file: f,
from: "." + f.name.split(".").slice(-1), from,
to: converters[0].supportedFormats[0], to,
blobUrl: URL.createObjectURL(f), blobUrl: URL.createObjectURL(f),
id: Math.random().toString(36).substring(2), id: Math.random().toString(36).substring(2),
})), };
}),
]; ];
ourFiles = []; ourFiles = [];

View File

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

View File

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