diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 7bc0f7d..89963b6 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -289,13 +289,25 @@ export async function outdatedCommand( io: CliIo, options: OutputOptions = {}, ): Promise { - 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) @@ -353,7 +365,21 @@ export async function updateCommand(deps: CliDeps, io: CliIo): Promise { 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) @@ -364,43 +390,39 @@ export async function updateCommand(deps: CliDeps, io: CliIo): Promise { ) } - 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.') diff --git a/src/index.ts b/src/index.ts index 20f617b..5d77354 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/render/avatar.ts b/src/render/avatar.ts index f69e0f5..c8ff992 100644 --- a/src/render/avatar.ts +++ b/src/render/avatar.ts @@ -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' @@ -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 } /** @@ -56,20 +59,21 @@ export async function loadAvatar( } async function loadCached(key: string, options: LoadAvatarOptions): Promise { - 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 } }) diff --git a/src/render/background.ts b/src/render/background.ts index 70bcc2a..2bca41d 100644 --- a/src/render/background.ts +++ b/src/render/background.ts @@ -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 { if (!theme.backgroundImage) return Promise.resolve(null) - return loadAvatar(theme.backgroundImage.source, options) + return loadAvatar(theme.backgroundImage.source, { ...options, cache: backgroundImageCache }) } /** diff --git a/src/render/backgroundImageCache.ts b/src/render/backgroundImageCache.ts new file mode 100644 index 0000000..5b75592 --- /dev/null +++ b/src/render/backgroundImageCache.ts @@ -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({ + 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 } +} diff --git a/src/render/pipeline.test.ts b/src/render/pipeline.test.ts index 0e34b3d..89ecb8c 100644 --- a/src/render/pipeline.test.ts +++ b/src/render/pipeline.test.ts @@ -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]) @@ -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) diff --git a/src/text/fit.test.ts b/src/text/fit.test.ts index a159d8d..81df3bf 100644 --- a/src/text/fit.test.ts +++ b/src/text/fit.test.ts @@ -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) + }) }) diff --git a/src/text/fit.ts b/src/text/fit.ts index e982d86..9929633 100644 --- a/src/text/fit.ts +++ b/src/text/fit.ts @@ -3,7 +3,7 @@ import type { Segment } from '../core/types' import type { TextOverflow } from '../theme/types' import { graphemes } from '../util/grapheme' import type { BreakpointOptions } from './breakpoint' -import type { EmojiMetrics, TextMeasurer } from './measure' +import { type EmojiMetrics, measureSegments, type TextMeasurer } from './measure' import { type Line, lineToString, wrapSegments } from './wrap' export interface FitOptions extends BreakpointOptions { @@ -38,6 +38,13 @@ export interface FitResult { * smaller size can occasionally need *more* lines, so "fits" is not perfectly * monotonic and a binary search can settle on the wrong side of a boundary. * With measurement memoized this is a handful of microseconds either way. + * + * Before doing a full wrap at each size, `mayFit` checks a cheap lower bound: + * no wrapping algorithm can pack the segments' total width into fewer lines + * than `total / maxWidth`, so once even that best case already exceeds the + * line budget, the real wrap is guaranteed to fail too and can be skipped. + * This never changes which font size wins — it only skips sizes that were + * always going to fail — so it's safe alongside the non-monotonicity above. */ export function fitText(segments: readonly Segment[], options: FitOptions): FitResult { const step = options.step ?? 1 @@ -47,7 +54,15 @@ export function fitText(segments: readonly Segment[], options: FitOptions): FitR let smallest: { lines: Line[]; fontSize: number } | null = null for (let fontSize = max; fontSize >= min; fontSize -= step) { - const lines = wrapAt(segments, fontSize, options) + const measurer = options.measurerFor(fontSize) + const metrics = options.metricsFor(fontSize) + + // The last candidate must always be fully computed: if nothing fits, + // `smallest` needs real lines from it for the shrink/truncate fallback. + const isLastCandidate = fontSize - step < min + if (!isLastCandidate && !mayFit(segments, measurer, metrics, fontSize, options)) continue + + const lines = wrapAt(segments, measurer, metrics, options) if (fits(lines, fontSize, options)) { return { lines, fontSize, truncated: false } } @@ -55,7 +70,9 @@ export function fitText(segments: readonly Segment[], options: FitOptions): FitR } if (!smallest) { - const lines = wrapAt(segments, min, options) + const measurer = options.measurerFor(min) + const metrics = options.metricsFor(min) + const lines = wrapAt(segments, measurer, metrics, options) smallest = { lines, fontSize: min } } @@ -77,16 +94,40 @@ export function fitText(segments: readonly Segment[], options: FitOptions): FitR } } -function wrapAt(segments: readonly Segment[], fontSize: number, options: FitOptions): Line[] { +function wrapAt( + segments: readonly Segment[], + measurer: TextMeasurer, + metrics: EmojiMetrics, + options: FitOptions, +): Line[] { return wrapSegments(segments, { maxWidth: options.maxWidth, - measurer: options.measurerFor(fontSize), - metrics: options.metricsFor(fontSize), + measurer, + metrics, ...(options.phraseBreak === undefined ? {} : { phraseBreak: options.phraseBreak }), ...(options.locale === undefined ? {} : { locale: options.locale }), }) } +/** + * A lower bound on the lines `wrapAt` could possibly need: no wrapping + * algorithm can fit more than `maxWidth` of content per line, so packing the + * segments' total width that tightly is the best case. Kinsoku, forced + * breaks and word boundaries can only need as many or more lines than this, + * never fewer. + */ +function mayFit( + segments: readonly Segment[], + measurer: TextMeasurer, + metrics: EmojiMetrics, + fontSize: number, + options: FitOptions, +): boolean { + const totalWidth = measureSegments(segments, measurer, metrics) + const bestCaseLines = Math.max(1, Math.ceil(totalWidth / options.maxWidth)) + return bestCaseLines <= maxLines(fontSize, options) +} + function maxLines(fontSize: number, options: FitOptions): number { const geometric = Math.max(1, Math.floor(options.maxHeight / (fontSize * options.lineHeight))) return options.maxLines === undefined ? geometric : Math.min(geometric, options.maxLines) diff --git a/src/util/projectRoot.ts b/src/util/projectRoot.ts index 9cf2f02..63b6029 100644 --- a/src/util/projectRoot.ts +++ b/src/util/projectRoot.ts @@ -1,6 +1,14 @@ import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' +/** + * Memoized by `startDir`: callers that rely on the `process.cwd()` default + * (every font/Twemoji cache-dir resolution in a run) all walk the same + * ancestors, and a project's own root doesn't move while the process is + * alive. + */ +const cache = new Map() + /** * The nearest ancestor of `startDir` (inclusive) that has a `package.json`. * @@ -9,13 +17,23 @@ import { dirname, join } from 'node:path' * having to handle `null`. */ export function findProjectRoot(startDir: string = process.cwd()): string { + const cached = cache.get(startDir) + if (cached !== undefined) return cached + let dir = startDir + let root = startDir while (true) { - if (existsSync(join(dir, 'package.json'))) return dir + if (existsSync(join(dir, 'package.json'))) { + root = dir + break + } const parent = dirname(dir) - if (parent === dir) return startDir + if (parent === dir) break dir = parent } + + cache.set(startDir, root) + return root }