From c5032d0c431dd35c866933f3138a4c5d35f0490e Mon Sep 17 00:00:00 2001 From: Ted Liang Date: Tue, 9 Dec 2025 09:19:49 +1100 Subject: [PATCH] refactor: extract image-helpers (#2261) --- .../routes/_share+/share.$slug.opengraph.tsx | 5 ++--- .../api+/branding.logo.organisation.$orgId.ts | 15 +++++---------- .../routes/api+/branding.logo.team.$teamId.ts | 15 +++++---------- apps/remix/package.json | 1 - package-lock.json | 1 - .../ai/envelope/detect-fields/index.ts | 19 ++----------------- .../server-only/profile/get-avatar-image.ts | 11 +++-------- .../server-only/profile/set-avatar-image.ts | 8 ++------ packages/lib/utils/images/avatar.ts | 16 ++++++++++++++++ packages/lib/utils/images/logo.ts | 10 ++++++++++ .../images/resize-image-to-gemini-image.ts | 19 +++++++++++++++++++ packages/lib/utils/images/svg-to-png.ts | 5 +++++ 12 files changed, 69 insertions(+), 56 deletions(-) create mode 100644 packages/lib/utils/images/avatar.ts create mode 100644 packages/lib/utils/images/logo.ts create mode 100644 packages/lib/utils/images/resize-image-to-gemini-image.ts create mode 100644 packages/lib/utils/images/svg-to-png.ts diff --git a/apps/remix/app/routes/_share+/share.$slug.opengraph.tsx b/apps/remix/app/routes/_share+/share.$slug.opengraph.tsx index 7e4cbd636..83a6087d4 100644 --- a/apps/remix/app/routes/_share+/share.$slug.opengraph.tsx +++ b/apps/remix/app/routes/_share+/share.$slug.opengraph.tsx @@ -1,9 +1,9 @@ import satori from 'satori'; -import sharp from 'sharp'; import { P, match } from 'ts-pattern'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug'; +import { svgToPng } from '@documenso/lib/utils/images/svg-to-png'; import type { Route } from './+types/share.$slug.opengraph'; @@ -181,8 +181,7 @@ export const loader = async ({ params }: Route.LoaderArgs) => { }, ); - // Convert SVG to PNG using sharp - const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer(); + const pngBuffer = await svgToPng(svg.toString()); return new Response(pngBuffer, { headers: { diff --git a/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts b/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts index 603834990..da6c8da1a 100644 --- a/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts +++ b/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts @@ -1,6 +1,5 @@ -import sharp from 'sharp'; - import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; +import { loadLogo } from '@documenso/lib/utils/images/logo'; import { prisma } from '@documenso/prisma'; import type { Route } from './+types/branding.logo.organisation.$orgId'; @@ -63,16 +62,12 @@ export async function loader({ params }: Route.LoaderArgs) { ); } - const img = await sharp(file) - .toFormat('png', { - quality: 80, - }) - .toBuffer(); + const { content, contentType } = await loadLogo(file); - return new Response(Buffer.from(img), { + return new Response(content, { headers: { - 'Content-Type': 'image/png', - 'Content-Length': img.length.toString(), + 'Content-Type': contentType, + 'Content-Length': content.length.toString(), // Stale while revalidate for 1 hours to 24 hours 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', }, diff --git a/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts index 096c0b19f..30a0e5b3c 100644 --- a/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts +++ b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts @@ -1,7 +1,6 @@ -import sharp from 'sharp'; - import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; +import { loadLogo } from '@documenso/lib/utils/images/logo'; import type { Route } from './+types/branding.logo.team.$teamId'; @@ -56,16 +55,12 @@ export async function loader({ params }: Route.LoaderArgs) { ); } - const img = await sharp(file) - .toFormat('png', { - quality: 80, - }) - .toBuffer(); + const { content, contentType } = await loadLogo(file); - return new Response(img, { + return new Response(content, { headers: { - 'Content-Type': 'image/png', - 'Content-Length': img.length.toString(), + 'Content-Type': contentType, + 'Content-Length': content.length.toString(), // Stale while revalidate for 1 hours to 24 hours 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', }, diff --git a/apps/remix/package.json b/apps/remix/package.json index 962574130..ed0cd1d9e 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -70,7 +70,6 @@ "remeda": "^2.32.0", "remix-themes": "^2.0.4", "satori": "^0.18.3", - "sharp": "0.34.5", "tailwindcss": "^3.4.18", "ts-pattern": "^5.9.0", "ua-parser-js": "^1.0.41", diff --git a/package-lock.json b/package-lock.json index e3fa13294..a803339da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -167,7 +167,6 @@ "remeda": "^2.32.0", "remix-themes": "^2.0.4", "satori": "^0.18.3", - "sharp": "0.34.5", "tailwindcss": "^3.4.18", "ts-pattern": "^5.9.0", "ua-parser-js": "^1.0.41", diff --git a/packages/lib/server-only/ai/envelope/detect-fields/index.ts b/packages/lib/server-only/ai/envelope/detect-fields/index.ts index a3c9f1e3c..da85cd571 100644 --- a/packages/lib/server-only/ai/envelope/detect-fields/index.ts +++ b/packages/lib/server-only/ai/envelope/detect-fields/index.ts @@ -2,12 +2,12 @@ import { createCanvas, loadImage } from '@napi-rs/canvas'; import { DocumentStatus, type Field, RecipientRole } from '@prisma/client'; import { generateObject } from 'ai'; import pMap from 'p-map'; -import sharp from 'sharp'; import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../../../errors/app-error'; import { getFileServerSide } from '../../../../universal/upload/get-file.server'; +import { resizeImageToGeminiImage } from '../../../../utils/images/resize-image-to-gemini-image'; import { getEnvelopeById } from '../../../envelope/get-envelope-by-id'; import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients'; import { vertex } from '../../google'; @@ -238,21 +238,6 @@ const maskFieldsOnImage = async ({ image, width, height, fields }: MaskFieldsOnI return canvas.encode('jpeg'); }; -const TARGET_SIZE = 1000; - -type ResizeImageOptions = { - image: Buffer; - size?: number; -}; - -/** - * Resize image to 1000x1000 using fill strategy. - * Scales to cover the target area and crops any overflow. - */ -const resizeImageToSquare = async ({ image, size = TARGET_SIZE }: ResizeImageOptions) => { - return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer(); -}; - type DetectFieldsFromPageOptions = { image: Buffer; pageNumber: number; @@ -267,7 +252,7 @@ const detectFieldsFromPage = async ({ context, }: DetectFieldsFromPageOptions) => { // Resize to 1000x1000 for consistent coordinate mapping - const resizedImage = await resizeImageToSquare({ image }); + const resizedImage = await resizeImageToGeminiImage({ image }); // Build messages array const messages: Parameters[0]['messages'] = [ diff --git a/packages/lib/server-only/profile/get-avatar-image.ts b/packages/lib/server-only/profile/get-avatar-image.ts index 992869dcb..0ea75f257 100644 --- a/packages/lib/server-only/profile/get-avatar-image.ts +++ b/packages/lib/server-only/profile/get-avatar-image.ts @@ -1,7 +1,7 @@ -import sharp from 'sharp'; - import { prisma } from '@documenso/prisma'; +import { loadAvatar } from '../../utils/images/avatar'; + export type GetAvatarImageOptions = { id: string; }; @@ -17,10 +17,5 @@ export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => { return null; } - const bytes = Buffer.from(avatarImage.bytes, 'base64'); - - return { - contentType: 'image/jpeg', - content: await sharp(bytes).toFormat('jpeg').toBuffer(), - }; + return await loadAvatar(avatarImage.bytes); }; diff --git a/packages/lib/server-only/profile/set-avatar-image.ts b/packages/lib/server-only/profile/set-avatar-image.ts index 97b8cabe4..a67a6fe08 100644 --- a/packages/lib/server-only/profile/set-avatar-image.ts +++ b/packages/lib/server-only/profile/set-avatar-image.ts @@ -1,11 +1,10 @@ -import sharp from 'sharp'; - import { prisma } from '@documenso/prisma'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations'; import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; import { AppError } from '../../errors/app-error'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; +import { optimiseAvatar } from '../../utils/images/avatar'; import { buildOrganisationWhereQuery } from '../../utils/organisations'; import { buildTeamWhereQuery } from '../../utils/teams'; @@ -100,10 +99,7 @@ export const setAvatarImage = async ({ let newAvatarImageId: string | null = null; if (bytes) { - const optimisedBytes = await sharp(Buffer.from(bytes, 'base64')) - .resize(512, 512) - .toFormat('jpeg', { quality: 75 }) - .toBuffer(); + const optimisedBytes = await optimiseAvatar(bytes); const avatarImage = await prisma.avatarImage.create({ data: { diff --git a/packages/lib/utils/images/avatar.ts b/packages/lib/utils/images/avatar.ts new file mode 100644 index 000000000..5bd978e9b --- /dev/null +++ b/packages/lib/utils/images/avatar.ts @@ -0,0 +1,16 @@ +import sharp from 'sharp'; + +export const optimiseAvatar = async (bytes: string) => { + return await sharp(Buffer.from(bytes, 'base64')) + .resize(512, 512) + .toFormat('jpeg', { quality: 75 }) + .toBuffer(); +}; + +export const loadAvatar = async (bytes: string) => { + const content = await sharp(Buffer.from(bytes, 'base64')).toFormat('jpeg').toBuffer(); + return { + contentType: 'image/jpeg', + content, + }; +}; diff --git a/packages/lib/utils/images/logo.ts b/packages/lib/utils/images/logo.ts new file mode 100644 index 000000000..b81f7c012 --- /dev/null +++ b/packages/lib/utils/images/logo.ts @@ -0,0 +1,10 @@ +import sharp from 'sharp'; + +export const loadLogo = async (file: Uint8Array) => { + const content = await sharp(file).toFormat('png', { quality: 80 }).toBuffer(); + + return { + contentType: 'image/png', + content, + }; +}; diff --git a/packages/lib/utils/images/resize-image-to-gemini-image.ts b/packages/lib/utils/images/resize-image-to-gemini-image.ts new file mode 100644 index 000000000..b93cfedaa --- /dev/null +++ b/packages/lib/utils/images/resize-image-to-gemini-image.ts @@ -0,0 +1,19 @@ +import sharp from 'sharp'; + +export const TARGET_SIZE = 1000; + +type ResizeImageToGeminiImageOptions = { + image: Buffer; + size?: number; +}; + +/** + * Resize image to 1000x1000 using fill strategy. + * Scales to cover the target area and crops any overflow. + */ +export const resizeImageToGeminiImage = async ({ + image, + size = TARGET_SIZE, +}: ResizeImageToGeminiImageOptions) => { + return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer(); +}; diff --git a/packages/lib/utils/images/svg-to-png.ts b/packages/lib/utils/images/svg-to-png.ts new file mode 100644 index 000000000..c9ae3ab0e --- /dev/null +++ b/packages/lib/utils/images/svg-to-png.ts @@ -0,0 +1,5 @@ +import sharp from 'sharp'; + +export const svgToPng = async (svg: string) => { + return await sharp(Buffer.from(svg)).toFormat('png').toBuffer(); +};