mirror of https://github.com/VERT-sh/VERT.git
feat: imagemagick conversion
This commit is contained in:
parent
d3da9c122f
commit
f31032c610
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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">,
|
|
@ -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()];
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
};
|
|
@ -1,3 +1,3 @@
|
|||
export * from "./file";
|
||||
export * from "./util";
|
||||
export * from "./vips-worker";
|
||||
export * from "./conversion-worker";
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
Binary file not shown.
Loading…
Reference in New Issue