feat: imagemagick conversion

This commit is contained in:
not-nullptr 2024-11-12 15:56:17 +00:00
parent d3da9c122f
commit f31032c610
14 changed files with 294 additions and 75 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -31,7 +31,8 @@ body {
} }
.btn { .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 { .btn-highlight {

View File

@ -52,7 +52,7 @@
onclick={toggle} onclick={toggle}
> >
<!-- <p>{selected}</p> --> <!-- <p>{selected}</p> -->
<div class="grid grid-cols-1 grid-rows-1 w-fit"> <div class="grid grid-cols-1 grid-rows-1 w-fit text-left flex-grow-0">
{#key selected} {#key selected}
<p <p
in:blur={{ in:blur={{
@ -60,11 +60,11 @@
easing: quintOut, easing: quintOut,
blurMultiplier: 6, blurMultiplier: 6,
scale: { scale: {
start: 0.5, start: 0.9,
end: 1, end: 1,
}, },
y: { y: {
start: isUp ? 50 : -50, start: isUp ? -50 : 50,
end: 0, end: 0,
}, },
}} }}
@ -74,11 +74,11 @@
blurMultiplier: 6, blurMultiplier: 6,
scale: { scale: {
start: 1, start: 1,
end: 0.5, end: 0.9,
}, },
y: { y: {
start: 0, start: 0,
end: isUp ? -50 : 50, end: isUp ? 50 : -50,
}, },
}} }}
class="col-start-1 row-start-1 text-left" class="col-start-1 row-start-1 text-left"
@ -87,7 +87,9 @@
</p> </p>
{/key} {/key}
{#each options as option} {#each options as option}
<p class="col-start-1 row-start-1 opacity-0"> <p
class="col-start-1 row-start-1 invisible pointer-events-none"
>
{option} {option}
</p> </p>
{/each} {/each}

View File

@ -17,6 +17,7 @@ export class Converter {
* @param input The input file. * @param input The input file.
* @param to The format to convert to. Includes the dot. * @param to The format to convert to. Includes the dot.
*/ */
public ready: boolean = $state(false);
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: OmitBetterStrict<IFile, "extension">,

View File

@ -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()];

View File

@ -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<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,30 +1,39 @@
import type { IFile } from "$lib/types"; import type { IFile } from "$lib/types";
import { Converter } from "./converter"; 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";
import type { VipsWorkerMessage, OmitBetterStrict } from "$lib/types"; import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
export class VipsConverter extends Converter { export class VipsConverter extends Converter {
private worker: Worker = browser ? new VipsWorker() : null!; private worker: Worker = browser ? new VipsWorker() : null!;
private id = 0; private id = 0;
public name = "libvips"; public name = "libvips";
public ready = $state(false);
public supportedFormats = [ public supportedFormats = [
".jpg",
".jpeg",
".png",
".webp",
".tiff",
".tif",
".gif", ".gif",
".jfif", ".hdr",
".avif", ".jpe",
".jpeg",
".jpg",
".mat",
".pbm",
".pfm",
".pgm",
".png",
".pnm",
".ppm",
".raw",
".tif",
".tiff",
".webp",
]; ];
constructor() { constructor() {
super(); super();
if (!browser) return; if (!browser) return;
this.worker.onmessage = (e) => { 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; return res.output;
} }
if (res.type === "error") {
throw new Error(res.error);
}
throw new Error("Unknown message type"); throw new Error("Unknown message type");
} }
private sendMessage( private sendMessage(
message: OmitBetterStrict<VipsWorkerMessage, "id">, message: OmitBetterStrict<WorkerMessage, "id">,
): Promise<OmitBetterStrict<VipsWorkerMessage, "id">> { ): Promise<OmitBetterStrict<WorkerMessage, "id">> {
const id = this.id++; const id = this.id++;
let resolved = false; let resolved = false;
return new Promise((resolve) => { return new Promise((resolve) => {
@ -64,7 +77,7 @@ export class VipsConverter extends Converter {
this.worker.removeEventListener("message", onMessage); this.worker.removeEventListener("message", onMessage);
throw new Error("Timeout"); throw new Error("Timeout");
} }
}, 20000); }, 60000);
this.worker.addEventListener("message", onMessage); this.worker.addEventListener("message", onMessage);

View File

@ -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;
};

View File

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

View File

@ -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;
};

79
src/lib/workers/magick.ts Normal file
View File

@ -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<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,7 +1,10 @@
import type { VipsWorkerMessage, OmitBetterStrict } from "$lib/types"; import type { WorkerMessage, OmitBetterStrict } from "$lib/types";
import Vips from "wasm-vips"; import Vips from "wasm-vips";
const vipsPromise = Vips(); const vipsPromise = Vips({
// see https://github.com/kleisauke/wasm-vips/issues/85
dynamicLibraries: [],
});
vipsPromise vipsPromise
.then(() => { .then(() => {
@ -12,8 +15,8 @@ vipsPromise
}); });
const handleMessage = async ( const handleMessage = async (
message: VipsWorkerMessage, message: WorkerMessage,
): Promise<OmitBetterStrict<VipsWorkerMessage, "id"> | undefined> => { ): Promise<OmitBetterStrict<WorkerMessage, "id"> | undefined> => {
const vips = await vipsPromise; const vips = await vipsPromise;
switch (message.type) { switch (message.type) {
case "convert": { case "convert": {
@ -34,11 +37,19 @@ const handleMessage = async (
}; };
onmessage = async (e) => { onmessage = async (e) => {
const message: VipsWorkerMessage = e.data; const message: WorkerMessage = e.data;
const res = await handleMessage(message); try {
if (!res) return; const res = await handleMessage(message);
postMessage({ if (!res) return;
...res, postMessage({
id: message.id, ...res,
}); id: message.id,
});
} catch (e) {
postMessage({
type: "error",
error: e,
id: message.id,
});
}
}; };

View File

@ -33,29 +33,34 @@
}); });
const convertAll = async () => { const convertAll = async () => {
// for (let i = 0; i < files.files.length; i++) { // if (!converter.ready) return;
// const file = files.files[i]; // const workingFormats: string[] = [];
// const to = files.conversionTypes[i]; // try {
// const converter = converters.find( // await Promise.all(
// (c) => c.name === files.conversionTypes[i], // converter.supportedFormats.map(async (format) => {
// ); // try {
// if (!converter) { // const img = files.files[0];
// console.error("Converter not found"); // if (!img) return;
// continue; // console.log(`Converting to ${format}`);
// } // await converter.convert(
// const converted = await converter.convert({ // {
// name: file.file.name, // name: img.file.name,
// buffer: await file.file.arrayBuffer(), // buffer: await img.file.arrayBuffer(),
// }, to); // },
// files.files[i] = { // format,
// ...file, // );
// file: new File([converted.buffer], file.file.name, { // console.log(`Converted to ${format}`);
// type: file.file.type, // 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<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 file = files.files[i];
@ -187,6 +192,15 @@
(converter) => converter.name, (converter) => converter.name,
)} )}
bind:selected={converterName} 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> </div>
@ -197,15 +211,21 @@
class={clsx("btn flex-grow", { class={clsx("btn flex-grow", {
"btn-highlight": disabled, "btn-highlight": disabled,
})} })}
>Convert{files.files.length > 1 ? " All" : ""}</button disabled={!converter.ready}
> >
{#if converter.ready}
Convert {files.files.length > 1 ? "All" : ""}
{:else}
Loading...
{/if}
</button>
<button <button
onclick={downloadAll} onclick={downloadAll}
class={clsx("btn flex-grow", { class={clsx("btn flex-grow", {
"opacity-50 pointer-events-none": disabled,
"btn-highlight": !disabled, "btn-highlight": !disabled,
})} })}
>Download{files.files.length > 1 ? " All" : ""}</button {disabled}
>Download {files.files.length > 1 ? "All" : ""}</button
> >
</div> </div>
</div> </div>
@ -289,6 +309,10 @@
files.files = files.files.filter( files.files = files.files.filter(
(f) => f !== file, (f) => f !== file,
); );
files.conversionTypes =
files.conversionTypes.filter(
(_, j) => j !== i,
);
}} }}
class="ml-2 mr-1" class="ml-2 mr-1"
> >

BIN
static/magick.wasm Normal file

Binary file not shown.