feat: add lazy-loaded thumbnails to semantic search results with IntersectionObserver + concurrency queue
This commit is contained in:
@@ -473,15 +473,24 @@ async function semanticSearch() {
|
||||
const textAttr = (i.text_content || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
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, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
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(); ?>
|
||||
|
||||
Reference in New Issue
Block a user