Add a persistent, size-bounded rasterization cache - #131
Closed
philips-clanker wants to merge 8 commits into
Closed
Conversation
Rasterizing a .note file's pages is the most expensive step in every view/embed/export path, and re-renders from scratch every time even when the file hasn't changed. Add a content-addressable, per-page PNG cache (keyed by a hash of the note's own bytes + page number) stored under the plugin's data folder, capped at 100MB with oldest-inserted-first eviction. ImageConverter checks the cache before dispatching pages to the worker pool, and only rasterizes+caches whatever's missing. Adds a settings-tab entry showing cache size and a manual "clear cache" button. Closes #129.
get() now moves a hit to the most-recently-used end of the entries Map (delete+re-insert), so eviction removes the actual least-recently-used entry instead of just the oldest-inserted one. Reordering is kept in memory only, persisted to disk on the next put()/clear() rather than on every read, so cache hits stay cheap. Also fixes a latent accounting bug found while touching this code: the self-heal path in get() (an indexed entry whose blob is missing on disk) dropped the entry from the index but never subtracted its size from totalBytes, so totalCachedBytes could drift upward after cache corruption.
No visibility currently into whether the cache actually initialized or is doing anything useful — since it's designed to fail open, a silent init failure looks identical to "the cache just isn't helping." Logs (console.debug, so they only show up with dev tools open) report cache init success/failure and, per rasterization call, how many pages were served from cache vs. rendered fresh and how long it took.
… call ImageConverter previously owned its own WorkerPool, constructed and terminated on every single call — WorkerPool's constructor eagerly spawns hardwareConcurrency new Web Workers, so this happened even on a full raster-cache hit (zero pages actually rendered), paying full multi-worker startup/teardown cost for nothing on every note open. Make the pool a lazily-created (so plugin activation itself doesn't spin up workers), module-level singleton reused for the plugin's lifetime and torn down once in onunload(). Also ignore the runtime rasterCache/ directory: it lands at the repo root during manual testing (this repo symlinked in as a test vault's plugin folder), not something that belongs in git.
Every cache hit still paid a vault.adapter.readBinary + manual base64 decode, even for a page served moments ago in the same session (e.g. closing a note and reopening it). Add a smaller (20MB default), purely in-memory LRU layer that get()/put() populate and check first; it's never persisted and starts empty on every plugin reload, but skips disk I/O entirely for anything still warm. Eviction is independent per tier (memory can evict a page while its disk copy, and a much larger budget, stays put) and clear() empties both. Settings tab and debug logging updated to surface memory-tier stats alongside the existing disk-tier ones.
SupernoteView.onLoadFile awaited createImageBitmap() inside the per-page loop, so pages were decoded strictly sequentially regardless of whether the rasterized PNG for each came from the worker pool or either raster cache tier — a real, page-count-scaling cost paid on every single note open, cache-warm or not. Decode all pages' bitmaps up front via Promise.all, then just index into the results inside the (now purely DOM-building) loop. Also adds a debug-log timing line for this phase so it's measurable going forward.
…und PDF pipeline 12 cached pages took 3s to decode+build DOM for, which is far too slow for what should be cheap work — split the existing timing log into a decode-only and DOM-build-only number to see which one is actually responsible, and add timing to the background pdfDocPromise pipeline (assemblePdfFromImages/loadPdfJs/getDocument). That pipeline isn't awaited, but it still runs on the same single JS thread concurrently with the page-render loop, so it's a real candidate for stealing time from what looked like a pure decode/DOM cost.
The PDF built for pdf.js's getTextContent()/getViewport() (search and text selection) was unconditionally paying the full cost of embedding every page's real image, even though pdf.js's render() is never called against it — the image is never actually shown. pdf-lib's PNG embedding always fully decodes and recompresses the source PNG at save() time regardless of it already being compressed, which is real, size-proportional cost. Bump the supernote-typescript submodule to pick up addTextOnlyPdfPage() (philips/supernote-typescript#34) and use it here instead of assemblePdfFromImages(). Profiled against a real 12-page note with several user-uploaded-background pages: ~2.9s/~4.5MB before, down to ~40ms/~43KB after, with identical extracted/searchable text. This was the dominant cost behind "opening an already-cached note still feels slow" — confirmed by splitting the earlier timing logs into per-phase numbers and finding the "decode" and "assemble" timings were nearly identical, i.e. competing for the same main thread. assemblePdfFromImages() (real images) is unchanged and still used by the user-facing "export/attach as PDF" commands, which need actual page content in their output.
Merged
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
RasterCache, a content-addressable cache of rasterized page PNGs keyed by(hash of the .note file's own bytes, page number), with two tiers:CacheStoragestructural interface satisfied by Obsidian'sapp.vault.adapter, so the module has no dependency on theobsidianpackage and is unit-testable with a plain mock.ImageConverter.convertToImages: pages already cached are served without touching the worker pool; only missing pages are rendered and written back.ImageConverterused to own and tear down its ownWorkerPoolon every rasterization call, andWorkerPool's constructor eagerly spawnshardwareConcurrencynew Web Workers — so even a 100%-cache-hit open (zero pages rendered) still paid full multi-worker spin-up/teardown for nothing. Now a lazily-created, module-level singleton reused for the plugin's lifetime, torn down once inonunload().SupernoteView.onLoadFileawaitedcreateImageBitmap()one page at a time inside its per-page DOM-building loop — a page-count-scaling cost paid on every open regardless of how the PNG was obtained. Now decoded up front viaPromise.all.SupernoteViewbuilds an in-memory PDF purely so pdf.js can extract a text layer for search/selection (pdfPage.render()is never called against it — the embedded image is never actually shown). It was still unconditionally embedding every page's real image into that PDF.pdf-lib's PNG embedding always fully decodes and recompresses the source PNG at.save()time regardless of it already being compressed — real, size-proportional cost. Profiled against an actual user-provided 12-page note with several user-uploaded-background pages: ~2.9s / ~4.5MB → ~40ms / ~43KB, same extracted text either way. Fixed by bumping thesupernote-typescriptsubmodule to addaddTextOnlyPdfPage()(philips/supernote-typescript#34) — a PDF page with the recognized-text layer but no image — and using it for this internal-only pipeline. The user-facing "export/attach as PDF" commands are untouched and still embed real images, since those PDFs are genuinely opened by the user.hashBytesfingerprint fromdeviceSync.tsrather than adding a new hashing dependency.RasterCacheinstance is created inSupernotePlugin.onload(), stored under the plugin's own data folder (<plugin dir>/rasterCache/) — never as visible vault content.Design notes:
Mapinsertion-order reordering on hit; the disk tier's reordering is kept in memory only, persisted on the nextput()/clear()rather than on every read.Closes #129.
Test plan
npx tsc -noEmit -skipLibCheck— cleannpm run lint— clean (pre-existing unrelated warnings only)npx vitest run— 109 passed, including 14rasterCache.test.tscases (roundtrip, per-key isolation, LRU eviction on both tiers, memory tier avoiding repeat disk reads, independent per-tier eviction budgets, index persistence, fail-open behavior,clear())supernote-typescript's own test suite (26 tests, vitest) passes with the newaddTextOnlyPdfPage, including a new test asserting identical extracted text vs.addPdfPageat <1/10th the byte size./scripts/build— full production build succeeds