diff --git a/deno.jsonc b/deno.jsonc index d2722df..a028b40 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/encoding": "jsr:@std/encoding@^1.0.10", diff --git a/deno.lock b/deno.lock index 5d5b173..fee11a0 100644 --- a/deno.lock +++ b/deno.lock @@ -47,6 +47,7 @@ "npm:@badgateway/oauth2-client@^3.3.1": "3.3.1", "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.2__@types+node@25.6.0_typescript@6.0.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", @@ -915,6 +916,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": [ @@ -1759,6 +1763,7 @@ "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:jose@^6.2.3", diff --git a/lib/image.ts b/lib/image.ts index d5a8b17..c432a91 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -2,9 +2,18 @@ import type { AppBskyEmbedImages } from "@atcute/bluesky" import { type Did } from "@atcute/lexicons" import { isLegacyBlob } from "@atcute/lexicons/interfaces" +import { + ImageMagick, + initializeImageMagick, + MagickFormat, +} from "@imagemagick/magick-wasm" +// @ts-ignore: Handled by custom Vite plugin +import wasmBytes from "@imagemagick/magick-wasm/magick.wasm" import { postFile } from "../traq/index.ts" import { client } from "./blueskyClient.ts" +const TRAQ_IMAGE_MAX_PIXELS = 2560 * 1600 + interface UploadImageParams { accessToken: string did: Did @@ -44,7 +53,7 @@ export const uploadImages = async ( }, body: { channelId: targetChannelId, - file: downloadRes, + file: await resizeImage(downloadRes), }, }) @@ -58,3 +67,35 @@ export const uploadImages = async ( return imageIds } + +const isUint8ArrayOfArrayBuffer = ( + data: Uint8Array, +): data is Uint8Array => data.buffer instanceof ArrayBuffer + +const initMagickPromise = initializeImageMagick(wasmBytes) + +const resizeImage = async (imageBlob: Blob) => { + await initMagickPromise + + const bytes = ImageMagick.read(await imageBlob.bytes(), (image) => { + const imagePixels = image.height * image.width + + 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)) + + image.resize(newWidth, newHeight) + } + + return image.write(MagickFormat.WebP, (data) => { + if (isUint8ArrayOfArrayBuffer(data)) { + return data + } + + throw new Error("Unexpected data type from ImageMagick") + }) + }) + + return new Blob([bytes], { type: "image/webp" }) +} diff --git a/vite.config.ts b/vite.config.ts index 9ff3139..8181dec 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,50 @@ import { fresh } from "@fresh/plugin-vite" import { heyApiPlugin } from "@hey-api/vite-plugin" -import { defineConfig } from "vite" +import { defineConfig, type Plugin } from "vite" + +const WASM_ID_PREFIX = "\0wasm:" + +/** + * Vite plugin to load WASM files as Uint8Array + * + * Deno supports loading WASM files directly, but Vite doesn't. + * And using params like `?url` or `?raw` doesn't work for external packages, + * so we need to handle `.wasm` files manually. + */ +const wasmPlugin = (): Plugin => { + return { + name: "wasm-loader", + enforce: "pre", + async resolveId(id, importer, options) { + if (!id.endsWith(".wasm")) { + return null + } + + const resolved = await this.resolve(id, importer, { + ...options, + skipSelf: true, + }) + + if (!resolved) { + return null + } + + return WASM_ID_PREFIX + resolved.id + }, + load: async (id) => { + if (!id.startsWith(WASM_ID_PREFIX)) { + return null + } + + const path = id.slice(WASM_ID_PREFIX.length) + const bytes = await Deno.readFile(path) + + return `export default new Uint8Array(${ + JSON.stringify(Array.from(bytes)) + })` + }, + } +} export default defineConfig({ resolve: { @@ -9,6 +53,7 @@ export default defineConfig({ }, }, plugins: [ + wasmPlugin(), // Must be loaded first to handle WASM imports correctly fresh(), heyApiPlugin( {