Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: embed webp in svg output #96

Merged
merged 1 commit into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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