From 3e97c2b0f571530bff893ce502dd197f15ee163d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:44:14 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20image=20optimization=20=E2=80=94=20clie?= =?UTF-8?q?nt-side=20resize=20+=20server-side=20thumbnails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatars/banners now resize to max 512px/1920px and convert to WebP before upload (zero server cost). Chat image uploads generate an 800px-wide WebP thumbnail via Sharp; the feed shows the thumbnail, click opens the full-res original. Adds lazy loading to avatars. Federation-compatible: remote instances without this feature fall back gracefully. --- package.json | 2 +- packages/server/package.json | 3 +- packages/server/src/db/migrate.ts | 6 + packages/server/src/db/schema.ts | 1 + packages/server/src/routes/dm.ts | 1 + packages/server/src/routes/messages.ts | 1 + packages/server/src/routes/uploads.ts | 9 + packages/server/src/utils/fileCleanup.ts | 11 + packages/server/src/utils/thumbnail.ts | 66 +++++ packages/server/src/ws/events.ts | 1 + packages/shared/src/types.ts | 1 + packages/web/src/components/chat/Message.tsx | 5 +- .../modals/settingsPanels/AccountPanel.tsx | 6 +- .../spaceSettingsPanels/OverviewPanel.tsx | 6 +- packages/web/src/components/ui/Avatar.tsx | 1 + .../web/src/components/ui/ImageCropModal.tsx | 9 +- packages/web/src/utils/assetUrls.ts | 8 +- packages/web/src/utils/cropImage.ts | 70 ++++- pnpm-lock.yaml | 262 ++++++++++++++++++ 19 files changed, 449 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/utils/thumbnail.ts diff --git a/package.json b/package.json index 1a356257..a350afda 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build & pnpm --filter @backspace/web build" }, "pnpm": { - "onlyBuiltDependencies": ["better-sqlite3", "esbuild"] + "onlyBuiltDependencies": ["better-sqlite3", "esbuild", "sharp"] }, "engines": { "node": ">=20.0.0", diff --git a/packages/server/package.json b/packages/server/package.json index 26873e2d..3cbbc46b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -23,7 +23,8 @@ "drizzle-orm": "^0.33.0", "fastify": "^4.28.1", "jsonwebtoken": "^9.0.2", - "livekit-server-sdk": "^2.6.1" + "livekit-server-sdk": "^2.6.1", + "sharp": "^0.33.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index ea577faa..7fb39200 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -44,6 +44,12 @@ export function runMigrations(db: Database.Database): void { { name: 'closed', type: 'INTEGER DEFAULT 0' } ] }, + { + name: 'attachments', + columns: [ + { name: 'thumbnail_filename', type: 'TEXT' } + ] + }, { name: 'dm_channels', columns: [ diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 2e4eb763..071302af 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -85,6 +85,7 @@ export const attachments = sqliteTable('attachments', { originalName: text('original_name').notNull(), mimetype: text('mimetype').notNull(), size: integer('size').notNull(), + thumbnailFilename: text('thumbnail_filename'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 9cd3040c..f02f57cf 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -83,6 +83,7 @@ export function buildDmMessageWithUser( originalName: a.originalName, mimetype: a.mimetype, size: a.size, + thumbnailFilename: a.thumbnailFilename ?? null, createdAt: a.createdAt, })), reactions, diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index f75b985a..3b01c430 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -143,6 +143,7 @@ export function buildMessageWithUser( originalName: a.originalName, mimetype: a.mimetype, size: a.size, + thumbnailFilename: a.thumbnailFilename ?? null, createdAt: a.createdAt, })), reactions, diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts index fb1894e8..89ce28f6 100644 --- a/packages/server/src/routes/uploads.ts +++ b/packages/server/src/routes/uploads.ts @@ -8,6 +8,7 @@ import fs from 'fs'; import path from 'path'; import { pipeline } from 'stream/promises'; import type { Attachment } from '@backspace/shared'; +import { generateThumbnail, isResizableImage } from '../utils/thumbnail.js'; export async function uploadRoutes(app: FastifyInstance): Promise { // Ensure upload directory exists @@ -57,6 +58,12 @@ export async function uploadRoutes(app: FastifyInstance): Promise { const now = Date.now(); const db = getDb(); + // Generate thumbnail for resizable images (non-blocking — upload succeeds regardless) + let thumbnailFilename: string | null = null; + if (isResizableImage(mimetype)) { + thumbnailFilename = await generateThumbnail(filepath, mimetype, config.uploadDir); + } + // Save attachment record db.insert(schema.attachments).values({ id, @@ -64,6 +71,7 @@ export async function uploadRoutes(app: FastifyInstance): Promise { originalName, mimetype, size, + thumbnailFilename, createdAt: now, }).run(); @@ -74,6 +82,7 @@ export async function uploadRoutes(app: FastifyInstance): Promise { originalName, mimetype, size, + thumbnailFilename: thumbnailFilename ?? undefined, createdAt: now, }; diff --git a/packages/server/src/utils/fileCleanup.ts b/packages/server/src/utils/fileCleanup.ts index 096a858d..c2603004 100644 --- a/packages/server/src/utils/fileCleanup.ts +++ b/packages/server/src/utils/fileCleanup.ts @@ -1,11 +1,13 @@ import fs from 'fs'; import path from 'path'; import { config } from '../config.js'; +import { thumbFilename } from './thumbnail.js'; /** * Delete a single uploaded file by its stored filename. * Uses path.basename() to prevent directory traversal attacks. * Tolerates ENOENT (file already gone) but logs other errors. + * Also attempts to delete the corresponding thumbnail if one exists. */ export function deleteUploadFile(filename: string): void { const safeName = path.basename(filename); @@ -17,6 +19,15 @@ export function deleteUploadFile(filename: string): void { console.error(`Failed to delete upload file ${safeName}:`, err.message); } } + + // Also attempt to delete the thumbnail variant + const thumbName = thumbFilename(safeName); + const thumbPath = path.join(config.uploadDir, thumbName); + try { + fs.unlinkSync(thumbPath); + } catch { + // Thumbnail may not exist — that's fine + } } /** diff --git a/packages/server/src/utils/thumbnail.ts b/packages/server/src/utils/thumbnail.ts new file mode 100644 index 00000000..894c9a0e --- /dev/null +++ b/packages/server/src/utils/thumbnail.ts @@ -0,0 +1,66 @@ +import path from 'path'; +import sharp from 'sharp'; + +const RESIZABLE_MIMETYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'image/avif', + 'image/tiff', +]); + +const THUMBNAIL_MAX_WIDTH = 800; +const THUMBNAIL_QUALITY = 80; + +/** + * Derive a deterministic thumbnail filename from the original. + * e.g. "123456789.png" → "123456789_thumb.webp" + */ +export function thumbFilename(original: string): string { + const parsed = path.parse(original); + return `${parsed.name}_thumb.webp`; +} + +/** Check if the given mimetype is a resizable image format. */ +export function isResizableImage(mimetype: string): boolean { + return RESIZABLE_MIMETYPES.has(mimetype); +} + +/** + * Generate a WebP thumbnail for the given image file. + * Returns the thumbnail filename on success, or null if: + * - The image is already ≤ THUMBNAIL_MAX_WIDTH px wide + * - An error occurs (upload still succeeds without thumbnail) + */ +export async function generateThumbnail( + originalPath: string, + mimetype: string, + uploadDir: string, +): Promise { + if (!isResizableImage(mimetype)) return null; + + try { + const image = sharp(originalPath); + const metadata = await image.metadata(); + + // Skip if the original is already small enough + if (!metadata.width || metadata.width <= THUMBNAIL_MAX_WIDTH) { + return null; + } + + const originalFilename = path.basename(originalPath); + const thumbName = thumbFilename(originalFilename); + const thumbPath = path.join(uploadDir, thumbName); + + await sharp(originalPath) + .resize({ width: THUMBNAIL_MAX_WIDTH, withoutEnlargement: true }) + .webp({ quality: THUMBNAIL_QUALITY }) + .toFile(thumbPath); + + return thumbName; + } catch (err) { + console.error('Thumbnail generation failed (non-fatal):', err); + return null; + } +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 1310f8bf..723a0696 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -58,6 +58,7 @@ function getMessageWithUser(messageId: string): MessageWithUser | null { originalName: a.originalName, mimetype: a.mimetype, size: a.size, + thumbnailFilename: a.thumbnailFilename ?? null, createdAt: a.createdAt, })); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2205fa0b..09751ccd 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -209,6 +209,7 @@ export interface Attachment { originalName: string; mimetype: string; size: number; + thumbnailFilename?: string | null; createdAt: number; } diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index ca7223d3..3b71ca74 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -263,11 +263,14 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { {message.attachments.map((att) => { const isImage = att.mimetype.startsWith('image/'); const attUrl = att.filename.startsWith('http') ? att.filename : `/api/uploads/${att.filename}`; + const thumbUrl = att.thumbnailFilename + ? (att.thumbnailFilename.startsWith('http') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`) + : null; if (isImage) { return (
{att.originalName} openImagePreview(attUrl)} diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 3b801249..92da1350 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -139,7 +139,7 @@ export function AccountPanel() { const previewUrl = URL.createObjectURL(blob); setAvatarPreview(previewUrl); setAvatarCropSrc(null); - const file = new File([blob], 'avatar.png', { type: 'image/png' }); + const file = new File([blob], 'avatar.webp', { type: blob.type || 'image/webp' }); setUploadingAvatar(true); try { const attachment = await api.uploads.upload(file); @@ -158,7 +158,7 @@ export function AccountPanel() { const previewUrl = URL.createObjectURL(blob); setBannerPreview(previewUrl); setBannerCropSrc(null); - const file = new File([blob], 'banner.png', { type: 'image/png' }); + const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' }); setUploadingBanner(true); try { const attachment = await api.uploads.upload(file); @@ -737,6 +737,7 @@ export function AccountPanel() { title="Crop Avatar" cropShape="round" aspectRatio={1} + maxOutputDimension={512} /> ); diff --git a/packages/web/src/components/ui/Avatar.tsx b/packages/web/src/components/ui/Avatar.tsx index 7265f7ad..9a2a57e7 100644 --- a/packages/web/src/components/ui/Avatar.tsx +++ b/packages/web/src/components/ui/Avatar.tsx @@ -105,6 +105,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick, src={(src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:')) ? src : `/api/uploads/${src}`} alt={name} + loading="lazy" className="w-full h-full rounded-full object-cover" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; diff --git a/packages/web/src/components/ui/ImageCropModal.tsx b/packages/web/src/components/ui/ImageCropModal.tsx index 62925a47..e706e7b9 100644 --- a/packages/web/src/components/ui/ImageCropModal.tsx +++ b/packages/web/src/components/ui/ImageCropModal.tsx @@ -11,6 +11,8 @@ interface ImageCropModalProps { title?: string; aspectRatio?: number; cropShape?: 'round' | 'rect'; + maxOutputDimension?: number; + outputType?: string; } export function ImageCropModal({ @@ -21,6 +23,8 @@ export function ImageCropModal({ title = 'Crop Image', aspectRatio = 1, cropShape = 'round', + maxOutputDimension, + outputType, }: ImageCropModalProps) { const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); @@ -35,7 +39,10 @@ export function ImageCropModal({ if (!croppedAreaPixels) return; setIsProcessing(true); try { - const blob = await cropImage(imageSrc, croppedAreaPixels); + const blob = await cropImage(imageSrc, croppedAreaPixels, outputType ?? 'image/webp', { + maxDimension: maxOutputDimension, + outputType, + }); onCropComplete(blob); onClose(); } catch { diff --git a/packages/web/src/utils/assetUrls.ts b/packages/web/src/utils/assetUrls.ts index 23672266..3da3b630 100644 --- a/packages/web/src/utils/assetUrls.ts +++ b/packages/web/src/utils/assetUrls.ts @@ -43,7 +43,7 @@ export function normalizeUserAssets( +export function normalizeMessageAssets( message: T, origin: string, ): T { @@ -52,6 +52,9 @@ export function normalizeMessageAssets { +interface CropOptions { + maxDimension?: number; + quality?: number; + outputType?: string; +} + +export function cropImage( + imageSrc: string, + pixelCrop: PixelCrop, + outputType = 'image/webp', + options: CropOptions = {}, +): Promise { + const { maxDimension, quality = 0.85 } = options; + const finalType = options.outputType ?? outputType; + return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = pixelCrop.width; - canvas.height = pixelCrop.height; - const ctx = canvas.getContext('2d'); - if (!ctx) { + // Step 1: Draw the crop region at original size + const cropCanvas = document.createElement('canvas'); + cropCanvas.width = pixelCrop.width; + cropCanvas.height = pixelCrop.height; + const cropCtx = cropCanvas.getContext('2d'); + if (!cropCtx) { reject(new Error('Failed to get canvas context')); return; } - ctx.drawImage( + cropCtx.drawImage( img, pixelCrop.x, pixelCrop.y, @@ -29,12 +44,45 @@ export function cropImage(imageSrc: string, pixelCrop: PixelCrop, outputType = ' pixelCrop.width, pixelCrop.height, ); - canvas.toBlob( + + // Step 2: Downscale if either dimension exceeds maxDimension + let outputCanvas = cropCanvas; + if (maxDimension && (pixelCrop.width > maxDimension || pixelCrop.height > maxDimension)) { + const scale = maxDimension / Math.max(pixelCrop.width, pixelCrop.height); + const scaledW = Math.round(pixelCrop.width * scale); + const scaledH = Math.round(pixelCrop.height * scale); + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = scaledW; + scaledCanvas.height = scaledH; + const scaledCtx = scaledCanvas.getContext('2d'); + if (!scaledCtx) { + reject(new Error('Failed to get scaled canvas context')); + return; + } + scaledCtx.drawImage(cropCanvas, 0, 0, scaledW, scaledH); + outputCanvas = scaledCanvas; + } + + // Step 3: Export to blob — try WebP first, fall back to PNG if unsupported + outputCanvas.toBlob( (blob) => { - if (blob) resolve(blob); - else reject(new Error('Canvas toBlob returned null')); + if (blob) { + resolve(blob); + } else if (finalType === 'image/webp') { + // WebP not supported (old Safari) — fall back to PNG + outputCanvas.toBlob( + (pngBlob) => { + if (pngBlob) resolve(pngBlob); + else reject(new Error('Canvas toBlob returned null')); + }, + 'image/png', + ); + } else { + reject(new Error('Canvas toBlob returned null')); + } }, - outputType, + finalType, + quality, ); }; img.onerror = () => reject(new Error('Failed to load image')); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52e56d66..6cae9bb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: livekit-server-sdk: specifier: ^2.6.1 version: 2.15.0 + sharp: + specifier: ^0.33.0 + version: 0.33.5 devDependencies: '@types/bcryptjs': specifier: ^2.4.6 @@ -362,6 +365,9 @@ packages: resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==} engines: {node: '>=16.4'} + '@emnapi/runtime@1.9.0': + resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1016,6 +1022,123 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1804,10 +1927,17 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2580,6 +2710,9 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -3610,6 +3743,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3634,6 +3771,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -4406,6 +4546,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@emnapi/runtime@1.9.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -4792,6 +4937,81 @@ snapshots: '@gar/promisify@1.1.3': {} + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.9.0 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5643,8 +5863,18 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + color-support@1.1.3: {} + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -6548,6 +6778,8 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-arrayish@0.3.4: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -7832,6 +8064,32 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7852,6 +8110,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + simple-update-notifier@2.0.0: dependencies: semver: 7.7.4