feat: initialize frame-analyzer project with video analysis capabilities

- Add package.json with dependencies for React, Vite, and TypeScript.
- Create main application structure in App.tsx for video loading and frame analysis.
- Implement video scanning logic to identify candidate frames based on visual changes.
- Add candidate ranking and trimming functionality to optimize selected frames.
- Develop media handling utilities for capturing frames and managing video playback.
- Introduce clipboard functionality for copying and downloading frames.
- Design user interface with responsive styles for video selection and analysis controls.
- Establish TypeScript types for video loading, scan settings, and candidate frames.
- Configure TypeScript and Vite for project compilation and development.
This commit is contained in:
Ben
2026-05-14 00:41:35 -07:00
parent 2e704349f7
commit e308adc642
17 changed files with 3180 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import { rankAndTrimCandidates } from "./candidateRanking";
import { frameDifferenceRatio } 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 candidates: CandidateFrame[] = [];
let lastAcceptedFrame: ImageData | null = null;
let lastAcceptedTime: number | null = null;
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 (!lastAcceptedFrame) {
lastAcceptedFrame = imageData;
if (settings.includeFirstFrame) {
const thumbnailUrl = await createThumbnailUrl(video);
candidates.push({
id: crypto.randomUUID(),
time,
score: 1,
reason: "initial-frame",
thumbnailUrl,
});
lastAcceptedTime = time;
}
} else {
const diffRatio = frameDifferenceRatio(
lastAcceptedFrame,
imageData,
settings.pixelDeltaThreshold,
);
const hasEnoughChange = diffRatio >= settings.changedPixelRatioThreshold;
const hasEnoughTimeGap =
lastAcceptedTime === null ||
time - lastAcceptedTime >= settings.minSecondsBetweenCaptures;
if (hasEnoughChange && hasEnoughTimeGap) {
const thumbnailUrl = await createThumbnailUrl(video);
candidates.push({
id: crypto.randomUUID(),
time,
score: diffRatio,
reason: "visual-change",
thumbnailUrl,
});
lastAcceptedFrame = imageData;
lastAcceptedTime = time;
}
}
callbacks.onProgress(Math.min(time / video.duration, 1));
if (candidates.length >= settings.maxCandidates) {
break;
}
}
callbacks.onProgress(1);
const ranked = rankAndTrimCandidates(candidates, settings.finalTargetCount, video.duration);
const rankedIds = new Set(ranked.map((candidate) => candidate.id));
candidates
.filter((candidate) => !rankedIds.has(candidate.id))
.forEach((candidate) => URL.revokeObjectURL(candidate.thumbnailUrl));
return ranked;
} catch (error) {
candidates.forEach((candidate) => URL.revokeObjectURL(candidate.thumbnailUrl));
throw error;
}
}