Skip to content

Commit

Permalink
perf: embed webp in svg output
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber committed Nov 6, 2024
1 parent 8deca9d commit 6f19c0c
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 38 deletions.
23 changes: 15 additions & 8 deletions src/processing/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -47,32 +47,39 @@ export async function resolveAvatars(

if (pngBuffer) {
// Store the highest resolution version we use of the original image
ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120)
// Stored in webp to save space
ship.sponsor.avatarBuffer = await resizeImage(pngBuffer, 120, 'webp')
}
})))
}

const cache = new Map<Buffer, Map<number, Buffer>>()
const cache = new Map<Buffer, Map<string, Buffer>>()
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
}
Expand Down
28 changes: 15 additions & 13 deletions src/processing/svg.ts
Original file line number Diff line number Diff line change
@@ -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 `
<clipPath id="${cropId}">
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}" />
</clipPath>
<image x="${x}" y="${y}" width="${size}" height="${size}" href="data:image/png;base64,${base64Image}" clip-path="url(#${cropId})"/>`
<image x="${x}" y="${y}" width="${size}" height="${size}" href="data:image/${imageFormat};base64,${base64Image}" clip-path="url(#${cropId})"/>`
}

export async function generateBadge(
Expand All @@ -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
Expand All @@ -36,24 +37,25 @@ export async function generateBadge(
name = `${name.slice(0, preset.name.maxLength - 3)}...`
}

let avatar
const { size } = preset.avatar
let avatar = sponsor.avatarBuffer!
if (size < 50) {
avatar = await resizeImage(sponsor.avatarBuffer!, 50)
avatar = await resizeImage(avatar, 50, imageFormat)
}
else if (size < 90) {
avatar = await resizeImage(sponsor.avatarBuffer!, 80)
else if (size < 80) {
avatar = await resizeImage(avatar, 80, imageFormat)
}
else {
avatar = await resizeImage(sponsor.avatarBuffer!, 120)
else if (imageFormat === 'png') {
avatar = await resizeImage(avatar, 120, imageFormat)
}

avatar = avatar.toString('base64')
const avatarBase64 = avatar.toString('base64')

return `<a ${url ? `href="${url}" ` : ''}class="${preset.classes || 'sponsorkit-link'}" target="_blank" id="${login}">
${preset.name
? `<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>
`
: ''}${genSvgImage(x, y, size, avatar, radius)}
: ''}${genSvgImage(x, y, size, radius, avatarBase64, imageFormat)}
</a>`.trim()
}

Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions src/renders/circles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const circlesRenderer: SponsorkitRenderer = {
},
},
0.5,
config.imageFormat,
))
}

Expand Down
18 changes: 9 additions & 9 deletions src/renders/tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()
},
}
44 changes: 36 additions & 8 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,28 +272,56 @@ 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<string> | undefined

await Promise.all([
renderOptions.formats.map(async (format) => {
if (!outputFormats.includes(format))
throw new Error(`Unsupported format: ${format}`)

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) {
// Sharp can't render embedded Webp so re-generate with png
// https://github.com/lovell/sharp/issues/4254
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)}`)
}),
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -281,6 +283,13 @@ export interface SponsorkitRenderOptions {
*/
includePastSponsors?: boolean

/**
* Format of embedded images
*
* @default 'webp'
*/
imageFormat?: ImageFormat

/**
* Hook to modify sponsors data before rendering.
*/
Expand Down

0 comments on commit 6f19c0c

Please sign in to comment.