Skip to content
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
102 changes: 62 additions & 40 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,25 @@ export async function outdatedCommand(
io: CliIo,
options: OutputOptions = {},
): Promise<number> {
const packageStatus = await (deps.checkPackageUpdate ?? checkPackageUpdate)(currentVersion())
const twemoji = await (deps.twemojiInfo ?? twemojiInfo)()
const twemojiLatest =
twemoji.version === null ? null : await (deps.latestTwemojiVersion ?? latestTwemojiVersion)()
const fonts = (deps.listInstalledFonts ?? listInstalledFonts)()
const fontStatuses =
fonts.length > 0 ? await (deps.checkFontUpdates ?? checkFontUpdates)(fonts) : []
// Each check is independent — only twemojiLatest depends on twemoji's own
// result — so they run concurrently instead of one round-trip at a time.
const [packageStatus, { twemoji, twemojiLatest }, { fonts, fontStatuses }] = await Promise.all([
(deps.checkPackageUpdate ?? checkPackageUpdate)(currentVersion()),
(async () => {
const twemoji = await (deps.twemojiInfo ?? twemojiInfo)()
const twemojiLatest =
twemoji.version === null
? null
: await (deps.latestTwemojiVersion ?? latestTwemojiVersion)()
return { twemoji, twemojiLatest }
})(),
(async () => {
const fonts = (deps.listInstalledFonts ?? listInstalledFonts)()
const fontStatuses =
fonts.length > 0 ? await (deps.checkFontUpdates ?? checkFontUpdates)(fonts) : []
return { fonts, fontStatuses }
})(),
])

const packageOutdated =
packageStatus.latest !== null && isNewerVersion(packageStatus.current, packageStatus.latest)
Expand Down Expand Up @@ -353,7 +365,21 @@ export async function updateCommand(deps: CliDeps, io: CliIo): Promise<number> {
let failed = false
let didAnything = false

const packageStatus = await (deps.checkPackageUpdate ?? checkPackageUpdate)(currentVersion())
// Gathering what needs updating is read-only and each check is
// independent, so it runs concurrently; the actual updates below stay
// sequential so their output keeps the same package → Twemoji → fonts order.
const [packageStatus, twemoji, fonts] = await Promise.all([
(deps.checkPackageUpdate ?? checkPackageUpdate)(currentVersion()),
(deps.twemojiInfo ?? twemojiInfo)(),
Promise.resolve((deps.listInstalledFonts ?? listInstalledFonts)()),
])
const [twemojiLatest, fontStatuses] = await Promise.all([
twemoji.version === null
? Promise.resolve(null)
: (deps.latestTwemojiVersion ?? latestTwemojiVersion)(),
fonts.length > 0 ? (deps.checkFontUpdates ?? checkFontUpdates)(fonts) : Promise.resolve([]),
])

if (
packageStatus.latest !== null &&
isNewerVersion(packageStatus.current, packageStatus.latest)
Expand All @@ -364,43 +390,39 @@ export async function updateCommand(deps: CliDeps, io: CliIo): Promise<number> {
)
}

const twemoji = await (deps.twemojiInfo ?? twemojiInfo)()
if (twemoji.version !== null) {
const latest = await (deps.latestTwemojiVersion ?? latestTwemojiVersion)()
if (latest !== null && isNewerVersion(twemoji.version, latest)) {
didAnything = true
io.line('Twemoji')
try {
await (deps.uninstallTwemoji ?? uninstallTwemoji)()
const result = await (deps.installTwemoji ?? installTwemoji)()
io.line(` ✓ updated to ${result.version}`)
} catch (cause) {
io.line(` ✗ Twemoji — ${cause instanceof Error ? cause.message : String(cause)}`)
failed = true
}
if (
twemoji.version !== null &&
twemojiLatest !== null &&
isNewerVersion(twemoji.version, twemojiLatest)
) {
didAnything = true
io.line('Twemoji')
try {
await (deps.uninstallTwemoji ?? uninstallTwemoji)()
const result = await (deps.installTwemoji ?? installTwemoji)()
io.line(` ✓ updated to ${result.version}`)
} catch (cause) {
io.line(` ✗ Twemoji — ${cause instanceof Error ? cause.message : String(cause)}`)
failed = true
}
}

const fonts = (deps.listInstalledFonts ?? listInstalledFonts)()
if (fonts.length > 0) {
const statuses = await (deps.checkFontUpdates ?? checkFontUpdates)(fonts)
const outdatedFamilies = statuses
.filter((status) => status.outdated)
.map((status) => status.family)

if (outdatedFamilies.length > 0) {
didAnything = true
io.line('Fonts')
const results = await (deps.installFonts ?? installFonts)(outdatedFamilies)
for (const result of results) {
if (result.ok) io.line(` ✓ ${result.family}`)
else {
io.line(` ✗ ${result.family} — not available (see the warning above)`)
failed = true
}
const outdatedFamilies = fontStatuses
.filter((status) => status.outdated)
.map((status) => status.family)

if (outdatedFamilies.length > 0) {
didAnything = true
io.line('Fonts')
const results = await (deps.installFonts ?? installFonts)(outdatedFamilies)
for (const result of results) {
if (result.ok) io.line(` ✓ ${result.family}`)
else {
io.line(` ✗ ${result.family} — not available (see the warning above)`)
failed = true
}
await (deps.pruneFonts ?? pruneFonts)(outdatedFamilies)
}
await (deps.pruneFonts ?? pruneFonts)(outdatedFamilies)
}

if (!didAnything) io.line('Nothing to update.')
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export { installFonts, listInstalledFonts, uninstallFonts } from './font/install
export { DEFAULT_FONT_FAMILIES, FALLBACK_FAMILY } from './font/sources'
export type { AvatarCacheOptions } from './render/avatarCache'
export { avatarCacheInfo, clearAvatarCache, configureAvatarCache } from './render/avatarCache'
export type { BackgroundImageCacheOptions } from './render/backgroundImageCache'
export {
backgroundImageCacheInfo,
clearBackgroundImageCache,
configureBackgroundImageCache,
} from './render/backgroundImageCache'
export { stripDiscordMarkdown } from './text/discordMarkdown'
export { stripMarkdown } from './text/markdown'
export { stripMfm } from './text/mfm'
Expand Down
14 changes: 9 additions & 5 deletions src/render/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AvatarSource } from '../core/types'
import { createClient } from '../http/client'
import { parseColor, toCSS } from '../theme/color'
import type { AvatarTheme } from '../theme/types'
import type { AssetCache } from '../util/assetCache'
import { avatarCache } from './avatarCache'
import { createCanvas, type Image, loadImage, type SKRSContext2D } from './canvasFactory'

Expand All @@ -16,6 +17,8 @@ const defaultFetcher: AvatarFetcher = (url, signal) => http.getBuffer(url, signa
export interface LoadAvatarOptions {
signal?: AbortSignal
fetcher?: AvatarFetcher
/** Which cache to dedupe through. Defaults to the shared avatar cache. */
cache?: AssetCache<Image>
}

/**
Expand Down Expand Up @@ -56,20 +59,21 @@ export async function loadAvatar(
}

async function loadCached(key: string, options: LoadAvatarOptions): Promise<Image | null> {
const cached = avatarCache.cached(key)
const cache = options.cache ?? avatarCache
const cached = cache.cached(key)
if (cached) return cached
if (avatarCache.isKnownFailure(key)) return null
if (cache.isKnownFailure(key)) return null

return avatarCache.coalesce(key, async () => {
return cache.coalesce(key, async () => {
try {
const bytes = /^https?:\/\//i.test(key)
? await (options.fetcher ?? defaultFetcher)(key, options.signal)
: await readFile(key)
const image = await loadImage(bytes)
avatarCache.remember(key, image)
cache.remember(key, image)
return image
} catch {
avatarCache.rememberFailure(key)
cache.rememberFailure(key)
return null
}
})
Expand Down
9 changes: 5 additions & 4 deletions src/render/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ import {
type LoadAvatarOptions,
loadAvatar,
} from './avatar'
import { backgroundImageCache } from './backgroundImageCache'
import { createCanvas, type Image, type SKRSContext2D } from './canvasFactory'
import { gradientLine } from './layout'

/**
* Loads `theme.backgroundImage`'s source, going through the same cache and
* fetch path an avatar uses — it is the same kind of asset, just drawn
* somewhere else.
* Loads `theme.backgroundImage`'s source, going through the same fetch path
* an avatar uses but its own cache (`backgroundImageCache`) — it's the same
* kind of asset, just drawn somewhere else and reused very differently.
*/
export function loadBackgroundImage(
theme: Theme,
options: LoadAvatarOptions = {},
): Promise<Image | null> {
if (!theme.backgroundImage) return Promise.resolve(null)
return loadAvatar(theme.backgroundImage.source, options)
return loadAvatar(theme.backgroundImage.source, { ...options, cache: backgroundImageCache })
}

/**
Expand Down
35 changes: 35 additions & 0 deletions src/render/backgroundImageCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { type AssetCacheOptions, createAssetCache } from '../util/assetCache'
import type { Image } from './canvasFactory'

export type BackgroundImageCacheOptions = AssetCacheOptions

/**
* The cache for `theme.backgroundImage`, kept separate from `avatarCache`
* (see `assetCache.ts` for why unrelated asset kinds get their own instance):
* a background is typically one of a handful of fixed, reused assets rather
* than a different picture per user, so it gets a longer TTL and far fewer
* slots than avatars — and a burst of avatar fetches can no longer evict it.
*/
export const backgroundImageCache = createAssetCache<Image>({
maxEntries: 16,
ttlMs: 30 * 60_000,
negativeTtlMs: 30_000,
enabled: true,
})

export function configureBackgroundImageCache(options: BackgroundImageCacheOptions = {}): void {
backgroundImageCache.configure(options)
}

export function clearBackgroundImageCache(): void {
backgroundImageCache.clear()
}

export function backgroundImageCacheInfo(): {
images: number
failures: number
inFlight: number
} {
const info = backgroundImageCache.info()
return { images: info.entries, failures: info.failures, inFlight: info.inFlight }
}
39 changes: 39 additions & 0 deletions src/render/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { clearEmojiCache } from '../emoji/cache'
import { resetAutoloadForTests } from '../font/autoload'
import { colorThemeGradient, colorThemeTextBase, resolveColorTheme } from '../theme/colorThemes'
import { resetFilterDetectionForTests } from './avatar'
import { avatarCacheInfo, clearAvatarCache, configureAvatarCache } from './avatarCache'
import { backgroundImageCacheInfo, clearBackgroundImageCache } from './backgroundImageCache'
import { createCanvas } from './canvasFactory'

const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
Expand Down Expand Up @@ -150,6 +152,43 @@ describe('render', () => {
expect([r, g, b]).toEqual([0, 0, 0])
})

describe('avatar/background image cache separation', () => {
afterEach(() => {
configureAvatarCache({})
clearAvatarCache()
clearBackgroundImageCache()
})

it('caches an avatar and a background image in their own caches', async () => {
await quote()
.setAvatar('https://cdn.test/avatar.png')
.setTheme({
backgroundImage: { source: 'https://cdn.test/bg.png', fit: 'cover', opacity: 1 },
})
.render()

expect(avatarCacheInfo().images).toBe(1)
expect(backgroundImageCacheInfo().images).toBe(1)
})

it('a burst of distinct avatars does not evict a cached background image', async () => {
await quote()
.setTheme({
backgroundImage: { source: 'https://cdn.test/bg.png', fit: 'cover', opacity: 1 },
})
.render()
expect(backgroundImageCacheInfo().images).toBe(1)

// A 1-entry avatar cache overflows on the second distinct avatar; if it
// shared storage with the background image, this would evict it too.
configureAvatarCache({ maxEntries: 1 })
await quote().setAvatar('https://cdn.test/avatar-a.png').render()
await quote().setAvatar('https://cdn.test/avatar-b.png').render()

expect(backgroundImageCacheInfo().images).toBe(1)
})
})

it('uses white for the light theme background', async () => {
const [r, g, b] = await pixelAt(quote().setTheme('light'), 1100, 5)

Expand Down
35 changes: 35 additions & 0 deletions src/text/fit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,39 @@ describe('fitText', () => {
expect(line.startsWith('。')).toBe(false)
}
})

it('skips tokenizing and measuring per-character for sizes a width lower bound already rules out', () => {
// With no spaces, CJK text without a phrase break gets a fallback break
// candidate at every character — so a real wrap of this 500-character
// run measures on the order of 500 tokens. At font sizes the width check
// can already rule out, none of that tokenizing should happen: only the
// single whole-segment measurement `mayFit` itself needs.
let measureCalls = 0
const source = '猫'.repeat(500)
const result = fitText(
segmentText(source),
options({
maxFontSize: 1000,
minFontSize: 10,
maxHeight: 100,
phraseBreak: false,
measurerFor: (fontSize) => {
const measurer = fakeMeasurer(fontSize / 2)
return {
measureText: (text) => {
measureCalls++
return measurer.measureText(text)
},
}
},
}),
)

expect(result.truncated).toBe(true)
expect(result.fontSize).toBe(10)
// ~990 candidate sizes, each needing only the one `mayFit` measurement,
// versus ~500 per size (one per character) if every size were fully
// wrapped — the total should land near the former, nowhere near the latter.
expect(measureCalls).toBeLessThan(2000)
})
})
Loading
Loading