diff --git a/bun.lockb b/bun.lockb index 124fa2b..70a4bc8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/app.css b/src/app.css index b49b970..443bde6 100644 --- a/src/app.css +++ b/src/app.css @@ -31,7 +31,8 @@ body { } .btn { - @apply font-display flex items-center justify-center overflow-hidden relative cursor-pointer px-4 border-2 border-solid bg-background border-foreground-muted-alt rounded-xl p-2 focus:!outline-none hover:scale-105 transition-all duration-200 active:scale-95; + @apply font-display flex items-center justify-center overflow-hidden relative cursor-pointer px-4 border-2 border-solid bg-background border-foreground-muted-alt rounded-xl p-2 focus:!outline-none hover:scale-105 duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none; + transition: opacity 0.2s ease, transform 0.2s ease, background-color 0.2s ease; } .btn-highlight { diff --git a/src/lib/components/functional/Dropdown.svelte b/src/lib/components/functional/Dropdown.svelte index ba867fa..cc1dc98 100644 --- a/src/lib/components/functional/Dropdown.svelte +++ b/src/lib/components/functional/Dropdown.svelte @@ -52,7 +52,7 @@ onclick={toggle} > -
+
{#key selected}

{/key} {#each options as option} -

+

{/each} diff --git a/src/lib/converters/converter.ts b/src/lib/converters/converter.svelte.ts similarity index 94% rename from src/lib/converters/converter.ts rename to src/lib/converters/converter.svelte.ts index 9c16dbc..1a1b27f 100644 --- a/src/lib/converters/converter.ts +++ b/src/lib/converters/converter.svelte.ts @@ -17,6 +17,7 @@ export class Converter { * @param input The input file. * @param to The format to convert to. Includes the dot. */ + public ready: boolean = $state(false); public async convert( // eslint-disable-next-line @typescript-eslint/no-unused-vars input: OmitBetterStrict, diff --git a/src/lib/converters/index.ts b/src/lib/converters/index.ts index 0f009af..1eef40e 100644 --- a/src/lib/converters/index.ts +++ b/src/lib/converters/index.ts @@ -1,3 +1,4 @@ -import { VipsConverter } from "./vips"; +import { MagickConverter } from "./magick.svelte"; +import { VipsConverter } from "./vips.svelte"; -export const converters = [new VipsConverter()]; +export const converters = [new VipsConverter(), new MagickConverter()]; diff --git a/src/lib/converters/magick.svelte.ts b/src/lib/converters/magick.svelte.ts new file mode 100644 index 0000000..283c96c --- /dev/null +++ b/src/lib/converters/magick.svelte.ts @@ -0,0 +1,73 @@ +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"; + +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()}`, + ); + + 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, + to: string, + ): Promise { + 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, + ): Promise> { + 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 }); + }); + } +} diff --git a/src/lib/converters/vips.ts b/src/lib/converters/vips.svelte.ts similarity index 72% rename from src/lib/converters/vips.ts rename to src/lib/converters/vips.svelte.ts index c73cef5..b56bb9f 100644 --- a/src/lib/converters/vips.ts +++ b/src/lib/converters/vips.svelte.ts @@ -1,30 +1,39 @@ import type { IFile } from "$lib/types"; -import { Converter } from "./converter"; +import { Converter } from "./converter.svelte"; import VipsWorker from "$lib/workers/vips?worker"; import { browser } from "$app/environment"; -import type { VipsWorkerMessage, OmitBetterStrict } from "$lib/types"; +import type { WorkerMessage, OmitBetterStrict } from "$lib/types"; export class VipsConverter extends Converter { private worker: Worker = browser ? new VipsWorker() : null!; private id = 0; public name = "libvips"; + public ready = $state(false); public supportedFormats = [ - ".jpg", - ".jpeg", - ".png", - ".webp", - ".tiff", - ".tif", ".gif", - ".jfif", - ".avif", + ".hdr", + ".jpe", + ".jpeg", + ".jpg", + ".mat", + ".pbm", + ".pfm", + ".pgm", + ".png", + ".pnm", + ".ppm", + ".raw", + ".tif", + ".tiff", + ".webp", ]; constructor() { super(); if (!browser) return; this.worker.onmessage = (e) => { - console.log(e.data); + const message: WorkerMessage = e.data; + if (message.type === "loaded") this.ready = true; }; } @@ -42,12 +51,16 @@ export class VipsConverter extends Converter { return res.output; } + if (res.type === "error") { + throw new Error(res.error); + } + throw new Error("Unknown message type"); } private sendMessage( - message: OmitBetterStrict, - ): Promise> { + message: OmitBetterStrict, + ): Promise> { const id = this.id++; let resolved = false; return new Promise((resolve) => { @@ -64,7 +77,7 @@ export class VipsConverter extends Converter { this.worker.removeEventListener("message", onMessage); throw new Error("Timeout"); } - }, 20000); + }, 60000); this.worker.addEventListener("message", onMessage); diff --git a/src/lib/types/conversion-worker.ts b/src/lib/types/conversion-worker.ts new file mode 100644 index 0000000..1a73b7a --- /dev/null +++ b/src/lib/types/conversion-worker.ts @@ -0,0 +1,30 @@ +import type { IFile } from "./file"; + +interface ConvertMessage { + type: "convert"; + input: IFile; + to: string; +} + +interface FinishedMessage { + type: "finished"; + output: IFile; +} + +interface LoadedMessage { + type: "loaded"; +} + +interface ErrorMessage { + type: "error"; + error: string; +} + +export type WorkerMessage = ( + | ConvertMessage + | FinishedMessage + | LoadedMessage + | ErrorMessage +) & { + id: number; +}; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 947c3eb..f1c0a1e 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,3 +1,3 @@ export * from "./file"; export * from "./util"; -export * from "./vips-worker"; +export * from "./conversion-worker"; diff --git a/src/lib/types/vips-worker.ts b/src/lib/types/vips-worker.ts deleted file mode 100644 index 8358a42..0000000 --- a/src/lib/types/vips-worker.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IFile } from "./file"; - -interface VipsConvertMessage { - type: "convert"; - input: IFile; - to: string; -} - -interface VipsFinishedMessage { - type: "finished"; - output: IFile; -} - -export type VipsWorkerMessage = (VipsConvertMessage | VipsFinishedMessage) & { - id: number; -}; diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts new file mode 100644 index 0000000..9fb783c --- /dev/null +++ b/src/lib/workers/magick.ts @@ -0,0 +1,79 @@ +import type { WorkerMessage, OmitBetterStrict } from "$lib/types"; +import { + ImageMagick, + initializeImageMagick, + MagickFormat, +} 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)); + +magickPromise + .then(() => { + postMessage({ type: "loaded" }); + }) + .catch((error) => { + postMessage({ type: "error", error }); + }); + +const handleMessage = async ( + message: WorkerMessage, +): Promise | 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, + }); + } +}; diff --git a/src/lib/workers/vips.ts b/src/lib/workers/vips.ts index 8b59997..fbf5dbc 100644 --- a/src/lib/workers/vips.ts +++ b/src/lib/workers/vips.ts @@ -1,7 +1,10 @@ -import type { VipsWorkerMessage, OmitBetterStrict } from "$lib/types"; +import type { WorkerMessage, OmitBetterStrict } from "$lib/types"; import Vips from "wasm-vips"; -const vipsPromise = Vips(); +const vipsPromise = Vips({ + // see https://github.com/kleisauke/wasm-vips/issues/85 + dynamicLibraries: [], +}); vipsPromise .then(() => { @@ -12,8 +15,8 @@ vipsPromise }); const handleMessage = async ( - message: VipsWorkerMessage, -): Promise | undefined> => { + message: WorkerMessage, +): Promise | undefined> => { const vips = await vipsPromise; switch (message.type) { case "convert": { @@ -34,11 +37,19 @@ const handleMessage = async ( }; onmessage = async (e) => { - const message: VipsWorkerMessage = e.data; - const res = await handleMessage(message); - if (!res) return; - postMessage({ - ...res, - id: message.id, - }); + 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, + }); + } }; diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte index 4e4b2e5..3c2f2d7 100644 --- a/src/routes/convert/+page.svelte +++ b/src/routes/convert/+page.svelte @@ -33,29 +33,34 @@ }); const convertAll = async () => { - // for (let i = 0; i < files.files.length; i++) { - // const file = files.files[i]; - // const to = files.conversionTypes[i]; - // const converter = converters.find( - // (c) => c.name === files.conversionTypes[i], - // ); - // if (!converter) { - // console.error("Converter not found"); - // continue; - // } - // const converted = await converter.convert({ - // name: file.file.name, - // buffer: await file.file.arrayBuffer(), - // }, to); - // files.files[i] = { - // ...file, - // file: new File([converted.buffer], file.file.name, { - // type: file.file.type, + // 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); + // } // }), - // blobUrl: URL.createObjectURL(new Blob([converted.buffer], { type: file.file.type })), - // }; + // ); + // } catch { + // console.error("Failed to convert to any format"); // } - + // console.log(workingFormats); + // return; const promises: Promise[] = []; for (let i = 0; i < files.files.length; i++) { const file = files.files[i]; @@ -187,6 +192,15 @@ (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], + ); + }} />
@@ -197,15 +211,21 @@ class={clsx("btn flex-grow", { "btn-highlight": disabled, })} - >Convert{files.files.length > 1 ? " All" : ""} + {#if converter.ready} + Convert {files.files.length > 1 ? "All" : ""} + {:else} + Loading... + {/if} + Download {files.files.length > 1 ? "All" : ""} @@ -289,6 +309,10 @@ files.files = files.files.filter( (f) => f !== file, ); + files.conversionTypes = + files.conversionTypes.filter( + (_, j) => j !== i, + ); }} class="ml-2 mr-1" > diff --git a/static/magick.wasm b/static/magick.wasm new file mode 100644 index 0000000..626cd6d Binary files /dev/null and b/static/magick.wasm differ