From db7b9406a72d54fb8c44e82bbbf70fabe100cf89 Mon Sep 17 00:00:00 2001
From: not-nullptr <62841684+not-nullptr@users.noreply.github.com>
Date: Tue, 15 Apr 2025 15:11:05 +0100
Subject: [PATCH] feat: .icns support
---
bun.lock | 7 +
package.json | 1 +
src/app.html | 94 ++--
src/lib/components/layout/Navbar/Base.svelte | 394 ++++++-------
src/lib/converters/index.ts | 2 +-
src/lib/parse/ani.ts | 300 +++++-----
src/lib/types/conversion-worker.ts | 64 +--
src/lib/workers/vips.ts | 562 ++++++++++---------
src/routes/jpegify/+page.svelte | 226 ++++----
9 files changed, 832 insertions(+), 818 deletions(-)
diff --git a/bun.lock b/bun.lock
index 94833c0..1a41b8b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,6 +7,7 @@
"@bjorn3/browser_wasi_shim": "^0.4.1",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
+ "@fiahfy/icns": "^0.0.7",
"@fontsource/azeret-mono": "^5.1.1",
"@fontsource/lexend": "^5.1.2",
"@fontsource/radio-canada-big": "^5.1.1",
@@ -120,6 +121,10 @@
"@ffmpeg/util": ["@ffmpeg/util@0.12.2", "", {}, "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw=="],
+ "@fiahfy/icns": ["@fiahfy/icns@0.0.7", "", { "dependencies": { "@fiahfy/packbits": "^0.0.6", "pngjs": "^6.0.0" } }, "sha512-0apAtbUXTU3Opy/Z4h69o53voBa+am8FmdZauyagUMskAVYN1a5yIRk48Sf+tEdBLlefbvqLWPJ4pxr/Y/QtTg=="],
+
+ "@fiahfy/packbits": ["@fiahfy/packbits@0.0.6", "", {}, "sha512-XuhF/edg+iIvXjkCWgfj6fWtRi/KrEPg2ILXj1l86EN4EssuOiPcLKgkMDr9cL8jTGtVd/MKUWW6Y0/ZVf1PGA=="],
+
"@fontsource/azeret-mono": ["@fontsource/azeret-mono@5.2.5", "", {}, "sha512-GRzKYuD1CVOS6Jag/ohDCycLV9a3TK6y1T73A8q0JoDZTVO85DNapqLK+SV2gYtTFldahNAlDSIaizv9MLhR1A=="],
"@fontsource/lexend": ["@fontsource/lexend@5.2.5", "", {}, "sha512-Mv2XQ+B4ek2lNCGRW5ddLTW8T3xTT17AnCk1IETpoef57XHz+e42fUfLAYMrmiJLOGpR44qnyJ5S6D323A5EIw=="],
@@ -608,6 +613,8 @@
"pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="],
+ "pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
+
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
diff --git a/package.json b/package.json
index 38ce68c..6eeb872 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@bjorn3/browser_wasi_shim": "^0.4.1",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
+ "@fiahfy/icns": "^0.0.7",
"@fontsource/azeret-mono": "^5.1.1",
"@fontsource/lexend": "^5.1.2",
"@fontsource/radio-canada-big": "^5.1.1",
diff --git a/src/app.html b/src/app.html
index ef2158d..a8f439a 100644
--- a/src/app.html
+++ b/src/app.html
@@ -1,47 +1,47 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- %sveltekit.head%
-
-
-
- %sveltekit.body%
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+
+ %sveltekit.body%
+
+
diff --git a/src/lib/components/layout/Navbar/Base.svelte b/src/lib/components/layout/Navbar/Base.svelte
index 1e2d4ca..515bae3 100644
--- a/src/lib/components/layout/Navbar/Base.svelte
+++ b/src/lib/components/layout/Navbar/Base.svelte
@@ -1,197 +1,197 @@
-
-
-{#snippet link(item: (typeof items)[0], index: number)}
- {@const Icon = item.icon}
-
-
- {#key item.name}
-
-
-
- {#if item.badge}
-
- {#key item.badge}
-
- {item.badge}
-
- {/key}
-
- {/if}
-
-
- {item.name}
-
-
- {/key}
-
-
-{/snippet}
-
-
-
- {@const linkRect = linkRects.at(selectedIndex) || linkRects[0]}
- {#if linkRect}
-
- {/if}
-
-
-
-
-
- {#each items as item, i (item.url)}
- {@render link(item, i)}
- {/each}
-
-
-
-
+
+
+{#snippet link(item: (typeof items)[0], index: number)}
+ {@const Icon = item.icon}
+
+
+ {#key item.name}
+
+
+
+ {#if item.badge}
+
+ {#key item.badge}
+
+ {item.badge}
+
+ {/key}
+
+ {/if}
+
+
+ {item.name}
+
+
+ {/key}
+
+
+{/snippet}
+
+
+
+ {@const linkRect = linkRects.at(selectedIndex) || linkRects[0]}
+ {#if linkRect}
+
+ {/if}
+
+
+
+
+
+ {#each items as item, i (item.url)}
+ {@render link(item, i)}
+ {/each}
+
+
+
+
diff --git a/src/lib/converters/index.ts b/src/lib/converters/index.ts
index 434a08a..3ccb073 100644
--- a/src/lib/converters/index.ts
+++ b/src/lib/converters/index.ts
@@ -1,4 +1,4 @@
-import type { Converter, FormatInfo } from "./converter.svelte";
+import type { Converter } from "./converter.svelte";
import { FFmpegConverter } from "./ffmpeg.svelte";
import { PandocConverter } from "./pandoc.svelte";
import { VertdConverter } from "./vertd.svelte";
diff --git a/src/lib/parse/ani.ts b/src/lib/parse/ani.ts
index 814b069..09b9712 100644
--- a/src/lib/parse/ani.ts
+++ b/src/lib/parse/ani.ts
@@ -1,150 +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 };
-}
+// 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 14c8346..820f2ae 100644
--- a/src/lib/types/conversion-worker.ts
+++ b/src/lib/types/conversion-worker.ts
@@ -1,32 +1,32 @@
-import { VertFile } from "./file.svelte";
-
-interface ConvertMessage {
- type: "convert";
- input: VertFile;
- to: string;
- compression: number | null;
-}
-
-interface FinishedMessage {
- type: "finished";
- output: ArrayBufferLike;
- zip?: boolean;
-}
-
-interface LoadedMessage {
- type: "loaded";
-}
-
-interface ErrorMessage {
- type: "error";
- error: string;
-}
-
-export type WorkerMessage = (
- | ConvertMessage
- | FinishedMessage
- | LoadedMessage
- | ErrorMessage
-) & {
- id: number;
-};
+import { VertFile } from "./file.svelte";
+
+interface ConvertMessage {
+ type: "convert";
+ input: VertFile;
+ to: string;
+ compression: number | null;
+}
+
+interface FinishedMessage {
+ type: "finished";
+ output: ArrayBufferLike;
+ zip?: boolean;
+}
+
+interface LoadedMessage {
+ type: "loaded";
+}
+
+interface ErrorMessage {
+ type: "error";
+ error: string;
+}
+
+export type WorkerMessage = (
+ | ConvertMessage
+ | FinishedMessage
+ | LoadedMessage
+ | ErrorMessage
+) & {
+ id: number;
+};
diff --git a/src/lib/workers/vips.ts b/src/lib/workers/vips.ts
index c73abdf..8c76276 100644
--- a/src/lib/workers/vips.ts
+++ b/src/lib/workers/vips.ts
@@ -1,278 +1,284 @@
-import Vips from "wasm-vips";
-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: [],
-});
-
-const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
-
-const magickRequiredFormats = [
- ".dng",
- ".heic",
- ".ico",
- ".cur",
- ".ani",
- ".cr2",
- ".nef",
-];
-const unsupportedFrom: string[] = [];
-const unsupportedTo = [...magickRequiredFormats];
-
-vipsPromise
- .then(() => {
- postMessage({ type: "loaded" });
- })
- .catch((error) => {
- postMessage({ type: "error", error });
- });
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const handleMessage = async (message: any): Promise => {
- const vips = await vipsPromise;
- 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",
- error: `Unsupported input format ${message.input.from}`,
- };
- }
-
- if (unsupportedTo.includes(message.to)) {
- return {
- type: "error",
- error: `Unsupported output format ${message.to}`,
- };
- }
-
- const buffer = await message.input.file.arrayBuffer();
- if (
- magickRequiredFormats.includes(message.input.from) ||
- magickRequiredFormats.includes(message.to)
- ) {
- // only wait when we need to
- await magickPromise;
-
- // 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
- .slice(1)
- .toUpperCase() as MagickFormat,
- }),
- );
-
- const converted = await magickConvert(img, message.to);
-
- return {
- type: "finished",
- output: converted,
- };
- }
-
- let image = vips.Image.newFromBuffer(buffer, "");
-
- // check if animated image & keep it animated when converting
- if (image.getTypeof("n-pages") > 0) {
- image = vips.Image.newFromBuffer(buffer, "[n=-1]");
- }
-
- const opts: { [key: string]: string } = {};
- if (typeof message.compression !== "undefined") {
- opts["Q"] = Math.min(100, message.compression + 1).toString();
- }
-
- const output = image.writeToBuffer(message.to, opts);
- image.delete();
- return {
- type: "finished",
- output: output.buffer,
- };
- }
- }
-};
-
-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 {
- const res = await handleMessage(message);
- if (!res) return;
- postMessage({
- ...res,
- id: message.id,
- });
- } catch (e) {
- postMessage({
- type: "error",
- error: e,
- id: message.id,
- });
- }
-};
+import Vips from "wasm-vips";
+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";
+import { Icns } from "@fiahfy/icns/dist";
+
+const vipsPromise = Vips({
+ dynamicLibraries: [],
+});
+
+const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
+
+const magickRequiredFormats = [
+ ".dng",
+ ".heic",
+ ".ico",
+ ".cur",
+ ".ani",
+ ".cr2",
+ ".nef",
+];
+const unsupportedFrom: string[] = [];
+const unsupportedTo = [...magickRequiredFormats];
+
+vipsPromise
+ .then(() => {
+ postMessage({ type: "loaded" });
+ })
+ .catch((error) => {
+ postMessage({ type: "error", error });
+ });
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const handleMessage = async (message: any): Promise => {
+ const vips = await vipsPromise;
+ 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",
+ error: `Unsupported input format ${message.input.from}`,
+ };
+ }
+
+ if (unsupportedTo.includes(message.to)) {
+ return {
+ type: "error",
+ error: `Unsupported output format ${message.to}`,
+ };
+ }
+
+ const buffer = await message.input.file.arrayBuffer();
+ if (
+ magickRequiredFormats.includes(message.input.from) ||
+ magickRequiredFormats.includes(message.to)
+ ) {
+ // only wait when we need to
+ await magickPromise;
+
+ // 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
+ .slice(1)
+ .toUpperCase() as MagickFormat,
+ }),
+ );
+
+ const converted = await magickConvert(img, message.to);
+
+ return {
+ type: "finished",
+ output: converted,
+ };
+ }
+
+ if (message.input.from === ".icns") {
+ const icns = Icns.from(new Uint8Array(buffer));
+ console.log(icns);
+ }
+
+ let image = vips.Image.newFromBuffer(buffer, "");
+
+ // check if animated image & keep it animated when converting
+ if (image.getTypeof("n-pages") > 0) {
+ image = vips.Image.newFromBuffer(buffer, "[n=-1]");
+ }
+
+ const opts: { [key: string]: string } = {};
+ if (typeof message.compression !== "undefined") {
+ opts["Q"] = Math.min(100, message.compression + 1).toString();
+ }
+
+ const output = image.writeToBuffer(message.to, opts);
+ image.delete();
+ return {
+ type: "finished",
+ output: output.buffer,
+ };
+ }
+ }
+};
+
+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 {
+ const res = await handleMessage(message);
+ if (!res) return;
+ postMessage({
+ ...res,
+ id: message.id,
+ });
+ } catch (e) {
+ postMessage({
+ type: "error",
+ error: e,
+ id: message.id,
+ });
+ }
+};
diff --git a/src/routes/jpegify/+page.svelte b/src/routes/jpegify/+page.svelte
index 0937a81..739abdd 100644
--- a/src/routes/jpegify/+page.svelte
+++ b/src/routes/jpegify/+page.svelte
@@ -1,113 +1,113 @@
-
-
-
-
SECRET JPEGIFY!!!
-
- (shh... don't tell anyone!)
-
-
-
-
-
- {#each images as file, i (file.id)}
-
-
-
-
)
-
)
-
-
-
-
-
-
-
- {/each}
-
-
+
+
+
+
SECRET JPEGIFY!!!
+
+ (shh... don't tell anyone!)
+
+
+
+
+
+ {#each images as file, i (file.id)}
+
+
+
+
)
+
)
+
+
+
+
+
+
+
+ {/each}
+
+