Draw the small emote where there is one

The other half of the picker's memory: with the grid windowed it built a
screenful instead of everything, but a screenful of 500x500 animated GIFs is
still most of what it cost. The daemon now keeps a 64-pixel copy of each
emote (nobilis 8cf3578); this asks for it.

One place decides, because there was already only one place that built an
emote URL. lib/emotecache rewrites a Kick emote URL to its local copy when
there is one, and the formatter and the picker both go through it - so
messages get this as well as the picker, without either knowing about the
other.

Deliberately synchronous, because both callers are: the formatter returns a
string and the picker renders a cell, and neither can await. So the map is
filled in the background and read without blocking. An emote that is not in
it yet falls back to Kick's own URL - the right picture, just an expensive
one, rather than a gap where a picture should be.

What is already shrunk is fetched once at startup. The cache is on disk and
outlives the window, so there is no reason to rediscover it a megabyte at a
time. Cells ask for their own emotes as they are built, which the windowing
already makes viewport-shaped; an earlier draft asked for every emote the
picker could show, which on this account was several hundred megabytes of
downloads to open a menu.

Measured on the same buffer, one renderer, cost of opening the picker and
scrolling the whole list:

  original                  +311 MB    55.2 MB decoded
  windowed                  +146 MB    25.1 MB
  windowed + shrunk, warm    +33 MB     6.1 MB

