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 {
|
.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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">,
|
||||||
|
|
@ -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 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);
|
||||||
|
|
||||||
|
|
@ -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 "./file";
|
||||||
export * from "./util";
|
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";
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue