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 {
@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 {

View File

@ -52,7 +52,7 @@
onclick={toggle}
>
<!-- <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}
<p
in:blur={{
@ -60,11 +60,11 @@
easing: quintOut,
blurMultiplier: 6,
scale: {
start: 0.5,
start: 0.9,
end: 1,
},
y: {
start: isUp ? 50 : -50,
start: isUp ? -50 : 50,
end: 0,
},
}}
@ -74,11 +74,11 @@
blurMultiplier: 6,
scale: {
start: 1,
end: 0.5,
end: 0.9,
},
y: {
start: 0,
end: isUp ? -50 : 50,
end: isUp ? 50 : -50,
},
}}
class="col-start-1 row-start-1 text-left"
@ -87,7 +87,9 @@
</p>
{/key}
{#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}
</p>
{/each}

View File

@ -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<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 { 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<VipsWorkerMessage, "id">,
): Promise<OmitBetterStrict<VipsWorkerMessage, "id">> {
message: OmitBetterStrict<WorkerMessage, "id">,
): Promise<OmitBetterStrict<WorkerMessage, "id">> {
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);

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 "./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";
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<OmitBetterStrict<VipsWorkerMessage, "id"> | undefined> => {
message: WorkerMessage,
): Promise<OmitBetterStrict<WorkerMessage, "id"> | 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,
});
}
};

View File

@ -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<void>[] = [];
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],
);
}}
/>
</div>
</div>
@ -197,15 +211,21 @@
class={clsx("btn flex-grow", {
"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
onclick={downloadAll}
class={clsx("btn flex-grow", {
"opacity-50 pointer-events-none": disabled,
"btn-highlight": !disabled,
})}
>Download{files.files.length > 1 ? " All" : ""}</button
{disabled}
>Download {files.files.length > 1 ? "All" : ""}</button
>
</div>
</div>
@ -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"
>

BIN
static/magick.wasm Normal file

Binary file not shown.