feat: add lazy-loaded thumbnails to semantic search results with IntersectionObserver + concurrency queue

This commit is contained in:
OpenCode
2026-06-04 07:39:55 +08:00
parent 72a4e8f3b1
commit b83f466271

View File

@@ -473,15 +473,24 @@ async function semanticSearch() {
const textAttr = (i.text_content || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const startTime = i.start_time || (i.start_frame && i.fps ? i.start_frame / i.fps : 0);
const endTime = i.end_time || (i.end_frame && i.fps ? i.end_frame / i.fps : startTime + 10);
const thumbFrame = Math.round(startTime * 24);
return `<div class="chunk-item clickable"
data-file-uuid="${i.file_uuid}"
data-start-time="${startTime}"
data-end-time="${endTime}"
data-text="${textAttr}">
<div style="font-weight: 600; color: #1e40af;">${i.name} (${i.source || 'unknown'})</div>
<div class="chunk-time">${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s${i.chunk_id ? ` | ${i.chunk_id}` : ''}</div>
<div style="margin-top: 4px;font-size:13px;color:#555;">${i.text_content || ''}</div>
<button class="play-btn" style="margin-top:6px;">▶ 播放片段</button>
<div style="display:flex;gap:10px;">
<img class="search-thumb" loading="lazy"
data-src="${API_BASE}/file/${i.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${thumbFrame}"
style="width:120px;height:68px;object-fit:cover;border-radius:4px;flex-shrink:0;background:#e2e8f0;"
alt="">
<div style="flex:1;">
<div style="font-weight:600;color:#1e40af;">${i.name} (${i.source || 'unknown'})</div>
<div class="chunk-time">${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s${i.chunk_id ? ` | ${i.chunk_id}` : ''}</div>
<div style="margin-top:4px;font-size:13px;color:#555;">${i.text_content || ''}</div>
<button class="play-btn" style="margin-top:6px;">▶ 播放片段</button>
</div>
</div>
</div>`;
}).join('');
resultEl.innerHTML = `<p style="padding: 0.5rem; background: #d1fae5; border-radius: 4px; margin-bottom: 0.5rem;">找到 ${data.total || 0} 個身份相關片段</p>${resultsHtml || '<p>沒有結果</p>'}`;
@@ -498,23 +507,72 @@ async function semanticSearch() {
const textAttr = (r.text || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const startTime = r.start_time || (r.start_frame && r.fps ? r.start_frame / r.fps : 0);
const endTime = r.end_time || (r.end_frame && r.fps ? r.end_frame / r.fps : startTime + 10);
const thumbFrame = Math.round(startTime * 24);
return `<div class="chunk-item clickable"
data-file-uuid="${r.file_uuid}"
data-start-time="${startTime}"
data-end-time="${endTime}"
data-text="${textAttr}">
<div class="chunk-time">${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s${r.chunk_id ? ` | ${r.chunk_id}` : ''}</div>
<div style="margin-top: 4px;font-size:13px;color:#555;">${r.text || ''}</div>
<div class="badge-status completed" style="margin-top: 4px;">相似度: ${(r.score * 100).toFixed(1)}%</div>
<button class="play-btn" style="margin-top:6px;">▶ 播放片段</button>
<div style="display:flex;gap:10px;">
<img class="search-thumb" loading="lazy"
data-src="${API_BASE}/file/${r.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${thumbFrame}"
style="width:120px;height:68px;object-fit:cover;border-radius:4px;flex-shrink:0;background:#e2e8f0;"
alt="">
<div style="flex:1;">
<div class="chunk-time">${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s${r.chunk_id ? ` | ${r.chunk_id}` : ''}</div>
<div style="margin-top:4px;font-size:13px;color:#555;">${r.text || ''}</div>
<div class="badge-status completed" style="margin-top:4px;">相似度: ${(r.score * 100).toFixed(1)}%</div>
<button class="play-btn" style="margin-top:6px;">▶ 播放片段</button>
</div>
</div>
</div>`;
}).join('');
resultEl.innerHTML = `<p style="padding: 0.5rem; background: #d1fae5; border-radius: 4px; margin-bottom: 0.5rem;">找到 ${data.total || 0} 個片段</p>${resultsHtml || '<p>沒有結果</p>'}`;
}
loadSearchThumbnails();
} catch (e) {
resultEl.innerHTML = '<p style="color:#ef4444; padding: 1rem;">錯誤: ' + e.message + '</p>';
}
}
function loadSearchThumbnails(){
const imgs = Array.from(document.querySelectorAll('img.search-thumb[data-src]'));
const MAX = 4;
let queue = [];
let inflight = 0;
function next(){
while (inflight < MAX && queue.length) {
const img = queue.shift();
inflight++;
img.onload = function(){ inflight--; next(); };
img.onerror = function(){ inflight--; next(); };
img.src = img.dataset.src;
}
}
if ('IntersectionObserver' in window) {
const obs = new IntersectionObserver(function(entries){
entries.forEach(function(e){
if (!e.isIntersecting) return;
var img = e.target;
if (img.dataset.loaded) return;
img.dataset.loaded = '1';
queue.push(img);
obs.unobserve(img);
next();
});
}, { rootMargin: '200px 0px' });
imgs.forEach(function(img){ obs.observe(img); });
} else {
imgs.forEach(function(img){
if (img.dataset.loaded) return;
img.dataset.loaded = '1';
queue.push(img);
});
next();
}
}
</script>
<?php get_footer(); ?>