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} -
- -
- {/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} +
+ +
+ {/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)} -
- -
- {file.name} - {file.name} -
-
- - -
-
-
- {/each} -
-
+ + +
+

SECRET JPEGIFY!!!

+

+ (shh... don't tell anyone!) +

+ + + +
+ {#each images as file, i (file.id)} +
+ +
+ {file.name} + {file.name} +
+
+ + +
+
+
+ {/each} +
+