Files
Frame-Analyzer/src/analysis/scanVideo.ts
T

114 lines
3.2 KiB
TypeScript

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<CandidateFrame[]> {
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;
}
}