Skip to content

Commit 7385fc7

Browse files
perf: embed webp in svg output (#96)
1 parent 8deca9d commit 7385fc7

File tree

6 files changed

+85
-38
lines changed

6 files changed

+85
-38
lines changed

src/processing/image.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { consola } from 'consola'
33
import { $fetch } from 'ofetch'
44
import sharp from 'sharp'
55
import { version } from '../../package.json'
6-
import type { SponsorkitConfig, Sponsorship } from '../types'
6+
import type { ImageFormat, SponsorkitConfig, Sponsorship } from '../types'
77

88
async function fetchImage(url: string) {
99
const arrayBuffer = await $fetch(url, {
@@ -47,32 +47,39 @@ export async function resolveAvatars(
4747

4848
if (pngBuffer) {
4949
// Store the highest resolution version we use of the original image
50-
ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120)
50+
// Stored in webp to save space
51+
ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120, 'webp')
5152
}
5253
})))
5354
}
5455

55-
const cache = new Map<Buffer, Map<number, Buffer>>()
56+
const cache = new Map<Buffer, Map<string, Buffer>>()
5657
export async function resizeImage(
5758
image: Buffer,
5859
size = 100,
60+
format: ImageFormat,
5961
) {
62+
const cacheKey = `${size}:${format}`
6063
if (cache.has(image)) {
61-
const cacheHit = cache.get(image)!.get(size)
64+
const cacheHit = cache.get(image)!.get(cacheKey)
6265
if (cacheHit) {
6366
return cacheHit
6467
}
6568
}
6669

67-
const result = await sharp(image)
70+
let processing = sharp(image)
6871
.resize(size, size, { fit: sharp.fit.cover })
69-
.png({ quality: 80, compressionLevel: 8 })
70-
.toBuffer()
72+
73+
processing = (format === 'webp')
74+
? processing.webp()
75+
: processing.png({ quality: 80, compressionLevel: 8 })
76+
77+
const result = await processing.toBuffer()
7178

7279
if (!cache.has(image)) {
7380
cache.set(image, new Map())
7481
}
75-
cache.get(image)!.set(size, result)
82+
cache.get(image)!.set(cacheKey, result)
7683

7784
return result
7885
}

src/processing/svg.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import { resizeImage } from './image'
2-
import type { BadgePreset, Sponsor, SponsorkitRenderOptions, Sponsorship } from '../types'
2+
import type { BadgePreset, ImageFormat, Sponsor, SponsorkitRenderOptions, Sponsorship } from '../types'
33

