perf: paginate large voice libraries

This commit is contained in:
mARTin-B78 2026-09-04 08:05:09 +02:00
parent 8235b36afd
commit e52d462dac
3 changed files with 33 additions and 3 deletions

View File

@ -43,6 +43,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
changing those fields causes the existing pair to be flagged stale. changing those fields causes the existing pair to be flagged stale.
- **Character Table Pagination.** Productions now render 50 character cards/rows at a time with an explicit Show more - **Character Table Pagination.** Productions now render 50 character cards/rows at a time with an explicit Show more
control, keeping sorting and record lookups intact while reducing DOM and image work for large casts. control, keeping sorting and record lookups intact while reducing DOM and image work for large casts.
- **Voice Table Pagination.** The voice library now renders up to 50 sorted rows initially and adds a Show more
control for larger result sets, while retaining the full dataset for filtering and sorting.
- **Upload Filename Boundaries.** Audio, STT, route-sound and picture uploads now reject path-like or control-character - **Upload Filename Boundaries.** Audio, STT, route-sound and picture uploads now reject path-like or control-character
filenames before deriving extensions; voice ZIP imports also reject absolute, traversal and malformed archive paths. filenames before deriving extensions; voice ZIP imports also reject absolute, traversal and malformed archive paths.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL

View File

@ -53,8 +53,8 @@ the first block before investing in architecture or polish.
cross-record validation so mismatched profile/reference files are detected even when both are current-version. cross-record validation so mismatched profile/reference files are detected even when both are current-version.
- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate - [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate
repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs. repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs.
- [ ] **M — Improve large-table performance.** Character productions now paginate at 50 visible rows; paginate voice - [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50
tables, update only affected rows, and disable preloading on low-memory devices. visible rows; updating only affected rows and disabling preloading on low-memory devices remain.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses now have bounded eviction; - [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses now have bounded eviction;
add equivalent LRU cleanup for audio/image entries and object URLs. add equivalent LRU cleanup for audio/image entries and object URLs.
- [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling, - [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling,

View File

@ -1816,6 +1816,22 @@ function renderVoiceList() {
return 0; return 0;
}); });
// Keep the complete filtered/sorted list in memory, but avoid creating a
// large DOM (and hundreds of audio/image nodes) for big libraries.
const pageSignature = JSON.stringify([
cat, showDisabled, window._voiceGroupFilter || '', window._voiceTagFilter || '',
_libraryIssueFilter || '', _sortField, _sortDir, !!window._voiceTableView, _libraryFilters,
]);
if (window._voicePageSignature !== pageSignature) {
window._voicePageSignature = pageSignature;
window._voicePage = 1;
}
const totalFiltered = filtered.length;
const pageSize = 50;
const page = Math.max(1, Number(window._voicePage) || 1);
const pageEnd = Math.min(totalFiltered, page * pageSize);
const pageItems = filtered.slice(0, pageEnd);
updateLibraryInsights(); updateLibraryInsights();
if (_libraryIssueFilter) { if (_libraryIssueFilter) {
const note = document.createElement('div'); const note = document.createElement('div');
@ -1955,8 +1971,20 @@ function renderVoiceList() {
if (window._voiceGroupByTag) renderVoicesByTag(list, filtered); if (window._voiceGroupByTag) renderVoicesByTag(list, filtered);
else { else {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
filtered.forEach(v => frag.appendChild(makeVoiceRow(v))); pageItems.forEach(v => frag.appendChild(makeVoiceRow(v)));
list.appendChild(frag); list.appendChild(frag);
if (pageEnd < totalFiltered) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'btn-secondary vl-load-more';
more.style.margin = '12px 0';
more.textContent = `Show more voices (${pageEnd} / ${totalFiltered})`;
more.addEventListener('click', () => {
window._voicePage = page + 1;
renderVoiceList();
});
list.appendChild(more);
}
} }
if (_pendingSelectId) { if (_pendingSelectId) {
const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`); const wrap = list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);