feat: ico, heic, dng, cur, ani

This commit is contained in:
not-nullptr 2025-04-14 15:45:20 +01:00
parent 0d330f221a
commit 118aaa1745
6 changed files with 361 additions and 29 deletions

View File

@ -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=="],

View File

@ -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"
}

View File

@ -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,
);
}

150
src/lib/parse/ani.ts Normal file
View File

@ -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<T>(
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 };
}

View File

@ -10,6 +10,7 @@ interface ConvertMessage {
interface FinishedMessage {
type: "finished";
output: ArrayBufferLike;
zip?: boolean;
}
interface LoadedMessage {

View File

@ -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<any> => {
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<any> => {
) {
// 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<any> => {
}),
);
const dngBuffer = await new Promise<Uint8Array>((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<any> => {
}
};
const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
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<Blob> => {
const canvas = new OffscreenCanvas(img.width, img.height);
return new Promise<Blob>((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 {