mirror of https://github.com/VERT-sh/VERT.git
353 lines
8.3 KiB
TypeScript
353 lines
8.3 KiB
TypeScript
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 { parseIcns } from "vert-wasm";
|
|
|
|
const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
|
|
|
|
magickPromise
|
|
.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<any> => {
|
|
switch (message.type) {
|
|
case "convert": {
|
|
const compression: number | undefined = message.compression;
|
|
if (!message.to.startsWith(".")) message.to = `.${message.to}`;
|
|
message.to = message.to.toLowerCase();
|
|
if (message.to === ".jfif") message.to = ".jpeg";
|
|
if (message.input.from === ".jfif") message.input.from = ".jpeg";
|
|
if (message.input.from === ".fit") message.input.from = ".fits";
|
|
|
|
const buffer = await message.input.file.arrayBuffer();
|
|
// 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,
|
|
compression,
|
|
);
|
|
convertedImgs[i] = output;
|
|
}),
|
|
);
|
|
|
|
const zip = makeZip(
|
|
convertedImgs.map(
|
|
(img, i) =>
|
|
new File(
|
|
[new Uint8Array(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,
|
|
compression,
|
|
);
|
|
files.push(
|
|
new File(
|
|
[new Uint8Array(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);
|
|
}
|
|
} else if (message.input.from === ".icns") {
|
|
const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer));
|
|
if (typeof icns === "string") {
|
|
return {
|
|
type: "error",
|
|
error: `Failed to read ICNS -- ${icns}`,
|
|
};
|
|
}
|
|
|
|
const formats = [
|
|
MagickFormat.Png,
|
|
MagickFormat.Jpeg,
|
|
MagickFormat.Rgba,
|
|
MagickFormat.Rgb,
|
|
];
|
|
const outputs: Uint8Array[] = [];
|
|
for (const file of icns) {
|
|
for (const format of formats) {
|
|
try {
|
|
const img = MagickImage.create(
|
|
file,
|
|
new MagickReadSettings({
|
|
format: format,
|
|
}),
|
|
);
|
|
const converted = await magickConvert(
|
|
img,
|
|
message.to,
|
|
compression,
|
|
);
|
|
outputs.push(converted);
|
|
break;
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const zip = makeZip(
|
|
outputs.map(
|
|
(img, i) =>
|
|
new File(
|
|
[new Uint8Array(img)],
|
|
`image${i}.${message.to.slice(1)}`,
|
|
),
|
|
),
|
|
"images.zip",
|
|
);
|
|
const zipBytes = await readToEnd(zip.getReader());
|
|
return {
|
|
type: "finished",
|
|
output: zipBytes,
|
|
zip: true,
|
|
};
|
|
}
|
|
|
|
// build frames of animated formats (webp/gif)
|
|
// APNG does not work on magick-wasm since it needs ffmpeg built-in (not in magick-wasm) - handle in ffmpeg
|
|
if (
|
|
(message.input.from === ".webp" ||
|
|
message.input.from === ".gif") &&
|
|
(message.to === ".gif" || message.to === ".webp")
|
|
) {
|
|
const collection = MagickImageCollection.create(
|
|
new Uint8Array(buffer),
|
|
);
|
|
const format =
|
|
message.to === ".gif"
|
|
? MagickFormat.Gif
|
|
: MagickFormat.WebP;
|
|
const result = await new Promise<Uint8Array>((resolve) => {
|
|
collection.write(format, (output) => {
|
|
resolve(structuredClone(output));
|
|
});
|
|
});
|
|
collection.dispose();
|
|
return {
|
|
type: "finished",
|
|
output: result,
|
|
};
|
|
}
|
|
|
|
const img = MagickImage.create(
|
|
new Uint8Array(buffer),
|
|
new MagickReadSettings({
|
|
format: message.input.from
|
|
.slice(1)
|
|
.toUpperCase() as MagickFormat,
|
|
}),
|
|
);
|
|
|
|
// extract metadata
|
|
let metadata: Map<string, string> | undefined;
|
|
try {
|
|
metadata = new Map();
|
|
|
|
const exifProfile = img.getProfile("exif");
|
|
if (exifProfile) {
|
|
metadata.set("exif:profile", "true");
|
|
}
|
|
|
|
const iccProfile = img.getProfile("icc");
|
|
if (iccProfile) {
|
|
metadata.set("icc:profile", "true");
|
|
}
|
|
|
|
const attributeNames = img.attributeNames;
|
|
if (attributeNames && attributeNames.length > 0) {
|
|
for (const attrName of attributeNames) {
|
|
try {
|
|
if (
|
|
attrName.startsWith("exif:") ||
|
|
attrName.startsWith("icc:") ||
|
|
attrName.startsWith("date:") ||
|
|
attrName.startsWith("tiff:") ||
|
|
attrName.startsWith("xmp:") ||
|
|
attrName.startsWith("iptc:")
|
|
) {
|
|
const value = img.getAttribute(attrName);
|
|
if (value) {
|
|
metadata.set(attrName, value);
|
|
}
|
|
}
|
|
} catch {
|
|
// do nothing
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Parsed ${metadata.size} metadata values`);
|
|
|
|
if (metadata.size === 0) metadata = undefined;
|
|
} catch (e) {
|
|
console.warn("Failed to extract metadata:", e);
|
|
metadata = undefined;
|
|
}
|
|
|
|
const converted = await magickConvert(
|
|
img,
|
|
message.to,
|
|
compression,
|
|
metadata,
|
|
);
|
|
|
|
return {
|
|
type: "finished",
|
|
output: converted,
|
|
};
|
|
}
|
|
}
|
|
};
|
|
|
|
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.map((chunk) => new Uint8Array(chunk)),
|
|
{ type: "application/zip" },
|
|
);
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
return new Uint8Array(arrayBuffer);
|
|
};
|
|
|
|
const magickConvert = async (
|
|
img: IMagickImage,
|
|
to: string,
|
|
compression?: number,
|
|
originalMetadata?: Map<string, string>,
|
|
) => {
|
|
let fmt = to.slice(1).toUpperCase();
|
|
if (fmt === "JFIF") fmt = "JPEG";
|
|
|
|
const result = await new Promise<Uint8Array>((resolve) => {
|
|
// magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480)
|
|
if (compression) img.quality = compression;
|
|
|
|
if (originalMetadata) {
|
|
originalMetadata.forEach((value, key) => {
|
|
try {
|
|
if (!key.endsWith(":profile")) img.setAttribute(key, value);
|
|
} catch (e) {
|
|
console.warn(`Failed to set metadata ${key}: ${e}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => {
|
|
resolve(structuredClone(o));
|
|
});
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
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,
|
|
});
|
|
}
|
|
};
|