From 3a7b98ec7557a37f93b9c21ce29c123739f638ad Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 14 May 2026 11:11:27 -0700 Subject: [PATCH] feat: enhance candidate ranking logic and improve video seeking functionality --- src/analysis/candidateRanking.ts | 41 ++++++++++++++++++++++++++++---- src/media/video.ts | 26 +++++++++++++++++++- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/analysis/candidateRanking.ts b/src/analysis/candidateRanking.ts index 779a936..52f3f76 100644 --- a/src/analysis/candidateRanking.ts +++ b/src/analysis/candidateRanking.ts @@ -39,16 +39,16 @@ export function rankAndTrimCandidates( for (let bucket = 0; bucket < bucketCount; bucket++) { const start = bucket * bucketSize; const end = bucket === bucketCount - 1 ? duration + 0.001 : start + bucketSize; - const best = pool - .filter((candidate) => candidate.time >= start && candidate.time < end) - .sort((a, b) => b.score - a.score)[0]; + const best = bestCandidateInWindow(pool, start, end); - if (best && respectsMinGap([...selected.values()], best, minGapSeconds)) { + if (best) { selected.set(frameKey(best.time), best); } } - for (const candidate of [...pool].sort((a, b) => b.score - a.score)) { + for (const candidate of [...pool].sort( + (a, b) => temporalCoverageScore(selected, b, duration) - temporalCoverageScore(selected, a, duration), + )) { if (selected.size >= targetCount) break; if (respectsMinGap([...selected.values()], candidate, minGapSeconds)) { selected.set(frameKey(candidate.time), candidate); @@ -89,3 +89,34 @@ function frameKey(time: number) { function respectsMinGap(selected: ScoredFrame[], candidate: ScoredFrame, minGapSeconds: number) { return selected.every((item) => Math.abs(item.time - candidate.time) >= minGapSeconds); } + +function bestCandidateInWindow(candidates: ScoredFrame[], start: number, end: number) { + const windowCandidates = candidates.filter((candidate) => candidate.time >= start && candidate.time < end); + if (windowCandidates.length === 0) { + return undefined; + } + + const floor = percentile(windowCandidates, 0.5) * 0.8; + return windowCandidates + .filter((candidate) => candidate.score >= floor) + .sort((a, b) => b.score - a.score)[0]; +} + +function temporalCoverageScore( + selected: Map, + candidate: ScoredFrame, + duration: number, +) { + const selectedValues = [...selected.values()]; + if (selectedValues.length === 0) { + return candidate.score; + } + + const nearestDistance = Math.min( + ...selectedValues.map((item) => Math.abs(item.time - candidate.time)), + candidate.time, + Math.max(0, duration - candidate.time), + ); + + return nearestDistance * 2 + candidate.score; +} diff --git a/src/media/video.ts b/src/media/video.ts index 63b8111..36a0cb0 100644 --- a/src/media/video.ts +++ b/src/media/video.ts @@ -21,9 +21,12 @@ export async function waitForVideoMetadata(video: HTMLVideoElement): Promise { - const target = Math.min(Math.max(time, 0), video.duration || time); + const duration = Number.isFinite(video.duration) ? video.duration : time; + const frameSafety = 1 / 120; + const target = Math.min(Math.max(time, 0), Math.max(0, duration - frameSafety)); if (Math.abs(video.currentTime - target) < 0.01 && video.readyState >= 2) { + await waitForRenderableFrame(video); return; } @@ -53,4 +56,25 @@ export async function seekVideo(video: HTMLVideoElement, time: number): Promise< queueMicrotask(finish); } }); + + await waitForRenderableFrame(video); +} + +async function waitForRenderableFrame(video: HTMLVideoElement): Promise { + if ("requestVideoFrameCallback" in video) { + await new Promise((resolve) => { + const handle = ( + video as HTMLVideoElement & { + requestVideoFrameCallback: (callback: () => void) => number; + } + ).requestVideoFrameCallback(() => resolve()); + + if (!handle && video.readyState >= 2) { + resolve(); + } + }); + return; + } + + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }