notes/animation-cache-rewrite.md
Status, 2026-08-29: built and running. The shared cache layer, both backends on top of it, and the lottie move into
Telegram.Nativeare all done and exercised in the app. What follows is the reasoning, kept because the decisions are the part worth preserving; the Outcome section below records what was actually built, what changed along the way, and what is still unverified.
Written 2026-08-29, from a session that started as an outline/shimmer measurement and ended in
the sticker cache. Nothing here is implemented. The code references were all read rather than
recalled, and the three reference clients are the local checkouts at
C:\Source\{Android,Telegram-iOS,tdesktop}.
Not for speed. The per-item costs we measured are small: the placeholder path is 24-47 µs of geometry plus 23-68 µs of Composition objects per realized sticker, a tenth of a frame for a 30-item burst. Two things are actually wrong:
And a structural reason, which is the real argument for a rewrite rather than more patches: four owners share an animation's lifetime and none of them can enforce it.
All verified in this session.
AnimatedImageTask has no disposal. It is abstract with NextFrame and Seek and
nothing else (AnimatedImage.cs:1842). AnimatedImagePresenter.Dispose() (:1476) does
Volatile.Write(ref _task, null) — it drops the reference and never closes the
LottieAnimation / CachedVideoAnimation behind it, so the native animation, its cache file
handle and its buffers survive until the RCW is finalized. This is why the cost appears only
while caching: an animation mid-cache holds the rlottie tree and render buffers, one replaying
an existing file holds a fraction of that.
A strong capture defeats a deliberate weak one (AnimatedImage.cs:1982):
_delegates[correlationId] = new WeakReference<AnimatedImagePresenter>(sender);
_workQueue.Run(() => Work(new WorkItem(correlationId, sender.Presentation)));
Someone thought about not rooting the presenter and then captured sender in the lambda.
Every queued item roots its presenter until it runs, and the queue is longest exactly during
a cache-building scroll. Hoisting var presentation = sender.Presentation; above the lambda
fixes this one in isolation.
Disposal is deferred to a tick that may not come. UnloadImpl (:1071) sets
_disposing = true; _ticking = false and relies on one further RenderNextFrame (:1409)
to call Dispose(). It should fire — Skip does not clear _ticking — but the invariant is
"someone will tick me again", which nothing enforces.
Cancellation is fully built and has never fired. Cache() pushes
WorkItem(get_weak(), ...), and both compress workers resolve it —
if (auto item{ work->animation.get() }) (CachedVideoAnimation.cpp:402,
LottieAnimation.cpp:735) — silently skipping the item when the animation is gone. It never
is, because of (1): nothing releases the animation, so the weak reference never dies. The
memory bug and the cancellation gap are one bug, and fixing the lifetime turns cancellation
on by itself.
No work is lost by this. A whole cache build is a single work item, so an item dropped from
the queue has rendered nothing. Mid-build cancellation — the only kind that could waste
frames already rendered, and what Android does with its cancelled flag — is deliberately
not wanted here: if a build has started, finishing it is cheaper than redoing it when the
user scrolls back a few seconds later. No partial files, no resume, no format change.
Two copies of the same machinery. s_compressQueue, s_compressWorker, s_compressLock
and the LZ4 frame format exist independently in Telegram.Native/CachedVideoAnimation.cpp:22
and RLottie.UWP/Telegram.Lottie/LottieAnimation.cpp:685, in two repositories.
The build holds a lock that readers need. Two presenters can land on the same cache
identity while differing in playback policy — the same sticker at the same pixel size in a
second window, scrolled back to, or in the panel and a bubble that happen to match. Both
writers take GetLockForKey(m_cacheKey) and hold it for the entire build
(CachedVideoAnimation.cpp:432, LottieAnimation.cpp:765), and the readers take the same
lock — LoadFromFile (:153, :240, :336) and, worse, the per-frame cache read
(CachedVideoAnimation.cpp:264, LottieAnimation.cpp:597). A second presenter arriving
mid-build therefore blocks for seconds, and not alone: LoadFromFile runs on
AnimatedImageLoader._workQueue and the frame read on the shared FifoActionWorker, both
FIFO, so one blocked call stalls every animation behind it. Most likely on a cold cache,
which is when builds are in flight.
What is already right: the writer re-checks the header under the lock and skips a redundant
build, and dwShareMode = 0 keeps two builds from interleaving. Only the waiting is wrong.
Per-animation scratch buffers. m_decompressBuffer = new uint8_t[m_maxFrameSize] per
CachedVideoAnimation (:274), plus a frame buffer set per task. Nothing is pooled across
the dozens of animations a panel has in flight.
Not broken, contrary to a first reading: the first frame is always displayed before caching
starts. LottieAnimation.cpp:646-664 renders into the caller's buffer and only then sets
m_readyToCache, so the sticker shows a static first frame while the cache builds. Unigram
already has what Android exposes as CacheOptions.firstFrame and iOS as
getFirstFrameSynchronously; it just falls out of the render loop instead of being named.
Android — RLottieDrawable.java, messenger/utils/BitmapsCache.java.
Cancellation is explicit and two-layered: checkCacheCancel() (:643) drops the task when
parentViews.isEmpty() && getCallback() == null, cancelRunnable removes it if it has not
started, and createCache() re-checks an AtomicBoolean cancelled inside the frame loop
(BitmapsCache.java:261), recycling bitmaps and closing the file mid-build. Generation runs on
one dedicated DispatchQueue. Scratch bitmaps and byte buffers come from a static
CacheGeneratorSharedTools shared by every concurrent generation and released when a global
task counter reaches zero (:158-177). Compression is pipelined onto a separate executor
through N slots with a CountDownLatch each, so memory is bounded by N frames rather than by
frame count or by how many stickers are building at once. Release is explicit — recycle(),
recycleNativePtr(), bitmapsCache.recycle() — deferred only while tasks are in flight.
iOS — submodules/TelegramUI/Components/AnimationCache/.
AnimationCache is a protocol over both animation kinds: get(sourceId:size:fetch:),
getFirstFrame(queue:sourceId:size:...), getFirstFrameSynchronously(sourceId:size:). Frames
are appended through an AnimationCacheItemWriter (add(with drawingBlock:...) / finish()),
so writing is streaming rather than a batch. Frames are stored as DCT/YUV planes
(DCTAnimationCacheImpl), and DCTMultiAnimationRendererImpl drives many animations from one
renderer — their answer to the emoji grid.
tdesktop — SourceFiles/chat_helpers/stickers_lottie.cpp.
The cache key is LottieCacheKeyShift(replacementsTag, sizeTag) where sizeTag is a
StickerLottieSize enum, not a pixel count: a closed set of size classes, stored in the
shared cache database rather than as per-file blobs. Above
kDontCacheLottieAfterArea = 512 * 512 (:32) lottie is not cached at all.
Shipped, in Telegram.Native/Cache/: FrameCacheFormat.h, FrameCodec.h, FrameCacheReader,
FrameCacheWriter, FrameProducer.h, FrameCacheService. CachedVideoAnimation and
LottieAnimation are each a reader, a producer and a frame index. rlottie and gzip-hpp are
submodules under Libraries/, built by Libraries/rlottie-build/rlottie.vcxproj as a static
library; tlottie is a prebuilt Rust staticlib. The vendored RLottie.* binaries and every csproj
reference to them are gone, and the C# side moved from using RLottie; to Telegram.Native.
.tgfc file for both renderers. The old .cache/.tcache split existed to let the two
caches coexist; Fela's call was that the frames are interchangeable, and they are — both emit
premultiplied BGRA. That is only true because tlottie is constructed with
TLOTTIE_CHANNEL_BGRA; its default is RGBA, and losing the flag would persist channel-swapped
frames to disk and serve them to the other renderer.RenderSync(CanvasBitmap, …) would have pulled
a second Win2D into the payload for one caller; DiceView moved to the IBuffer overload
instead. RenderSync(String, Int32) had no callers and went too.s_compressQueue was a std::stack for exactly this reason.IsReadyToCache
forever, costing a skipped tick per frame. Video adopts only at a loop boundary, because its
reader restarts at frame 0.VideoAnimatedImageTask no longer compares TotalFrame against its own _index.
TotalFrame is 0 until a cache exists, so the two disagreed the moment one was adopted
mid-playback. completed is authoritative.AnimatedImageTask has a Dispose that closes the native animation; AnimatedImagePresenter
guards it with a borrow count so a close cannot land inside a render; the strong sender capture
that defeated the WeakReference at the loader is hoisted; and the UI-affine half of teardown is
posted to the dispatcher rather than skipped, which had been dropping two WriteableBitmaps on
every teardown that ran on the scheduler thread. The bitmaps are pooled by
AnimatedImageLoader.BitmapRecyclePool, one per XamlRoot.
WholeProgramOptimization for a static library that has never been through it.AnimatedImageLoader._closed is never assigned, so the loader is never removed from _loaders
and Bitmaps.Clear() is dead code._disposing/_ticking deferral can collapse now that disposal is borrow-safe; deliberately
left alone so a crash would be attributable.AnimatedListHandler already knows which items those are.Considered on 2026-08-29 and parked, not rejected. The cache key is the sticker's local file
path, which contains the session id, so the same sticker downloaded under two accounts is cached
twice at every size. remoteFile.unique_id is documented as "the same for the same file even for
different users and is persistent over time" and is exactly the right key.
The two halves are one change: sharing across accounts means leaving the per-session directory the
files currently sit in, so it comes with a move out of TDLib's tree - TemporaryState being the
natural home, since these are pure derived data and the reader treats a missing file as no cache.
The LoadFromData caches already moved there for that reason.
What it costs, and the reason it is worth a second look rather than a quick change:
.tgfc files are counted as sticker storage because
they happen to live in TDLib's directory. That is inheritance rather than design, and it may be
wrong in the other direction too: optimizeStorage works from TDLib's own file database, so
foreign files in that directory are plausibly invisible to its cleanup - which would mean "clear
stickers" frees less than it reports, and FlushStickerCache is the only thing that ever removes
them. Worth confirming before treating the current numbers as a feature. If the user-visible
story matters, reporting the cache folder's own size is a few lines.unique_id may be empty, per the schema, and local assets built by
TdExtensions.GetLocalFile have no remote at all. Needs the path as a fallback.AnimatedImageSource -> AnimatedImagePresentation -> the loader's Work is not.
AnimatedImagePresentation is also the presenter pool key, so adding a field changes what counts
as the same presenter: two accounts showing one sticker at one size would start sharing a
presentation, which is a bonus or a surprise depending on whether anything downstream assumes
per-account identity. That is the part to think about first.Racing builds are already safe - first writer wins through the rename, so two accounts hitting the same sticker at once costs one wasted build rather than a corrupt file.
Moving the SVG outline parser into C++ was considered and is not worth doing: the geometry
build is 24-47 µs per shimmer, ~190 ms across a 5,000-shimmer session, and C:\Source\SurfaceSpike
found the surface strategies indistinguishable once the synthetic scroll load was removed. That
also weakens the case for getStickerOutlineSvgPath to just its own merits — ~7× less JSON and one
string rather than ~50 objects per outline — since there is no native builder for an hstring to
cross into.
Fela, 2026-08-29: LottieAnimation and CachedVideoAnimation are re-architected from the
ground up rather than patched, and lottie moves into Telegram.Native so the two share
everything they can. The rendering backends are untouched — VideoAnimation for ffmpeg,
rlottie/tlottie for vector.
That is the right call because what the two classes duplicate is nearly everything except
frame production: the cache key, the file format (header, length-prefixed LZ4 frames, trailing
offset/timing table, maxFrameSize), the compress queue and worker, the scratch buffers, the
key locking, the read path, the precache → readyToCache → caching → read state machine, and
the handle lifetime. BufferSurface.cpp is duplicated outright — 18 lines, same name, one copy
per repository.
A sequential producer, not a random-access renderer: Prepare() / NextFrame(buffer) /
Release(), plus Reset(). RenderFrame(index, buffer) fits lottie and lies about ffmpeg,
which cannot seek cheaply. Android's BitmapsCache.Cacheable
(prepareForGenerateCache/getNextFrame/releaseForGenerateCache) is the same shape for the
same reason. Random access is a property of the cache file's offset table, not of the backend.
The asymmetry that survives is the uncached fallback: lottie can render an arbitrary frame on demand while the cache builds, video cannot. So a backend declares whether it supports random access, and the fallback branches on that rather than pretending the two are alike.
Colour replacement and the Fitz modifier belong to the producer, not the cache. They are applied
post-render inside LottieAnimation today (the m_color.A != 0x00 loop) and they are part of
the cache key; keeping them in the cache layer is what makes that confusing.
Telegram.Native takes on rlottie/tlottie, and RLottie.UWP goes away entirely — with it the
vendored per-architecture RLottie.dll/.winmd/.lib, which were a solution in search of a
problem. That changes the build for all four configurations. The
tlottie switch comes along with it: m_useTLottie, the .tcache extension, and the property
that both caches coexist so toggling never forces a re-render. The C# side is cheap: using RLottie; in AnimatedImage.cs and AnimatedImageSource.cs, and no CsWinRT.cs entries to
sweep. There is no test harness for any of this, on the most visible surface in the app.
The decision everything else follows from: one owner, and disposal is not deferred.
The owner is AnimatedImagePresenter, where the handle already lives. It is the ref-counted,
pooled object with a defined "last view detached" moment in UnloadImpl; moving the handle up
into AnimatedImageLoader would only create a second lifetime to reconcile against the
presenter's. Three things change, none of them the location:
AnimatedImageTask gets an explicit Dispose that closes the animation.UnloadImpl disposes synchronously instead of setting _disposing and hoping for a tick.An animation handle is owned by exactly one object, is IDisposable, and is disposed
synchronously when its last view detaches. The reason disposal is deferred today is that a
background render may be mid-frame. The fix is not to postpone the release but to make the
borrow explicit: the render worker takes a strong reference for the duration of one frame and
drops it, so the owner can release immediately and the native animation dies when the last
borrow ends. "Dispose later, when the tick notices" becomes "nobody is borrowing, so it is gone".
On top of that:
Telegram.Native, replacing the two copies of
the compress queue. Cache identity and dedup already work — the native key is
path + colourReplacementHash + fitzModifier + WxH + renderer for lottie
(LottieAnimation.cpp:209-237) and path + WxH + optional .fit for video
(CachedVideoAnimation.cpp:130-149), and GetLockForKey plus the header re-check already
stops two presenters from building the same file twice. What unifying buys is one queue, one
buffer pool and one format, not new behaviour.m_caching, so neither knows the other is building). A reader that finds a build
in flight renders directly, which is already the no-cache fallback and costs nothing new.CacheGeneratorSharedTools is the model, including
the global task counter that decides when to let go.Keep the frame format as LZ4 over raw BGRA. iOS and Android both trade encode/decode CPU for smaller files (DCT, WEBP), and that is the wrong trade here: with ~100 concurrent animations in the emoji grid the binding constraint is decode cost per frame, which is what LZ4 is best at. A deliberate divergence, not an oversight.
AnimatedImage and what hangs off it need work too, but as a second phase, not mixed into
the cache rewrite — the cache layer has no test harness and neither does the presentation layer,
and doing both at once means no known-good state to bisect against.
The open question there was WriteableBitmap, and the spike at C:\Source\SurfaceSpike
answered it: not for speed — only for memory ownership.
Measured (100 tiles at 192×192, 30 fps each, static, Debug/CoreCLR): WriteableBitmap 0.06 ms
per composition tick and 29% of a core; a CompositionDrawingSurface per tile 0.00 ms and 31%;
an atlas — one surface, one draw session per batch — 0.00 ms and 18%. Nothing dropped a frame in
any variant. SoftwareBitmap was eliminated: three to five times the UI-thread cost, unable to hold
the tick rate, ~9% of frames dropped waiting on SetBitmapAsync.
So: WriteableBitmap is fast, simple, reuses XAML's device, and is not what makes the panel
stutter. Per-tile composition is ruled out for good — it frees the UI thread and then spends the
saving on BeginDraw/EndDraw transactions at ~41 µs each. The atlas is a real lever (the gap
widens with load: 47% against 67% at 60 fps) but it is headroom for later, not a fix for anything
we have measured. Do not reopen this on performance grounds.
The ownership case, which does stand. AnimatedImage.cs:80-88 already documents it: PixelBuffer
holds the WriteableBitmap as a plain COM reference from C++, which keeps it alive but not
reachable, so the reference tracker can collect the framework peer while the native side still
holds the object — after which ImageBrush.ImageSource fails with E_FAIL. The code works around
it by passing frame geometry as numbers rather than reading it back off the brush.
That is two owners, one of which the runtime cannot see, and no amount of tidying fixes it:
CleanOnSourceChanged defaults to true, so Unload already nulls LayoutRoot.Background and
drops the brush. The five opt-out sites (SendMessagesView.xaml:25, Generic.xaml 2154/2251/
2334/2408) keep the last frame up while a new source loads, and they are styles with one template
each — bounded, not the leak.IWinRTObject.NativeObject.Dispose(), as FormattedTextBlock.cs:2861
does for handles it owns exclusively) is wrong here: XAML and the render thread hold references
too, so it may free nothing, and the renderer holds the PixelBuffer while writing.A surface the app owns outright collapses the two owners into one, which is the same rule this note already demands of the animation handle.
Pooling, if the surface move proves too big. Bounded by the concurrently visible count rather
than the scrolled-past count, so the ceiling is knowable (~29 MB at panel size) where today's is
not. It must be per-XamlRoot, since new WriteableBitmap needs the UI thread — which is where
AnimatedImageLoader already lives. Trim on real events (panel closed, deactivated, suspending,
AppMemoryUsageIncreased) with a slow timer only as a backstop; a timer alone is a proxy for those,
and picking its interval in the abstract is guessing. Cap per size bucket and globally, or the
high-water mark becomes the sum of every bucket's peak.
The prerequisite for both is the same, and it is already step 1 of phase one. Returning a bitmap
to a pool while the renderer still holds its IBuffer is a use-after-return; disposing it early is
a use-after-free. Both need the borrow rule — the worker holds a strong reference for the duration
of one frame, and the owner cannot release until every borrow ends. Build that and pooling and
disposal both become available, on either surface type.
AnimationScheduler and the single CompositionTarget.Rendering subscription already amortize
the tick across all visible animations, so the scheduling half is in reasonable shape; it is the
per-animation GPU resources and the per-frame upload that are not.
Out of scope entirely: the outline and shimmer path, which we measured to death and which is not the problem.
Now, outside the rewrite: the two C# lifetime fixes — AnimatedImageTask disposal and the
closure capture at AnimatedImage.cs:1982. They live in Telegram/, they are needed whatever
the native side becomes, they recover the memory, and the first of them turns on the
cancellation that is already written. Do them before the rewrite starts, so the rewrite is not
also carrying the memory bug.
Before those, instrument: register AnimatedImagePresenter and AnimatedImageTask with
Instrumentation.Register so the existing orphan analyzer can see them, then do a cache-deleted
scroll. That says whether the retained objects are rooted (defects 2 and 3) or merely
uncollected (defect 1), which have opposite fixes and which the RAM graph cannot tell apart.
In the rewrite, in dependency order: the shared cache layer and its file protocol
(temporary file plus atomic rename, the in-flight key set, one queue, one scratch pool), then
the two backends behind the sequential producer interface, then the lottie move into
Telegram.Native and the retirement of the duplicate BufferSurface.
Everything the earlier draft listed as separate in-place patches — temp file, in-flight keys, scratch pool, unification — folds into the rewrite rather than being done twice.
SurfaceImage / Direct2D path abandoned? See the scope section — it gates the
whole second phase.GetLockForKey's map thread-safe, and are its entries ever evicted? It hands out a
reference into a map keyed by cache-key string, and both are worth confirming.limitFps missing from the video cache key? It is a CachedVideoAnimation::LoadFromFile
parameter but does not appear in m_cacheKey. Lottie does not take it at all — the app steps
the frame index at playback (framesPerUpdate) — so lottie is unaffected. If it changes which
frames get written for video, two presentations differing only in that flag share one file.Settled while writing this: cancellation needs no partial-file handling (see defect 4), and the
pool key and the cache identity are already separate — the record is the presenter's key, the
native m_cacheKey is the file's.