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:
+1
-1
Submodule nobilis updated: e264ea6612...8cf3578985
@@ -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])
|
||||
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user