241 emotes occupy 21MB shrunk, against about 120MB at full size.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 20:25:17 -04:00
co-authored by Claude Opus 5
parent 5d9ef82353
commit db802f0923
5 changed files with 135 additions and 7 deletions
+1 -1
Submodule nobilis updated: e264ea6612...8cf3578985
+6
View File
@@ -31,6 +31,7 @@ import { watchDelta, watchedKickBuffers } from './lib/kickwatch'
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
import { bufferDisplayName } from './lib/util'
import type { BufferEntry } from './state/store'
import { loadLocalEmotes } from './lib/emotecache'
export default function App(): JSX.Element {
const store = useStore()
@@ -67,6 +68,11 @@ export default function App(): JSX.Element {
if (!prefsReady || booted) return
setBooted(true)
void store.init(savedBufferId, savedGroupId)
// What the daemon has already shrunk, asked for once. Without it every
// Kick emote would be drawn at its full 500x500 once per session before
// its small copy was noticed - the cache is on disk and outlives the
// window, so there is no reason to rediscover it a megabyte at a time.
void loadLocalEmotes()
return () => store.dispose()
}, [prefsReady, booted, savedBufferId, savedGroupId, store])
+8 -2
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { resolveMediaUrl } from '../lib/util'
import { Icon } from './Icon'
import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format'
import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache'
import type { CustomEmoji } from '../../../shared/wire'
/**
@@ -69,7 +70,7 @@ function emojiToken(e: CustomEmoji): string {
/** Where its picture is: said outright where the service gives one. */
function emojiImage(e: CustomEmoji): string {
return e.url ?? discordEmojiUrl(e.id)
return e.url ? drawableEmoteUrl(e.url) : discordEmojiUrl(e.id)
}
const MAX_RECENT = 16
@@ -341,6 +342,11 @@ export function EmojiPicker({
return [...byPack.entries()]
}, [q, stickers])
// Redrawn when small copies arrive, so a cell that opened on Kick's own
// full-size URL swaps to the shrunk one rather than waiting for a scroll.
const [, setEmoteTick] = useState(0)
useEffect(() => onLocalEmotes(() => setEmoteTick((n) => n + 1)), [])
const pick = (text: string): void => {
const next = [text, ...recent.filter((r) => r !== text)].slice(0, MAX_RECENT)
setRecent(next)
@@ -451,7 +457,7 @@ export function EmojiPicker({
onClick={() => pick(e.id)}
>
{e.url ? (
<img src={resolveMediaUrl(e.url)} alt={e.name} loading="lazy" />
<img src={resolveMediaUrl(drawableEmoteUrl(e.url))} alt={e.name} loading="lazy" />
) : src.service === 'discord' ? (
<img src={discordEmojiUrl(e.id, 48)} alt={e.name} loading="lazy" />
) : (
+110
View File
@@ -0,0 +1,110 @@
/**
* Small local copies of Kick emotes.
*
* Kick serves one size of an emote and it is the big one - typically 500x500,
* animated, about a megabyte - while a picker cell draws it at 26 pixels and a
* message smaller still. Decoded, a 35-frame emote is 33MB of frame buffers,
* which is why opening the picker cost hundreds of megabytes (issue #211).
*
* The daemon keeps a shrunk copy of each emote it has been asked for, 64
* pixels on the longest edge, animation intact: 33MB of frame buffers becomes
* 0.55MB. This module is the renderer's half - a map of emote id to local
* file, consulted whenever an emote URL is about to be used.
*
* It is deliberately synchronous. Emote URLs are built in two places that
* cannot await: the HTML formatter, which returns a string, and the picker,
* which renders a cell. So the map is filled in the background and read
* without blocking, and an emote not in it yet falls back to Kick's own URL -
* the right picture, just an expensive one, rather than a gap.
*/
/** Kick's own emote URL, which is the only shape worth rewriting. */
const KICK_EMOTE = /^https:\/\/files\.kick\.com\/emotes\/(\d+)\/fullsize$/
const local = new Map<string, string>()
/** Ids asked for but not yet answered, so nothing is requested twice. */
const pending = new Set<string>()
/** Ids seen but not yet requested, flushed together a tick later. */
const queued = new Set<string>()
let flushing: ReturnType<typeof setTimeout> | null = null
const listeners = new Set<() => void>()
/** The emote id in a Kick emote URL, or null for anything else. */
export function kickEmoteId(url: string): string | null {
return url.match(KICK_EMOTE)?.[1] ?? null
}
/** Told when new local copies land, so a view can draw them. */
export function onLocalEmotes(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
function merge(map: Record<string, string>): void {
let added = 0
for (const [id, url] of Object.entries(map)) {
if (!local.has(id)) added++
local.set(id, url)
pending.delete(id)
}
if (added > 0) for (const fn of listeners) fn()
}
/**
* Everything the daemon already has, asked for once at startup.
*
* Without this every emote would be drawn full-size once per session before
* its small copy was noticed - the cache is on disk and survives restarts, so
* there is no reason to rediscover it a megabyte at a time.
*/
export async function loadLocalEmotes(): Promise<void> {
try {
merge(await window.moho.rpc<Record<string, string>>('kickEmotes', {}))
} catch {
/* an older daemon has no such method; emotes stay full-size */
}
}
/** Ask for these emotes to be shrunk, batched so a screenful is one call. */
export function requestLocalEmotes(ids: string[]): void {
for (const id of ids) {
if (!local.has(id) && !pending.has(id)) queued.add(id)
}
if (queued.size === 0 || flushing) return
flushing = setTimeout(async () => {
flushing = null
const batch = [...queued]
queued.clear()
for (const id of batch) pending.add(id)
try {
merge(await window.moho.rpc<Record<string, string>>('kickEmotes', { ids: batch }))
} catch {
for (const id of batch) pending.delete(id)
}
}, 50)
}
/**
* The URL to actually draw for an emote: the small local copy where there is
* one, and Kick's own otherwise - with the small one requested for next time.
*
* Takes and returns a URL rather than an id so that both callers can use it
* without agreeing on what an emote's id means: the formatter builds the URL
* from a token, the picker is handed one by the daemon, and the only thing
* they share is the URL's shape.
*/
export function drawableEmoteUrl(url: string): string {
const id = kickEmoteId(url)
if (!id) return url
const small = local.get(id)
if (small) return small
requestLocalEmotes([id])
return url
}
/** Test seam: forget everything, so one test cannot colour the next. */
export function resetLocalEmotes(): void {
local.clear()
pending.clear()
queued.clear()
}
+10 -4
View File
@@ -1,4 +1,6 @@
import type { SneedchatSmilie } from '../../../shared/wire'
import { drawableEmoteUrl } from './emotecache'
import { resolveMediaUrl } from './util'
// Shared with the main process, which is what actually opens a clicked link.
export { clearnetLinks } from '../../../shared/clearnet'
@@ -55,12 +57,16 @@ export function discordEmojiUrl(id: string, size = 44): string {
/**
* The picture for a Kick emote.
*
* One size only. The `/default` and `/small` variants other emote hosts serve
* are a 403 here, so asking for anything but fullsize is a broken image in
* every message rather than a smaller one.
* One size only, from Kick: the `/default` and `/small` variants other emote
* hosts serve are a 403 here, so asking for anything but fullsize is a broken
* image in every message rather than a smaller one.
*
* Which is why the daemon keeps a shrunk copy instead, and why this asks for
* that first - fullsize is 500x500 and animated, for a picture drawn at about
* twenty pixels in a message. See lib/emotecache.
*/
export function kickEmoteUrl(id: string): string {
return `https://files.kick.com/emotes/${id}/fullsize`
return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`))
}
/** What something picked out of the emoji list looks like, when it isn't text. */