diff --git a/deno.jsonc b/deno.jsonc index 4a712ec..d646c9d 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -19,6 +19,7 @@ "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.1.2", "@hey-api/openapi-ts": "npm:@hey-api/openapi-ts@^0.97.0", "@hey-api/vite-plugin": "npm:@hey-api/vite-plugin@^0.3.1", + "@imagemagick/magick-wasm": "npm:@imagemagick/magick-wasm@^0.0.40", "@kysely/kysely": "jsr:@kysely/kysely@^0.28.16", "@preact/signals": "npm:@preact/signals@^2.9.0", "@std/async": "jsr:@std/async@^1.3.0", @@ -26,11 +27,11 @@ "@std/http": "jsr:@std/http@^1.1.0", "@subframe7536/iconv-lite": "npm:@subframe7536/iconv-lite@^0.8.1", "fresh": "jsr:@fresh/core@^2.3.3", - "image-size": "npm:image-size@^2.0.2", "jose": "npm:jose@^6.2.3", "mysql2": "npm:mysql2@^3.22.3", "preact": "npm:preact@^10.29.1", - "vite": "npm:vite@^7.3.2" + "vite": "npm:vite@^7.3.2", + "vite-plugin-static-copy": "npm:vite-plugin-static-copy@^4.1.0" }, "lint": { "rules": { diff --git a/deno.lock b/deno.lock index 909f40b..f201030 100644 --- a/deno.lock +++ b/deno.lock @@ -50,6 +50,7 @@ "npm:@hey-api/openapi-ts@*": "0.97.0_typescript@6.0.3", "npm:@hey-api/openapi-ts@0.97": "0.97.0_typescript@6.0.3", "npm:@hey-api/vite-plugin@~0.3.1": "0.3.1_@hey-api+openapi-ts@0.97.0__typescript@6.0.3_vite@7.3.3", + "npm:@imagemagick/magick-wasm@^0.0.40": "0.0.40", "npm:@opentelemetry/api@^1.9.0": "1.9.1", "npm:@preact/signals@^2.5.1": "2.9.0_preact@10.29.1", "npm:@preact/signals@^2.9.0": "2.9.0_preact@10.29.1", @@ -60,7 +61,6 @@ "npm:esbuild-wasm@~0.25.11": "0.25.12", "npm:esbuild@0.25.7": "0.25.7", "npm:esbuild@~0.25.5": "0.25.7", - "npm:image-size@^2.0.2": "2.0.2", "npm:jose@^6.2.3": "6.2.3", "npm:mysql2@^3.22.3": "3.22.3_@types+node@25.6.0", "npm:openapi-ts@*": "0.3.4_openapi-types@12.1.3", @@ -68,6 +68,7 @@ "npm:preact@^10.28.2": "10.29.1", "npm:preact@^10.29.1": "10.29.1", "npm:rollup@^4.55.1": "4.60.3", + "npm:vite-plugin-static-copy@^4.1.0": "4.1.0_vite@7.3.3", "npm:vite@^7.1.4": "7.3.3", "npm:vite@^7.3.2": "7.3.3" }, @@ -826,6 +827,9 @@ "vite" ] }, + "@imagemagick/magick-wasm@0.0.40": { + "integrity": "sha512-vwV4qZ5JfPblYoJTBKn/dAMElXMmIf4inEsCtjskZwU3609ejpz1pvXWE3UEt8G+0L2SYHdoBNKCPU4ezpDXaQ==" + }, "@jridgewell/gen-mapping@0.3.13": { "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": [ @@ -1108,6 +1112,13 @@ "ansi-colors@4.1.3": { "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" }, + "anymatch@3.1.3": { + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": [ + "normalize-path", + "picomatch@2.3.2" + ] + }, "argparse@2.0.1": { "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, @@ -1118,6 +1129,15 @@ "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "bin": true }, + "binary-extensions@2.3.0": { + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, "browserslist@4.28.2": { "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dependencies": [ @@ -1138,7 +1158,7 @@ "c12@3.3.4": { "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", "dependencies": [ - "chokidar", + "chokidar@5.0.0", "confbox", "defu", "dotenv", @@ -1158,10 +1178,25 @@ "caniuse-lite@1.0.30001792": { "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==" }, + "chokidar@3.6.0": { + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": [ + "anymatch", + "braces", + "glob-parent", + "is-binary-path", + "is-glob", + "normalize-path", + "readdirp@3.6.0" + ], + "optionalDependencies": [ + "fsevents" + ] + }, "chokidar@5.0.0": { "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dependencies": [ - "readdirp" + "readdirp@5.0.0" ] }, "color-support@1.1.3": { @@ -1319,6 +1354,12 @@ "picomatch@4.0.4" ] }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -1343,20 +1384,37 @@ "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", "bin": true }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, "iconv-lite@0.7.2": { "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dependencies": [ "safer-buffer" ] }, - "image-size@2.0.2": { - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "bin": true + "is-binary-path@2.1.0": { + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": [ + "binary-extensions" + ] }, "is-docker@3.0.0": { "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "bin": true }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, "is-in-ssh@1.0.0": { "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==" }, @@ -1367,6 +1425,9 @@ ], "bin": true }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, "is-property@1.0.2": { "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" }, @@ -1449,6 +1510,9 @@ "node-releases@2.0.38": { "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==" }, + "normalize-path@3.0.0": { + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, "ohash@2.0.11": { "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" }, @@ -1473,6 +1537,9 @@ "openapi-types@12.1.3": { "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" }, + "p-map@7.0.4": { + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==" + }, "partysocket@1.1.18": { "integrity": "sha512-SyuvH9VavWOSa14v6dYdp3yfSUDII4BQB1+TkGOFBkjfZKjnDBiba4fhdhwBlqGBkqw4ea3gTA1DYhSffX24Wg==", "dependencies": [ @@ -1532,6 +1599,12 @@ "destr" ] }, + "readdirp@3.6.0": { + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": [ + "picomatch@2.3.2" + ] + }, "readdirp@5.0.0": { "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==" }, @@ -1612,6 +1685,12 @@ "picomatch@4.0.4" ] }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, "type-fest@4.41.0": { "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" }, @@ -1634,6 +1713,16 @@ ], "bin": true }, + "vite-plugin-static-copy@4.1.0_vite@7.3.3": { + "integrity": "sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ==", + "dependencies": [ + "chokidar@3.6.0", + "p-map", + "picocolors", + "tinyglobby", + "vite" + ] + }, "vite@7.3.3": { "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dependencies": [ @@ -1691,12 +1780,13 @@ "npm:@badgateway/oauth2-client@^3.3.1", "npm:@hey-api/openapi-ts@0.97", "npm:@hey-api/vite-plugin@~0.3.1", + "npm:@imagemagick/magick-wasm@^0.0.40", "npm:@preact/signals@^2.9.0", "npm:@subframe7536/iconv-lite@~0.8.1", - "npm:image-size@^2.0.2", "npm:jose@^6.2.3", "npm:mysql2@^3.22.3", "npm:preact@^10.29.1", + "npm:vite-plugin-static-copy@^4.1.0", "npm:vite@^7.3.2" ] } diff --git a/src/lib/image.ts b/src/lib/image.ts index 2199968..80f3d5a 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -2,7 +2,11 @@ import type { AppBskyEmbedImages } from "@atcute/bluesky" import { type Did } from "@atcute/lexicons" import { isLegacyBlob } from "@atcute/lexicons/interfaces" -import { imageSize } from "image-size" +import { + ImageMagick, + initializeImageMagick, + MagickFormat, +} from "@imagemagick/magick-wasm" import { postFile } from "../traq/index.ts" import { client } from "./blueskyClient.ts" @@ -62,36 +66,41 @@ export const uploadImages = async ( return imageIds } +const isUint8ArrayOfArrayBuffer = ( + data: Uint8Array, +): data is Uint8Array => data.buffer instanceof ArrayBuffer + +const wasmUrl = import.meta.env.DEV + ? new URL( + "../../node_modules/@imagemagick/magick-wasm/dist/magick.wasm", + import.meta.url, + ) + : new URL("./magick.wasm", import.meta.url) +// `initializeImageMagick` accepts URL, but does not support protocols other than http(s). +// So we fetch the wasm file ourselves. +const initMagickPromise = fetch(wasmUrl).then((res) => res.arrayBuffer()) + .then((buf) => initializeImageMagick(buf)) + const resizeImage = async (imageBlob: Blob) => { - const { height, width } = imageSize(await imageBlob.bytes()) - const imagePixels = height * width - - if (imagePixels <= TRAQ_IMAGE_MAX_PIXELS) { - return imageBlob - } - - const scale = Math.sqrt(TRAQ_IMAGE_MAX_PIXELS / imagePixels) - const resizeHeight = Math.max(1, Math.floor(height * scale)) - const resizeWidth = Math.max(1, Math.floor(width * scale)) - const resizedBitmap = await createImageBitmap(imageBlob, { - resizeHeight, - resizeQuality: "medium", - resizeWidth, - }) - const offscreen = new OffscreenCanvas(resizeWidth, resizeHeight) - const ctx = offscreen.getContext("bitmaprenderer") + await initMagickPromise - if (!ctx) { - throw new Error("Failed to get bitmaprenderer context") - } + return ImageMagick.read(await imageBlob.bytes(), (image) => { + const imagePixels = image.height * image.width - ctx.transferFromImageBitmap(resizedBitmap) + if (imagePixels > TRAQ_IMAGE_MAX_PIXELS) { + const scale = Math.sqrt(TRAQ_IMAGE_MAX_PIXELS / imagePixels) + const newHeight = Math.max(1, Math.floor(image.height * scale)) + const newWidth = Math.max(1, Math.floor(image.width * scale)) - const blob = await offscreen.convertToBlob({ - type: "image/webp", - }) + image.resize(newWidth, newHeight) + } - resizedBitmap.close() + return image.write(MagickFormat.WebP, (data) => { + if (isUint8ArrayOfArrayBuffer(data)) { + return new Blob([data], { type: "image/webp" }) + } - return blob + throw new Error("Unexpected data type from ImageMagick") + }) + }) } diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vite.config.ts b/vite.config.ts index f810959..e9cad24 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { fresh } from "@fresh/plugin-vite" import { heyApiPlugin } from "@hey-api/vite-plugin" import { defineConfig } from "vite" +import { viteStaticCopy } from "vite-plugin-static-copy" export default defineConfig({ plugins: [ @@ -15,6 +16,16 @@ export default defineConfig({ output: "src/traq", }, }), + viteStaticCopy({ + environment: "ssr", + targets: [ + { + src: "node_modules/@imagemagick/magick-wasm/dist/magick.wasm", + dest: ".", + rename: { stripBase: true }, + }, + ], + }), ], resolve: { alias: {