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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frame Analyzer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1757
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "frame-analyzer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7",
"typescript": "^5.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2"
}
}
+574
View File
@@ -0,0 +1,574 @@
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import {
Clipboard,
Download,
FileVideo,
Play,
RotateCcw,
Square,
Trash2,
Upload,
} from "lucide-react";
import { scanVideoForCandidates } from "./analysis/scanVideo";
import { copyImageBlobToClipboard, downloadBlob } from "./media/clipboard";
import { createThumbnailUrl, extractFullResolutionFrame } from "./media/canvas";
import { seekVideo, waitForVideoMetadata } from "./media/video";
import type { CandidateFrame, LoadedVideo, ScanSettings, ScanStatus } from "./types/scan";
const defaultSettings: ScanSettings = {
sampleIntervalSeconds: 0.5,
analysisWidth: 160,
analysisHeight: 90,
pixelDeltaThreshold: 32,
changedPixelRatioThreshold: 0.18,
minSecondsBetweenCaptures: 2,
maxCandidates: 20,
finalTargetCount: 8,
includeFirstFrame: true,
};
type Toast = {
kind: "info" | "error";
message: string;
};
export function App() {
const videoRef = useRef<HTMLVideoElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadedVideoRef = useRef<LoadedVideo | null>(null);
const candidatesRef = useRef<CandidateFrame[]>([]);
const [loadedVideo, setLoadedVideo] = useState<LoadedVideo | null>(null);
const [settings, setSettings] = useState<ScanSettings>(defaultSettings);
const [status, setStatus] = useState<ScanStatus>("idle");
const [progress, setProgress] = useState(0);
const [candidates, setCandidates] = useState<CandidateFrame[]>([]);
const [busyCandidateId, setBusyCandidateId] = useState<string | null>(null);
const [toast, setToast] = useState<Toast | null>(null);
const canScan = Boolean(loadedVideo) && status !== "loading" && status !== "scanning";
const privacyNote = useMemo(
() => "Local-only processing. The video stays in this browser session.",
[],
);
useEffect(() => {
loadedVideoRef.current = loadedVideo;
}, [loadedVideo]);
useEffect(() => {
candidatesRef.current = candidates;
}, [candidates]);
useEffect(() => {
return () => {
abortRef.current?.abort();
if (loadedVideoRef.current?.objectUrl) {
URL.revokeObjectURL(loadedVideoRef.current.objectUrl);
}
candidatesRef.current.forEach((candidate) => URL.revokeObjectURL(candidate.thumbnailUrl));
};
}, []);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("video/")) {
setToast({ kind: "error", message: "Select a video file that this browser can decode." });
return;
}
abortRef.current?.abort();
cleanupCurrentVideo();
setLoadedVideo(null);
setStatus("loading");
setProgress(0);
setToast(null);
const objectUrl = URL.createObjectURL(file);
const video = videoRef.current;
if (!video) return;
try {
video.src = objectUrl;
video.preload = "metadata";
video.load();
await waitForVideoMetadata(video);
setLoadedVideo({
file,
objectUrl,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
});
setStatus("idle");
} catch (error) {
URL.revokeObjectURL(objectUrl);
setStatus("error");
setToast({ kind: "error", message: getErrorMessage(error) });
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleScan() {
const video = videoRef.current;
if (!video || !loadedVideo) return;
abortRef.current?.abort();
const abortController = new AbortController();
abortRef.current = abortController;
candidates.forEach((candidate) => URL.revokeObjectURL(candidate.thumbnailUrl));
setCandidates([]);
setProgress(0);
setToast(null);
setStatus("scanning");
try {
await waitForVideoMetadata(video);
const nextCandidates = await scanVideoForCandidates(video, settings, {
signal: abortController.signal,
onProgress: setProgress,
});
setCandidates(nextCandidates);
setStatus("done");
setToast({
kind: "info",
message:
nextCandidates.length === 0
? "No candidate frames found. Lower the change threshold or include the first frame."
: `Found ${nextCandidates.length} candidate frame${nextCandidates.length === 1 ? "" : "s"}.`,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setStatus("idle");
setToast({ kind: "info", message: "Scan cancelled." });
} else {
setStatus("error");
setToast({ kind: "error", message: getErrorMessage(error) });
}
} finally {
if (abortRef.current === abortController) {
abortRef.current = null;
}
}
}
function handleCancelScan() {
abortRef.current?.abort();
}
async function handleCopy(candidate: CandidateFrame) {
const video = videoRef.current;
if (!video) return;
setBusyCandidateId(candidate.id);
setToast(null);
try {
const blob = await extractFullResolutionFrame(video, candidate.time, "image/png");
await copyImageBlobToClipboard(blob);
setToast({ kind: "info", message: `Copied frame at ${formatTime(candidate.time)}.` });
} catch (error) {
try {
const blob = await extractFullResolutionFrame(video, candidate.time, "image/png");
downloadBlob(blob, frameFilename(candidate.time));
setToast({
kind: "error",
message: `Clipboard was blocked. Downloaded ${frameFilename(candidate.time)} instead.`,
});
} catch {
setToast({ kind: "error", message: getErrorMessage(error) });
}
} finally {
setBusyCandidateId(null);
}
}
async function handleDownload(candidate: CandidateFrame) {
const video = videoRef.current;
if (!video) return;
setBusyCandidateId(candidate.id);
setToast(null);
try {
const blob = await extractFullResolutionFrame(video, candidate.time, "image/png");
downloadBlob(blob, frameFilename(candidate.time));
setToast({ kind: "info", message: `Downloaded ${frameFilename(candidate.time)}.` });
} catch (error) {
setToast({ kind: "error", message: getErrorMessage(error) });
} finally {
setBusyCandidateId(null);
}
}
async function handleManualAdd() {
const video = videoRef.current;
if (!video || !loadedVideo || status === "scanning") return;
setBusyCandidateId("manual");
setToast(null);
try {
const targetTime = Math.min(video.currentTime || 0, loadedVideo.duration);
await seekVideo(video, targetTime);
const thumbnailUrl = await createThumbnailUrl(video);
const candidate: CandidateFrame = {
id: crypto.randomUUID(),
time: targetTime,
score: 1,
reason: "manual",
thumbnailUrl,
};
setCandidates((current) => [...current, candidate].sort((a, b) => a.time - b.time));
setToast({ kind: "info", message: `Added frame at ${formatTime(targetTime)}.` });
} catch (error) {
setToast({ kind: "error", message: getErrorMessage(error) });
} finally {
setBusyCandidateId(null);
}
}
function handleRemoveCandidate(candidate: CandidateFrame) {
URL.revokeObjectURL(candidate.thumbnailUrl);
setCandidates((current) => current.filter((item) => item.id !== candidate.id));
}
function handleReset() {
abortRef.current?.abort();
cleanupCurrentVideo();
if (videoRef.current) {
videoRef.current.removeAttribute("src");
videoRef.current.load();
}
setLoadedVideo(null);
setSettings(defaultSettings);
setStatus("idle");
setProgress(0);
setToast(null);
}
function cleanupCurrentVideo() {
if (loadedVideo?.objectUrl) URL.revokeObjectURL(loadedVideo.objectUrl);
candidates.forEach((candidate) => URL.revokeObjectURL(candidate.thumbnailUrl));
setCandidates([]);
}
return (
<main className="appShell">
<section className="workspace">
<header className="topBar">
<div>
<h1>Frame Analyzer</h1>
<p>{privacyNote}</p>
</div>
<div className="topActions">
<label className="button primary" title="Select video">
<Upload size={17} aria-hidden="true" />
<span>Select video</span>
<input
ref={fileInputRef}
type="file"
accept="video/*"
onChange={handleFileChange}
/>
</label>
<button className="iconButton" title="Reset" onClick={handleReset}>
<RotateCcw size={18} aria-hidden="true" />
</button>
</div>
</header>
<section className="contentGrid">
<aside className="controlPanel">
<section className="panelBlock">
<h2>Video</h2>
<video
ref={videoRef}
muted
playsInline
controls={Boolean(loadedVideo)}
className={`videoPreview ${loadedVideo ? "" : "isEmpty"}`}
/>
{loadedVideo ? (
<div className="videoSummary">
<FileVideo size={24} aria-hidden="true" />
<div>
<strong>{loadedVideo.file.name}</strong>
<span>
{formatDuration(loadedVideo.duration)} · {formatBytes(loadedVideo.file.size)}
</span>
<span>
{loadedVideo.width}x{loadedVideo.height}
</span>
</div>
</div>
) : (
<p className="emptyCopy">Choose an MP4 or another browser-supported video.</p>
)}
</section>
<section className="panelBlock">
<h2>Scan Settings</h2>
<NumberControl
label="Sample interval"
value={settings.sampleIntervalSeconds}
min={0.25}
max={2}
step={0.25}
suffix="s"
onChange={(value) => updateSettings({ sampleIntervalSeconds: value })}
/>
<NumberControl
label="Change threshold"
value={settings.changedPixelRatioThreshold}
min={0.05}
max={0.45}
step={0.01}
formatValue={(value) => `${Math.round(value * 100)}%`}
onChange={(value) => updateSettings({ changedPixelRatioThreshold: value })}
/>
<NumberControl
label="Pixel delta"
value={settings.pixelDeltaThreshold}
min={16}
max={64}
step={1}
onChange={(value) => updateSettings({ pixelDeltaThreshold: value })}
/>
<NumberControl
label="Minimum gap"
value={settings.minSecondsBetweenCaptures}
min={0.5}
max={10}
step={0.5}
suffix="s"
onChange={(value) => updateSettings({ minSecondsBetweenCaptures: value })}
/>
<NumberControl
label="Target images"
value={settings.finalTargetCount}
min={4}
max={10}
step={1}
onChange={(value) => updateSettings({ finalTargetCount: value })}
/>
<NumberControl
label="Candidate pool"
value={settings.maxCandidates}
min={settings.finalTargetCount}
max={40}
step={1}
onChange={(value) => updateSettings({ maxCandidates: value })}
/>
<label className="selectControl">
<span>Analysis size</span>
<select
value={settings.analysisWidth}
onChange={(event) => {
const width = Number(event.target.value);
updateSettings({ analysisWidth: width, analysisHeight: Math.round(width * 0.5625) });
}}
>
<option value={160}>160x90</option>
<option value={320}>320x180</option>
</select>
</label>
<label className="checkboxControl">
<input
type="checkbox"
checked={settings.includeFirstFrame}
onChange={(event) => updateSettings({ includeFirstFrame: event.target.checked })}
/>
<span>Include first frame</span>
</label>
</section>
<section className="scanActions">
{status === "scanning" ? (
<button className="button danger" onClick={handleCancelScan}>
<Square size={17} aria-hidden="true" />
<span>Stop scan</span>
</button>
) : (
<button className="button primary" onClick={handleScan} disabled={!canScan}>
<Play size={17} aria-hidden="true" />
<span>Scan video</span>
</button>
)}
<button
className="button secondary"
onClick={handleManualAdd}
disabled={!loadedVideo || status === "scanning" || busyCandidateId === "manual"}
>
<FileVideo size={17} aria-hidden="true" />
<span>Add current</span>
</button>
</section>
</aside>
<section className="resultsPanel">
<div className="progressWrap" aria-live="polite">
<div className="progressHeader">
<span>{statusLabel(status)}</span>
<strong>{Math.round(progress * 100)}%</strong>
</div>
<div className="progressTrack">
<div className="progressBar" style={{ width: `${Math.round(progress * 100)}%` }} />
</div>
</div>
{toast ? <div className={`toast ${toast.kind}`}>{toast.message}</div> : null}
<div className="galleryHeader">
<div>
<h2>Candidate Frames</h2>
<p>{candidates.length} selected from detected visual changes.</p>
</div>
</div>
{candidates.length === 0 ? (
<div className="emptyState">
<FileVideo size={38} aria-hidden="true" />
<p>Candidate thumbnails will appear here after scanning.</p>
</div>
) : (
<div className="galleryGrid">
{candidates.map((candidate) => (
<article className="candidateCard" key={candidate.id}>
<img src={candidate.thumbnailUrl} alt={`Frame at ${formatTime(candidate.time)}`} />
<div className="candidateMeta">
<div>
<strong>{formatTime(candidate.time)}</strong>
<span>
{candidate.reason === "initial-frame"
? "initial"
: candidate.reason === "manual"
? "manual"
: `score ${candidate.score.toFixed(2)}`}
</span>
</div>
<div className="cardActions">
<button
className="iconButton"
title="Copy PNG"
onClick={() => handleCopy(candidate)}
disabled={busyCandidateId === candidate.id || status === "scanning"}
>
<Clipboard size={17} aria-hidden="true" />
</button>
<button
className="iconButton"
title="Download PNG"
onClick={() => handleDownload(candidate)}
disabled={busyCandidateId === candidate.id || status === "scanning"}
>
<Download size={17} aria-hidden="true" />
</button>
<button
className="iconButton"
title="Remove"
onClick={() => handleRemoveCandidate(candidate)}
disabled={status === "scanning"}
>
<Trash2 size={17} aria-hidden="true" />
</button>
</div>
</div>
</article>
))}
</div>
)}
</section>
</section>
</section>
</main>
);
function updateSettings(partial: Partial<ScanSettings>) {
setSettings((current) => {
const next = { ...current, ...partial };
if (next.maxCandidates < next.finalTargetCount) {
next.maxCandidates = next.finalTargetCount;
}
return next;
});
}
}
function NumberControl(props: {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
formatValue?: (value: number) => string;
onChange: (value: number) => void;
}) {
const valueText = props.formatValue ? props.formatValue(props.value) : `${props.value}${props.suffix ?? ""}`;
return (
<label className="numberControl">
<span>
{props.label}
<strong>{valueText}</strong>
</span>
<input
type="range"
min={props.min}
max={props.max}
step={props.step}
value={props.value}
onChange={(event) => props.onChange(Number(event.target.value))}
/>
</label>
);
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Something went wrong.";
}
function statusLabel(status: ScanStatus) {
switch (status) {
case "loading":
return "Loading video";
case "scanning":
return "Scanning";
case "done":
return "Scan complete";
case "error":
return "Needs attention";
default:
return "Ready";
}
}
function formatDuration(seconds: number) {
if (!Number.isFinite(seconds)) return "Unknown duration";
return formatTime(seconds);
}
function formatTime(seconds: number) {
const whole = Math.floor(seconds);
const minutes = Math.floor(whole / 60);
const remainingSeconds = whole % 60;
const tenths = Math.floor((seconds - whole) * 10);
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}.${tenths}`;
}
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function frameFilename(time: number) {
return `frame-${formatTime(time).replace(":", "-")}.png`;
}
+34
View File
@@ -0,0 +1,34 @@
import type { CandidateFrame } from "../types/scan";
export function rankAndTrimCandidates(
candidates: CandidateFrame[],
targetCount: number,
duration: number,
): CandidateFrame[] {
if (candidates.length <= targetCount) {
return [...candidates].sort((a, b) => a.time - b.time);
}
const bucketCount = Math.max(1, targetCount);
const bucketSize = Math.max(duration / bucketCount, 1);
const selected = new Map<string, CandidateFrame>();
for (let bucket = 0; bucket < bucketCount; bucket++) {
const start = bucket * bucketSize;
const end = bucket === bucketCount - 1 ? duration + 0.001 : start + bucketSize;
const best = candidates
.filter((candidate) => candidate.time >= start && candidate.time < end)
.sort((a, b) => b.score - a.score)[0];
if (best) {
selected.set(best.id, best);
}
}
for (const candidate of [...candidates].sort((a, b) => b.score - a.score)) {
if (selected.size >= targetCount) break;
selected.set(candidate.id, candidate);
}
return [...selected.values()].sort((a, b) => a.time - b.time);
}
+21
View File
@@ -0,0 +1,21 @@
export function frameDifferenceRatio(
a: ImageData,
b: ImageData,
pixelDeltaThreshold: number,
): number {
const dataA = a.data;
const dataB = b.data;
let changed = 0;
const pixels = dataA.length / 4;
for (let i = 0; i < dataA.length; i += 4) {
const lumA = 0.299 * dataA[i] + 0.587 * dataA[i + 1] + 0.114 * dataA[i + 2];
const lumB = 0.299 * dataB[i] + 0.587 * dataB[i + 1] + 0.114 * dataB[i + 2];
if (Math.abs(lumA - lumB) > pixelDeltaThreshold) {
changed++;
}
}
return changed / pixels;
}
+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;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+70
View File
@@ -0,0 +1,70 @@
import { seekVideo } from "./video";
export function captureAnalysisFrame(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
width: number,
height: number,
): ImageData {
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("Could not create the analysis canvas context.");
context.drawImage(video, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
}
export async function createThumbnailUrl(video: HTMLVideoElement, maxWidth = 360): Promise<string> {
const scale = Math.min(1, maxWidth / video.videoWidth);
const width = Math.max(1, Math.round(video.videoWidth * scale));
const height = Math.max(1, Math.round(video.videoHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not create thumbnail canvas context.");
context.drawImage(video, 0, 0, width, height);
const blob = await canvasToBlob(canvas, "image/webp", 0.82);
return URL.createObjectURL(blob);
}
export async function extractFullResolutionFrame(
video: HTMLVideoElement,
time: number,
type: "image/png" | "image/jpeg" | "image/webp" = "image/png",
quality?: number,
): Promise<Blob> {
await seekVideo(video, time);
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not create full-resolution canvas context.");
context.drawImage(video, 0, 0);
return canvasToBlob(canvas, type, quality);
}
export async function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality?: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) reject(new Error("Canvas export failed."));
else resolve(blob);
},
type,
quality,
);
});
}
+22
View File
@@ -0,0 +1,22 @@
export async function copyImageBlobToClipboard(blob: Blob): Promise<void> {
if (!navigator.clipboard || typeof ClipboardItem === "undefined") {
throw new Error("Image clipboard writing is not available in this browser.");
}
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+56
View File
@@ -0,0 +1,56 @@
export async function waitForVideoMetadata(video: HTMLVideoElement): Promise<void> {
if (Number.isFinite(video.duration) && video.videoWidth > 0) return;
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
video.removeEventListener("loadedmetadata", onLoaded);
video.removeEventListener("error", onError);
};
const onLoaded = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(video.error?.message || "This browser could not load the video."));
};
video.addEventListener("loadedmetadata", onLoaded, { once: true });
video.addEventListener("error", onError, { once: true });
});
}
export async function seekVideo(video: HTMLVideoElement, time: number): Promise<void> {
const target = Math.min(Math.max(time, 0), video.duration || time);
if (Math.abs(video.currentTime - target) < 0.01 && video.readyState >= 2) {
return;
}
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
video.removeEventListener("seeked", onSeeked);
video.removeEventListener("loadeddata", onFrameReady);
video.removeEventListener("error", onError);
};
const finish = () => {
cleanup();
resolve();
};
const onSeeked = finish;
const onFrameReady = finish;
const onError = () => {
cleanup();
reject(new Error(video.error?.message || "Video seek failed."));
};
video.addEventListener("seeked", onSeeked, { once: true });
video.addEventListener("loadeddata", onFrameReady, { once: true });
video.addEventListener("error", onError, { once: true });
video.currentTime = target;
if (Math.abs(video.currentTime - target) < 0.01 && video.readyState >= 2) {
queueMicrotask(finish);
}
});
}
+428
View File
@@ -0,0 +1,428 @@
:root {
color-scheme: light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #18202a;
background: #eef1f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
.appShell {
min-height: 100vh;
padding: 24px;
}
.workspace {
width: min(1440px, 100%);
margin: 0 auto;
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 0 24px;
}
.topBar h1 {
margin: 0;
font-size: 32px;
line-height: 1.1;
font-weight: 760;
}
.topBar p,
.galleryHeader p,
.emptyCopy {
margin: 6px 0 0;
color: #5d6878;
}
.topActions,
.scanActions,
.cardActions {
display: flex;
align-items: center;
gap: 10px;
}
.contentGrid {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 18px;
align-items: start;
}
.controlPanel,
.resultsPanel {
background: #ffffff;
border: 1px solid #dce2ea;
border-radius: 8px;
box-shadow: 0 18px 45px rgb(30 44 65 / 8%);
}
.controlPanel {
position: sticky;
top: 20px;
overflow: hidden;
}
.panelBlock {
padding: 18px;
border-bottom: 1px solid #e7ebf1;
}
.panelBlock h2,
.galleryHeader h2 {
margin: 0 0 14px;
font-size: 15px;
line-height: 1.2;
text-transform: uppercase;
letter-spacing: 0;
color: #273241;
}
.videoSummary {
display: grid;
grid-template-columns: 30px 1fr;
gap: 12px;
align-items: start;
margin-top: 14px;
}
.videoPreview {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 8px;
background: #101722;
}
.videoPreview.isEmpty {
display: none;
}
.videoSummary strong {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
line-height: 1.25;
}
.videoSummary span {
display: block;
margin-top: 5px;
color: #5d6878;
font-size: 13px;
}
.button,
.iconButton {
border: 1px solid transparent;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
transition:
background 140ms ease,
border-color 140ms ease,
transform 140ms ease;
}
.button {
padding: 0 14px;
font-weight: 680;
}
.button input[type="file"] {
display: none;
}
.button.primary {
color: #ffffff;
background: #176b58;
}
.button.secondary {
color: #283342;
background: #f5f7fa;
border-color: #d6dde7;
}
.button.danger {
color: #ffffff;
background: #b83232;
}
.button:hover:not(:disabled),
.iconButton:hover:not(:disabled) {
transform: translateY(-1px);
}
.iconButton {
width: 40px;
height: 40px;
color: #2f3a49;
background: #f7f9fc;
border-color: #d7dde6;
}
.numberControl,
.selectControl,
.checkboxControl {
display: grid;
gap: 8px;
margin-top: 14px;
}
.numberControl span,
.selectControl span {
display: flex;
justify-content: space-between;
gap: 12px;
color: #3e4958;
font-size: 13px;
font-weight: 650;
}
.numberControl strong {
color: #176b58;
}
.numberControl input[type="range"] {
width: 100%;
accent-color: #176b58;
}
.selectControl select {
width: 100%;
height: 38px;
border: 1px solid #d6dde7;
border-radius: 8px;
background: #ffffff;
color: #273241;
padding: 0 10px;
}
.checkboxControl {
grid-template-columns: 18px 1fr;
align-items: center;
color: #3e4958;
font-size: 14px;
}
.checkboxControl input {
width: 16px;
height: 16px;
accent-color: #176b58;
}
.scanActions {
padding: 18px;
align-items: stretch;
}
.scanActions .button {
flex: 1 1 0;
min-width: 0;
}
.resultsPanel {
min-height: 640px;
padding: 18px;
}
.progressWrap {
padding: 16px;
border: 1px solid #e0e6ee;
border-radius: 8px;
background: #f8fafc;
}
.progressHeader {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #3e4958;
}
.progressTrack {
height: 10px;
margin-top: 12px;
border-radius: 999px;
background: #dfe5ec;
overflow: hidden;
}
.progressBar {
height: 100%;
border-radius: inherit;
background: #176b58;
transition: width 160ms ease;
}
.toast {
margin-top: 14px;
padding: 12px 14px;
border-radius: 8px;
font-size: 14px;
}
.toast.info {
color: #19483f;
background: #e8f5ef;
border: 1px solid #b8ddcf;
}
.toast.error {
color: #7a2222;
background: #fff0ef;
border: 1px solid #f1c2bd;
}
.galleryHeader {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
padding: 22px 0 14px;
}
.galleryHeader h2 {
margin-bottom: 4px;
}
.emptyState {
display: grid;
place-items: center;
align-content: center;
min-height: 430px;
border: 1px dashed #cbd4df;
border-radius: 8px;
color: #6a7480;
text-align: center;
padding: 30px;
}
.galleryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(245px, 1fr));
gap: 14px;
}
.candidateCard {
overflow: hidden;
border: 1px solid #dfe5ec;
border-radius: 8px;
background: #ffffff;
}
.candidateCard img {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: #e7ecf2;
}
.candidateMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
}
.candidateMeta strong,
.candidateMeta span {
display: block;
}
.candidateMeta strong {
font-size: 14px;
}
.candidateMeta span {
margin-top: 3px;
color: #667180;
font-size: 12px;
}
.candidateMeta .iconButton {
width: 34px;
height: 34px;
min-height: 34px;
}
@media (max-width: 920px) {
.appShell {
padding: 16px;
}
.topBar,
.contentGrid {
display: grid;
grid-template-columns: 1fr;
}
.controlPanel {
position: static;
}
}
@media (max-width: 560px) {
.topActions,
.scanActions {
display: grid;
grid-template-columns: 1fr auto;
width: 100%;
}
.topActions .button {
justify-content: center;
}
.topBar h1 {
font-size: 26px;
}
.candidateMeta {
align-items: flex-start;
flex-direction: column;
}
.cardActions {
width: 100%;
justify-content: space-between;
}
}
+29
View File
@@ -0,0 +1,29 @@
export type LoadedVideo = {
file: File;
objectUrl: string;
duration: number;
width: number;
height: number;
};
export type ScanSettings = {
sampleIntervalSeconds: number;
analysisWidth: number;
analysisHeight: number;
pixelDeltaThreshold: number;
changedPixelRatioThreshold: number;
minSecondsBetweenCaptures: number;
maxCandidates: number;
finalTargetCount: number;
includeFirstFrame: boolean;
};
export type CandidateFrame = {
id: string;
time: number;
score: number;
reason: "initial-frame" | "visual-change" | "manual";
thumbnailUrl: string;
};
export type ScanStatus = "idle" | "loading" | "scanning" | "done" | "error";
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});