114 lines
3.2 KiB
TypeScript
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;
|
|
}
|
|
}
|