From 118aaa1745b05c3a7b491e2264f3d51c193c77c0 Mon Sep 17 00:00:00 2001 From: not-nullptr <62841684+not-nullptr@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:45:20 +0100 Subject: [PATCH] feat: ico, heic, dng, cur, ani --- bun.lock | 14 +++ package.json | 2 + src/lib/converters/vips.svelte.ts | 48 ++++---- src/lib/parse/ani.ts | 150 +++++++++++++++++++++++++ src/lib/types/conversion-worker.ts | 1 + src/lib/workers/vips.ts | 175 +++++++++++++++++++++++++++-- 6 files changed, 361 insertions(+), 29 deletions(-) create mode 100644 src/lib/parse/ani.ts diff --git a/bun.lock b/bun.lock index 667bac1..94833c0 100644 --- a/bun.lock +++ b/bun.lock @@ -11,11 +11,13 @@ "@fontsource/lexend": "^5.1.2", "@fontsource/radio-canada-big": "^5.1.1", "@imagemagick/magick-wasm": "^0.0.34", + "byte-data": "^19.0.1", "client-zip": "^2.4.6", "clsx": "^2.1.1", "lucide-svelte": "^0.475.0", "music-metadata": "^11.0.0", "p-queue": "^8.1.0", + "riff-file": "^1.0.3", "vite-plugin-static-copy": "^2.2.0", "wasm-vips": "^0.0.11", }, @@ -302,6 +304,8 @@ "browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], + "byte-data": ["byte-data@19.0.1", "", {}, "sha512-xRvkTvO28wr0+0rErSETHD8Cw+P444Az3/jkTezaMw5R+TTW8ZNXuvPZf9/ZhnSRRvlMnJsVhc+ecYvOMy/MQQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], @@ -368,6 +372,8 @@ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "endianness": ["endianness@8.0.2", "", {}, "sha512-IU+77+jJ7lpw2qZ3NUuqBZFy3GuioNgXUdsL1L9tooDNTaw0TgOnwNuc+8Ns+haDaTifK97QLzmOANJtI/rGvw=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], @@ -458,6 +464,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ieee754-buffer": ["ieee754-buffer@2.0.0", "", {}, "sha512-AXUAT0nMEi7h1Is8HXGXof3eejl/GabZFKSj8Ym6kVRUSwrAb52EkAXywiCQYSHGQMRn7lvfY7vhPMjVc+Kybg=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "immutable": ["immutable@5.0.3", "", {}, "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw=="], @@ -640,6 +648,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "riff-file": ["riff-file@1.0.3", "", { "dependencies": { "byte-data": "^18.0.3" } }, "sha512-Vv8wwGr0BCks7VMI3Lv0houZee4DaHFjjTT0LMhMJKio2YmLncLeIVpK63ydSverngNk8XQPU3fbeP3bWgSIig=="], + "rollup": ["rollup@4.34.9", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.34.9", "@rollup/rollup-android-arm64": "4.34.9", "@rollup/rollup-darwin-arm64": "4.34.9", "@rollup/rollup-darwin-x64": "4.34.9", "@rollup/rollup-freebsd-arm64": "4.34.9", "@rollup/rollup-freebsd-x64": "4.34.9", "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", "@rollup/rollup-linux-arm-musleabihf": "4.34.9", "@rollup/rollup-linux-arm64-gnu": "4.34.9", "@rollup/rollup-linux-arm64-musl": "4.34.9", "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", "@rollup/rollup-linux-riscv64-gnu": "4.34.9", "@rollup/rollup-linux-s390x-gnu": "4.34.9", "@rollup/rollup-linux-x64-gnu": "4.34.9", "@rollup/rollup-linux-x64-musl": "4.34.9", "@rollup/rollup-win32-arm64-msvc": "4.34.9", "@rollup/rollup-win32-ia32-msvc": "4.34.9", "@rollup/rollup-win32-x64-msvc": "4.34.9", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -718,6 +728,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "utf8-buffer": ["utf8-buffer@1.0.0", "", {}, "sha512-ueuhzvWnp5JU5CiGSY4WdKbiN/PO2AZ/lpeLiz2l38qwdLy/cW40XobgyuIWucNyum0B33bVB0owjFCeGBSLqg=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vite": ["vite@5.4.14", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA=="], @@ -764,6 +776,8 @@ "postcss-load-config/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "riff-file/byte-data": ["byte-data@18.1.1", "", { "dependencies": { "endianness": "^8.0.2", "ieee754-buffer": "^2.0.0", "utf8-buffer": "^1.0.0" } }, "sha512-Kv/B0r7adgnCcrs/y703sac2XFLdHW5kPfis1j8+Ij/hmEcWhBKf+1pNTv+vsNqXb207Uiyri8bpnogNxR/4Lg=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index efed84e..38ce68c 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,13 @@ "@fontsource/lexend": "^5.1.2", "@fontsource/radio-canada-big": "^5.1.1", "@imagemagick/magick-wasm": "^0.0.34", + "byte-data": "^19.0.1", "client-zip": "^2.4.6", "clsx": "^2.1.1", "lucide-svelte": "^0.475.0", "music-metadata": "^11.0.0", "p-queue": "^8.1.0", + "riff-file": "^1.0.3", "vite-plugin-static-copy": "^2.2.0", "wasm-vips": "^0.0.11" } diff --git a/src/lib/converters/vips.svelte.ts b/src/lib/converters/vips.svelte.ts index 031d4b4..bdba091 100644 --- a/src/lib/converters/vips.svelte.ts +++ b/src/lib/converters/vips.svelte.ts @@ -15,27 +15,35 @@ export class VipsConverter extends Converter { private id = 0; public name = "libvips"; public ready = $state(false); - public supportedFormats = [ - ".png", - ".jpeg", - ".jpg", - ".webp", - ".gif", - ".hdr", - ".jpe", - ".dng", - ".mat", - ".pbm", - ".pfm", - ".pgm", - ".pnm", - ".ppm", - ".raw", - ".tif", - ".tiff", - ".jfif", + public static supportedFormatsStatic = [ + ...new Set([ + ".png", + ".jpeg", + ".jpg", + ".webp", + ".gif", + ".ico", + ".cur", + ".ani", + ".heic", + ".hdr", + ".jpe", + ".dng", + ".mat", + ".pbm", + ".pfm", + ".pgm", + ".pnm", + ".ppm", + ".raw", + ".tif", + ".tiff", + ".jfif", + ]), ]; + public supportedFormats = VipsConverter.supportedFormatsStatic; + public readonly reportsProgress = false; constructor() { @@ -87,7 +95,7 @@ export class VipsConverter extends Converter { log(["converters", this.name], `converted ${input.name} to ${to}`); return new VertFile( new File([res.output as unknown as BlobPart], input.name), - to, + res.zip ? ".zip" : to, ); } diff --git a/src/lib/parse/ani.ts b/src/lib/parse/ani.ts new file mode 100644 index 0000000..814b069 --- /dev/null +++ b/src/lib/parse/ani.ts @@ -0,0 +1,150 @@ +// THIS CODE IS FROM https://github.com/captbaritone/webamp/blob/15b0312cb794973a0e615d894df942452e920c36/packages/ani-cursor/src/parser.ts +// LICENSED UNDER MIT. (c) Jordan Eldredge and Webamp contributors + +// this code is ripped from their project because i didn't want to +// re-invent the wheel, BUT the library they provide (ani-cursor) +// doesn't expose the internals. + +import { RIFFFile } from "riff-file"; +import { unpackArray, unpackString } from "byte-data"; + +type Chunk = { + format: string; + chunkId: string; + chunkData: { + start: number; + end: number; + }; + subChunks: Chunk[]; +}; + +// https://www.informit.com/articles/article.aspx?p=1189080&seqNum=3 +type AniMetadata = { + cbSize: number; // Data structure size (in bytes) + nFrames: number; // Number of images (also known as frames) stored in the file + nSteps: number; // Number of frames to be displayed before the animation repeats + iWidth: number; // Width of frame (in pixels) + iHeight: number; // Height of frame (in pixels) + iBitCount: number; // Number of bits per pixel + nPlanes: number; // Number of color planes + iDispRate: number; // Default frame display rate (measured in 1/60th-of-a-second units) + bfAttributes: number; // ANI attribute bit flags +}; + +type ParsedAni = { + rate: number[] | null; + seq: number[] | null; + images: Uint8Array[]; + metadata: AniMetadata; + artist: string | null; + title: string | null; +}; + +const DWORD = { bits: 32, be: false, signed: false, fp: false }; + +export function parseAni(arr: Uint8Array): ParsedAni { + const riff = new RIFFFile(); + + riff.setSignature(arr); + + const signature = riff.signature as Chunk; + if (signature.format !== "ACON") { + throw new Error( + `Expected format. Expected "ACON", got "${signature.format}"`, + ); + } + + // Helper function to get a chunk by chunkId and transform it if it's non-null. + function mapChunk( + chunkId: string, + mapper: (chunk: Chunk) => T, + ): T | null { + const chunk = riff.findChunk(chunkId) as Chunk | null; + return chunk == null ? null : mapper(chunk); + } + + function readImages(chunk: Chunk, frameCount: number): Uint8Array[] { + return chunk.subChunks.slice(0, frameCount).map((c) => { + if (c.chunkId !== "icon") { + throw new Error(`Unexpected chunk type in fram: ${c.chunkId}`); + } + return arr.slice(c.chunkData.start, c.chunkData.end); + }); + } + + const metadata = mapChunk("anih", (c) => { + const words = unpackArray( + arr, + DWORD, + c.chunkData.start, + c.chunkData.end, + ); + return { + cbSize: words[0], + nFrames: words[1], + nSteps: words[2], + iWidth: words[3], + iHeight: words[4], + iBitCount: words[5], + nPlanes: words[6], + iDispRate: words[7], + bfAttributes: words[8], + }; + }); + + if (metadata == null) { + throw new Error("Did not find anih"); + } + + const rate = mapChunk("rate", (c) => { + return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); + }); + // chunkIds are always four chars, hence the trailing space. + const seq = mapChunk("seq ", (c) => { + return unpackArray(arr, DWORD, c.chunkData.start, c.chunkData.end); + }); + + const lists = riff.findChunk("LIST", true) as Chunk[] | null; + const imageChunk = lists?.find((c) => c.format === "fram"); + if (imageChunk == null) { + throw new Error("Did not find fram LIST"); + } + + let images = readImages(imageChunk, metadata.nFrames); + + let title = null; + let artist = null; + + const infoChunk = lists?.find((c) => c.format === "INFO"); + if (infoChunk != null) { + infoChunk.subChunks.forEach((c) => { + switch (c.chunkId) { + case "INAM": + title = unpackString( + arr, + c.chunkData.start, + c.chunkData.end, + ); + break; + case "IART": + artist = unpackString( + arr, + c.chunkData.start, + c.chunkData.end, + ); + break; + case "LIST": + // Some cursors with an artist of "Created with Take ONE 3.5 (unregisterred version)" seem to have their frames here for some reason? + if (c.format === "fram") { + images = readImages(c, metadata.nFrames); + } + break; + + default: + // Unexpected subchunk + } + }); + } + + return { images, rate, seq, metadata, artist, title }; +} diff --git a/src/lib/types/conversion-worker.ts b/src/lib/types/conversion-worker.ts index 3c8d7d5..14c8346 100644 --- a/src/lib/types/conversion-worker.ts +++ b/src/lib/types/conversion-worker.ts @@ -10,6 +10,7 @@ interface ConvertMessage { interface FinishedMessage { type: "finished"; output: ArrayBufferLike; + zip?: boolean; } interface LoadedMessage { diff --git a/src/lib/workers/vips.ts b/src/lib/workers/vips.ts index 1875903..ad9a6a0 100644 --- a/src/lib/workers/vips.ts +++ b/src/lib/workers/vips.ts @@ -3,9 +3,13 @@ import { initializeImageMagick, MagickFormat, MagickImage, + MagickImageCollection, MagickReadSettings, + type IMagickImage, } from "@imagemagick/magick-wasm"; +import { makeZip } from "client-zip"; import wasm from "@imagemagick/magick-wasm/magick.wasm?url"; +import { parseAni } from "$lib/parse/ani"; const vipsPromise = Vips({ dynamicLibraries: [], @@ -13,9 +17,9 @@ const vipsPromise = Vips({ const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url)); -const magickRequiredFormats = [".dng"]; +const magickRequiredFormats = [".dng", ".heic", ".ico", ".cur", ".ani"]; const unsupportedFrom: string[] = []; -const unsupportedTo = [".dng"]; +const unsupportedTo = [...magickRequiredFormats]; vipsPromise .then(() => { @@ -31,6 +35,7 @@ const handleMessage = async (message: any): Promise => { switch (message.type) { case "convert": { if (!message.to.startsWith(".")) message.to = `.${message.to}`; + console.log(message); if (unsupportedFrom.includes(message.input.from)) { return { type: "error", @@ -52,7 +57,101 @@ const handleMessage = async (message: any): Promise => { ) { // only wait when we need to await magickPromise; - const magick = MagickImage.create( + + // special ico handling to split them all into separate images + if (message.input.from === ".ico") { + const imgs = MagickImageCollection.create(); + + while (true) { + try { + const img = MagickImage.create( + new Uint8Array(buffer), + new MagickReadSettings({ + format: MagickFormat.Ico, + frameIndex: imgs.length, + }), + ); + imgs.push(img); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + break; + } + } + + if (imgs.length === 0) { + return { + type: "error", + error: `Failed to read ICO -- no images found inside?`, + }; + } + + const convertedImgs: Uint8Array[] = []; + await Promise.all( + imgs.map(async (img, i) => { + const output = await magickConvert(img, message.to); + convertedImgs[i] = output; + }), + ); + + const zip = makeZip( + convertedImgs.map( + (img, i) => + new File( + [img], + `image${i}.${message.to.slice(1)}`, + ), + ), + "images.zip", + ); + + // read the ReadableStream to the end + const zipBytes = await readToEnd(zip.getReader()); + + imgs.dispose(); + + return { + type: "finished", + output: zipBytes, + zip: true, + }; + } else if (message.input.from === ".ani") { + console.log("Parsing ANI file"); + try { + const parsedAni = parseAni(new Uint8Array(buffer)); + const files: File[] = []; + await Promise.all( + parsedAni.images.map(async (img, i) => { + const blob = await magickConvert( + MagickImage.create( + img, + new MagickReadSettings({ + format: MagickFormat.Ico, + }), + ), + message.to, + ); + files.push( + new File([blob], `image${i}${message.to}`), + ); + }), + ); + + const zip = makeZip(files, "images.zip"); + const zipBytes = await readToEnd(zip.getReader()); + + return { + type: "finished", + output: zipBytes, + zip: true, + }; + } catch (e) { + console.error(e); + } + } + + console.log(message.input.from); + + const img = MagickImage.create( new Uint8Array(buffer), new MagickReadSettings({ format: message.input.from @@ -61,17 +160,14 @@ const handleMessage = async (message: any): Promise => { }), ); - const dngBuffer = await new Promise((resolve) => - magick.write(message.to.slice(1).toUpperCase(), (data) => { - resolve(data); - }), - ); + const converted = await magickConvert(img, message.to); return { type: "finished", - output: dngBuffer, + output: converted, }; } + let image = vips.Image.newFromBuffer(buffer, ""); // check if animated image & keep it animated when converting @@ -94,6 +190,67 @@ const handleMessage = async (message: any): Promise => { } }; +const readToEnd = async (reader: ReadableStreamDefaultReader) => { + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const { value, done: d } = await reader.read(); + if (value) chunks.push(value); + done = d; + } + const blob = new Blob(chunks, { type: "application/zip" }); + const arrayBuffer = await blob.arrayBuffer(); + return new Uint8Array(arrayBuffer); +}; + +const magickToBlob = async (img: IMagickImage): Promise => { + const canvas = new OffscreenCanvas(img.width, img.height); + return new Promise((resolve, reject) => + img.getPixels(async (p) => { + const area = p.getArea(0, 0, img.width, img.height); + const chunkSize = img.hasAlpha ? 4 : 3; + const chunks = Math.ceil(area.length / chunkSize); + const data = new Uint8ClampedArray(chunks * 4); + + for (let j = 0, k = 0; j < area.length; j += chunkSize, k += 4) { + data[k] = area[j]; + data[k + 1] = area[j + 1]; + data[k + 2] = area[j + 2]; + data[k + 3] = img.hasAlpha ? area[j + 3] : 255; + } + + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Failed to get canvas context")); + return; + } + + ctx.putImageData(new ImageData(data, img.width, img.height), 0, 0); + + const blob = await canvas.convertToBlob({ + type: "image/png", + }); + + resolve(blob); + }), + ); +}; + +const magickConvert = async (img: IMagickImage, to: string) => { + const vips = await vipsPromise; + + const intermediary = await magickToBlob(img); + const buf = await intermediary.arrayBuffer(); + + const imgVips = vips.Image.newFromBuffer(buf); + const output = imgVips.writeToBuffer(to); + + imgVips.delete(); + img.dispose(); + + return output; +}; + onmessage = async (e) => { const message = e.data; try {