44
let id = 0
55
export function genSvgImage(
66
x: number,
77
y: number,
88
size: number,
9-
base64Image: string,
109
radius: number,
10+
base64Image: string,
11+
imageFormat: ImageFormat,
1112
) {
1213
const cropId = `c${id++}`
1314
return `
1415
<clipPath id="${cropId}">
1516
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" />
1617
</clipPath>
17-
<image x="${x}" y="${y}" width="${size}" height="${size}" href="data:image/png;base64,${base64Image}" clip-path="url(#${cropId})"/>`
18+
<image x="${x}" y="${y}" width="${size}" height="${size}" href="data:image/${imageFormat};base64,${base64Image}" clip-path="url(#${cropId})"/>`
1819
}
1920

2021
export async function generateBadge(
@@ -23,8 +24,8 @@ export async function generateBadge(
2324
sponsor: Sponsor,
2425
preset: BadgePreset,
2526
radius: number,
27+
imageFormat: ImageFormat,
2628
) {
27-
const size = preset.avatar.size
2829
const { login } = sponsor
2930
let name = (sponsor.name || sponsor.login).trim()
3031
const url = sponsor.websiteUrl || sponsor.linkUrl
@@ -36,24 +37,25 @@ export async function generateBadge(
3637
name = `${name.slice(0, preset.name.maxLength - 3)}...`
3738
}
3839

39-
let avatar
40+
const { size } = preset.avatar
41+
let avatar = sponsor.avatarBuffer!
4042
if (size < 50) {
41-
avatar = await resizeImage(sponsor.avatarBuffer!, 50)
43+
avatar = await resizeImage(avatar, 50, imageFormat)
4244
}
43-
else if (size < 90) {
44-
avatar = await resizeImage(sponsor.avatarBuffer!, 80)
45+
else if (size < 80) {
46+
avatar = await resizeImage(avatar, 80, imageFormat)
4547
}
46-
else {
47-
avatar = await resizeImage(sponsor.avatarBuffer!, 120)
48+
else if (imageFormat === 'png') {
49+
avatar = await resizeImage(avatar, 120, imageFormat)
4850
}
4951

50-
avatar = avatar.toString('base64')
52+
const avatarBase64 = avatar.toString('base64')
5153

5254
return `<a ${url ? `href="${url}" ` : ''}class="${preset.classes || 'sponsorkit-link'}" target="_blank" id="${login}">
5355
${preset.name
5456
? `<text x="${x + size / 2}" y="${y + size + 18}" text-anchor="middle" class="${preset.name.classes || 'sponsorkit-name'}" fill="${preset.name.color || 'currentColor'}">${encodeHtmlEntities(name)}</text>
5557
`
56-
: ''}${genSvgImage(x, y, size, avatar, radius)}
58+
: ''}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat)}
5759
</a>`.trim()
5860
}
5961

@@ -90,7 +92,7 @@ export class SvgComposer {
9092
const x = offsetX + preset.boxWidth * i
9193
const y = this.height
9294
const radius = s.sponsor.type === 'Organization' ? 0.1 : 0.5
93-
return await generateBadge(x, y, s.sponsor, preset, radius)
95+
return await generateBadge(x, y, s.sponsor, preset, radius, this.config.imageFormat)
9496
}))
9597

9698
this.body += sponsorLine.join('\n')

src/renders/circles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const circlesRenderer: SponsorkitRenderer = {
4747
},
4848
},
4949
0.5,
50+
config.imageFormat,
5051
))
5152
}
5253

src/renders/tiers.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,6 @@ import { tierPresets } from '../configs/tier-presets'
33
import { SvgComposer } from '../processing/svg'
44
import type { SponsorkitConfig, SponsorkitRenderer, Sponsorship } from '../types'
55

6-
export const tiersRenderer: SponsorkitRenderer = {
7-
name: 'sponsorkit:tiers',
8-
async renderSVG(config, sponsors) {
9-
const composer = new SvgComposer(config)
10-
await (config.customComposer || tiersComposer)(composer, sponsors, config)
11-
return composer.generateSvg()
12-
},
13-
}
14-
156
export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship[], config: SponsorkitConfig) {
167
const tierPartitions = partitionTiers(sponsors, config.tiers!, config.includePastSponsors)
178

@@ -44,3 +35,12 @@ export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship
4435

4536
composer.addSpan(config.padding?.bottom ?? 20)
4637
}
38+
39+
export const tiersRenderer: SponsorkitRenderer = {
40+
name: 'sponsorkit:tiers',
41+
async renderSVG(config, sponsors) {
42+
const composer = new SvgComposer(config)
43+
await (config.customComposer || tiersComposer)(composer, sponsors, config)
44+
return composer.generateSvg()
45+
},
46+
}

src/run.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -272,28 +272,56 @@ export async function applyRenderer(
272272
if (!renderOptions.includePrivate)
273273
sponsors = sponsors.filter(s => s.privacyLevel !== 'PRIVATE')
274274

275+
if (!renderOptions.imageFormat)
276+
renderOptions.imageFormat = 'webp'
277+
275278
t.info(`${logPrefix} Composing SVG...`)
276-
let svg = await renderer.renderSVG(renderOptions, sponsors)
277-
svg = await renderOptions.onSvgGenerated?.(svg) || svg
279+
280+
const processingSvg = (async () => {
281+
let svgWebp = await renderer.renderSVG(renderOptions, sponsors)
282+
283+
if (renderOptions.onSvgGenerated) {
284+
svgWebp = await renderOptions.onSvgGenerated(svgWebp) || svgWebp
285+
}
286+
return svgWebp
287+
})()
278288

279289
if (renderOptions.formats) {
290+
let svgPng: Promise<string> | undefined
291+
280292
await Promise.all([
281293
renderOptions.formats.map(async (format) => {
282294
if (!outputFormats.includes(format))
283295
throw new Error(`Unsupported format: ${format}`)
284296

285297
const path = join(dir, `${renderOptions.name}.${format}`)
286298

287-
let data: string | Buffer = svg
288-
if (format === 'png') {
289-
data = await svgToPng(svg)
299+
let data: string | Buffer
300+
301+
if (format === 'svg') {
302+
data = await processingSvg
290303
}
291304

292-
if (format === 'webp') {
293-
data = await svgToWebp(svg)
305+
if (format === 'png' || format === 'webp') {
306+
if (!svgPng) {
307+
// Sharp can't render embedded Webp so re-generate with png
308+
// https://github.com/lovell/sharp/issues/4254
309+
svgPng = renderer.renderSVG({
310+
...renderOptions,
311+
imageFormat: 'png',
312+
}, sponsors)
313+
}
314+
315+
if (format === 'png') {
316+
data = await svgToPng(await svgPng)
317+
}
318+
319+
if (format === 'webp') {
320+
data = await svgToWebp(await svgPng)
321+
}
294322
}
295323

296-
await fsp.writeFile(path, data)
324+
await fsp.writeFile(path, data!)
297325

298326
t.success(`${logPrefix} Wrote to ${r(path)}`)
299327
}),

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Buffer } from 'node:buffer'
22
import type { SvgComposer } from './processing/svg'
33

4+
export type ImageFormat = 'png' | 'webp'
5+
46
export interface BadgePreset {
57
boxWidth: number
68
boxHeight: number
@@ -281,6 +283,13 @@ export interface SponsorkitRenderOptions {
281283
*/
282284
includePastSponsors?: boolean
283285

286+
/**
287+
* Format of embedded images
288+
*
289+
* @default 'webp'
290+
*/
291+
imageFormat?: ImageFormat
292+
284293
/**
285294
* Hook to modify sponsors data before rendering.
286295
*/

0 commit comments

Comments
 (0)