From 589bd87534c26951fb8b15ea4a87864ed9b47496 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Wed, 6 Nov 2024 14:03:37 +0900 Subject: [PATCH] perf: embed webp in svg output --- src/processing/image.ts | 22 +++++++++++++-------- src/processing/svg.ts | 22 +++++++++++---------- src/renders/circles.ts | 2 ++ src/renders/tiers.ts | 18 +++++++++--------- src/run.ts | 42 +++++++++++++++++++++++++++++++++-------- src/types.ts | 9 +++++++++ 6 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src/processing/image.ts b/src/processing/image.ts index e56d761..019ddc2 100644 --- a/src/processing/image.ts +++ b/src/processing/image.ts @@ -3,7 +3,7 @@ import { consola } from 'consola' import { $fetch } from 'ofetch' import sharp from 'sharp' import { version } from '../../package.json' -import type { SponsorkitConfig, Sponsorship } from '../types' +import type { ImageFormat, SponsorkitConfig, Sponsorship } from '../types' async function fetchImage(url: string) { const arrayBuffer = await $fetch(url, { @@ -47,32 +47,38 @@ export async function resolveAvatars( if (pngBuffer) { // Store the highest resolution version we use of the original image - ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120) + ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120, 'webp') } }))) } -const cache = new Map>() +const cache = new Map>() export async function resizeImage( image: Buffer, size = 100, + format: ImageFormat, ) { + const cacheKey = `${size}:${format}` if (cache.has(image)) { - const cacheHit = cache.get(image)!.get(size) + const cacheHit = cache.get(image)!.get(cacheKey) if (cacheHit) { return cacheHit } } - const result = await sharp(image) + let processing = sharp(image) .resize(size, size, { fit: sharp.fit.cover }) - .png({ quality: 80, compressionLevel: 8 }) - .toBuffer() + + processing = (format === 'webp') + ? processing.webp() + : processing.png({ quality: 80, compressionLevel: 8 }) + + const result = await processing.toBuffer() if (!cache.has(image)) { cache.set(image, new Map()) } - cache.get(image)!.set(size, result) + cache.get(image)!.set(cacheKey, result) return result } diff --git a/src/processing/svg.ts b/src/processing/svg.ts index 270587e..f0dc06c 100644 --- a/src/processing/svg.ts +++ b/src/processing/svg.ts @@ -1,20 +1,21 @@ import { resizeImage } from './image' -import type { BadgePreset, Sponsor, SponsorkitRenderOptions, Sponsorship } from '../types' +import type { BadgePreset, ImageFormat, Sponsor, SponsorkitRenderOptions, Sponsorship } from '../types' let id = 0 export function genSvgImage( x: number, y: number, size: number, - base64Image: string, radius: number, + base64Image: string, + imageFormat: ImageFormat, ) { const cropId = `c${id++}` return ` - ` + ` } export async function generateBadge( @@ -23,8 +24,8 @@ export async function generateBadge( sponsor: Sponsor, preset: BadgePreset, radius: number, + imageFormat: ImageFormat, ) { - const size = preset.avatar.size const { login } = sponsor let name = (sponsor.name || sponsor.login).trim() const url = sponsor.websiteUrl || sponsor.linkUrl @@ -36,15 +37,16 @@ export async function generateBadge( name = `${name.slice(0, preset.name.maxLength - 3)}...` } + const { size } = preset.avatar let avatar if (size < 50) { - avatar = await resizeImage(sponsor.avatarBuffer!, 50) + avatar = await resizeImage(sponsor.avatarBuffer!, 50, imageFormat) } - else if (size < 90) { - avatar = await resizeImage(sponsor.avatarBuffer!, 80) + else if (size < 80) { + avatar = await resizeImage(sponsor.avatarBuffer!, 80, imageFormat) } else { - avatar = await resizeImage(sponsor.avatarBuffer!, 120) + avatar = await resizeImage(sponsor.avatarBuffer!, 120, imageFormat) } avatar = avatar.toString('base64') @@ -53,7 +55,7 @@ export async function generateBadge( ${preset.name ? `${encodeHtmlEntities(name)} ` - : ''}${genSvgImage(x, y, size, avatar, radius)} + : ''}${genSvgImage(x, y, size, radius, avatar, imageFormat)} `.trim() } @@ -90,7 +92,7 @@ export class SvgComposer { const x = offsetX + preset.boxWidth * i const y = this.height const radius = s.sponsor.type === 'Organization' ? 0.1 : 0.5 - return await generateBadge(x, y, s.sponsor, preset, radius) + return await generateBadge(x, y, s.sponsor, preset, radius, this.config.imageFormat) })) this.body += sponsorLine.join('\n') diff --git a/src/renders/circles.ts b/src/renders/circles.ts index e1ad84f..c5a35da 100644 --- a/src/renders/circles.ts +++ b/src/renders/circles.ts @@ -33,6 +33,7 @@ export const circlesRenderer: SponsorkitRenderer = { p.padding(config.width / 400) const circles = p(root as any).descendants().slice(1) + const { imageFormat } = config for (const circle of circles) { composer.addRaw(await generateBadge( circle.x - circle.r, @@ -47,6 +48,7 @@ export const circlesRenderer: SponsorkitRenderer = { }, }, 0.5, + imageFormat, )) } diff --git a/src/renders/tiers.ts b/src/renders/tiers.ts index c4ed588..e7229c6 100644 --- a/src/renders/tiers.ts +++ b/src/renders/tiers.ts @@ -3,15 +3,6 @@ import { tierPresets } from '../configs/tier-presets' import { SvgComposer } from '../processing/svg' import type { SponsorkitConfig, SponsorkitRenderer, Sponsorship } from '../types' -export const tiersRenderer: SponsorkitRenderer = { - name: 'sponsorkit:tiers', - async renderSVG(config, sponsors) { - const composer = new SvgComposer(config) - await (config.customComposer || tiersComposer)(composer, sponsors, config) - return composer.generateSvg() - }, -} - export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship[], config: SponsorkitConfig) { const tierPartitions = partitionTiers(sponsors, config.tiers!, config.includePastSponsors) @@ -44,3 +35,12 @@ export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship composer.addSpan(config.padding?.bottom ?? 20) } + +export const tiersRenderer: SponsorkitRenderer = { + name: 'sponsorkit:tiers', + async renderSVG(config, sponsors) { + const composer = new SvgComposer(config) + await (config.customComposer || tiersComposer)(composer, sponsors, config) + return composer.generateSvg() + }, +} diff --git a/src/run.ts b/src/run.ts index 80f3041..3c43cfe 100644 --- a/src/run.ts +++ b/src/run.ts @@ -272,11 +272,23 @@ export async function applyRenderer( if (!renderOptions.includePrivate) sponsors = sponsors.filter(s => s.privacyLevel !== 'PRIVATE') + if (!renderOptions.imageFormat) + renderOptions.imageFormat = 'webp' + t.info(`${logPrefix} Composing SVG...`) - let svg = await renderer.renderSVG(renderOptions, sponsors) - svg = await renderOptions.onSvgGenerated?.(svg) || svg + + const processingSvg = (async () => { + let svgWebp = await renderer.renderSVG(renderOptions, sponsors) + + if (renderOptions.onSvgGenerated) { + svgWebp = await renderOptions.onSvgGenerated(svgWebp) || svgWebp + } + return svgWebp + })() if (renderOptions.formats) { + let svgPng: Promise | undefined + await Promise.all([ renderOptions.formats.map(async (format) => { if (!outputFormats.includes(format)) @@ -284,16 +296,30 @@ export async function applyRenderer( const path = join(dir, `${renderOptions.name}.${format}`) - let data: string | Buffer = svg - if (format === 'png') { - data = await svgToPng(svg) + let data: string | Buffer + + if (format === 'svg') { + data = await processingSvg } - if (format === 'webp') { - data = await svgToWebp(svg) + if (format === 'png' || format === 'webp') { + if (!svgPng) { + svgPng = renderer.renderSVG({ + ...renderOptions, + imageFormat: 'png', + }, sponsors) + } + + if (format === 'png') { + data = await svgToPng(await svgPng) + } + + if (format === 'webp') { + data = await svgToWebp(await svgPng) + } } - await fsp.writeFile(path, data) + await fsp.writeFile(path, data!) t.success(`${logPrefix} Wrote to ${r(path)}`) }), diff --git a/src/types.ts b/src/types.ts index 34178a4..3e40f71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ import type { Buffer } from 'node:buffer' import type { SvgComposer } from './processing/svg' +export type ImageFormat = 'png' | 'webp' + export interface BadgePreset { boxWidth: number boxHeight: number @@ -281,6 +283,13 @@ export interface SponsorkitRenderOptions { */ includePastSponsors?: boolean + /** + * Format of embedded images + * + * @default 'webp' + */ + imageFormat?: ImageFormat + /** * Hook to modify sponsors data before rendering. */