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:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
+12
@@ -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>
|
||||
Generated
+1757
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user