chore: refactor IFile to VertFile

This commit is contained in:
not-nullptr 2024-11-14 10:58:56 +00:00
parent b3d279604d
commit 6bd3efd6f0
13 changed files with 116 additions and 248 deletions

View File

@ -1,4 +1,4 @@
import type { IFile, OmitBetterStrict } from "$lib/types"; import type { VertFile } from "$lib/types";
/** /**
* Base class for all converters. * Base class for all converters.
@ -21,10 +21,10 @@ export class Converter {
public async convert( public async convert(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
input: OmitBetterStrict<IFile, "extension">, input: VertFile,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
to: string, to: string,
): Promise<IFile> { ): Promise<VertFile> {
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
} }

View File

@ -1,6 +1,5 @@
import type { IFile } from "$lib/types"; import { VertFile } from "$lib/types";
import { Converter } from "./converter.svelte"; import { Converter } from "./converter.svelte";
import type { OmitBetterStrict } from "$lib/types";
import { FFmpeg } from "@ffmpeg/ffmpeg"; import { FFmpeg } from "@ffmpeg/ffmpeg";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { log } from "$lib/logger"; import { log } from "$lib/logger";
@ -42,19 +41,23 @@ export class FFmpegConverter extends Converter {
})(); })();
} }
public async convert( public async convert(input: VertFile, to: string): Promise<VertFile> {
input: OmitBetterStrict<IFile, "extension">,
to: string,
): Promise<IFile> {
if (!to.startsWith(".")) to = `.${to}`; if (!to.startsWith(".")) to = `.${to}`;
const ffmpeg = new FFmpeg(); const ffmpeg = new FFmpeg();
ffmpeg.on("progress", (progress) => {
log(
["converters", this.name],
`progress for "${input.name}": ${progress.progress * 100}%`,
);
input.progress = progress.progress * 100;
});
const baseURL = const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm"; "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm";
await ffmpeg.load({ await ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`, coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`, wasmURL: `${baseURL}/ffmpeg-core.wasm`,
}); });
const buf = new Uint8Array(input.buffer); const buf = new Uint8Array(await input.file.arrayBuffer());
await ffmpeg.writeFile("input", buf); await ffmpeg.writeFile("input", buf);
log( log(
["converters", this.name], ["converters", this.name],
@ -70,10 +73,6 @@ export class FFmpegConverter extends Converter {
`read ${input.name.split(".").slice(0, -1).join(".") + to} from ffmpeg virtual fs`, `read ${input.name.split(".").slice(0, -1).join(".") + to} from ffmpeg virtual fs`,
); );
ffmpeg.terminate(); ffmpeg.terminate();
return { return new VertFile(new File([output], input.name), to);
...input,
buffer: output.buffer,
extension: to,
};
} }
} }

View File

@ -1,82 +0,0 @@
import type { IFile } from "$lib/types";
import { Converter } from "./converter.svelte";
import MagickWorker from "$lib/workers/magick?worker";
import { browser } from "$app/environment";
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
import { MagickFormat } from "@imagemagick/magick-wasm";
const sortFirst = [".png", ".jpeg", ".jpg", ".webp", ".gif"];
export class MagickConverter extends Converter {
private worker: Worker = browser ? new MagickWorker() : null!;
private id = 0;
public name = "imagemagick";
public ready = $state(false);
public supportedFormats = Object.keys(MagickFormat)
.map((key) => `.${key.toLowerCase()}`)
.sort((a, b) => {
const aIndex = sortFirst.indexOf(a);
const bIndex = sortFirst.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
constructor() {
super();
if (!browser) return;
this.worker.onmessage = (e) => {
const message: WorkerMessage = e.data;
if (message.type === "loaded") this.ready = true;
};
}
public async convert(
input: OmitBetterStrict<IFile, "extension">,
to: string,
): Promise<IFile> {
const res = await this.sendMessage({
type: "convert",
input: input as unknown as IFile,
to,
});
if (res.type === "finished") {
return res.output;
}
if (res.type === "error") {
throw new Error(res.error);
}
throw new Error("Unknown message type");
}
private sendMessage(
message: OmitBetterStrict<WorkerMessage, "id">,
): Promise<OmitBetterStrict<WorkerMessage, "id">> {
const id = this.id++;
let resolved = false;
return new Promise((resolve) => {
const onMessage = (e: MessageEvent) => {
if (e.data.id === id) {
this.worker.removeEventListener("message", onMessage);
resolve(e.data);
resolved = true;
}
};
setTimeout(() => {
if (!resolved) {
this.worker.removeEventListener("message", onMessage);
throw new Error("Timeout");
}
}, 60000);
this.worker.addEventListener("message", onMessage);
this.worker.postMessage({ ...message, id });
});
}
}

View File

@ -1,4 +1,4 @@
import type { IFile } from "$lib/types"; import { VertFile } from "$lib/types";
import { Converter } from "./converter.svelte"; import { Converter } from "./converter.svelte";
import VipsWorker from "$lib/workers/vips?worker"; import VipsWorker from "$lib/workers/vips?worker";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
@ -39,14 +39,11 @@ export class VipsConverter extends Converter {
}; };
} }
public async convert( public async convert(input: VertFile, to: string): Promise<VertFile> {
input: OmitBetterStrict<IFile, "extension">,
to: string,
): Promise<IFile> {
log(["converters", this.name], `converting ${input.name} to ${to}`); log(["converters", this.name], `converting ${input.name} to ${to}`);
const res = await this.sendMessage({ const res = await this.sendMessage({
type: "convert", type: "convert",
input: input as unknown as IFile, input,
to, to,
}); });

View File

@ -1,17 +1,8 @@
import { log } from "$lib/logger"; import { log } from "$lib/logger";
import type { IFile } from "$lib/types"; import { VertFile } from "$lib/types";
class Files { class Files {
public files = $state< public files = $state<VertFile[]>([]);
{
file: File;
from: string;
to: string;
blobUrl: string;
id: string;
result?: (IFile & { blobUrl: string; animating: boolean }) | null;
}[]
>([]);
} }
class Theme { class Theme {

View File

@ -1,14 +1,14 @@
import type { IFile } from "./file"; import { VertFile } from "./file.svelte";
interface ConvertMessage { interface ConvertMessage {
type: "convert"; type: "convert";
input: IFile; input: VertFile;
to: string; to: string;
} }
interface FinishedMessage { interface FinishedMessage {
type: "finished"; type: "finished";
output: IFile; output: VertFile;
} }
interface LoadedMessage { interface LoadedMessage {

View File

@ -0,0 +1,25 @@
export class VertFile {
public id: string = Math.random().toString(36).slice(2, 8);
public get from() {
return "." + this.file.name.split(".").pop()!;
}
public get name() {
return this.file.name;
}
public progress = $state(0);
// public result: VertFile | null = null;
public result = $state<VertFile | null>(null);
public to = $state("");
constructor(
public readonly file: File,
to: string,
public readonly blobUrl?: string,
) {
this.to = to;
}
}

View File

@ -1,5 +0,0 @@
export interface IFile {
name: string;
extension: string;
buffer: ArrayBuffer;
}

View File

@ -1,3 +1,3 @@
export * from "./file"; export * from "./file.svelte";
export * from "./util"; export * from "./util";
export * from "./conversion-worker"; export * from "./conversion-worker";

View File

@ -1,77 +0,0 @@
import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
import {
ImageMagick,
initializeImageMagick,
MagickFormat,
} from "@imagemagick/magick-wasm";
import wasmUrl from "@imagemagick/magick-wasm/magick.wasm?url";
const magickPromise = fetch(wasmUrl)
.then((r) => r.arrayBuffer())
.then((r) => initializeImageMagick(r));
magickPromise
.then(() => {
postMessage({ type: "loaded" });
})
.catch((error) => {
postMessage({ type: "error", error });
});
const handleMessage = async (
message: WorkerMessage,
): Promise<OmitBetterStrict<WorkerMessage, "id"> | undefined> => {
await magickPromise;
switch (message.type) {
case "convert": {
if (!message.to.startsWith(".")) message.to = `.${message.to}`;
message.to = message.to.slice(1);
// unfortunately this lib uses some hacks to dispose images when the promise is resolved
// this means we can't promisify it :(
return new Promise((resolve) => {
ImageMagick.read(
new Uint8Array(message.input.buffer),
(img) => {
const keys = Object.keys(MagickFormat);
const values = Object.values(MagickFormat);
const index = keys.findIndex(
(key) =>
key.toLowerCase() === message.to.toLowerCase(),
);
const format = values[index];
img.write(format, (output) => {
resolve({
type: "finished",
output: {
...message.input,
buffer: output,
extension: message.to,
},
});
});
img.dispose();
},
);
});
}
}
};
onmessage = async (e) => {
const message: WorkerMessage = e.data;
try {
const res = await handleMessage(message);
if (!res) return;
postMessage({
...res,
id: message.id,
});
} catch (e) {
postMessage({
type: "error",
error: e,
id: message.id,
});
}
};

View File

@ -1,4 +1,8 @@
import type { WorkerMessage, OmitBetterStrict } from "$lib/types"; import {
type WorkerMessage,
type OmitBetterStrict,
VertFile,
} from "$lib/types";
import Vips from "wasm-vips"; import Vips from "wasm-vips";
const vipsPromise = Vips({ const vipsPromise = Vips({
@ -21,16 +25,17 @@ const handleMessage = async (
switch (message.type) { switch (message.type) {
case "convert": { case "convert": {
if (!message.to.startsWith(".")) message.to = `.${message.to}`; if (!message.to.startsWith(".")) message.to = `.${message.to}`;
const image = vips.Image.newFromBuffer(message.input.buffer); const image = vips.Image.newFromBuffer(
await message.input.file.arrayBuffer(),
);
const output = image.writeToBuffer(message.to); const output = image.writeToBuffer(message.to);
image.delete(); image.delete();
return { return {
type: "finished", type: "finished",
output: { output: new VertFile(
...message.input, new File([output.buffer], message.input.name),
buffer: output.buffer, message.to,
extension: message.to, ),
},
}; };
} }
} }

View File

@ -4,6 +4,7 @@
import { converters } from "$lib/converters"; import { converters } from "$lib/converters";
import { log } from "$lib/logger/index.js"; import { log } from "$lib/logger/index.js";
import { files } from "$lib/store/index.svelte"; import { files } from "$lib/store/index.svelte";
import { VertFile } from "$lib/types/file.svelte.js";
import { Check } from "lucide-svelte"; import { Check } from "lucide-svelte";
const { data } = $props(); const { data } = $props();
@ -42,31 +43,58 @@
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height); ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
// get the blob // get the blob
canvas.toBlob( canvas.toBlob(
(blob) => { async (blob) => {
resolve({ // resolve({
file: f, // file: f,
from, // from,
// to,
// blobUrl:
// blob === null
// ? ""
// : URL.createObjectURL(blob),
// id: Math.random().toString(36).substring(2),
// buffer: await f.arrayBuffer(),
// extension: from,
// name: f.name,
// result: null,
// progress: 0,
// });
resolve(
new VertFile(
new File([blob!], f.name, {
type: blob!.type,
}),
to, to,
blobUrl: URL.createObjectURL(blob!),
blob === null ),
? "" );
: URL.createObjectURL(blob),
id: Math.random().toString(36).substring(2),
});
}, },
"image/jpeg", "image/jpeg",
0.75, 0.75,
); );
}; };
img.onerror = () => { img.onerror = async () => {
resolve({ // resolve({
file: f, // file: f,
from, // from,
// to,
// blobUrl: "",
// id: Math.random().toString(36).substring(2),
// name: f.name,
// buffer: await f.arrayBuffer(),
// extension: from,
// result: null,
// progress: 0,
// });
resolve(
new VertFile(
new File([await f.arrayBuffer()], f.name, {
type: f.type,
}),
to, to,
blobUrl: "", ),
id: Math.random().toString(36).substring(2), );
});
}; };
}, },
); );

View File

@ -81,25 +81,8 @@
if (!converter) throw new Error("No converter found"); if (!converter) throw new Error("No converter found");
const to = file.to; const to = file.to;
processings[i] = true; processings[i] = true;
const converted = await converter.convert( const converted = await converter.convert(file, to);
{ file.result = converted;
name: file.file.name,
buffer: await file.file.arrayBuffer(),
},
to,
);
files.files[i] = {
...file,
result: {
...converted,
blobUrl: URL.createObjectURL(
new Blob([converted.buffer], {
type: file.file.type,
}),
),
animating: true,
},
};
processings[i] = false; processings[i] = false;
})(), })(),
); );
@ -123,7 +106,7 @@
dlFiles.push({ dlFiles.push({
name: file.file.name.replace(/\.[^/.]+$/, "") + file.to, name: file.file.name.replace(/\.[^/.]+$/, "") + file.to,
lastModified: Date.now(), lastModified: Date.now(),
input: result.buffer, input: await result.file.arrayBuffer(),
}); });
} }
if (files.files.length === 0) return; if (files.files.length === 0) return;
@ -257,7 +240,7 @@
); );
})()} })()}
<div <div
class="w-full rounded-xl" class="w-full rounded-xl relative"
animate:flip={{ duration, easing: quintOut }} animate:flip={{ duration, easing: quintOut }}
out:blur={{ out:blur={{
duration, duration,
@ -267,7 +250,7 @@
> >
<div <div
class={clsx( class={clsx(
"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 overflow-hidden flex-shrink-0 items-center w-full rounded-xl",
{ {
"initial-fade": !finisheds[i], "initial-fade": !finisheds[i],
processing: processing:
@ -279,6 +262,10 @@
? 'var(--accent-bg)' ? 'var(--accent-bg)'
: 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);" : 'var(--fg-muted-alt)'}; transition: border 1000ms ease; transition: filter {duration}ms var(--transition), transform {duration}ms var(--transition);"
> >
<div
class="absolute top-0 left-0 bg-red-500 h-full"
style="width: {file.progress}%; transition: width 500ms linear;"
></div>
<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"
> >