Skip to content

Commit 00e51a6

Browse files
perf: remove avatar sizes from cache (3/4) (#94)
1 parent 3a871e1 commit 00e51a6

File tree

7 files changed

+125
-119
lines changed

7 files changed

+125
-119
lines changed

src/cache.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Buffer } from 'node:buffer'
2+
import type { Sponsorship } from './types'
3+
4+
export function stringifyCache(cache: Sponsorship[]): string {
5+
return JSON.stringify(
6+
cache,
7+
(_key, value) => {
8+
if (value && value.type === 'Buffer' && Array.isArray(value.data)) {
9+
return Buffer.from(value.data).toString('base64')
10+
}
11+
return value
12+
},
13+
2,
14+
)
15+
}
16+
17+
export function parseCache(cache: string): Sponsorship[] {
18+
return JSON.parse(cache, (key, value) => {
19+
if (key === 'avatarBuffer') {
20+
return Buffer.from(value, 'base64')
21+
}
22+
return value
23+
})
24+
}

src/processing/image.ts

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,75 +5,70 @@ import sharp from 'sharp'
55
import { version } from '../../package.json'
66
import type { SponsorkitConfig, Sponsorship } from '../types'
77

8+
async function fetchImage(url: string) {
9+
const arrayBuffer = await $fetch(url, {
10+
responseType: 'arrayBuffer',
11+
headers: {
12+
'User-Agent': `Mozilla/5.0 Chrome/124.0.0.0 Safari/537.36 Sponsorkit/${version}`,
13+
},
14+
})
15+
return Buffer.from(arrayBuffer)
16+
}
17+
818
export async function resolveAvatars(
919
ships: Sponsorship[],
1020
getFallbackAvatar: SponsorkitConfig['fallbackAvatar'],
1121
t = consola,
1222
) {
13-
const fallbackAvatar = await (async () => {
23+
const fallbackAvatar = await (() => {
1424
if (typeof getFallbackAvatar === 'string') {
15-
const data = await $fetch(getFallbackAvatar, { responseType: 'arrayBuffer' })
16-
return Buffer.from(data)
25+
return fetchImage(getFallbackAvatar)
1726
}
1827
if (getFallbackAvatar)
1928
return getFallbackAvatar
20-
return undefined
2129
})()
2230

23-
const fallbackDataUri = fallbackAvatar && (await round(fallbackAvatar, 0.5, 100)).toString('base64')
24-
2531
const pLimit = await import('p-limit').then(r => r.default)
2632
const limit = pLimit(15)
2733

2834
return Promise.all(ships.map(ship => limit(async () => {
29-
const pngArrayBuffer = (ship.privacyLevel === 'PRIVATE' || !ship.sponsor.avatarUrl)
30-
? fallbackAvatar
31-
: await $fetch(ship.sponsor.avatarUrl, {
32-
responseType: 'arrayBuffer',
33-
headers: {
34-
'User-Agent': `Mozilla/5.0 Chrome/124.0.0.0 Safari/537.36 Sponsorkit/${version}`,
35-
},
36-
})
37-
.catch((e) => {
38-
t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`)
39-
t.error(e)
40-
if (fallbackAvatar)
41-
return fallbackAvatar
42-
throw e
43-
})
35+
if (ship.privacyLevel === 'PRIVATE' || !ship.sponsor.avatarUrl) {
36+
ship.sponsor.avatarBuffer = fallbackAvatar
37+
return
38+
}
4439

45-
if (ship.privacyLevel === 'PRIVATE' && fallbackDataUri)
46-
ship.sponsor.avatarUrl = fallbackDataUri
40+
const pngBuffer = await fetchImage(ship.sponsor.avatarUrl).catch((e) => {
41+
t.error(`Failed to fetch avatar for ${ship.sponsor.login || ship.sponsor.name} [${ship.sponsor.avatarUrl}]`)
42+
t.error(e)
43+
if (fallbackAvatar)
44+
return fallbackAvatar
45+
throw e
46+
})
4747

48-
if (pngArrayBuffer) {
49-
const pngBuffer = Buffer.from(pngArrayBuffer)
48+
if (pngBuffer) {
5049
const radius = ship.sponsor.type === 'Organization' ? 0.1 : 0.5
51-
const [
52-
highRes,
53-
mediumRes,
54-
lowRes,
55-
] = await Promise.all([
56-
round(pngBuffer, radius, 120),
57-
round(pngBuffer, radius, 80),
58-
round(pngBuffer, radius, 50),
59-
])
60-
61-
const highResBase64 = highRes.toString('base64')
6250

63-
ship.sponsor.avatarBuffer = highResBase64
64-
ship.sponsor.avatarUrlHighRes = highResBase64
65-
ship.sponsor.avatarUrlMediumRes = mediumRes.toString('base64')
66-
ship.sponsor.avatarUrlLowRes = lowRes.toString('base64')
51+
// Store the highest resolution version we use of the original image
52+
ship.sponsor.avatarBuffer = await round(pngBuffer, radius, 120)
6753
}
6854
})))
6955
}
7056

57+
const cache = new Map<string, Map<Buffer, Buffer>>()
7158
export async function round(image: Buffer, radius = 0.5, size = 100) {
59+
const cacheKey = `${radius}:${size}`
60+
if (cache.has(cacheKey)) {
61+
const cacheHit = cache.get(cacheKey)!.get(image)
62+
if (cacheHit) {
63+
return cacheHit
64+
}
65+
}
66+
7267
const rect = Buffer.from(
7368
`<svg><rect x="0" y="0" width="${size}" height="${size}" rx="${size * radius}" ry="${size * radius}"/></svg>`,
7469
)
7570

76-
return await sharp(image)
71+
const result = await sharp(image)
7772
.resize(size, size, { fit: sharp.fit.cover })
7873
.composite([{
7974
blend: 'dest-in',
@@ -82,6 +77,13 @@ export async function round(image: Buffer, radius = 0.5, size = 100) {
8277
}])
8378
.png({ quality: 80, compressionLevel: 8 })
8479
.toBuffer()
80+
81+
if (!cache.has(cacheKey)) {
82+
cache.set(cacheKey, new Map())
83+
}
84+
cache.get(cacheKey)!.set(image, result)
85+
86+
return result
8587
}
8688

8789
export function svgToPng(svg: string) {

src/processing/svg.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { round } from './image'
12
import type { BadgePreset, Sponsor, SponsorkitRenderOptions, Sponsorship } from '../types'
23

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

9-
export function generateBadge(
10+
export async function generateBadge(
1011
x: number,
1112
y: number,
1213
sponsor: Sponsor,
1314
preset: BadgePreset,
15+
radius: number,
1416
) {
1517
const size = preset.avatar.size
1618
const { login } = sponsor
@@ -24,18 +26,24 @@ export function generateBadge(
2426
name = `${name.slice(0, preset.name.maxLength - 3)}...`
2527
}
2628

27-
const avatarUrl = (size < 50
28-
? sponsor.avatarUrlLowRes
29-
: size < 90
30-
? sponsor.avatarUrlMediumRes
31-
: sponsor.avatarUrlHighRes
32-
) || sponsor.avatarUrl
29+
let avatar
30+
if (size < 50) {
31+
avatar = await round(sponsor.avatarBuffer!, radius, 50)
32+
}
33+
else if (size < 90) {
34+
avatar = await round(sponsor.avatarBuffer!, radius, 80)
35+
}
36+
else {
37+
avatar = await round(sponsor.avatarBuffer!, radius, 120)
38+
}
39+
40+
avatar = avatar.toString('base64')
3341

3442
return `<a ${url ? `href="${url}" ` : ''}class="${preset.classes || 'sponsorkit-link'}" target="_blank" id="${login}">
3543
${preset.name
3644
? `<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>
3745
`
38-
: ''}${genSvgImage(x, y, size, avatarUrl)}
46+
: ''}${genSvgImage(x, y, size, avatar)}
3947
</a>`.trim()
4048
}
4149

@@ -65,26 +73,26 @@ export class SvgComposer {
6573
return this
6674
}
6775

68-
addSponsorLine(sponsors: Sponsorship[], preset: BadgePreset) {
76+
async addSponsorLine(sponsors: Sponsorship[], preset: BadgePreset) {
6977
const offsetX = (this.config.width - sponsors.length * preset.boxWidth) / 2 + (preset.boxWidth - preset.avatar.size) / 2
70-
this.body += sponsors
71-
.map((s, i) => {
78+
const sponsorLine = await Promise.all(sponsors
79+
.map(async (s, i) => {
7280
const x = offsetX + preset.boxWidth * i
7381
const y = this.height
74-
return generateBadge(x, y, s.sponsor, preset)
75-
})
76-
.join('\n')
82+
const radius = s.sponsor.type === 'Organization' ? 0.1 : 0.5
83+
return await generateBadge(x, y, s.sponsor, preset, radius)
84+
}))
85+
86+
this.body += sponsorLine.join('\n')
7787
this.height += preset.boxHeight
7888
}
7989

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

83-
Array.from({ length: Math.ceil(sponsors.length / perLine) })
84-
.fill(0)
85-
.forEach((_, i) => {
86-
this.addSponsorLine(sponsors.slice(i * perLine, (i + 1) * perLine), preset)
87-
})
93+
for (let i = 0; i < Math.ceil(sponsors.length / perLine); i++) {
94+
await this.addSponsorLine(sponsors.slice(i * perLine, (i + 1) * perLine), preset)
95+
}
8896

8997
return this
9098
}

src/renders/circles.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { Buffer } from 'node:buffer'
2-
import { round } from '../processing/image'
31
import { generateBadge, SvgComposer } from '../processing/svg'
4-
import type { Sponsor, SponsorkitRenderer, Sponsorship } from '../types'
2+
import type { SponsorkitRenderer, Sponsorship } from '../types'
53

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

3836
for (const circle of circles) {
39-
composer.addRaw(generateBadge(
37+
composer.addRaw(await generateBadge(
4038
circle.x - circle.r,
4139
circle.y - circle.r,
42-
await getRoundedAvatars(circle.data.sponsor),
40+
circle.data.sponsor,
4341
{
4442
name: false,
4543
boxHeight: circle.r * 2,
@@ -48,6 +46,7 @@ export const circlesRenderer: SponsorkitRenderer = {
4846
size: circle.r * 2,
4947
},
5048
},
49+
0.5,
5150
))
5251
}
5352

@@ -62,27 +61,3 @@ function lerp(a: number, b: number, t: number) {
6261
return a
6362
return a + (b - a) * t
6463
}
65-
66-
async function getRoundedAvatars(sponsor: Sponsor) {
67-
if (!sponsor.avatarBuffer || sponsor.type === 'User')
68-
return sponsor
69-
70-
const data = Buffer.from(sponsor.avatarBuffer, 'base64')
71-
const [
72-
highRes,
73-
mediumRes,
74-
lowRes,
75-
] = await Promise.all([
76-
round(data, 0.5, 120),
77-
round(data, 0.5, 80),
78-
round(data, 0.5, 50),
79-
])
80-
81-
/// keep-sorted
82-
return {
83-
...sponsor,
84-
avatarUrlHighRes: highRes.toString('base64'),
85-
avatarUrlLowRes: mediumRes.toString('base64'),
86-
avatarUrlMediumRes: lowRes.toString('base64'),
87-
}
88-
}

src/renders/tiers.ts

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,30 @@ export async function tiersComposer(composer: SvgComposer, sponsors: Sponsorship
1717

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

20-
tierPartitions
21-
.forEach(({ tier: t, sponsors }) => {
22-
t.composeBefore?.(composer, sponsors, config)
23-
if (t.compose) {
24-
t.compose(composer, sponsors, config)
25-
}
26-
else {
27-
const preset = t.preset || tierPresets.base
28-
if (sponsors.length && preset.avatar.size) {
29-
const paddingTop = t.padding?.top ?? 20
30-
const paddingBottom = t.padding?.bottom ?? 10
31-
if (paddingTop)
32-
composer.addSpan(paddingTop)
33-
if (t.title) {
34-
composer
35-
.addTitle(t.title)
36-
.addSpan(5)
37-
}
38-
composer.addSponsorGrid(sponsors, preset)
39-
if (paddingBottom)
40-
composer.addSpan(paddingBottom)
20+
for (const { tier: t, sponsors } of tierPartitions) {
21+
t.composeBefore?.(composer, sponsors, config)
22+
if (t.compose) {
23+
t.compose(composer, sponsors, config)
24+
}
25+
else {
26+
const preset = t.preset || tierPresets.base
27+
if (sponsors.length && preset.avatar.size) {
28+
const paddingTop = t.padding?.top ?? 20
29+
const paddingBottom = t.padding?.bottom ?? 10
30+
if (paddingTop)
31+
composer.addSpan(paddingTop)
32+
if (t.title) {
33+
composer
34+
.addTitle(t.title)
35+
.addSpan(5)
4136
}
37+
await composer.addSponsorGrid(sponsors, preset)
38+
if (paddingBottom)
39+
composer.addSpan(paddingBottom)
4240
}
43-
t.composeAfter?.(composer, sponsors, config)
44-
})
41+
}
42+
t.composeAfter?.(composer, sponsors, config)
43+
}
4544

4645
composer.addSpan(config.padding?.bottom ?? 20)
4746
}

src/run.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { consola } from 'consola'
88
import c from 'picocolors'
99
import type { Buffer } from 'node:buffer'
1010
import { version } from '../package.json'
11+
import { parseCache, stringifyCache } from './cache'
1112
import { loadConfig } from './configs'
1213
import { resolveAvatars, svgToPng, svgToWebp } from './processing/image'
1314
import { guessProviders, resolveProviders } from './providers'
@@ -202,10 +203,10 @@ export async function run(inlineConfig?: SponsorkitConfig, t = consola) {
202203
t.success('Avatars resolved')
203204

204205
await fsp.mkdir(dirname(cacheFile), { recursive: true })
205-
await fsp.writeFile(cacheFile, JSON.stringify(allSponsors, null, 2))
206+
await fsp.writeFile(cacheFile, stringifyCache(allSponsors))
206207
}
207208
else {
208-
allSponsors = JSON.parse(await fsp.readFile(cacheFile, 'utf-8'))
209+
allSponsors = parseCache(await fsp.readFile(cacheFile, 'utf8'))
209210
t.success(`Loaded from cache ${r(cacheFile)}`)
210211
}
211212

src/types.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,7 @@ export interface Sponsor {
3535
login: string
3636
name: string
3737
avatarUrl: string
38-
avatarBuffer?: string
39-
avatarUrlHighRes?: string
40-
avatarUrlMediumRes?: string
41-
avatarUrlLowRes?: string
38+
avatarBuffer?: Buffer
4239
websiteUrl?: string
4340
linkUrl?: string
4441
/**

0 commit comments

Comments
 (0)