perf: bound browser library image cache
This commit is contained in:
parent
e515554446
commit
4b01a853fb
@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Bounded library image lifecycle.** Profile, concept-art, and voice-library images now use a viewport-aware
|
||||||
|
browser cache with normal/low-memory limits and restoration URLs for evicted off-screen sources.
|
||||||
- **Shared provider-health probes.** TTS, STT, and container connectivity checks now use one bounded helper with
|
- **Shared provider-health probes.** TTS, STT, and container connectivity checks now use one bounded helper with
|
||||||
normalized connection, timeout, HTTP-status, and JSON-shape handling while retaining provider-specific endpoints.
|
normalized connection, timeout, HTTP-status, and JSON-shape handling while retaining provider-specific endpoints.
|
||||||
- **Section-scoped feature loading.** The shared module manifest now builds a 17-module foundation bundle and loads
|
- **Section-scoped feature loading.** The shared module manifest now builds a 17-module foundation bundle and loads
|
||||||
|
|||||||
10
TODO.md
10
TODO.md
@ -23,8 +23,7 @@ caching, background jobs, canonical character identity, forms, and the frontend
|
|||||||
namespace/manifest foundations and section-scoped lazy loading are working and
|
namespace/manifest foundations and section-scoped lazy loading are working and
|
||||||
regression-tested. Remaining P1 work is intentionally open where it needs a real
|
regression-tested. Remaining P1 work is intentionally open where it needs a real
|
||||||
architecture change or workload measurement: the larger Audiobook/Rehearser/style
|
architecture change or workload measurement: the larger Audiobook/Rehearser/style
|
||||||
split, centralized provider-health handling, full constrained-device workloads, and
|
split, and full constrained-device workloads.
|
||||||
browser image/profile-cache eviction.
|
|
||||||
|
|
||||||
### P0 — do first
|
### P0 — do first
|
||||||
|
|
||||||
@ -140,9 +139,12 @@ browser image/profile-cache eviction.
|
|||||||
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
|
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
|
||||||
invalidation after library metadata/folder mutations; the SQLite voice index remains authoritative.
|
invalidation after library metadata/folder mutations; the SQLite voice index remains authoritative.
|
||||||
- [x] Lazy-load profile images and concept art.
|
- [x] Lazy-load profile images and concept art.
|
||||||
- [ ] Update only affected table rows instead of rebuilding entire tables.
|
- [x] Update only affected table rows instead of rebuilding entire tables where the operation is row-local; benchmark,
|
||||||
|
normalization, rating, and voice-note updates now patch the existing visible rows. Structural changes still trigger
|
||||||
|
a full filtered rebuild so sorting, grouping, and pagination remain correct.
|
||||||
- [x] Add an LRU cache for decoded Rehearser audio and remove old entries automatically. Persisted line-audio is
|
- [x] Add an LRU cache for decoded Rehearser audio and remove old entries automatically. Persisted line-audio is
|
||||||
already bounded; profile/image cache eviction remains a separate browser concern.
|
already bounded; profile/concept and voice-library images now use a bounded viewport-aware browser cache that
|
||||||
|
removes old decoded sources while retaining restoration URLs.
|
||||||
- [x] Revoke discarded object URLs with `URL.revokeObjectURL(objectUrl)`; completed synthesized voice previews and
|
- [x] Revoke discarded object URLs with `URL.revokeObjectURL(objectUrl)`; completed synthesized voice previews and
|
||||||
character/Rehearser local auditions now release their Blob URLs when playback ends or is replaced.
|
character/Rehearser local auditions now release their Blob URLs when playback ends or is replaced.
|
||||||
- [x] Add low-memory mode using `navigator.deviceMemory` where available; shared worker pools cap at two tasks on devices reporting approximately 4 GB RAM or less.
|
- [x] Add low-memory mode using `navigator.deviceMemory` where available; shared worker pools cap at two tasks on devices reporting approximately 4 GB RAM or less.
|
||||||
|
|||||||
4
static/dist/main.min.js
vendored
4
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
57
static/js/image-cache.js
Normal file
57
static/js/image-cache.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// ── Bounded decoded-image lifecycle ─────────────────────────────────────────
|
||||||
|
// Native loading="lazy" controls network work, but browsers may retain decoded
|
||||||
|
// pixels for every image that has entered the viewport. Keep a small LRU of
|
||||||
|
// visible library images and drop src from old, off-screen elements so their
|
||||||
|
// decoded surfaces can be reclaimed. data-ttvc-src restores them on re-entry.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var root = window.TTVC = window.TTVC || {};
|
||||||
|
var entries = new Map();
|
||||||
|
var visible = new Set();
|
||||||
|
var observer = 'IntersectionObserver' in window ? new IntersectionObserver(function (records) {
|
||||||
|
records.forEach(function (record) {
|
||||||
|
var img = record.target;
|
||||||
|
if (record.isIntersecting) {
|
||||||
|
visible.add(img);
|
||||||
|
var source = img.dataset.ttvcSrc;
|
||||||
|
if (source && !img.getAttribute('src')) img.setAttribute('src', source);
|
||||||
|
entries.delete(img); entries.set(img, Date.now());
|
||||||
|
} else {
|
||||||
|
visible.delete(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
evict();
|
||||||
|
}, { rootMargin: '160px' }) : null;
|
||||||
|
|
||||||
|
function limit() {
|
||||||
|
return typeof window.ttvcIsLowMemoryDevice === 'function' && window.ttvcIsLowMemoryDevice() ? 24 : 64;
|
||||||
|
}
|
||||||
|
function evict() {
|
||||||
|
// Disconnected nodes are no longer useful cache entries.
|
||||||
|
entries.forEach(function (_, img) { if (!img.isConnected) entries.delete(img); });
|
||||||
|
while (entries.size > limit()) {
|
||||||
|
var candidate = null;
|
||||||
|
for (var img of entries.keys()) {
|
||||||
|
if (!visible.has(img)) { candidate = img; break; }
|
||||||
|
}
|
||||||
|
if (!candidate) break; // never evict an image currently near the viewport
|
||||||
|
entries.delete(candidate);
|
||||||
|
if (candidate.getAttribute('src')) candidate.dataset.ttvcSrc = candidate.getAttribute('src');
|
||||||
|
candidate.removeAttribute('src');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function observeImages(container) {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('img').forEach(function (img) {
|
||||||
|
if (!img.dataset.ttvcSrc && img.getAttribute('src')) img.dataset.ttvcSrc = img.getAttribute('src');
|
||||||
|
if (observer && !img.dataset.ttvcObserved) {
|
||||||
|
img.dataset.ttvcObserved = '1';
|
||||||
|
observer.observe(img);
|
||||||
|
}
|
||||||
|
entries.delete(img); entries.set(img, Date.now());
|
||||||
|
});
|
||||||
|
evict();
|
||||||
|
}
|
||||||
|
root.imageCache = { observe: observeImages, evict: evict, size: function () { return entries.size; } };
|
||||||
|
window.ttvcObserveImages = observeImages;
|
||||||
|
})();
|
||||||
@ -590,6 +590,7 @@ async function libraryRenderCharacters() {
|
|||||||
productions.appendChild(prod);
|
productions.appendChild(prod);
|
||||||
});
|
});
|
||||||
container.appendChild(productions);
|
container.appendChild(productions);
|
||||||
|
if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container);
|
||||||
|
|
||||||
// Scroll to a specific book's production block — set by callers that land
|
// Scroll to a specific book's production block — set by callers that land
|
||||||
// here from a book-scoped context (e.g. the "Assign Voices" workflow step)
|
// here from a book-scoped context (e.g. the "Assign Voices" workflow step)
|
||||||
|
|||||||
@ -1998,6 +1998,7 @@ function renderVoiceList() {
|
|||||||
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
|
if (wrap) { _pendingSelectId = null; selectVoice(wrap); }
|
||||||
}
|
}
|
||||||
syncSortHeaders();
|
syncSortHeaders();
|
||||||
|
if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group the list into collapsible virtual "folders" by tag (a voice with several
|
// Group the list into collapsible virtual "folders" by tag (a voice with several
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"core": [
|
"core": [
|
||||||
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
|
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs", "image-cache",
|
||||||
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
|
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
|
||||||
],
|
],
|
||||||
"lazy": {
|
"lazy": {
|
||||||
|
|||||||
@ -101,3 +101,26 @@ test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) =
|
|||||||
});
|
});
|
||||||
expect(size).toBeLessThanOrEqual(128);
|
expect(size).toBeLessThanOrEqual(128);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('library image cache evicts decoded image sources but preserves restoration data', async ({ page }) => {
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => window.TTVC?.imageCache, null, { timeout: 15_000 });
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
document.body.appendChild(holder);
|
||||||
|
for (let index = 0; index < 90; index += 1) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = '/api/voice/picture/cache-' + index;
|
||||||
|
holder.appendChild(img);
|
||||||
|
}
|
||||||
|
window.TTVC.imageCache.observe(holder);
|
||||||
|
const images = [...holder.querySelectorAll('img')];
|
||||||
|
const evicted = images.filter(img => !img.getAttribute('src'));
|
||||||
|
const restorable = evicted.every(img => img.dataset.ttvcSrc);
|
||||||
|
holder.remove();
|
||||||
|
return { size: window.TTVC.imageCache.size(), evicted: evicted.length, restorable };
|
||||||
|
});
|
||||||
|
expect(result.size).toBeLessThanOrEqual(64);
|
||||||
|
expect(result.evicted).toBeGreaterThan(0);
|
||||||
|
expect(result.restorable).toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user