Skip to content

Commit

Permalink
perf: remove avatar sizes from cache (3/4) (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber authored Nov 6, 2024
1 parent 3a871e1 commit 00e51a6
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 119 deletions.
24 changes: 24 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Buffer } from 'node:buffer'
import type { Sponsorship } from './types'

export function stringifyCache(cache: Sponsorship[]): string {
return JSON.stringify(
cache,
(_key, value) => {
if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
return Buffer.from(value.data).toString('base64')
}
return value
},
2,
)
}

export function parseCache(cache: string): Sponsorship[] {
return JSON.parse(cache, (key, value) => {
if (key === 'avatarBuffer') {
return Buffer.from(value, 'base64')
}
return value
})
}
84 changes: 43 additions & 41 deletions src/processing/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,75 +5,70 @@ import sharp from 'sharp'
import { version } from '../../package.json'
import type { SponsorkitConfig, Sponsorship } from '../types'

async function fetchImage(url: string) {
const arrayBuffer = await $fetch(url, {
responseType: 'arrayBuffer',
headers: {
'User-Agent': `Mozilla/5.0 Chrome/124.0.0.0 Safari/537.36 Sponsorkit/${version}`,
},
})
return Buffer.from(arrayBuffer)
}

export async function resolveAvatars(
ships: Sponsorship[],
getFallbackAvatar: SponsorkitConfig['fallbackAvatar'],
t = consola,
) {
const fallbackAvatar = await (async () => {
const fallbackAvatar = await (() => {
if (typeof getFallbackAvatar === 'string') {
const data = await $fetch(getFallbackAvatar, { responseType: 'arrayBuffer' })
return Buffer.from(data)
return fetchImage(getFallbackAvatar)
}
if (getFallbackAvatar)
return getFallbackAvatar
return undefined
})()

const fallbackDataUri = fallbackAvatar && (await round(fallbackAvatar, 0.5, 100)).toString('base64')

const pLimit = await import('p-limit').then(r => r.default)
const limit = pLimit(15)

return Promise.all(ships.map(ship => limit(async () => {
const pngArrayBuffer = (ship.privacyLevel === 'PRIVATE' || !ship.sponsor.avatarUrl)
? fallbackAvatar
: await $fetch(ship.sponsor.avatarUrl, {
responseType: 'arrayBuffer',
headers: {
'User-Agent': `Mozilla/5.0 Chrome/124.0.0.0 Safari/537.36 Sponsorkit/${version}`,
},
})
.catch((e) => {
t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`)
t.error(e)
if (fallbackAvatar)
return fallbackAvatar
throw e
})
if (ship.privacyLevel === 'PRIVATE' || !ship.sponsor.avatarUrl) {
ship.sponsor.avatarBuffer = fallbackAvatar
return
}

if (ship.privacyLevel === 'PRIVATE' && fallbackDataUri)
ship.sponsor.avatarUrl = fallbackDataUri
const pngBuffer = await fetchImage(ship.sponsor.avatarUrl).catch((e) => {
t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`)
t.error(e)
if (fallbackAvatar)
return fallbackAvatar
throw e
})

if (pngArrayBuffer) {
const pngBuffer = Buffer.from(pngArrayBuffer)
if (pngBuffer) {
const radius = ship.sponsor.type === 'Organization' ? 0.1 : 0.5
const [
highRes,
mediumRes,
lowRes,
] = await Promise.all([
round(pngBuffer, radius, 120),
round(pngBuffer, radius, 80),
round(pngBuffer, radius, 50),
])

const highResBase64 = highRes.toString('base64')

ship.sponsor.avatarBuffer = highResBase64
ship.sponsor.avatarUrlHighRes = highResBase64
ship.sponsor.avatarUrlMediumRes = mediumRes.toString('base64')
ship.sponsor.avatarUrlLowRes = lowRes.toString('base64')
// Store the highest resolution version we use of the original image
ship.sponsor.avatarBuffer = await round(pngBuffer, radius, 120)
}
})))
}

const cache = new Map<string, Map<Buffer, Buffer>>()
export async function round(image: Buffer, radius = 0.5, size = 100) {
const cacheKey = `${radius}:${size}`
if (cache.has(cacheKey)) {
const cacheHit = cache.get(cacheKey)!.get(image)
if (cacheHit) {
return cacheHit
}
}

const rect = Buffer.from(
`<svg><rect x="0" y="0" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}"/></svg>`,
)

return await sharp(image)
const result = await sharp(image)
.resize(size, size, { fit: sharp.fit.cover })
.composite([{
blend: 'dest-in',
Expand All @@ -82,6 +77,13 @@ export async function round(image: Buffer, radius = 0.5, size = 100) {
}])
.png({ quality: 80, compressionLevel: 8 })
.toBuffer()

if (!cache.has(cacheKey)) {
cache.set(cacheKey, new Map())
}
cache.get(cacheKey)!.set(image, result)

return result
}

export function svgToPng(svg: string) {
Expand Down
48 changes: 28 additions & 20 deletions src/processing/svg.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { round } from './image'
import type { BadgePreset, Sponsor, SponsorkitRenderOptions, Sponsorship } from '../types'

const dataImagePngBase64 = `data:image/png;base64,`
Expand All @@ -6,11 +7,12 @@ export function genSvgImage(x: number, y: number, size: number, base64Image: str
return `<image x="${x}" y="${y}" width="${size}" height="${size}" href="${dataImagePngBase64}${base64Image}"/>`
}

export function generateBadge(
export async function generateBadge(
x: number,
y: number,
sponsor: Sponsor,
preset: BadgePreset,
radius: number,
) {
const size = preset.avatar.size
const { login } = sponsor
Expand All @@ -24,18 +26,24 @@ export function generateBadge(
name = `${name.slice(0, preset.name.maxLength - 3)}...`
}

const avatarUrl = (size < 50
? sponsor.avatarUrlLowRes
: size < 90
? sponsor.avatarUrlMediumRes
: sponsor.avatarUrlHighRes
) || sponsor.avatarUrl
let avatar
if (size < 50) {
avatar = await round(sponsor.avatarBuffer!, radius, 50)
}
else if (size < 90) {
avatar = await round(sponsor.avatarBuffer!, radius, 80)
}
else {
avatar = await round(sponsor.avatarBuffer!, radius, 120)
}

avatar = 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, avatarUrl)}
: ''}${genSvgImage(x, y, size, avatar)}
</a>`.trim()
}

Expand Down Expand Up @@ -65,26 +73,26 @@ export class SvgComposer {
return this
}

addSponsorLine(sponsors: Sponsorship[], preset: BadgePreset) {
async addSponsorLine(sponsors: Sponsorship[], preset: BadgePreset) {
const offsetX = (this.config.width - sponsors.length * preset.boxWidth) / 2 + (preset.boxWidth - preset.avatar.size) / 2
this.body += sponsors
.map((s, i) => {
const sponsorLine = await Promise.all(sponsors
.map(async (s, i) => {
const x = offsetX + preset.boxWidth * i
const y = this.height
return generateBadge(x, y, s.sponsor, preset)
})
.join('\n')
const radius = s.sponsor.type === 'Organization' ? 0.1 : 0.5
return await generateBadge(x, y, s.sponsor, preset, radius)
}))

this.body += sponsorLine.join('\n')
this.height += preset.boxHeight
}

addSponsorGrid(sponsors: Sponsorship[], preset: BadgePreset) {
async addSponsorGrid(sponsors: Sponsorship[], preset: BadgePreset) {
const perLine = Math.floor((this.config.width - (preset.container?.sidePadding || 0) * 2) / preset.boxWidth)

Array.from({ length: Math.ceil(sponsors.length / perLine) })
.fill(0)
.forEach((_, i) => {
this.addSponsorLine(sponsors.slice(i * perLine, (i + 1) * perLine), preset)
})
for (let i = 0; i < Math.ceil(sponsors.length / perLine); i++) {
await this.addSponsorLine(sponsors.slice(i * perLine, (i + 1) * perLine), preset)
}

return this
}
Expand Down
33 changes: 4 additions & 29 deletions src/renders/circles.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Buffer } from 'node:buffer'
import { round } from '../processing/image'
import { generateBadge, SvgComposer } from '../processing/svg'
import type { Sponsor, SponsorkitRenderer, Sponsorship } from '../types'
import type { SponsorkitRenderer, Sponsorship } from '../types'

export const circlesRenderer: SponsorkitRenderer = {
name: 'sponsorkit:circles',
Expand Down Expand Up @@ -36,10 +34,10 @@ export const circlesRenderer: SponsorkitRenderer = {
const circles = p(root as any).descendants().slice(1)

for (const circle of circles) {
composer.addRaw(generateBadge(
composer.addRaw(await generateBadge(
circle.x - circle.r,
circle.y - circle.r,
await getRoundedAvatars(circle.data.sponsor),
circle.data.sponsor,
{
name: false,
boxHeight: circle.r * 2,
Expand All @@ -48,6 +46,7 @@ export const circlesRenderer: SponsorkitRenderer = {
size: circle.r * 2,
},
},
0.5,
))
}

Expand All @@ -62,27 +61,3 @@ function lerp(a: number, b: number, t: number) {
return a
return a + (b - a) * t
}

async function getRoundedAvatars(sponsor: Sponsor) {
if (!sponsor.avatarBuffer || sponsor.type === 'User')
return sponsor

const data = Buffer.from(sponsor.avatarBuffer, 'base64')
const [
highRes,
mediumRes,
lowRes,
] = await Promise.all([
round(data, 0.5, 120),
round(data, 0.5, 80),
round(data, 0.5, 50),
])

/// keep-sorted
return {
...sponsor,
avatarUrlHighRes: highRes.toString('base64'),
avatarUrlLowRes: mediumRes.toString('base64'),
avatarUrlMediumRes: lowRes.toString('base64'),
}
}
45 changes: 22 additions & 23 deletions src/renders/tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,30 @@ export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship

composer.addSpan(config.padding?.top ?? 20)

tierPartitions
.forEach(({ tier: t, sponsors }) => {
t.composeBefore?.(composer, sponsors, config)
if (t.compose) {
t.compose(composer, sponsors, config)
}
else {
const preset = t.preset || tierPresets.base
if (sponsors.length && preset.avatar.size) {
const paddingTop = t.padding?.top ?? 20
const paddingBottom = t.padding?.bottom ?? 10
if (paddingTop)
composer.addSpan(paddingTop)
if (t.title) {
composer
.addTitle(t.title)
.addSpan(5)
}
composer.addSponsorGrid(sponsors, preset)
if (paddingBottom)
composer.addSpan(paddingBottom)
for (const { tier: t, sponsors } of tierPartitions) {
t.composeBefore?.(composer, sponsors, config)
if (t.compose) {
t.compose(composer, sponsors, config)
}
else {
const preset = t.preset || tierPresets.base
if (sponsors.length && preset.avatar.size) {
const paddingTop = t.padding?.top ?? 20
const paddingBottom = t.padding?.bottom ?? 10
if (paddingTop)
composer.addSpan(paddingTop)
if (t.title) {
composer
.addTitle(t.title)
.addSpan(5)
}
await composer.addSponsorGrid(sponsors, preset)
if (paddingBottom)
composer.addSpan(paddingBottom)
}
t.composeAfter?.(composer, sponsors, config)
})
}
t.composeAfter?.(composer, sponsors, config)
}

composer.addSpan(config.padding?.bottom ?? 20)
}
5 changes: 3 additions & 2 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { consola } from 'consola'
import c from 'picocolors'
import type { Buffer } from 'node:buffer'
import { version } from '../package.json'
import { parseCache, stringifyCache } from './cache'
import { loadConfig } from './configs'
import { resolveAvatars, svgToPng, svgToWebp } from './processing/image'
import { guessProviders, resolveProviders } from './providers'
Expand Down Expand Up @@ -202,10 +203,10 @@ export async function run(inlineConfig?: SponsorkitConfig, t = consola) {
t.success('Avatars resolved')

await fsp.mkdir(dirname(cacheFile), { recursive: true })
await fsp.writeFile(cacheFile, JSON.stringify(allSponsors, null, 2))
await fsp.writeFile(cacheFile, stringifyCache(allSponsors))
}
else {
allSponsors = JSON.parse(await fsp.readFile(cacheFile, 'utf-8'))
allSponsors = parseCache(await fsp.readFile(cacheFile, 'utf8'))
t.success(`Loaded from cache ${r(cacheFile)}`)
}

Expand Down
5 changes: 1 addition & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ export interface Sponsor {
login: string
name: string
avatarUrl: string
avatarBuffer?: string
avatarUrlHighRes?: string
avatarUrlMediumRes?: string
avatarUrlLowRes?: string
avatarBuffer?: Buffer
websiteUrl?: string
linkUrl?: string
/**
Expand Down

0 comments on commit 00e51a6

Please sign in to comment.