import { rankAndTrimCandidates } from "./candidateRanking"; import { hybridFrameScore } from "./frameDifference"; import { captureAnalysisFrame, createThumbnailUrl } from "../media/canvas"; import { seekVideo } from "../media/video"; import type { CandidateFrame, ScanSettings } from "../types/scan"; type ScanCallbacks = { onProgress: (progress: number) => void; signal: AbortSignal; }; export async function scanVideoForCandidates( video: HTMLVideoElement, settings: ScanSettings, callbacks: ScanCallbacks, ): Promise { const analysisCanvas = document.createElement("canvas"); const scoredFrames: Array<{ score: number; time: number }> = []; const selectedCandidates: CandidateFrame[] = []; let previousFrame: ImageData | null = null; let windowFrame: ImageData | null = null; let windowTime = 0; try { for ( let time = 0; time < video.duration; time += settings.sampleIntervalSeconds ) { if (callbacks.signal.aborted) { throw new DOMException("Scan cancelled.", "AbortError"); } await seekVideo(video, time); const imageData = captureAnalysisFrame( video, analysisCanvas, settings.analysisWidth, settings.analysisHeight, ); if (!previousFrame) { previousFrame = imageData; windowFrame = imageData; windowTime = time; } else { const scoreFromPrevious = hybridFrameScore( previousFrame, imageData, settings.pixelDeltaThreshold, ); const scoreFromWindow = windowFrame && time - windowTime >= settings.minSecondsBetweenCaptures * 0.5 ? hybridFrameScore(windowFrame, imageData, settings.pixelDeltaThreshold) : scoreFromPrevious; const score = Math.max(scoreFromPrevious, scoreFromWindow); scoredFrames.push({ time, score, }); previousFrame = imageData; if (time - windowTime >= settings.minSecondsBetweenCaptures) { windowFrame = imageData; windowTime = time; } } callbacks.onProgress(Math.min(time / video.duration, 1)); } callbacks.onProgress(1); const targetVisualCount = settings.includeFirstFrame ? Math.max(0, settings.finalTargetCount - 1) : settings.finalTargetCount; const selectedMoments = rankAndTrimCandidates( scoredFrames, targetVisualCount, video.duration, settings.minSecondsBetweenCaptures, ); if (settings.includeFirstFrame) { await seekVideo(video, 0); selectedCandidates.push({ id: crypto.randomUUID(), time: 0, score: 1, reason: "initial-frame", thumbnailUrl: await createThumbnailUrl(video), }); } for (const moment of selectedMoments) { await seekVideo(video, moment.time); selectedCandidates.push({ id: crypto.randomUUID(), time: moment.time, score: moment.score, reason: "visual-change", thumbnailUrl: await createThumbnailUrl(video), }); } return selectedCandidates.sort((a, b) => a.time - b.time); } catch (error) { selectedCandidates.forEach((candidate) => URL.revokeObjectURL(candidate.thumbnailUrl)); throw error; } }