Skip to content

Commit

Permalink
Images memory consumption (#77)
Browse files Browse the repository at this point in the history
* refactor(utils): emoji encoded svg loading

* chore(env): added launch.json for debugging

* fix(api): prefetch image content to avoid satori inflightRequests cache memory leak
  • Loading branch information
seth2810 authored Aug 16, 2024
1 parent 96a1be6 commit 52ec0d8
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 74 deletions.
36 changes: 36 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"runtimeArgs": [
"--inspect"
],
"skipFiles": [
"<node_internals>/**"
],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}
19 changes: 3 additions & 16 deletions src/components/OpenGraph/Artist/OpenGraphDefaultArtist.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
/* eslint-disable jsx-a11y/alt-text */
import { Logo } from '@/components/Logo';
import { defaultUserImageURL } from '@/contants';
import formatter from '@/utils/formatter';
import { getOrigin } from '@/utils/ssrUtils';
import type { Artist } from '@/utils/statsfm';
import { isFacebookURL } from '@/utils/urls';
import type Api from '@statsfm/statsfm.js';
import type { NextApiRequest } from 'next';
import type { JSXElementConstructor, ReactElement } from 'react';

export function OpenGraphDefaultArtist(
req: NextApiRequest,
_: Api,
artist: Artist,
): ReactElement<JSXElementConstructor<any>> {
const origin = getOrigin(req);

let imageURL = artist.image ?? defaultUserImageURL;
if (isFacebookURL(imageURL)) {
imageURL = defaultUserImageURL;
}

artistImageBase64: string,
): ReactElement<JSXElementConstructor<unknown>> {
return (
<div tw="flex flex-col flex-1 w-full h-full bg-[#18181c]">
<div tw="flex flex-row m-auto pl-32 pr-32">
Expand All @@ -29,7 +16,7 @@ export function OpenGraphDefaultArtist(
tw="rounded-full"
height="400px"
width="400px"
src={`${origin}/api/image?url=${encodeURIComponent(imageURL)}&w=256&q=75&f=image/png&fallbackImg=${defaultUserImageURL}`}
src={artistImageBase64}
/>
</div>
<div
Expand Down
19 changes: 3 additions & 16 deletions src/components/OpenGraph/User/OpenGraphDefaultUser.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
/* eslint-disable jsx-a11y/alt-text */
import { Logo } from '@/components/Logo';
import { PlusBadgePrefilled } from '@/components/User/PlusBadge';
import { defaultUserImageURL } from '@/contants';
import { getOrigin } from '@/utils/ssrUtils';
import { splitStringAtLength } from '@/utils/string';
import { isFacebookURL } from '@/utils/urls';
import type { UserPublic } from '@statsfm/statsfm.js';
import type Api from '@statsfm/statsfm.js';
import type { NextApiRequest } from 'next';
import type { JSXElementConstructor, ReactElement } from 'react';

export function OpenGraphDefaultUser(
req: NextApiRequest,
_: Api,
user: UserPublic,
): ReactElement<JSXElementConstructor<any>> {
const origin = getOrigin(req);

userImageBase64: string,
): ReactElement<JSXElementConstructor<unknown>> {
const customId = user.customId ?? user.id;

let imageURL = user.image ?? defaultUserImageURL;
if (isFacebookURL(imageURL)) {
imageURL = defaultUserImageURL;
}

return (
<div tw="flex flex-col flex-1 w-[1200px] h-full bg-[#18181c]">
<div tw="flex flex-row m-auto pl-32 pr-32">
Expand All @@ -32,7 +19,7 @@ export function OpenGraphDefaultUser(
tw="rounded-full"
height="400px"
width="400px"
src={`${origin}/api/image?url=${encodeURIComponent(imageURL)}&w=512&q=75&f=image/png&fallbackImg=${defaultUserImageURL}`}
src={userImageBase64}
/>
{user.isPlus && (
<div tw="absolute right-0 bottom-2 flex">
Expand Down
22 changes: 20 additions & 2 deletions src/pages/api/og/artist/[id].ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getApiInstance } from '@/utils/ssrUtils';
import { getApiInstance, getOrigin } from '@/utils/ssrUtils';
import type { NextApiRequest, NextApiResponse } from 'next';
import { renderToImage } from '@/utils/satori';
import { OpenGraphDefaultArtist } from '@/components/OpenGraph/Artist/OpenGraphDefaultArtist';
import { defaultUserImageURL } from '@/contants';
import { isFacebookURL } from '@/utils/urls';

export const runtime = 'nodejs';

Expand All @@ -18,7 +20,23 @@ export default async function handler(
return;
}

const image = await renderToImage(OpenGraphDefaultArtist(req, api, artist));
// prefetch image content to avoid satori inflightRequests cache memory leak
// @see https://github.com/vercel/satori/issues/592#issuecomment-2293820464
let artistImageURL = artist.image ?? defaultUserImageURL;
if (isFacebookURL(artistImageURL)) {
artistImageURL = defaultUserImageURL;
}

const origin = getOrigin(req);
const artistImageDownloadURL = `${origin}/api/image?url=${encodeURIComponent(artistImageURL)}&w=256&q=75&f=image/png&fallbackImg=${defaultUserImageURL}`;
const artistImageBase64 = await fetch(artistImageDownloadURL)
.then((res) => res.arrayBuffer())
.then((content) => Buffer.from(content))
.then((buff) => `data:image/png;base64,${buff.toString('base64')}`);

const image = await renderToImage(
OpenGraphDefaultArtist(artist, artistImageBase64),
);

res.setHeader('Content-Type', 'image/png');
res.send(image);
Expand Down
22 changes: 20 additions & 2 deletions src/pages/api/og/user/[id]/[[...variants]].ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getApiInstance } from '@/utils/ssrUtils';
import { getApiInstance, getOrigin } from '@/utils/ssrUtils';
import { OpenGraphDefaultUser } from '@/components/OpenGraph/User/OpenGraphDefaultUser';
import type { NextApiRequest, NextApiResponse } from 'next';
import { renderToImage } from '@/utils/satori';
import { defaultUserImageURL } from '@/contants';
import { isFacebookURL } from '@/utils/urls';

export const runtime = 'nodejs';

Expand All @@ -28,7 +30,23 @@ export default async function handler(
user.customId = '';
}

const image = await renderToImage(OpenGraphDefaultUser(req, api, user));
// prefetch image content to avoid satori inflightRequests cache memory leak
// @see https://github.com/vercel/satori/issues/592#issuecomment-2293820464
let userImageURL = user.image ?? defaultUserImageURL;
if (isFacebookURL(userImageURL)) {
userImageURL = defaultUserImageURL;
}

const origin = getOrigin(req);
const userImageDownloadURL = `${origin}/api/image?url=${encodeURIComponent(userImageURL)}&w=512&q=75&f=image/png&fallbackImg=${defaultUserImageURL}`;
const userImageBase64 = await fetch(userImageDownloadURL)
.then((res) => res.arrayBuffer())
.then((content) => Buffer.from(content))
.then((buff) => `data:image/png;base64,${buff.toString('base64')}`);

const image = await renderToImage(
OpenGraphDefaultUser(user, userImageBase64),
);

res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=3600');
Expand Down
12 changes: 8 additions & 4 deletions src/utils/satori.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Resvg } from '@resvg/resvg-js';
import fs from 'fs';
import { join } from 'path';
import type { ReactElement } from 'react';
import { loadEmoji, getIconCode } from './twemoji';
import { getIconCode, EmojiType, loadEmojiSVGEncoded } from './twemoji';

const fonts: SatoriOptions['fonts'] = [
// the first entry is used as the default font
Expand Down Expand Up @@ -61,17 +61,21 @@ export async function renderToImage(
fonts: options.fonts ?? fonts,
loadAdditionalAsset: async (code, segment) => {
if (code === 'emoji') {
return `data:image/svg+xml;base64,${btoa(await loadEmoji('twemoji', getIconCode(segment)))}`;
const emojiCode = getIconCode(segment);

return loadEmojiSVGEncoded(EmojiType.TWEMOJI, emojiCode);
}

return code;
},
});

const w = new Resvg(svg, {
const image = new Resvg(svg, {
fitTo: {
mode: 'width',
value: options.width,
},
});
return w.render().asPng();

return image.render().asPng();
}
75 changes: 41 additions & 34 deletions src/utils/twemoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,46 +33,53 @@ export function getIconCode(char: string) {
return toCodePoint(!char.includes(U200D) ? char.replace(UFE0Fg, '') : char);
}

export const apis = {
twemoji: (code: string) =>
`https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`,
openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/',
blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/',
noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/',
fluent: (code: string) =>
`https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_color.svg`,
fluentFlat: (code: string) =>
`https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_flat.svg`,
};

const emojiCache: Record<string, Promise<string>> = {};

export async function loadEmoji(type: keyof typeof apis, code: string) {
const key = `${type}:${code}`;

if (key in emojiCache) {
return emojiCache[key]!;
}

let selectedType = type;
export enum EmojiType {
TWEMOJI = 'twemoji',
OPENOJI = 'openmoji',
BLOBMOJI = 'blobmoji',
NOTO = 'noto',
FLUENT = 'fluent',
FLUENT_FLAT = 'fluentFlat',
}

if (!type || !apis[type]) {
selectedType = 'twemoji';
function getEmojiImageUrl(code: string, type: EmojiType): string {
switch (type) {
case 'twemoji':
return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`;
case 'openmoji':
return `https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/${code.toUpperCase()}.svg`;
case 'blobmoji':
return `https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/${code.toUpperCase()}.svg`;
case 'noto':
return `https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/${code.toUpperCase()}.svg`;
case 'fluent':
return `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_color.svg`;
case 'fluentFlat':
return `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_flat.svg`;
default:
return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`;
}
}

const api = apis[selectedType];
const cachedEmojis = new Map<string, string>();

let emojiPromise: Promise<string>;
export async function loadEmojiSVGEncoded(
type: EmojiType,
code: string,
): Promise<string> {
const key = `${type}:${code}`;

if (typeof api === 'function') {
emojiPromise = fetch(api(code)).then(async (r) => r.text());
} else {
emojiPromise = fetch(`${api}${code.toUpperCase()}.svg`).then(async (r) =>
r.text(),
);
if (cachedEmojis.has(key)) {
return cachedEmojis.get(key)!;
}

emojiCache[key] = emojiPromise; // Storing the promise in the cache
const emojiImageURL = getEmojiImageUrl(code, type);
const emojiSVGEncoded = await fetch(emojiImageURL)
.then((res) => res.arrayBuffer())
.then((content) => Buffer.from(content))
.then((buff) => `data:image/svg+xml;base64,${buff.toString('base64')}`);

cachedEmojis.set(key, emojiSVGEncoded);

return emojiPromise; // Returning the promise
return emojiSVGEncoded;
}

0 comments on commit 52ec0d8

Please sign in to comment.