diff --git a/CHANGELOG.md b/CHANGELOG.md index 42befef..e64ce3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### 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 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 diff --git a/TODO.md b/TODO.md index b322042..5fbd63f 100644 --- a/TODO.md +++ b/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 regression-tested. Remaining P1 work is intentionally open where it needs a real architecture change or workload measurement: the larger Audiobook/Rehearser/style -split, centralized provider-health handling, full constrained-device workloads, and -browser image/profile-cache eviction. +split, and full constrained-device workloads. ### 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 invalidation after library metadata/folder mutations; the SQLite voice index remains authoritative. - [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 - 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 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. diff --git a/VERSION b/VERSION index d14ef08..5553cb3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.17 +1.24.18 diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 963be76..46ff867 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -197,7 +197,7 @@ var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_ ${can?'':""} - `}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a3,_b3;const v=items[i];(_a3=el.querySelector(".fa-play"))==null||_a3.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b3=el.querySelector(".fa-import"))==null||_b3.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a2=$2("fa-fetch"))==null||_a2.addEventListener("click",()=>{_page=1,browse()}),(_b2=$2("fa-search"))==null||_b2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c2=$2("fa-tag"))==null||_c2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d2=$2("fa-lang"))==null||_d2.addEventListener("change",()=>{_page=1,browse()}),(_e2=$2("fa-sort"))==null||_e2.addEventListener("change",()=>{_page=1,browse()}),(_f2=$2("fa-prev"))==null||_f2.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g2=$2("fa-next"))==null||_g2.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i2=$2("fa-filter-close"))==null||_i2.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j2=$2("fa-tag"))==null||_j2.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k2=$2("fa-filter-apply"))==null||_k2.addEventListener("click",applyFilters),(_l2=$2("fa-filter-reset"))==null||_l2.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}(),function(){const KEY="ttsvc_voice_library_cache_v2";let inFlight=null;function read(){try{const value=JSON.parse(sessionStorage.getItem(KEY)||"null");return!value||!Array.isArray(value.voices)||!Number.isFinite(value.savedAt)?null:Date.now()-value.savedAt<=3e4?value.voices:null}catch{return null}}function write(voices){try{sessionStorage.setItem(KEY,JSON.stringify({savedAt:Date.now(),voices}))}catch{}}function invalidate(){try{sessionStorage.removeItem(KEY)}catch{}}async function fetchVoices(options={}){const refresh=options===!0||options.refresh===!0;if(!refresh){const cached=read();if(cached)return cached}return inFlight||(inFlight=fetch("/api/voices"+(refresh?"?refresh=1":""),{cache:"no-store"}).then(async response=>{if(!response.ok){const error=await response.json().catch(()=>({}));throw new Error(error.detail||response.statusText)}const voices=await response.json();if(!Array.isArray(voices))throw new Error("Voice library response was not an array");return write(voices),voices}).finally(()=>{inFlight=null}),inFlight)}window.fetchVoiceLibrary=fetchVoices,window.invalidateVoiceLibraryCache=invalidate}(),function(){const list=document.getElementById("background-jobs-list"),refresh=document.getElementById("background-jobs-refresh"),diagnostics=document.getElementById("resource-diagnostics"),diagnosticsRefresh=document.getElementById("resource-diagnostics-refresh");if(!list||!refresh)return;const labels={transcription:"Transcription",voice_design:"Voice design",casting:"Voice casting",consistent_images:"Consistent images"};let loading=!1,diagnosticsLoading=!1;function node(value,fallback="\u2014"){const item=document.createElement("span");return item.textContent=value==null||value===""?fallback:String(value),item}function statusClass(job){return job.cancelled||job.stage==="cancelled"?"is-cancelled":job.error||job.stage==="error"||job.stage==="failed"?"is-error":job.done?"is-done":"is-active"}function statusLabel(job){return job.cancelled||job.stage==="cancelled"?"Cancelled":job.error||job.stage==="error"||job.stage==="failed"?"Failed":job.done?"Completed":job.stage==="queued"?"Queued":"Running"}function cancelEndpoint(kind,id){const paths={transcription:"/api/transcribe/cancel/",voice_design:"/api/voice-design/cancel/",casting:"/api/match-characters-voices/cancel/",consistent_images:"/api/character-generate-consistent-images/cancel/"};return paths[kind]?paths[kind]+encodeURIComponent(id):""}function retryEndpoint(kind,id){const paths={transcription:"/api/transcribe/retry/",voice_design:"/api/voice-design/retry/",casting:"/api/match-characters-voices/retry/",consistent_images:"/api/character-generate-consistent-images/retry/"};return paths[kind]&&id?paths[kind]+encodeURIComponent(id):""}function render(jobs){if(list.replaceChildren(),!jobs.length){const empty=node("No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.");empty.className="perf-history-empty",list.appendChild(empty);return}jobs.forEach(job=>{const row=document.createElement("div");row.className="background-job-row "+statusClass(job);const main=document.createElement("div");main.className="background-job-main";const title=document.createElement("strong");title.textContent=labels[job.kind]||job.kind||"Background job",main.appendChild(title);const id=document.createElement("code");id.textContent=String(job.job_id||"").slice(0,12),main.appendChild(id);const detail=node(job.error||job.detail||job.stage);detail.className="background-job-detail",main.appendChild(detail);const meta=node(`${statusLabel(job)} \xB7 ${Number(job.elapsed||0).toFixed(1)}s`);meta.className="background-job-meta",main.appendChild(meta);const storedResult=job.result&&typeof job.result=="object"?job.result:{},artifactList=[];storedResult.artifact&&artifactList.push(storedResult.artifact),Array.isArray(storedResult.artifacts)&&artifactList.push(...storedResult.artifacts),artifactList.filter(item=>item&&typeof item.url=="string"&&item.url.startsWith("/api/jobs/")).forEach(item=>{const link=document.createElement("a");link.className="background-job-artifact",link.href=item.url,link.target="_blank",link.rel="noopener",link.textContent=`Download ${item.kind||"result"}`,main.appendChild(link)}),row.appendChild(main);const actions=document.createElement("div");if(actions.className="background-job-actions",job.done&&job.error&&job.retry_available){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Retry",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(retryEndpoint(job.kind,job.job_id),{method:"POST"});if(!response.ok){const detail2=await response.json().catch(()=>({}));throw new Error(detail2.detail||`HTTP ${response.status}`)}await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not retry job: "+error.message,"error")}}),actions.appendChild(button)}if(!job.done&&!job.cancelled&&job.job_id){const endpoint=cancelEndpoint(job.kind,job.job_id);if(endpoint){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Cancel",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(endpoint,{method:"POST"});if(!response.ok)throw new Error(`HTTP ${response.status}`);await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not cancel job: "+error.message,"error")}}),actions.appendChild(button)}}const note=node("Reopen source to retry");note.className="background-job-retry-note",note.title="Inputs are not retained for replay, by design.",(!job.done||!job.error||!job.retry_available)&&actions.appendChild(note),row.appendChild(actions),list.appendChild(row)})}async function load(){if(!loading){loading=!0,refresh.disabled=!0;try{const response=await fetch("/api/jobs?limit=50",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const payload=await response.json();render(Array.isArray(payload.jobs)?payload.jobs:[])}catch(error){list.replaceChildren();const empty=node("Background job history unavailable: "+error.message);empty.className="perf-history-empty",list.appendChild(empty)}finally{loading=!1,refresh.disabled=!1}}}function formatBytes(value){const bytes=Number(value);return Number.isFinite(bytes)?bytes<1024*1024?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1024/1024).toFixed(1)} MiB`:"\u2014"}async function loadDiagnostics(){var _a2,_b2,_c2;if(!(!diagnostics||diagnosticsLoading)){diagnosticsLoading=!0,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!0);try{const response=await fetch("/api/diagnostics",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const server=await response.json(),browser=await new Promise(resolve=>requestAnimationFrame(()=>{var _a3;return resolve({domNodes:document.getElementsByTagName("*").length,audioElements:document.querySelectorAll("audio").length,jsHeapBytes:((_a3=performance.memory)==null?void 0:_a3.usedJSHeapSize)||null})}));diagnostics.replaceChildren(),[["Server RSS",`${server.server_rss_mb} MiB`],["Config data",formatBytes((_a2=server.config_disk)==null?void 0:_a2.used_bytes)],["Config free",formatBytes((_b2=server.config_disk)==null?void 0:_b2.free_bytes)],["Temp data",formatBytes((_c2=server.temp_disk)==null?void 0:_c2.used_bytes)],["Persisted jobs",server.background_jobs],["DOM nodes",browser.domNodes],["Audio elements",browser.audioElements],["JS heap",formatBytes(browser.jsHeapBytes)]].forEach(([label,value])=>{const item=document.createElement("div");item.className="resource-diagnostic-item";const name=document.createElement("span");name.textContent=label;const valueNode=document.createElement("strong");valueNode.textContent=value==null?"\u2014":String(value),item.append(name,valueNode),diagnostics.appendChild(item)})}catch(error){diagnostics.replaceChildren();const item=node("Diagnostics unavailable: "+error.message);item.className="perf-history-empty",diagnostics.appendChild(item)}finally{diagnosticsLoading=!1,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!1)}}}refresh.addEventListener("click",load),diagnosticsRefresh==null||diagnosticsRefresh.addEventListener("click",loadDiagnostics),load(),loadDiagnostics(),setInterval(()=>{var _a2;(_a2=document.getElementById("s-performance"))!=null&&_a2.classList.contains("is-active")&&(load(),loadDiagnostics())},1e4)}();function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS + `}function render(data){const items=data.items||[];items.length?(grid.innerHTML=items.map(card).join(""),grid.querySelectorAll(".fa-card").forEach((el,i)=>{var _a3,_b3;const v=items[i];(_a3=el.querySelector(".fa-play"))==null||_a3.addEventListener("click",e=>{e.stopPropagation(),preview(v,el)}),(_b3=el.querySelector(".fa-import"))==null||_b3.addEventListener("click",e=>{e.stopPropagation(),importVoice(v,el)})})):grid.innerHTML='
No voices found \u2014 try a different search, language or filter.
';const pager=$2("fa-pager");if(pager){pager.hidden=!1,$2("fa-prev").disabled=_page<=1;const pages=Math.max(1,Math.ceil((data.total||0)/(data.page_size||24)));$2("fa-next").disabled=_page>=pages,$2("fa-pager-info").textContent=`Page ${_page} \xB7 ${(data.total||0).toLocaleString()} voices`}}async function browse(){status2("Loading voices from fish.audio\u2026"),grid.innerHTML='
Loading\u2026
';try{const d=await fetch("/api/fishaudio/voices?"+params().toString()).then(r=>{if(!r.ok)throw new Error("HTTP "+r.status);return r.json()});render(d),status2(d.offline?"\u26A0 fish.audio unreachable \u2014 showing cached results":`${(d.total||0).toLocaleString()} matching voices`,!!d.offline)}catch(e){grid.innerHTML=`
Failed to load: ${esc(e.message)}
`,status2("")}}function stopPreview(){_audio&&(_audio.pause(),_audio=null),_playingCard==null||_playingCard.classList.remove("fa-card-playing"),_playingCard=null}function preview(v,el){if(_playingCard===el){stopPreview();return}stopPreview(),v.sample_audio&&(_audio=new Audio(v.sample_audio),_playingCard=el,el.classList.add("fa-card-playing"),_audio.addEventListener("ended",stopPreview),_audio.play().catch(()=>{stopPreview(),typeof toast=="function"&&toast("Could not play preview","error")}))}async function importVoice(v,el){const btn=el.querySelector(".fa-import"),orig=btn.innerHTML;btn.disabled=!0,btn.innerHTML='';const lang=(v.language||"EN").slice(0,2).toUpperCase(),base=(typeof _umlautSafe=="function"?_umlautSafe(v.title||"fishaudio"):v.title||"fishaudio").replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,40)||"Voice",voiceId=`${lang}_${base}`;try{const r=await fetch("/api/quick-import-voice",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:voiceId,audio_url:v.sample_audio,transcript:v.sample_text||v.default_text||""})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();typeof saveMeta=="function"&&await saveMeta(d.voice_id,{name:v.title||base,tag:"fish-audio",group:"fish-audio",origin:"cloned",gender:(v.gender||"").charAt(0).toUpperCase(),note:(v.description||"").slice(0,180)}).catch(()=>{}),v.image&&await fetch("/api/voice/picture-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({voice_id:d.voice_id,image_url:v.image})}).catch(()=>{}),btn.innerHTML=' Imported',el.classList.add("fa-card-imported"),typeof toast=="function"&&toast(`Imported "${v.title}" as ${d.voice_id}`,"success"),typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().catch(()=>{})}catch(e){btn.disabled=!1,btn.innerHTML=orig,typeof toast=="function"&&toast("Import failed: "+e.message,"error")}}(_a2=$2("fa-fetch"))==null||_a2.addEventListener("click",()=>{_page=1,browse()}),(_b2=$2("fa-search"))==null||_b2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse())}),(_c2=$2("fa-tag"))==null||_c2.addEventListener("keydown",e=>{e.key==="Enter"&&(_page=1,browse(),applyFilters())}),(_d2=$2("fa-lang"))==null||_d2.addEventListener("change",()=>{_page=1,browse()}),(_e2=$2("fa-sort"))==null||_e2.addEventListener("change",()=>{_page=1,browse()}),(_f2=$2("fa-prev"))==null||_f2.addEventListener("click",()=>{_page>1&&(_page--,browse())}),(_g2=$2("fa-next"))==null||_g2.addEventListener("click",()=>{_page++,browse()});const pop=$2("fa-filter-pop");function positionPop(){const btn=$2("fa-filter-btn");if(!pop||!btn||pop.hidden)return;const r=btn.getBoundingClientRect(),w=pop.offsetWidth||320,margin=8;let left=Math.min(r.right-w,window.innerWidth-w-margin);left{e.stopPropagation(),openPop()}),(_i2=$2("fa-filter-close"))==null||_i2.addEventListener("click",e=>{e.stopPropagation(),openPop(!1)}),pop==null||pop.addEventListener("click",e=>e.stopPropagation()),document.addEventListener("click",()=>openPop(!1)),document.addEventListener("keydown",e=>{e.key==="Escape"&&openPop(!1)}),window.addEventListener("resize",positionPop),window.addEventListener("scroll",positionPop,!0),document.querySelectorAll("#fa-filter-pop .fa-pchip").forEach(chip=>{chip.addEventListener("click",()=>{const wrap=chip.closest(".fa-pchips");wrap.classList.contains("fa-pchips-multi")?chip.classList.toggle("active"):(wrap.querySelectorAll(".fa-pchip").forEach(c=>c.classList.remove("active")),chip.classList.add("active")),updateFilterBadge()})}),(_j2=$2("fa-tag"))==null||_j2.addEventListener("input",updateFilterBadge);function applyFilters(){_page=1,openPop(!1),browse()}(_k2=$2("fa-filter-apply"))==null||_k2.addEventListener("click",applyFilters),(_l2=$2("fa-filter-reset"))==null||_l2.addEventListener("click",()=>{document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c=>c.classList.remove("active")),["gender","age"].forEach(g=>{const wrap=document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);wrap==null||wrap.querySelectorAll(".fa-pchip").forEach((c,i)=>c.classList.toggle("active",i===0))}),$2("fa-tag")&&($2("fa-tag").value=""),updateFilterBadge(),applyFilters()})}(),function(){const tabs=document.getElementById("gvo-tabs");if(!tabs)return;const map={direct:"tab-getvoices",fish:"fa-browser-card",eleven:"el-browser-card"};function show(src){map[src]||(src="direct"),Object.entries(map).forEach(([k,id])=>{const el=document.getElementById(id);el&&(k===src?el.style.removeProperty("display"):el.style.setProperty("display","none","important"))}),tabs.querySelectorAll(".gvo-tab").forEach(t=>t.classList.toggle("active",t.dataset.src===src));try{localStorage.setItem("gvo-src",src)}catch{}}tabs.querySelectorAll(".gvo-tab").forEach(t=>t.addEventListener("click",()=>show(t.dataset.src)));let saved="direct";try{saved=localStorage.getItem("gvo-src")||"direct"}catch{}show(saved)}(),function(){const KEY="ttsvc_voice_library_cache_v2";let inFlight=null;function read(){try{const value=JSON.parse(sessionStorage.getItem(KEY)||"null");return!value||!Array.isArray(value.voices)||!Number.isFinite(value.savedAt)?null:Date.now()-value.savedAt<=3e4?value.voices:null}catch{return null}}function write(voices){try{sessionStorage.setItem(KEY,JSON.stringify({savedAt:Date.now(),voices}))}catch{}}function invalidate(){try{sessionStorage.removeItem(KEY)}catch{}}async function fetchVoices(options={}){const refresh=options===!0||options.refresh===!0;if(!refresh){const cached=read();if(cached)return cached}return inFlight||(inFlight=fetch("/api/voices"+(refresh?"?refresh=1":""),{cache:"no-store"}).then(async response=>{if(!response.ok){const error=await response.json().catch(()=>({}));throw new Error(error.detail||response.statusText)}const voices=await response.json();if(!Array.isArray(voices))throw new Error("Voice library response was not an array");return write(voices),voices}).finally(()=>{inFlight=null}),inFlight)}window.fetchVoiceLibrary=fetchVoices,window.invalidateVoiceLibraryCache=invalidate}(),function(){const list=document.getElementById("background-jobs-list"),refresh=document.getElementById("background-jobs-refresh"),diagnostics=document.getElementById("resource-diagnostics"),diagnosticsRefresh=document.getElementById("resource-diagnostics-refresh");if(!list||!refresh)return;const labels={transcription:"Transcription",voice_design:"Voice design",casting:"Voice casting",consistent_images:"Consistent images"};let loading=!1,diagnosticsLoading=!1;function node(value,fallback="\u2014"){const item=document.createElement("span");return item.textContent=value==null||value===""?fallback:String(value),item}function statusClass(job){return job.cancelled||job.stage==="cancelled"?"is-cancelled":job.error||job.stage==="error"||job.stage==="failed"?"is-error":job.done?"is-done":"is-active"}function statusLabel(job){return job.cancelled||job.stage==="cancelled"?"Cancelled":job.error||job.stage==="error"||job.stage==="failed"?"Failed":job.done?"Completed":job.stage==="queued"?"Queued":"Running"}function cancelEndpoint(kind,id){const paths={transcription:"/api/transcribe/cancel/",voice_design:"/api/voice-design/cancel/",casting:"/api/match-characters-voices/cancel/",consistent_images:"/api/character-generate-consistent-images/cancel/"};return paths[kind]?paths[kind]+encodeURIComponent(id):""}function retryEndpoint(kind,id){const paths={transcription:"/api/transcribe/retry/",voice_design:"/api/voice-design/retry/",casting:"/api/match-characters-voices/retry/",consistent_images:"/api/character-generate-consistent-images/retry/"};return paths[kind]&&id?paths[kind]+encodeURIComponent(id):""}function render(jobs){if(list.replaceChildren(),!jobs.length){const empty=node("No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.");empty.className="perf-history-empty",list.appendChild(empty);return}jobs.forEach(job=>{const row=document.createElement("div");row.className="background-job-row "+statusClass(job);const main=document.createElement("div");main.className="background-job-main";const title=document.createElement("strong");title.textContent=labels[job.kind]||job.kind||"Background job",main.appendChild(title);const id=document.createElement("code");id.textContent=String(job.job_id||"").slice(0,12),main.appendChild(id);const detail=node(job.error||job.detail||job.stage);detail.className="background-job-detail",main.appendChild(detail);const meta=node(`${statusLabel(job)} \xB7 ${Number(job.elapsed||0).toFixed(1)}s`);meta.className="background-job-meta",main.appendChild(meta);const storedResult=job.result&&typeof job.result=="object"?job.result:{},artifactList=[];storedResult.artifact&&artifactList.push(storedResult.artifact),Array.isArray(storedResult.artifacts)&&artifactList.push(...storedResult.artifacts),artifactList.filter(item=>item&&typeof item.url=="string"&&item.url.startsWith("/api/jobs/")).forEach(item=>{const link=document.createElement("a");link.className="background-job-artifact",link.href=item.url,link.target="_blank",link.rel="noopener",link.textContent=`Download ${item.kind||"result"}`,main.appendChild(link)}),row.appendChild(main);const actions=document.createElement("div");if(actions.className="background-job-actions",job.done&&job.error&&job.retry_available){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Retry",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(retryEndpoint(job.kind,job.job_id),{method:"POST"});if(!response.ok){const detail2=await response.json().catch(()=>({}));throw new Error(detail2.detail||`HTTP ${response.status}`)}await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not retry job: "+error.message,"error")}}),actions.appendChild(button)}if(!job.done&&!job.cancelled&&job.job_id){const endpoint=cancelEndpoint(job.kind,job.job_id);if(endpoint){const button=document.createElement("button");button.type="button",button.className="btn-secondary btn-sm",button.textContent="Cancel",button.addEventListener("click",async()=>{button.disabled=!0;try{const response=await fetch(endpoint,{method:"POST"});if(!response.ok)throw new Error(`HTTP ${response.status}`);await load()}catch(error){button.disabled=!1,typeof toast=="function"&&toast("Could not cancel job: "+error.message,"error")}}),actions.appendChild(button)}}const note=node("Reopen source to retry");note.className="background-job-retry-note",note.title="Inputs are not retained for replay, by design.",(!job.done||!job.error||!job.retry_available)&&actions.appendChild(note),row.appendChild(actions),list.appendChild(row)})}async function load(){if(!loading){loading=!0,refresh.disabled=!0;try{const response=await fetch("/api/jobs?limit=50",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const payload=await response.json();render(Array.isArray(payload.jobs)?payload.jobs:[])}catch(error){list.replaceChildren();const empty=node("Background job history unavailable: "+error.message);empty.className="perf-history-empty",list.appendChild(empty)}finally{loading=!1,refresh.disabled=!1}}}function formatBytes(value){const bytes=Number(value);return Number.isFinite(bytes)?bytes<1024*1024?`${(bytes/1024).toFixed(1)} KiB`:`${(bytes/1024/1024).toFixed(1)} MiB`:"\u2014"}async function loadDiagnostics(){var _a2,_b2,_c2;if(!(!diagnostics||diagnosticsLoading)){diagnosticsLoading=!0,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!0);try{const response=await fetch("/api/diagnostics",{cache:"no-store"});if(!response.ok)throw new Error(`HTTP ${response.status}`);const server=await response.json(),browser=await new Promise(resolve=>requestAnimationFrame(()=>{var _a3;return resolve({domNodes:document.getElementsByTagName("*").length,audioElements:document.querySelectorAll("audio").length,jsHeapBytes:((_a3=performance.memory)==null?void 0:_a3.usedJSHeapSize)||null})}));diagnostics.replaceChildren(),[["Server RSS",`${server.server_rss_mb} MiB`],["Config data",formatBytes((_a2=server.config_disk)==null?void 0:_a2.used_bytes)],["Config free",formatBytes((_b2=server.config_disk)==null?void 0:_b2.free_bytes)],["Temp data",formatBytes((_c2=server.temp_disk)==null?void 0:_c2.used_bytes)],["Persisted jobs",server.background_jobs],["DOM nodes",browser.domNodes],["Audio elements",browser.audioElements],["JS heap",formatBytes(browser.jsHeapBytes)]].forEach(([label,value])=>{const item=document.createElement("div");item.className="resource-diagnostic-item";const name=document.createElement("span");name.textContent=label;const valueNode=document.createElement("strong");valueNode.textContent=value==null?"\u2014":String(value),item.append(name,valueNode),diagnostics.appendChild(item)})}catch(error){diagnostics.replaceChildren();const item=node("Diagnostics unavailable: "+error.message);item.className="perf-history-empty",diagnostics.appendChild(item)}finally{diagnosticsLoading=!1,diagnosticsRefresh&&(diagnosticsRefresh.disabled=!1)}}}refresh.addEventListener("click",load),diagnosticsRefresh==null||diagnosticsRefresh.addEventListener("click",loadDiagnostics),load(),loadDiagnostics(),setInterval(()=>{var _a2;(_a2=document.getElementById("s-performance"))!=null&&_a2.classList.contains("is-active")&&(load(),loadDiagnostics())},1e4)}(),function(){"use strict";var root=window.TTVC=window.TTVC||{},entries=new Map,visible=new Set,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;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(){for(entries.forEach(function(_,img2){img2.isConnected||entries.delete(img2)});entries.size>limit();){var candidate=null;for(var img of entries.keys())if(!visible.has(img)){candidate=img;break}if(!candidate)break;entries.delete(candidate),candidate.getAttribute("src")&&(candidate.dataset.ttvcSrc=candidate.getAttribute("src")),candidate.removeAttribute("src")}}function observeImages(container){container&&(container.querySelectorAll("img").forEach(function(img){!img.dataset.ttvcSrc&&img.getAttribute("src")&&(img.dataset.ttvcSrc=img.getAttribute("src")),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,size:function(){return entries.size}},window.ttvcObserveImages=observeImages}();function cleanBaseUrl(url){return String(url||"").trim().replace(/\/+$/,"")}function getTtsBaseUrl(){var _a2;return cleanBaseUrl((_a2=$("s-tts-url"))==null?void 0:_a2.value)||"http://localhost:8020"}function getTtsV1Url(){const base=getTtsBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getTtsStreamBaseUrl(){var _a2;return cleanBaseUrl(((_a2=$("s-tts-stream-url"))==null?void 0:_a2.value)||_appSettings.tts_stream_url)||"http://localhost:8023"}function getTtsStreamV1Url(){const base=getTtsStreamBaseUrl();return base.endsWith("/v1")?base:base+"/v1"}function getCreatorV1Url(){const loc=window.location,protocol=loc.protocol||"http:",port=loc.port?":"+loc.port:"",host=loc.hostname==="0.0.0.0"?"localhost":loc.hostname;return`${protocol}//${host}${port}/v1`}function updateCreatorUrlHints(){const warning=$("routing-url-warning"),badBindHost=window.location.hostname==="0.0.0.0";warning&&warning.classList.toggle("show",badBindHost)}function activeVoiceIds(){return(_voices||[]).filter(v=>v.enabled!==!1).slice().sort((a,b)=>a.id.localeCompare(b.id)).map(v=>v.id)}function integrationVoiceExample(){return activeVoiceIds()[0]||"EN_F_ExampleVoice"}function integrationVoiceList(){const ids=activeVoiceIds();return ids.length?ids.join(", "):"EN_F_ExampleVoice, DE_M_ExampleVoice"}function virtualDesignVoiceIds(){return Object.keys(loadDesignPresets?loadDesignPresets():{}).sort((a,b)=>a.localeCompare(b)).map(name=>"vd_"+(typeof _umlautSafe=="function"?_umlautSafe(name):name).replace(/[^A-Za-z0-9_.-]+/g,"_").replace(/^_+|_+$/g,""))}function renderIntegrationSnippets(){if(!$("snippet-sillytavern"))return;const base=getTtsBaseUrl(),v1=getTtsV1Url(),streamV1=getTtsStreamV1Url(),proxyV1=getCreatorV1Url(),proxyBase=proxyV1.replace(/\/v1$/,"");updateCreatorUrlHints();const voice=integrationVoiceExample(),voices=integrationVoiceList(),vdVoices=virtualDesignVoiceIds(),vdVoice=vdVoices[0]||"vd_EN_F_Warm_Narrator";$("integration-url-label").textContent="TTS backend: "+base,$("snippet-sillytavern").textContent=`Provider: OpenAI compatible TTS API base URL: ${v1} API key: dummy Model: qwen3-tts @@ -480,7 +480,7 @@ This warms each voice so the engine caches its .pt and first playback is instant - `,list.appendChild(emptyEl),fetch("/api/settings").then(r=>r.json()).then(s=>{const inp=document.getElementById("voices-empty-scan-dir");inp&&(inp.value=s.voices_scan_dir||"/voices")}).catch(()=>{});let _vefCurrentPath="/";const vefBrowser=document.getElementById("vef-dir-browser"),vefDirList=document.getElementById("vef-dir-list"),vefCrumb=document.getElementById("vef-breadcrumb"),vefSelPath=document.getElementById("vef-selected-path");async function vefNavigate(path){_vefCurrentPath=path,vefDirList.innerHTML='Loading\u2026',vefSelPath&&(vefSelPath.textContent=path);try{const data=await fetch("/api/browse-dirs?path="+encodeURIComponent(path)).then(r=>r.json()),parts=data.path.split("/").filter(Boolean),crumbs=[{label:"/",path:"/"}];parts.forEach((p,i)=>crumbs.push({label:p,path:"/"+parts.slice(0,i+1).join("/")})),vefCrumb.innerHTML=crumbs.map((c,i)=>i${escHtml(c.label)}/`:`${escHtml(c.label)}`).join(""),vefCrumb.querySelectorAll(".vef-crumb-btn").forEach(b=>b.addEventListener("click",()=>vefNavigate(b.dataset.path))),data.dirs.length?(vefDirList.innerHTML=data.dirs.map(d=>``).join(""),vefDirList.querySelectorAll(".vef-dir-item").forEach(b=>b.addEventListener("click",()=>vefNavigate(b.dataset.path)))):vefDirList.innerHTML='No subdirectories here.',vefSelPath&&(vefSelPath.textContent=data.path),_vefCurrentPath=data.path}catch(e){vefDirList.innerHTML=`Error: ${escHtml(e.message)}`}}(_b2=document.getElementById("voices-empty-browse-btn"))==null||_b2.addEventListener("click",()=>{var _a3,_b3;const open=vefBrowser.hidden;if(vefBrowser.hidden=!open,open){const cur=((_b3=(_a3=document.getElementById("voices-empty-scan-dir"))==null?void 0:_a3.value)==null?void 0:_b3.trim())||"/voices";vefNavigate(cur)}}),(_c2=document.getElementById("vef-select-btn"))==null||_c2.addEventListener("click",()=>{const inp=document.getElementById("voices-empty-scan-dir");inp&&(inp.value=_vefCurrentPath),vefBrowser&&(vefBrowser.hidden=!0)}),(_d2=document.getElementById("voices-empty-save-btn"))==null||_d2.addEventListener("click",async()=>{var _a3;const inp=document.getElementById("voices-empty-scan-dir"),dir=(_a3=inp==null?void 0:inp.value)==null?void 0:_a3.trim();if(!dir)return;const btn=document.getElementById("voices-empty-save-btn");btn.disabled=!0,btn.textContent="Saving\u2026";try{const s=await fetch("/api/settings").then(r2=>r2.json());s.voices_scan_dir=dir;const r=await fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);toast("Folder saved \u2014 reloading voices\u2026","success"),_libraryLoadPromise=null,await loadVoiceLibrary()}catch(e){toast("Failed: "+e.message,"error"),btn.disabled=!1,btn.textContent="Set & reload"}}),(_e2=document.getElementById("voices-goto-clone"))==null||_e2.addEventListener("click",()=>navTo("s-clone")),(_f2=document.getElementById("voices-goto-studio"))==null||_f2.addEventListener("click",()=>navTo("s-studio"))}else list.innerHTML+='
No voices match the current filters.
';return}if(window._voiceGroupByTag)renderVoicesByTag(list,filtered);else{const frag=document.createDocumentFragment();if(pageItems.forEach(v=>frag.appendChild(makeVoiceRow(v))),list.appendChild(frag),pageEnd{window._voicePage=page+1,renderVoiceList()}),list.appendChild(more)}}if(_pendingSelectId){const wrap=list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);wrap&&(_pendingSelectId=null,selectVoice(wrap))}syncSortHeaders()}function renderVoicesByTag(list,filtered){const UNTAG="\u2014 Untagged \u2014",groups=new Map;filtered.forEach(v=>{const tags=String(v.tag||"").split(",").map(t=>t.trim()).filter(Boolean);(tags.length?tags:[UNTAG]).forEach(t=>{groups.has(t)||groups.set(t,[]),groups.get(t).push(v)})});let collapsed={};try{collapsed=JSON.parse(localStorage.getItem("vl-tag-collapsed")||"{}")}catch{}const names=[...groups.keys()].sort((a,b)=>a===UNTAG?1:b===UNTAG?-1:a.toLowerCase().localeCompare(b.toLowerCase())),outer=document.createDocumentFragment();names.forEach(tag=>{const voices=groups.get(tag),isCol=!!collapsed[tag],sec=document.createElement("div");sec.className="vl-tag-group";const head=document.createElement("button");head.className="vl-tag-head"+(isCol?" collapsed":""),head.type="button",head.innerHTML=`${escHtml(tag)}${voices.length}`;const body=document.createElement("div");body.className="vl-tag-body",isCol&&(body.style.display="none");const rows=document.createDocumentFragment();voices.forEach(v=>rows.appendChild(makeVoiceRow(v))),body.appendChild(rows),head.addEventListener("click",()=>{const nowCol=body.style.display!=="none";body.style.display=nowCol?"none":"",head.classList.toggle("collapsed",nowCol),head.querySelector(".vl-tag-caret").className="mdi mdi-"+(nowCol?"chevron-right":"chevron-down")+" vl-tag-caret",head.querySelector(".mdi:nth-child(2)").className="mdi mdi-folder"+(nowCol?"":"-open")+"-outline";let c={};try{c=JSON.parse(localStorage.getItem("vl-tag-collapsed")||"{}")}catch{}c[tag]=nowCol,localStorage.setItem("vl-tag-collapsed",JSON.stringify(c))}),sec.appendChild(head),sec.appendChild(body),outer.appendChild(sec)}),list.appendChild(outer)}async function decodeVoiceAudio(v){const resp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const data=await resp.arrayBuffer();return new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(data.slice(0))}function drawOptimizerWave(canvas,buffer,start=0,end=buffer.duration){const dpr=window.devicePixelRatio||1,width=Math.max(1,canvas.clientWidth),height=Math.max(1,canvas.clientHeight);canvas.width=Math.round(width*dpr),canvas.height=Math.round(height*dpr);const ctx=canvas.getContext("2d");ctx.setTransform(dpr,0,0,dpr,0,0),ctx.clearRect(0,0,width,height),ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue("--bg")||"#111",ctx.fillRect(0,0,width,height);const data=buffer.getChannelData(0),step=Math.max(1,Math.floor(data.length/width)),mid=height/2;ctx.strokeStyle="#89b4fa",ctx.lineWidth=1,ctx.beginPath();for(let x=0;xmax&&(max=v)}ctx.moveTo(x,mid+min*mid*.92),ctx.lineTo(x,mid+max*mid*.92)}ctx.stroke();const sx=Math.max(0,Math.min(width,start/buffer.duration*width)),ex=Math.max(sx,Math.min(width,end/buffer.duration*width));ctx.fillStyle="rgba(166,227,161,.18)",ctx.fillRect(sx,0,ex-sx,height),ctx.strokeStyle="#a6e3a1",ctx.lineWidth=2,ctx.strokeRect(sx+.5,.5,Math.max(1,ex-sx-1),height-1);const selectedSec=Math.max(0,end-start);if(ex-sx>54&&selectedSec>0){const label=`${selectedSec.toFixed(1)}s`;ctx.font="12px ui-monospace, Menlo, Consolas, monospace";const textW=ctx.measureText(label).width+14,tx=Math.max(sx+6,Math.min(ex-textW-6,sx+(ex-sx-textW)/2));ctx.fillStyle="rgba(30,30,46,.78)",ctx.fillRect(tx,6,textW,22),ctx.fillStyle="#ffffff",ctx.fillText(label,tx+7,21)}const handleW=12;ctx.fillStyle="#3d5ce8",ctx.strokeStyle="#ffffff",[sx,ex].forEach((x,idx)=>{const hx=Math.max(0,Math.min(width-handleW,x-handleW/2));ctx.fillRect(hx,0,handleW,height),ctx.strokeRect(hx+.5,.5,Math.max(1,handleW-1),height-1),ctx.fillStyle="#ffffff",ctx.fillRect(hx+3,Math.max(12,height/2-11),2,22),ctx.fillRect(hx+7,Math.max(12,height/2-11),2,22),ctx.fillStyle="#3d5ce8"})}function makeVoiceRow(v){var _a2;const wrap=document.createElement("div");wrap.className="vl-row"+(v.enabled===!1?" vr-disabled":""),wrap.dataset.id=v.id;const color=avatarColor(v.id);wrap.dataset.color=color,wrap.dataset.hasRef=v.has_ref?"true":"false",wrap.dataset.dbfs=fmtDbfs(v),wrap.dataset.lang=v.lang||v.id.split("_")[0].toUpperCase();const langCode=v.lang||v.id.split("_")[0].toUpperCase(),_idParts=String(v.id).split("_"),dispName=v.name&&String(v.name).trim()?String(v.name).trim():_idParts.length>1?_idParts[_idParts.length-1]:v.id,langOpts=FLAG_OPTIONS[langCode]||[],flagOpts=langOpts.length>1?langOpts:ALL_FLAGS,currentFlag=v.flag||LANG_FLAG_DEFAULT[langCode]||"",flagEmoji=currentFlag?cc2flag(currentFlag):'',flagCode=currentFlag?ccDisplay(currentFlag):"?";wrap.dataset.flagEmoji=flagEmoji,wrap.dataset.flagCc=currentFlag||"",wrap.dataset.rating=v.rating||0,wrap.dataset.hasPicture=v.has_picture?"true":"false";const fileType=voiceFileType(v),dbfs=fmtDbfs(v),dbTitle=v.loudness?`avg ${dbfs} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",benchText=fmtBenchmark(v),benchTitle=benchmarkTitle(v),benchCls=benchmarkClass(v),genderMap={F:"\u2640",M:"\u2642",N:"\u26A5","":"?"},genderMapLetter={F:"F",M:"M",N:"D","":"?"},genderLabel={F:"Female",M:"Male",N:"Diverse","":"\u2014"},genderClass={F:"g-f",M:"g-m",N:"g-n","":"g-n"},gender=v.gender||"",starsHtml=[1,2,3,4,5].map(i=>``).join(""),picSrc=v.has_picture?`/api/voice/picture/${encodeURIComponent(v.id)}`:null,pickerHtml=flagOpts.length>1?flagOpts.map(([cc,label])=>`${cc2flag(cc)}${ccDisplay(cc)}`).join(""):'No regional variants',isClone=!!v.has_ref,initial=v.id[0]?v.id[0].toUpperCase():"?",flagIconHtml=currentFlag?``:null,silHtml=``,iconHtml=window.voiceAvatarIcon?window.voiceAvatarIcon(v.avatar,34):null,avatarClass=picSrc?"vl-avatar vl-avatar-photo":iconHtml?"vl-avatar vl-avatar-icon":"vl-avatar vl-avatar-sil",avatarStyle=picSrc||iconHtml?"":`style="background:${color}"`,avatarContent=picSrc?``:iconHtml||silHtml;wrap.innerHTML=` + `,list.appendChild(emptyEl),fetch("/api/settings").then(r=>r.json()).then(s=>{const inp=document.getElementById("voices-empty-scan-dir");inp&&(inp.value=s.voices_scan_dir||"/voices")}).catch(()=>{});let _vefCurrentPath="/";const vefBrowser=document.getElementById("vef-dir-browser"),vefDirList=document.getElementById("vef-dir-list"),vefCrumb=document.getElementById("vef-breadcrumb"),vefSelPath=document.getElementById("vef-selected-path");async function vefNavigate(path){_vefCurrentPath=path,vefDirList.innerHTML='Loading\u2026',vefSelPath&&(vefSelPath.textContent=path);try{const data=await fetch("/api/browse-dirs?path="+encodeURIComponent(path)).then(r=>r.json()),parts=data.path.split("/").filter(Boolean),crumbs=[{label:"/",path:"/"}];parts.forEach((p,i)=>crumbs.push({label:p,path:"/"+parts.slice(0,i+1).join("/")})),vefCrumb.innerHTML=crumbs.map((c,i)=>i${escHtml(c.label)}/`:`${escHtml(c.label)}`).join(""),vefCrumb.querySelectorAll(".vef-crumb-btn").forEach(b=>b.addEventListener("click",()=>vefNavigate(b.dataset.path))),data.dirs.length?(vefDirList.innerHTML=data.dirs.map(d=>``).join(""),vefDirList.querySelectorAll(".vef-dir-item").forEach(b=>b.addEventListener("click",()=>vefNavigate(b.dataset.path)))):vefDirList.innerHTML='No subdirectories here.',vefSelPath&&(vefSelPath.textContent=data.path),_vefCurrentPath=data.path}catch(e){vefDirList.innerHTML=`Error: ${escHtml(e.message)}`}}(_b2=document.getElementById("voices-empty-browse-btn"))==null||_b2.addEventListener("click",()=>{var _a3,_b3;const open=vefBrowser.hidden;if(vefBrowser.hidden=!open,open){const cur=((_b3=(_a3=document.getElementById("voices-empty-scan-dir"))==null?void 0:_a3.value)==null?void 0:_b3.trim())||"/voices";vefNavigate(cur)}}),(_c2=document.getElementById("vef-select-btn"))==null||_c2.addEventListener("click",()=>{const inp=document.getElementById("voices-empty-scan-dir");inp&&(inp.value=_vefCurrentPath),vefBrowser&&(vefBrowser.hidden=!0)}),(_d2=document.getElementById("voices-empty-save-btn"))==null||_d2.addEventListener("click",async()=>{var _a3;const inp=document.getElementById("voices-empty-scan-dir"),dir=(_a3=inp==null?void 0:inp.value)==null?void 0:_a3.trim();if(!dir)return;const btn=document.getElementById("voices-empty-save-btn");btn.disabled=!0,btn.textContent="Saving\u2026";try{const s=await fetch("/api/settings").then(r2=>r2.json());s.voices_scan_dir=dir;const r=await fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);toast("Folder saved \u2014 reloading voices\u2026","success"),_libraryLoadPromise=null,await loadVoiceLibrary()}catch(e){toast("Failed: "+e.message,"error"),btn.disabled=!1,btn.textContent="Set & reload"}}),(_e2=document.getElementById("voices-goto-clone"))==null||_e2.addEventListener("click",()=>navTo("s-clone")),(_f2=document.getElementById("voices-goto-studio"))==null||_f2.addEventListener("click",()=>navTo("s-studio"))}else list.innerHTML+='
No voices match the current filters.
';return}if(window._voiceGroupByTag)renderVoicesByTag(list,filtered);else{const frag=document.createDocumentFragment();if(pageItems.forEach(v=>frag.appendChild(makeVoiceRow(v))),list.appendChild(frag),pageEnd{window._voicePage=page+1,renderVoiceList()}),list.appendChild(more)}}if(_pendingSelectId){const wrap=list.querySelector(`[data-id="${CSS.escape(_pendingSelectId)}"]`);wrap&&(_pendingSelectId=null,selectVoice(wrap))}syncSortHeaders(),typeof window.ttvcObserveImages=="function"&&window.ttvcObserveImages(list)}function renderVoicesByTag(list,filtered){const UNTAG="\u2014 Untagged \u2014",groups=new Map;filtered.forEach(v=>{const tags=String(v.tag||"").split(",").map(t=>t.trim()).filter(Boolean);(tags.length?tags:[UNTAG]).forEach(t=>{groups.has(t)||groups.set(t,[]),groups.get(t).push(v)})});let collapsed={};try{collapsed=JSON.parse(localStorage.getItem("vl-tag-collapsed")||"{}")}catch{}const names=[...groups.keys()].sort((a,b)=>a===UNTAG?1:b===UNTAG?-1:a.toLowerCase().localeCompare(b.toLowerCase())),outer=document.createDocumentFragment();names.forEach(tag=>{const voices=groups.get(tag),isCol=!!collapsed[tag],sec=document.createElement("div");sec.className="vl-tag-group";const head=document.createElement("button");head.className="vl-tag-head"+(isCol?" collapsed":""),head.type="button",head.innerHTML=`${escHtml(tag)}${voices.length}`;const body=document.createElement("div");body.className="vl-tag-body",isCol&&(body.style.display="none");const rows=document.createDocumentFragment();voices.forEach(v=>rows.appendChild(makeVoiceRow(v))),body.appendChild(rows),head.addEventListener("click",()=>{const nowCol=body.style.display!=="none";body.style.display=nowCol?"none":"",head.classList.toggle("collapsed",nowCol),head.querySelector(".vl-tag-caret").className="mdi mdi-"+(nowCol?"chevron-right":"chevron-down")+" vl-tag-caret",head.querySelector(".mdi:nth-child(2)").className="mdi mdi-folder"+(nowCol?"":"-open")+"-outline";let c={};try{c=JSON.parse(localStorage.getItem("vl-tag-collapsed")||"{}")}catch{}c[tag]=nowCol,localStorage.setItem("vl-tag-collapsed",JSON.stringify(c))}),sec.appendChild(head),sec.appendChild(body),outer.appendChild(sec)}),list.appendChild(outer)}async function decodeVoiceAudio(v){const resp=await fetch(voiceFileUrl(v),{cache:"no-store"});if(!resp.ok)throw new Error(resp.statusText||"Audio not found");const data=await resp.arrayBuffer();return new(window.AudioContext||window.webkitAudioContext)().decodeAudioData(data.slice(0))}function drawOptimizerWave(canvas,buffer,start=0,end=buffer.duration){const dpr=window.devicePixelRatio||1,width=Math.max(1,canvas.clientWidth),height=Math.max(1,canvas.clientHeight);canvas.width=Math.round(width*dpr),canvas.height=Math.round(height*dpr);const ctx=canvas.getContext("2d");ctx.setTransform(dpr,0,0,dpr,0,0),ctx.clearRect(0,0,width,height),ctx.fillStyle=getComputedStyle(document.documentElement).getPropertyValue("--bg")||"#111",ctx.fillRect(0,0,width,height);const data=buffer.getChannelData(0),step=Math.max(1,Math.floor(data.length/width)),mid=height/2;ctx.strokeStyle="#89b4fa",ctx.lineWidth=1,ctx.beginPath();for(let x=0;xmax&&(max=v)}ctx.moveTo(x,mid+min*mid*.92),ctx.lineTo(x,mid+max*mid*.92)}ctx.stroke();const sx=Math.max(0,Math.min(width,start/buffer.duration*width)),ex=Math.max(sx,Math.min(width,end/buffer.duration*width));ctx.fillStyle="rgba(166,227,161,.18)",ctx.fillRect(sx,0,ex-sx,height),ctx.strokeStyle="#a6e3a1",ctx.lineWidth=2,ctx.strokeRect(sx+.5,.5,Math.max(1,ex-sx-1),height-1);const selectedSec=Math.max(0,end-start);if(ex-sx>54&&selectedSec>0){const label=`${selectedSec.toFixed(1)}s`;ctx.font="12px ui-monospace, Menlo, Consolas, monospace";const textW=ctx.measureText(label).width+14,tx=Math.max(sx+6,Math.min(ex-textW-6,sx+(ex-sx-textW)/2));ctx.fillStyle="rgba(30,30,46,.78)",ctx.fillRect(tx,6,textW,22),ctx.fillStyle="#ffffff",ctx.fillText(label,tx+7,21)}const handleW=12;ctx.fillStyle="#3d5ce8",ctx.strokeStyle="#ffffff",[sx,ex].forEach((x,idx)=>{const hx=Math.max(0,Math.min(width-handleW,x-handleW/2));ctx.fillRect(hx,0,handleW,height),ctx.strokeRect(hx+.5,.5,Math.max(1,handleW-1),height-1),ctx.fillStyle="#ffffff",ctx.fillRect(hx+3,Math.max(12,height/2-11),2,22),ctx.fillRect(hx+7,Math.max(12,height/2-11),2,22),ctx.fillStyle="#3d5ce8"})}function makeVoiceRow(v){var _a2;const wrap=document.createElement("div");wrap.className="vl-row"+(v.enabled===!1?" vr-disabled":""),wrap.dataset.id=v.id;const color=avatarColor(v.id);wrap.dataset.color=color,wrap.dataset.hasRef=v.has_ref?"true":"false",wrap.dataset.dbfs=fmtDbfs(v),wrap.dataset.lang=v.lang||v.id.split("_")[0].toUpperCase();const langCode=v.lang||v.id.split("_")[0].toUpperCase(),_idParts=String(v.id).split("_"),dispName=v.name&&String(v.name).trim()?String(v.name).trim():_idParts.length>1?_idParts[_idParts.length-1]:v.id,langOpts=FLAG_OPTIONS[langCode]||[],flagOpts=langOpts.length>1?langOpts:ALL_FLAGS,currentFlag=v.flag||LANG_FLAG_DEFAULT[langCode]||"",flagEmoji=currentFlag?cc2flag(currentFlag):'',flagCode=currentFlag?ccDisplay(currentFlag):"?";wrap.dataset.flagEmoji=flagEmoji,wrap.dataset.flagCc=currentFlag||"",wrap.dataset.rating=v.rating||0,wrap.dataset.hasPicture=v.has_picture?"true":"false";const fileType=voiceFileType(v),dbfs=fmtDbfs(v),dbTitle=v.loudness?`avg ${dbfs} dBFS${v.loudness.peak_dbfs!=null?", peak "+Number(v.loudness.peak_dbfs).toFixed(1)+" dBFS":""}`:"",benchText=fmtBenchmark(v),benchTitle=benchmarkTitle(v),benchCls=benchmarkClass(v),genderMap={F:"\u2640",M:"\u2642",N:"\u26A5","":"?"},genderMapLetter={F:"F",M:"M",N:"D","":"?"},genderLabel={F:"Female",M:"Male",N:"Diverse","":"\u2014"},genderClass={F:"g-f",M:"g-m",N:"g-n","":"g-n"},gender=v.gender||"",starsHtml=[1,2,3,4,5].map(i=>``).join(""),picSrc=v.has_picture?`/api/voice/picture/${encodeURIComponent(v.id)}`:null,pickerHtml=flagOpts.length>1?flagOpts.map(([cc,label])=>`${cc2flag(cc)}${ccDisplay(cc)}`).join(""):'No regional variants',isClone=!!v.has_ref,initial=v.id[0]?v.id[0].toUpperCase():"?",flagIconHtml=currentFlag?``:null,silHtml=``,iconHtml=window.voiceAvatarIcon?window.voiceAvatarIcon(v.avatar,34):null,avatarClass=picSrc?"vl-avatar vl-avatar-photo":iconHtml?"vl-avatar vl-avatar-icon":"vl-avatar vl-avatar-sil",avatarStyle=picSrc||iconHtml?"":`style="background:${color}"`,avatarContent=picSrc?``:iconHtml||silHtml;wrap.innerHTML=`
${avatarContent}
diff --git a/static/js/image-cache.js b/static/js/image-cache.js new file mode 100644 index 0000000..7e71c41 --- /dev/null +++ b/static/js/image-cache.js @@ -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; +})(); diff --git a/static/js/library-characters.js b/static/js/library-characters.js index e859332..4e2ea61 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -590,6 +590,7 @@ async function libraryRenderCharacters() { productions.appendChild(prod); }); container.appendChild(productions); + if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container); // 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) diff --git a/static/js/voice-library.js b/static/js/voice-library.js index 728ef93..19f6636 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -1998,6 +1998,7 @@ function renderVoiceList() { if (wrap) { _pendingSelectId = null; selectVoice(wrap); } } syncSortHeaders(); + if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(list); } // Group the list into collapsible virtual "folders" by tag (a voice with several diff --git a/static/module-manifest.json b/static/module-manifest.json index f6b2feb..ac2ed74 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -1,6 +1,6 @@ { "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" ], "lazy": { diff --git a/tests/constrained.spec.js b/tests/constrained.spec.js index ee871bd..cf79fea 100644 --- a/tests/constrained.spec.js +++ b/tests/constrained.spec.js @@ -101,3 +101,26 @@ test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) = }); 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(); +});