This commit is contained in:
2026-03-07 11:37:37 -05:00
parent aa5ad8327e
commit 32f63fe43b
32 changed files with 2470 additions and 328 deletions

View File

@@ -1335,6 +1335,11 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="
},
"cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -2066,6 +2071,11 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2199,6 +2209,14 @@
"type-check": "~0.4.0"
}
},
"lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"requires": {
"immediate": "~3.0.5"
}
},
"lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -2232,6 +2250,14 @@
"@types/trusted-types": "^2.0.2"
}
},
"localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"requires": {
"lie": "3.1.1"
}
},
"locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -2270,6 +2296,15 @@
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="
},
"match-sorter": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.1.0.tgz",
"integrity": "sha512-0HX3BHPixkbECX+Vt7nS1vJ6P2twPgGTU3PMXjWrl1eyVCL24tFHeyYN1FN5RKLzve0TyzNI9qntqQGbebnfPQ==",
"requires": {
"@babel/runtime": "^7.23.8",
"remove-accents": "0.5.0"
}
},
"memoize": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz",
@@ -2334,6 +2369,11 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
},
"object-path": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/object-path/-/object-path-0.6.0.tgz",
"integrity": "sha512-fxrwsCFi3/p+LeLOAwo/wyRMODZxdGBtUlWRzsEpsUVrisZbEfZ21arxLGfaWfcnqb8oHPNihIb4XPE8CQPN5A=="
},
"optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2543,6 +2583,23 @@
"resolved": "https://registry.npmjs.org/react-list/-/react-list-0.8.18.tgz",
"integrity": "sha512-1OSdDvzuKuwDJvQNuhXxxL+jTmmdtKg1i6KtYgxI9XR98kbOql1FcSGP+Lcvo91fk3cYng+Z6YkC6X9HRJwxfw=="
},
"react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"requires": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
}
},
"react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"requires": {
"react-router": "7.9.3"
}
},
"react-split-pane": {
"version": "0.1.92",
"resolved": "https://registry.npmjs.org/react-split-pane/-/react-split-pane-0.1.92.tgz",
@@ -2591,6 +2648,11 @@
"memoize-one": ">=3.1.1 <6"
}
},
"remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
},
"require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2723,6 +2785,11 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="
},
"set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
},
"shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
@@ -2748,6 +2815,14 @@
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
"integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="
},
"sort-by": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/sort-by/-/sort-by-1.2.0.tgz",
"integrity": "sha512-aRyW65r3xMnf4nxJRluCg0H/woJpksU1dQxRtXYzau30sNBOmf5HACpDd9MZDhKh7ALQ5FgSOfMPwZEtUmMqcg==",
"requires": {
"object-path": "0.6.0"
}
},
"source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",

View File

@@ -22,15 +22,19 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"flex-layout-system": "^2.0.3",
"localforage": "^1.10.0",
"match-sorter": "^8.1.0",
"react": "^19.1.1",
"react-calendar": "^6.0.0",
"react-date-range": "^2.0.1",
"react-datepicker": "^8.7.0",
"react-dom": "^19.1.1",
"react-flexbox-grid": "^2.1.2",
"react-router-dom": "^7.9.3",
"react-split-pane": "^0.1.92",
"react-table": "^7.8.0",
"rsuite": "^5.83.3",
"sort-by": "^1.2.0",
"timelines-chart": "^2.14.2",
"uplot": "^1.6.32",
"uplot-react": "^1.2.4"

View File

@@ -118,4 +118,59 @@
.controls-section {
padding: 8px 0;
}
}
/* Animated drawer for logout button */
.controls-section.drawer-open {
/* position: fixed; */
/* top: 0; */
/* left: 0; */
/* width: 300px; */
/* max-width: 80vw; */
/* height: 100vh; */
background: #23272f;
/* box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3); */
/* padding: 60px 20px 20px 20px; */
transform: translateY(-100%);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1500;
overflow-y: auto;
animation: slideInFromTop 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* Animation keyframes */
@keyframes slideInFromTop {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Style the logout button inside the drawer */
.controls-section.drawer-open .control-group {
display: block;
margin: 15px 0;
width: 100%;
}
.controls-section.drawer-open button {
width: 100%;
padding: 12px;
margin: 5px 0;
border: none;
border-radius: 6px;
background: #ff4757;
color: white;
font-size: 1.1em;
cursor: pointer;
transition: background 0.2s ease;
}
.controls-section.drawer-open button:hover {
background: #ff3838;
}

View File

@@ -1,9 +1,8 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import Login from "./components/Login";
import EmbedTimeline from "./components/EmbedTimeline";
import VideoPlayer from "./components/VideoPlayer";
// import ModernDateRangeSelector from './components/ModernDateRangeSelector';
import CompactDateRangePicker from "./components/CompactDateRangePicker";
import CustomDateRangePicker from "./components/DateRangePicker";
import "./App.css";
import StatusesDisplayHUD from "./components/StatusDisplay";
@@ -17,6 +16,9 @@ function App() {
const playerRef = useRef(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const playerInstanceRef = useRef(null);
const [selectedCamera, setSelectedCamera] = useState("Leopards 1");
const [selectedHighRes, setSelectedHighRes] = useState(false);
const [videoPlaying, setVideoPlaying] = useState(true);
// State for the values
window.chartRef = chartRef;
window.playerRef = playerRef;
@@ -31,11 +33,11 @@ function App() {
//
const [startRange, setStartRange] = useState(
new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000)
new Date(new Date().getTime() - 1 * 24 * 60 * 60 * 1000),
);
const [endRange, setEndRange] = useState(new Date());
// const [endRange, setEndRange] = useState(new Date(new Date().getTime() - 6 * 24 * 60 * 60 * 1000));
const [queryText, setQueryText] = useState("A clouded leopard and a human");
const [queryText, setQueryText] = useState("Two clouded leopards being aggressive");
const [sliderValue, setSliderValue] = useState(0);
// State to track last submitted values
@@ -43,6 +45,7 @@ function App() {
startRange,
endRange,
queryText,
selectedCamera,
});
// // Check if any value has changed
@@ -60,7 +63,10 @@ function App() {
const endChanged =
endRange.getTime() !== new Date(lastSubmitted.endRange).getTime();
const queryChanged = textChanged || startChanged || endChanged;
const cameraChanged = selectedCamera !== lastSubmitted.selectedCamera;
const queryChanged =
textChanged || startChanged || endChanged || cameraChanged;
const streamComputeStatus = () => {
fetch("api/return_status")
@@ -109,14 +115,17 @@ function App() {
};
// Function to resubmit fetch
const handleResubmit = (doTestMode = false) => {
setVideoPlaying(false);
let startRangeUse;
let endRangeUse;
setDrawerOpen(false);
if (doTestMode == true) {
startRangeUse = new Date(
new Date().getTime() - 2 * 24 * 60 * 60 * 1000
new Date().getTime() - 2 * 24 * 60 * 60 * 1000,
);
endRangeUse = new Date(
new Date().getTime() - 1 * 24 * 60 * 60 * 1000
new Date().getTime() - 1 * 24 * 60 * 60 * 1000,
);
} else {
startRangeUse = startRange;
@@ -131,9 +140,10 @@ function App() {
params.append("endRange", endRangeUse.toISOString());
params.append("threshold", 0.0);
params.append("query", queryText);
params.append("camera", selectedCamera);
setDataResults({ videos: [], breaks: [] });
fetch("api/videos.json?" + params.toString())
authenticatedFetch("api/videos.json?" + params.toString())
.then((res) => res.json())
.then((data) => {
streamComputeStatus();
@@ -145,18 +155,36 @@ function App() {
startRange: startRangeUse,
endRange: endRangeUse,
queryText,
selectedCamera,
});
};
function selectHighResFunc(selectedHighRes, setSelectedHighRes, toggleCheckbox = true) {
console.log(selectedHighRes);
const params = new URLSearchParams();
params.append("do_high_res", !selectedHighRes);
authenticatedFetch("api/set_parameter?" + params.toString())
.then((res) => res.json())
.then((data) => {
if (toggleCheckbox) {
setSelectedHighRes(!selectedHighRes);
}
});
}
function updateDataAndValue(newValue) {
const floatValue = parseFloat(newValue);
setSliderValue(floatValue);
var newData = JSON.parse(JSON.stringify(original_data.current));
newData["videos"] = newData["videos"].filter(
(vid) => vid["embed_scores"]["score"][1] >= floatValue
(vid) => vid["embed_scores"]["score"][1] >= floatValue,
);
newData['threshold'] = floatValue;
console.log(newData['threshold'])
newData["threshold"] = floatValue;
setDataResults(newData);
}
@@ -199,7 +227,7 @@ function App() {
series: [{}, { markLine: { data: [vv_new] } }],
},
false,
["series.markLine"]
["series.markLine"],
);
} else {
chart.setOption(
@@ -207,67 +235,115 @@ function App() {
series: [{}, { markLine: markLine }],
},
false,
["series.markLine"]
["series.markLine"],
);
}
}
const handleTimelineClick = useCallback(
(path, timeoffset) => {
const handleTimelineClick = useCallback((path, timeoffset) => {
if (playerRef.current && playerInstanceRef.current) {
const player = playerInstanceRef.current;
console.log("Setting video source:", "api/" + path);
console.log("Target time offset:", timeoffset);
// Clear any existing source first
const token = localStorage.getItem("access_token");
const authenticatedUrl = `api/${path}?token=${token}`;
player.reset();
// Configure player for range request support
const videoElement = player.el().querySelector("video");
if (videoElement) {
videoElement.crossOrigin = "anonymous";
videoElement.preload = "metadata";
}
player.src({
src: "api/" + path,
src: authenticatedUrl,
type: "video/mp4",
withCredentials: false,
});
setVideoPlaying(true);
// Ensure range headers are sent for seeking
player.ready(() => {
const tech = player.tech();
if (tech && tech.el_) {
tech.el_.addEventListener("loadstart", () => {
// Force range request capability
if (
tech.el_.seekable &&
tech.el_.seekable.length === 0
) {
console.log(
"Video doesn't support seeking - range headers may be needed",
);
}
});
}
});
player.load();
// Add multiple event listeners for debugging
player.one("loadedmetadata", () => {
console.log("Video metadata loaded");
console.log("Video duration:", player.duration());
player.currentTime(timeoffset);
// Try to play after setting time
const playPromise = player.play();
if (playPromise !== undefined) {
playPromise.then(() => {
console.log("Video started playing");
}).catch(error => {
console.error("Error playing video:", error);
});
}
console.log("Time offset:", timeoffset);
});
// Wait for the video to be ready for seeking
player.one("canplay", () => {
console.log("Video can start playing - setting time");
const duration = player.duration();
// Ensure timeoffset is valid
const seekTime = Math.max(
0,
Math.min(timeoffset, duration - 0.1),
);
console.log("Seeking to:", seekTime, "of", duration);
player.currentTime(seekTime);
// Wait a bit before playing to ensure seek completed
setTimeout(() => {
const playPromise = player.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log(
"Video started playing at time:",
player.currentTime(),
);
})
.catch((error) => {
console.error("Error playing video:", error);
});
}
}, 100);
});
player.one("error", (e) => {
console.error("Video error:", e);
console.error("Player error:", player.error());
});
player.one("loadstart", () => {
console.log("Load started");
});
player.one("canplay", () => {
console.log("Video can start playing");
});
} else {
console.error("Player ref not available");
}
},
[]
);
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
selectHighResFunc(
selectedHighRes,
setSelectedHighRes,
false
)
handleResubmit(params.get("test_mode") === "true");
}, []);
@@ -279,7 +355,7 @@ const handleTimelineClick = useCallback(
function pollForResult() {
const poll = () => {
fetch("api/videos_result.json")
authenticatedFetch("api/videos_result.json")
.then((res) => res.json())
.then((data) => {
if (data.status === "processing") {
@@ -287,22 +363,254 @@ const handleTimelineClick = useCallback(
} else {
const max_value = Math.max(
...data["videos"].map(
(vid) => vid["embed_scores"]["score"][1]
)
(vid) => vid["embed_scores"]["score"][1],
),
);
setSliderMax(max_value);
original_data.current = data;
setDataResults(data);
updateDataAndValue(sliderValue)
}
});
};
poll();
}
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [checkingAuth, setCheckingAuth] = useState(true);
// Check authentication on load
useEffect(() => {
const token = localStorage.getItem("access_token");
if (token) {
// Verify token is still valid
fetch("/api/videos_result.json", {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (response.ok) {
setIsAuthenticated(true);
} else {
localStorage.removeItem("access_token");
setIsAuthenticated(false);
}
})
.catch(() => {
localStorage.removeItem("access_token");
setIsAuthenticated(false);
})
.finally(() => {
setCheckingAuth(false);
});
} else {
setCheckingAuth(false);
}
}, []);
// Add authentication header to all API calls
const authenticatedFetch = useCallback((url, options = {}) => {
const token = localStorage.getItem("access_token");
const headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
return fetch(url, { ...options, headers });
}, []);
const handleLoginSuccess = () => {
setIsAuthenticated(true);
handleResubmit(params.get("test_mode") === "true");
};
const handleLogout = () => {
localStorage.removeItem("access_token");
setIsAuthenticated(false);
};
if (checkingAuth) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
background: "#181a20",
color: "#fff",
}}
>
Loading...
</div>
);
}
if (!isAuthenticated) {
return <Login onLoginSuccess={handleLoginSuccess} />;
}
return (
<div className="app-container">
{/* {drawerOpen && (
<div
className="drawer-backdrop"
onClick={() => setDrawerOpen(false)}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
background: "rgba(0, 0, 0, 0.5)",
zIndex: 1400,
opacity: drawerOpen ? 1 : 0,
transition: "opacity 0.3s ease",
}}
/>
)} */}
{drawerOpen && (
<div
className={`controls-section ${
drawerOpen ? "drawer-open" : ""
}`}
>
<div
className="control-group"
style={{
display: "flex",
alignItems: "center",
gap: "20px",
justifyContent: "flex-start",
}}
>
<button
onClick={handleLogout}
style={{
flexShrink: 0,
width: "auto",
minWidth: "80px",
marginLeft: "20px",
}}
>
Logout
</button>
<div
className="radio-group"
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
flexShrink: 0,
width: "auto",
border: "2px solid #4a5568",
borderRadius: "8px",
padding: "12px",
backgroundColor: "#2d3748",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 1"
checked={selectedCamera === "Leopards 1"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 1
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 2"
checked={selectedCamera === "Leopards 2"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 2
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 3"
checked={selectedCamera === "Leopards 3"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 3
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 4"
checked={selectedCamera === "Leopards 4"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 4
</label>
</div>
</div>
</div>
)}
<div className="controls-section">
<div className="control-group">
<button
className="drawer-toggle"
onClick={() => setDrawerOpen(!drawerOpen)}
>
{drawerOpen ? "✕" : "Options"}
</button>
</div>
<div className="control-group">
<CustomDateRangePicker
startDate={startRange}
@@ -321,21 +629,36 @@ const handleTimelineClick = useCallback(
if (e.key === "Enter") handleResubmit();
}}
style={{
// padding: "8px",
// borderRadius: "4px",
// border: "1px solid #343a40",
// color: "#fff",
// backgroundColor: "#23272f",
// width: "100%",
// minWidth: 0,
// boxSizing: "border-box",
// padding: "8px",
// borderRadius: "4px",
// border: "1px solid #343a40",
// color: "#fff",
// backgroundColor: "#23272f",
// width: "100%",
// minWidth: 0,
// boxSizing: "border-box",
fontSize: "1.1em",
// transition: "width 0.2s",
// transition: "width 0.2s",
}}
ref={inputRef}
size={Math.max(queryText.length, 25)}
/>
</div>
<div className="control-group">
<input
type="checkbox"
name="do_high_res"
checked={selectedHighRes}
onChange={(e) =>
selectHighResFunc(
selectedHighRes,
setSelectedHighRes,
)
}
/>
HD Video
</div>
<div
className="control-group"
style={{ visibility: queryChanged ? "hidden" : "visible" }}
@@ -371,7 +694,9 @@ const handleTimelineClick = useCallback(
className="control-group"
style={{ visibility: queryChanged ? "hidden" : "visible" }}
>
<span style={{ color: "#fff" }}>{sliderValue.toFixed(2)}</span>
<span style={{ color: "#fff" }}>
{sliderValue.toFixed(2)}
</span>
</div>
<div
className="control-group"
@@ -389,6 +714,16 @@ const handleTimelineClick = useCallback(
Resubmit
</button>
</div>
{videoPlaying && (
<div className="control-group">
<button onClick={() => window.open("api/media_download/low")}>Download Low-Res</button>
</div>
)}
{videoPlaying && (
<div className="control-group">
<button onClick={() => window.open("api/media_download/high")}>Download High-Res</button>
</div>
)}
</div>
<div className="status-section">
@@ -400,14 +735,16 @@ const handleTimelineClick = useCallback(
chartRef={chartRef}
data_in={dataResults}
onTimelineClick={handleTimelineClick}
authenticatedFetch={authenticatedFetch}
/>
</div>
<div className="video-section vjs-16-9 vjs-fluid">
<VideoPlayer
videoRef={playerRef}
playerInstanceRef={playerInstanceRef}
setMarkerTimeFunc={setMarkerValueNonReactive}
authenticatedFetch={authenticatedFetch}
/>
</div>
</div>

View File

@@ -0,0 +1,31 @@
.multiple-timelines-videos {
display: flex;
flex-direction: column;
gap: 0; /* Remove gap to ensure exact 50% */
padding: 0; /* Remove padding for exact sizing */
box-sizing: border-box;
}
/* Make each VideoPlayerTimelineStack take exactly half the available space */
.multiple-timelines-videos > * {
flex: 0 0 50%; /* Exact 50% - no grow, no shrink */
min-height: 0vh;
overflow: hidden;
box-sizing: border-box;
}
/* Force Video.js to respect container dimensions */
.video-timeline-container .video-js,
.timelines-and-video-parent .video-js {
width: 100% !important;
height: 100% !important;
max-width: 100%;
max-height: 100%;
}
.video-timeline-container .video-js .vjs-tech,
.timelines-and-video-parent .video-js .vjs-tech {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}

View File

@@ -0,0 +1,80 @@
import React, { useState } from "react";
import VideoPlayerMultiple from "./components/VideoPlayerMultiple.jsx";
import TimelineStacked from "./components/TimelineStacked.jsx";
import VideoPlayerTimelineStack from "./components/VideoPlayerTimelineStack.jsx";
import VideoUploadDropzone from "./components/VideoUploadDropzone.jsx";
import "./Explorer.css";
function Explorer() {
const [queries, setQueries] = useState([
{ id: 1, text: "", submitted: false },
]);
const [nextId, setNextId] = useState(2);
const [uploadedVideos, setUploadedVideos] = useState([
// Default video
]);
const handleQueryChange = (id, text) => {
setQueries((prev) =>
prev.map((query) => (query.id === id ? { ...query, text } : query))
);
};
const handleSubmitQuery = (id) => {
const query = queries.find((q) => q.id === id);
if (query && query.text.trim()) {
// Mark this query as submitted
setQueries((prev) =>
prev.map((q) => (q.id === id ? { ...q, submitted: true } : q))
);
// Add a new empty query box
setQueries((prev) => [
...prev,
{ id: nextId, text: "", submitted: false },
]);
setNextId((prev) => prev + 1);
// TODO: Handle the query submission (API call, etc.)
console.log(`Submitted query: "${query.text}"`);
}
};
const handleRemoveQuery = (id) => {
if (queries.length > 1) {
setQueries((prev) => prev.filter((query) => query.id !== id));
}
};
const handleKeyPress = (e, id) => {
if (e.key === "Enter") {
handleSubmitQuery(id);
}
};
const handleVideoUpload = (videoUrl) => {
setUploadedVideos((prev) => [...prev, videoUrl]);
};
const handleVideoDelete = (index) => {
setUploadedVideos((prev) => prev.filter((_, i) => i !== index));
};
return (
<div className="explorer-container">
{/* Video Players */}
<div className="multiple-timelines-videos">
{uploadedVideos.map((videoSrc, index) => (
<VideoPlayerTimelineStack
key={index}
video_src={videoSrc}
onDelete={() => handleVideoDelete(index)}
/>
))}
</div>
<VideoUploadDropzone onVideoUpload={handleVideoUpload} />
</div>
);
}
export default Explorer;

View File

@@ -1,28 +0,0 @@
import React, { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
export default function CompactDateRangePicker({ startDate, endDate, setStartDate, setEndDate}) {
// const [startDate, setStartDate] = useState(null);
// const [endDate, setEndDate] = useState(null);
console.log(startDate)
console.log(endDate)
console.log(setStartDate)
console.log(setEndDate)
return (
<DatePicker
selectsRange
startDate={startDate}
endDate={endDate}
onChange={([start, end]) => {
setStartDate(start);
setEndDate(end);
if (end && onChange) onChange({ startDate: start, endDate: end });
}}
isClearable
maxDate={new Date()}
placeholderText="Select date range"
withPortal
/>
);
}

View File

@@ -5,6 +5,7 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
chartRef,
data_in,
onTimelineClick,
authenticatedFetch,
markerTime,
}) {
// Use useRef instead of props/state to avoid re-renders
@@ -19,13 +20,14 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
// --- Constants ---
const BREAK_GAP = 0;
const ZOOM_THRESHOLD = 4 * 60 * 60 * 1000; // 1 hour in ms
const BREAK_THRESHOLD_SECONDS = 60 * 15;
const fetchLineData = async (startTime, endTime) => {
try {
const params = new URLSearchParams();
params.append("start_time", new Date(startTime).toISOString());
params.append("end_time", new Date(endTime).toISOString());
const response = await fetch(
const response = await authenticatedFetch(
"/api/line_data.json?" + params.toString()
);
@@ -65,7 +67,7 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
const handleDataZoom = async (params, chart) => {
if (!params || !chart) return;
console.log(zoomed_range.current);
// console.log(zoomed_range.current);
let plot_start_time = zoomed_range.current[0];
let plot_end_time = zoomed_range.current[1];
@@ -81,9 +83,8 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
let offset_start = dataZoom.startValue - plot_start_time;
let offset_end = dataZoom.endValue - plot_end_time;
console.log(offset_start, offset_end);
// console.log(offset_start, offset_end);
if (offset_start < 0 || offset_end > 0) {
console.log("Do force getting data");
isZoomedInRef.current = false;
}
@@ -109,11 +110,11 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
const fetchedLineData = await fetchLineData(startTime, endTime);
lineDataRef.current = fetchedLineData;
console.log("Fetched updated data");
// console.log("Fetched updated data");
// Add line directly to chart without React re-render
addLineToChart(chart, fetchedLineData);
} else if (!shouldShowLine && isZoomedInRef.current) {
console.log("zoomed out");
// console.log("zoomed out");
isZoomedInRef.current = false;
lineDataRef.current = null;
zoomed_range.current = [null, null];
@@ -192,6 +193,29 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
).sort((a, b) => new Date(a[0]) - new Date(b[0]));
}
function calculateDayBreaks(videos) {
const breaks = [];
if (videos.length < 3) {
return breaks;
}
for (let i = 0; i < videos.length - 1; i++) {
let end_now = new Date(videos[i]["end_time"]*1000)
let start_next = new Date(videos[i + 1]["start_time"]*1000)
Math.abs(start_next.getDay() - end_now.getDay())
if (Math.abs(start_next.getDay() - end_now.getDay()) > 0) {
// console.log("Day break:", end_now, start_next)
let c_st = end_now.getTime();
let e_st = start_next.getTime();
breaks.push([c_st, e_st, e_st/2 + c_st/2]);
}
}
return breaks;
}
function calculateBreaks(videos) {
const breaks = [];
if (videos.length < 3) {
@@ -202,9 +226,8 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
for (let i = 0; i < videos.length - 1; i++) {
let end_now = videos[i]["end_time"];
let start_next = videos[i + 1]["start_time"];
if (start_next - end_now > 60 * 60) {
// still in unix timestamp. break only if spaces of 60 minutes
breaks.push([end_now, start_next]);
if (start_next - end_now > BREAK_THRESHOLD_SECONDS) {
breaks.push([end_now*1000, start_next*1000, (end_now/2 + start_next/2)*1000]);
}
}
@@ -229,8 +252,8 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
function prepareBreaks(breaksRaw) {
return breaksRaw.map(([start, end]) => ({
start: new Date(1000 * start),
end: new Date(1000 * end),
start: new Date(start),
end: new Date( end),
gap: BREAK_GAP,
isExpanded: false,
}));
@@ -322,8 +345,8 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
symbol: "none",
lineStyle: { width: 100, opacity: 0 },
data: [],
smooth: true,
sampling: 'lttb',
smooth: true,
sampling: "lttb",
z: 4,
};
}
@@ -332,9 +355,12 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
const videoData = prepareVideoData(data_in["videos"]);
const withNulls = videoData;
data_in["calc_breaks"] = calculateBreaks(data_in["videos"]);
data_in["day_breaks"] = calculateDayBreaks(data_in["videos"]);
window.data_in = data_in
// const withNulls = fillNulls(videoData);
const breaks = prepareBreaks(data_in["calc_breaks"]);
const breaks = prepareBreaks(data_in["day_breaks"]);
window.breaks = breaks;
const virtualTime = buildVirtualTimeMapper(breaks);
const breaks_split = data_in["calc_breaks"].flat(1).map(function (x) {
@@ -344,14 +370,14 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
// breaks_split.unshift(new Date(videoData[0][0]).getTime())
// breaks_split.push(new Date(videoData.at(-1)[0]).getTime())
// }
const paired_splits = [];
for (let i = 0; i < breaks_split.length; i += 2) {
paired_splits.push([
breaks_split[i],
breaks_split[i + 1],
breaks_split[i] / 2 + breaks_split[i + 1] / 2,
]);
}
const paired_splits = data_in['calc_breaks']
// for (let i = 0; i < breaks_split.length; i += 2) {
// paired_splits.push([
// breaks_split[i],
// breaks_split[i + 1],
// breaks_split[i] / 2 + breaks_split[i + 1] / 2,
// ]);
// }
const split_centers = paired_splits.map((d) => new Date(d[2]));
const splitCenterVirtualTimes = split_centers.map((d) => virtualTime(d));
const splitCenterLabels = split_centers.map((d) =>
@@ -416,12 +442,12 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
fontSize: 10,
},
}));
// console.log(breakMarkLines)
// Attach break mark lines to the first series
if (seriesNormal[0]) {
seriesNormal[0].markLine = {
symbol: ["none", "none"],
data: [...(breakMarkLines || []), ...(splitCenterMarkLines || [])],
data: [...(breakMarkLines)],
lineStyle: { type: "dashed", color: "#888", width: 2 },
label: {
show: true,
@@ -437,7 +463,7 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
(virtualData.length > 0 ? virtualData[0][0] : 0) - 100000;
const virtual_x_max =
(virtualData.length > 0 ? virtualData[virtualData.length - 1][0] : 1) +
4100000;
100000;
const option = {
animation: false,
@@ -547,7 +573,7 @@ const EmbedTimeline = React.memo(function EmbedTimeline({
const dataCoord = echarts.convertFromPixel({ seriesIndex: 0 }, pixel);
const res = await fetch("/api/events/click", {
const res = await authenticatedFetch("/api/events/click", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({

View File

@@ -0,0 +1,76 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #181a20;
}
.login-box {
background: #23272f;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
width: 100%;
max-width: 400px;
}
.login-box h2 {
color: #fff;
text-align: center;
margin-bottom: 30px;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid #343a40;
border-radius: 6px;
background: #181a20;
color: #fff;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3a7afe;
}
.form-group input::placeholder {
color: #888;
}
button {
width: 100%;
padding: 12px;
background: #3a7afe;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #2c5ce6;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.error {
color: #ff6b6b;
text-align: center;
margin-bottom: 15px;
font-size: 14px;
}

View File

@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import './Login.css';
export default function Login({ onLoginSuccess }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('api/login', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('access_token', data.access_token);
onLoginSuccess();
} else {
setError('Invalid username or password');
}
} catch (error) {
setError('Login failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-box">
<h2>Login Required</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group">
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
.multiple-timelines-interface {
padding: 0;
margin: 0;
}
.input-group {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
margin-bottom: 0;
}
.item-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
margin: 0;
width: 50%;
height: 22px;
transition: opacity 0.2s;
}
.item-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.item-input:focus {
outline: none;
border-color: #007bff;
}
.submit-button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
margin: 0;
min-width: 100px; /* Fixed minimum width to prevent size change */
width: 25%;
height: 40px; /* Fixed height to prevent vertical size change */
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.2s ease; /* Only transition background, not size */
box-sizing: border-box; /* Ensure padding is included in width/height */
}
.submit-button:hover:not(:disabled) {
background: #0056b3;
}
.submit-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.submit-button.loading {
background: #ffc107;
cursor: not-allowed;
}
.submit-button.loading:hover {
background: #ffc107;
}
.spinner {
width: 14px; /* Fixed width */
height: 14px; /* Fixed height - same as width for perfect circle */
border: 2px solid transparent;
border-top: 2px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
flex-shrink: 0; /* Prevent the spinner from shrinking */
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.timelines-container {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 0;
}
.timeline-item {
padding: 0;
margin: 0;
background: transparent;
border: none;
}

View File

@@ -0,0 +1,95 @@
import React, { useState } from "react";
import TimelineStacked from "./TimelineStacked";
import "./MultipleTimelinesInterface.css";
function MultipleTimelinesInterface({ video_path }) {
const [value, setValue] = useState("");
const [timelines, setTimelines] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
if (value.trim() && !isLoading) {
setIsLoading(true);
const prompt = value.trim();
const key = prompt;
const params = new URLSearchParams();
params.append("video_path", video_path);
params.append("prompt", prompt);
try {
const response = await fetch("api/match_scores?" + params.toString());
const data = await response.json();
const new_timeline_prefs = {
prompt: prompt,
key: key,
data: data,
};
setTimelines([...timelines, new_timeline_prefs]);
setValue(""); // Clear the input after submission
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setIsLoading(false);
}
}
};
const handleKeyPress = (e) => {
if (e.key === "Enter") {
handleSubmit();
}
};
const handleDeleteTimeline = (timelineId) => {
setTimelines(prev => prev.filter(timeline => timeline.key !== timelineId));
};
return (
<div className="multiple-timelines-interface">
<div className="timelines-container">
{timelines.map((timeline) => (
<div key={timeline.key} className="timeline-item">
<TimelineStacked
prompt={timeline.prompt}
data={timeline.data}
msg_bus_topic={video_path}
onDelete={() => handleDeleteTimeline(timeline.key)}
/>
</div>
))}
</div>
<br></br>
<div className="input-group">
<input
type="text"
placeholder="Enter text..."
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyPress={handleKeyPress}
className="item-input"
disabled={isLoading}
/>
<button
onClick={handleSubmit}
className={`submit-button ${isLoading ? 'loading' : ''}`}
disabled={!value.trim() || isLoading}
>
{isLoading ? (
<>
<span className="spinner"></span>
Loading...
</>
) : (
'Submit'
)}
</button>
</div>
</div>
);
}
export default MultipleTimelinesInterface;

View File

@@ -0,0 +1,36 @@
.timeline-stacked-container {
position: relative;
width: 100%;
height: 100%;
}
.delete-timeline-btn {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background: rgba(255, 71, 87, 0.9);
color: white;
border: none;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
}
.delete-timeline-btn:hover {
background: rgba(255, 71, 87, 1);
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.4);
}
.delete-timeline-btn:active {
transform: scale(0.95);
}

View File

@@ -0,0 +1,152 @@
import ReactECharts from "echarts-for-react";
import { useRef, useState, useEffect } from "react";
import eventBus from "../util/EventBus.jsx";
import "./TimelineStacked.css"; // We'll create this CSS file
function TimelineStacked({ prompt, data, msg_bus_topic, onDelete }) {
const chartRef = useRef(null);
const [markerValue, setMarkerValue] = useState(0.0);
const unsubscribeRef = useRef(null);
// Calculate min and max x values from data
// Sort data by x values and calculate min/max
const getSortedDataAndLimits = () => {
if (!data || !data["data"] || data["data"].length === 0) {
return {
sortedData: [],
min: 0,
max: 100,
};
}
// Sort data by x values (first element of each point)
const sortedData = [...data["data"]].sort((a, b) => a[0] - b[0]);
const xValues = sortedData.map((point) => point[0]);
return {
sortedData: sortedData,
min: Math.min(...xValues),
max: Math.max(...xValues),
};
};
const { sortedData, min, max } = getSortedDataAndLimits();
useEffect(() => {
// Subscribe and store the unsubscribe function
const { unSubscribe } = eventBus.subscribe(msg_bus_topic, (obj) => {
setMarkerValue(obj["time"]);
});
unsubscribeRef.current = unSubscribe;
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
}
};
}, [msg_bus_topic]);
const handleDelete = () => {
// Unsubscribe from event bus to prevent memory leaks
if (unsubscribeRef.current) {
unsubscribeRef.current();
}
// Call parent's delete handler
if (onDelete) {
onDelete();
}
};
const option = {
animation: false,
grid: {
left: 0,
right: 0,
top: 20, // Add space for the delete button
bottom: 0,
containLabel: false,
},
graphic: [
{
type: "text",
left: 0,
top: 0,
style: {
text: prompt || "",
fontSize: 12,
fontWeight: "bold",
fill: "#FFFFFFF0",
backgroundColor: "rgba(255, 255, 255, 0.1)",
padding: [4, 8],
borderRadius: 4,
},
z: 100,
},
],
xAxis: {
type: "value",
min: min,
max: max,
show: false,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
yAxis: {
type: "value",
show: true,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: true },
splitLine: { show: false },
},
series: [
{
type: "line",
symbol: "none",
lineStyle: { width: 1, opacity: 1 },
data: sortedData,
smooth: false,
z: 4,
markLine: {
symbol: "none",
lineStyle: {
color: "#ff4757",
width: 2,
type: "solid",
},
data: [
{
xAxis: markerValue,
label: {
show: false,
},
},
],
},
},
],
};
return (
<div className="timeline-stacked-container">
<button
className="delete-timeline-btn"
onClick={handleDelete}
title="Delete this timeline"
>
×
</button>
<ReactECharts
ref={chartRef}
option={option}
style={{ width: "100%", height: "100%" }}
/>
</div>
);
}
export default TimelineStacked;

View File

@@ -0,0 +1,89 @@
.video-dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f8f9fa;
margin: 20px 0;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
}
.video-dropzone:hover {
border-color: #007bff;
background: #f0f7ff;
}
.video-dropzone.drag-over {
border-color: #28a745;
background: #f0fff4;
transform: scale(1.02);
}
.video-dropzone.uploading {
border-color: #ffc107;
background: #fffbf0;
cursor: not-allowed;
}
.upload-prompt {
color: #666;
}
.upload-icon {
font-size: 48px;
margin-bottom: 16px;
}
.upload-prompt p {
margin: 8px 0;
font-size: 16px;
}
.upload-subtitle {
color: #999;
font-size: 14px;
}
.supported-formats {
margin-top: 16px;
font-size: 12px;
color: #999;
font-style: italic;
}
.upload-status {
color: #666;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.explorer-container {
padding: 20px;
}
.multiple-timelines-videos {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@@ -1,72 +1,95 @@
import React, { useRef, useEffect, forwardRef, useImperativeHandle } from "react";
import React, {
useRef,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import videojs from "video.js";
import "video.js/dist/video-js.css";
const VideoPlayer = function VideoPlayer({videoRef, playerInstanceRef, setMarkerTimeFunc}) {
const VideoPlayer = function VideoPlayer({
videoRef,
playerInstanceRef,
setMarkerTimeFunc,
authenticatedFetch
}) {
useEffect(() => {
// Prevent double init in StrictMode
if (!playerInstanceRef.current && videoRef.current) {
const token = localStorage.getItem("access_token"); // Move token here
useEffect(() => {
// Prevent double init in StrictMode
if (!playerInstanceRef.current && videoRef.current) {
playerInstanceRef.current = videojs(videoRef.current, {
controls: true,
preload: "auto",
autoplay: true,
});
playerInstanceRef.current = videojs(videoRef.current, {
controls: true,
preload: "auto",
autoplay: true,
fluid: true,
html5: {
vhs: {
overrideNative: true, // use Video.js native range handling
},
nativeVideoTracks: false,
},
responsive: true,
techOrder: ['html5'],
})
playerInstanceRef.current.on('timeupdate', async function (event) {
const res = await fetch('api/events/video_step', {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ timestamp: this.currentTime() }),
});
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const { path, timeoffset, do_update, absolute_time} = await res.json();
setMarkerTimeFunc(1000*absolute_time)
if (do_update) {
playerInstanceRef.current.src({ src: 'api/' + path, type: "video/mp4" });
playerInstanceRef.current.on("timeupdate", async function (event) {
const token = localStorage.getItem("access_token");
const res = await authenticatedFetch("api/events/video_step", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timestamp: this.currentTime() }),
});
// Seek after metadata is loaded
playerInstanceRef.current.on("loadedmetadata", () => {
playerInstanceRef.current.currentTime(timeoffset);
});
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
let js_ = await res.json()
const { path, timeoffset, do_update, absolute_time, halt } = js_
setMarkerTimeFunc(1000 * absolute_time);
if (halt) {
this.pause();
}
if (do_update) {
const authenticatedUrl = `api/${path}?token=${token}`;
console.log(authenticatedUrl)
playerInstanceRef.current.src({
src: authenticatedUrl,
type: "video/mp4",
});
// Seek after metadata is loaded
playerInstanceRef.current.on("loadedmetadata", () => {
playerInstanceRef.current.currentTime(timeoffset);
});
}
});
}
return () => {
if (playerInstanceRef.current) {
playerInstanceRef.current.dispose();
playerInstanceRef.current = null;
}
};
}, []);
}
)
}
return () => {
if (playerInstanceRef.current) {
playerInstanceRef.current.dispose();
playerInstanceRef.current = null;
}
};
}, []);
return (
<div style={{ width: "100%", height: "100%" }}>
<div data-vjs-player style={{ width: "100%", height: "100%" }}>
<video
ref={videoRef}
className="video-js vjs-big-play-centered"
playsInline
style={{
width: "100%",
height: "100%",
backgroundColor: "black",
objectFit: "contain"
}}
/>
</div>
</div>
);
return (
<div style={{ width: "100%", height: "100%" }}>
<div data-vjs-player style={{ width: "100%", height: "100%" }}>
<video
ref={videoRef}
className="video-js vjs-big-play-centered"
playsInline
style={{
width: "100%",
height: "100%",
backgroundColor: "black",
objectFit: "contain",
}}
/>
</div>
</div>
);
};
export default VideoPlayer;

View File

@@ -0,0 +1,11 @@
import videojs from "video.js";
function VideoPlayerMultiple()
{
}
export default VideoPlayerMultiple

View File

@@ -0,0 +1,81 @@
.timelines-and-video-parent {
display: flex;
width: 100%;
height: 100%;
flex-direction: row;
overflow: hidden;
box-sizing: border-box;
position: relative; /* Add this for absolute positioning of delete button */
}
.video-parent {
flex: 1;
height: 100%;
min-width: 0;
background: #000;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.timelines-parent {
flex: 1;
height: 100%;
min-width: 0;
background: #20232a;
color: #fff;
padding: 16px;
box-sizing: border-box;
overflow-y: auto;
}
/* Delete button styling */
.delete-video-btn {
position: absolute;
top: 0px;
left: 0px;
width: 28px;
height: 28px;
background: rgba(255, 71, 87, 0.9);
color: white;
border: none;
border-radius: 50%;
font-size: 16px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
transition: all 0.2s ease;
backdrop-filter: blur(4px);
}
.delete-video-btn:hover {
background: rgba(255, 71, 87, 1);
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(255, 71, 87, 0.4);
}
.delete-video-btn:active {
transform: scale(0.95);
}
/* Force Video.js to fit properly within container */
.video-parent .video-js {
max-width: 100%;
max-height: 100%;
width: auto !important;
height: auto !important;
}
.video-parent .video-js video,
.video-parent .video-js .vjs-tech {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain !important;
}

View File

@@ -0,0 +1,103 @@
import React, {
useRef,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import videojs from "video.js";
import eventBus from "../util/EventBus.jsx";
import TimelineStacked from "./TimelineStacked.jsx";
import "video.js/dist/video-js.css";
import MultipleTimelinesInterface from "./MultipleTimelinesInterface.jsx";
import "./VideoPlayerTimelineStack.css";
const VideoPlayerTimelineStack = function VideoPlayerTimelineStack({
video_src,
onDelete
}) {
const playerInstanceRef = useRef(null);
const videoRef = useRef(null);
useEffect(() => {
// Prevent double init in StrictMode
if (!playerInstanceRef.current && videoRef.current) {
playerInstanceRef.current = videojs(videoRef.current, {
controls: true,
preload: "auto",
autoplay: true,
fluid: true,
responsive: true,
fill: false,
aspectRatio: "16:9", // Add aspect ratio
width: 932,
height: 523, // Calculate based on aspect ratio
});
}
playerInstanceRef.current.on("timeupdate", async function (event) {
eventBus.publish(video_src, { time: this.currentTime() });
});
if (playerInstanceRef.current && videoRef.current) {
const player = playerInstanceRef.current;
player.src({ src: video_src, type: "video/mp4" });
}
const resizeObserver = new ResizeObserver((entries) => {
console.log(entries);
console.log(entries);
});
const videoContainer = videoRef.current.closest(".video-parent");
if (videoContainer) {
resizeObserver.observe(videoContainer);
}
return () => {
if (playerInstanceRef.current) {
playerInstanceRef.current.dispose();
playerInstanceRef.current = null;
}
};
}, [video_src]); // Add video_src to dependencies
const handleDelete = () => {
// Clean up video player before deletion
if (playerInstanceRef.current) {
playerInstanceRef.current.dispose();
playerInstanceRef.current = null;
}
// Call parent's delete handler
if (onDelete) {
onDelete();
}
};
return (
<div className="timelines-and-video-parent">
<button
className="delete-video-btn"
onClick={handleDelete}
title="Delete this video player"
>
×
</button>
<div className="video-parent">
<video
ref={videoRef}
className="video-js vjs-default-skin"
controls
preload="auto"
data-setup="{}"
/>
</div>
<div className="timelines-parent">
<MultipleTimelinesInterface video_path={video_src} />
</div>
</div>
);
};
export default VideoPlayerTimelineStack;

View File

@@ -0,0 +1,89 @@
.video-dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f8f9fa;
margin: 20px 0;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
}
.video-dropzone:hover {
border-color: #007bff;
background: #f0f7ff;
}
.video-dropzone.drag-over {
border-color: #28a745;
background: #f0fff4;
transform: scale(1.02);
}
.video-dropzone.uploading {
border-color: #ffc107;
background: #fffbf0;
cursor: not-allowed;
}
.upload-prompt {
color: #666;
}
.upload-icon {
font-size: 48px;
margin-bottom: 16px;
}
.upload-prompt p {
margin: 8px 0;
font-size: 16px;
}
.upload-subtitle {
color: #999;
font-size: 14px;
}
.supported-formats {
margin-top: 16px;
font-size: 12px;
color: #999;
font-style: italic;
}
.upload-status {
color: #666;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.explorer-container {
padding: 20px;
}
.multiple-timelines-videos {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@@ -0,0 +1,129 @@
import React, { useState, useRef } from 'react';
import './VideoUploadDropzone.css';
function VideoUploadDropzone({ onVideoUpload }) {
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef(null);
const handleDragOver = (e) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
};
const handleFileSelect = (e) => {
const files = e.target.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
};
const handleFileUpload = async (file) => {
// Validate file type
if (!file.type.startsWith('video/')) {
alert('Please select a valid video file');
return;
}
// Validate file size (e.g., max 100MB)
const maxSize = 100 * 1024 * 1024; // 100MB
if (file.size > maxSize) {
alert('File size too large. Please select a file under 100MB');
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append('video', file);
// Get auth token
const token = localStorage.getItem('access_token');
const response = await fetch('/api/upload-video', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const result = await response.json();
// Call the callback with the uploaded video URL
if (onVideoUpload && result.video_url) {
onVideoUpload(result.video_url);
}
} catch (error) {
console.error('Upload error:', error);
alert('Upload failed. Please try again.');
} finally {
setIsUploading(false);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div
className={`video-dropzone ${isDragOver ? 'drag-over' : ''} ${isUploading ? 'uploading' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept="video/*"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{isUploading ? (
<div className="upload-status">
<div className="spinner"></div>
<p>Uploading video...</p>
</div>
) : (
<div className="upload-prompt">
<div className="upload-icon">📹</div>
<p>Drag and drop a video file here</p>
<p className="upload-subtitle">or click to select</p>
<div className="supported-formats">
Supports: MP4, AVI, MOV, WebM
</div>
</div>
)}
</div>
);
}
export default VideoUploadDropzone;

View File

@@ -2,12 +2,12 @@
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
height: 100%;
color-scheme: light dark ;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
overflow: auto;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;

View File

@@ -1,10 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import Explorer from "./Explorer.jsx"
import {
BrowserRouter as Router,
Route,
Routes,
BrowserRouter,
} from "react-router-dom";
createRoot(document.getElementById('root')).render(
// <StrictMode>
<App />
// </StrictMode>,
)
createRoot(document.getElementById("root")).render(
// <StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/explorer" element={<Explorer />} />
</Routes>
</BrowserRouter>
// </StrictMode>,
);

View File

@@ -0,0 +1,118 @@
class EventBus {
constructor() {
// initialize event list
this.eventObject = {};
// id of the callback function list
this.callbackId = 0;
}
// publish event
publish(eventName, ...args) {
// Get all the callback functions of the current event
if (!this.eventObject[eventName]) {
// Use object storage to improve the efficiency of deletion when logging out the callback function
this.eventObject[eventName] = {};
}
const callbackObject = this.eventObject[eventName];
if (!callbackObject) return console.warn(eventName + " not found!");
// execute each callback function
for (let id in callbackObject) {
// pass parameters when executing
callbackObject[id](...args);
// The callback function that is only subscribed once needs to be deleted
if (id[0] === "d") {
delete callbackObject[id];
}
}
}
// Subscribe to events
subscribe(eventName, callback) {
// initialize this event
if (!this.eventObject[eventName]) {
// Use object storage to improve the efficiency of deletion when logging out the callback function
this.eventObject[eventName] = {};
}
const id = this.callbackId++;
// store the callback function of the subscriber
// callbackId needs to be incremented after use for the next callback function
this.eventObject[eventName][id] = callback;
// Every time you subscribe to an event, a unique unsubscribe function is generated
const unSubscribe = () => {
// clear the callback function of this subscriber
if (!this.eventObject[eventName]) {
console.warn(`Event "${eventName}" no longer exists`);
return;
}
if (!this.eventObject[eventName][id]) {
console.warn(`Callback with id "${id}" not found for event "${eventName}"`);
return;
}
delete this.eventObject[eventName][id];
// If this event has no subscribers, also clear the entire event object
if (Object.keys(this.eventObject[eventName]).length === 0) {
delete this.eventObject[eventName];
}
};
return { unSubscribe };
}
// only subscribe once
subscribeOnce(eventName, callback) {
// initialize this event
if (!this.eventObject[eventName]) {
// Use object storage to improve the efficiency of deletion when logging out the callback function
this.eventObject[eventName] = {};
}
// Callback function marked as subscribe only once
const id = "d" + this.callbackId++;
// store the callback function of the subscriber
// callbackId needs to be incremented after use for the next callback function
this.eventObject[eventName][id] = callback;
// Every time you subscribe to an event, a unique unsubscribe function is generated
const unSubscribe = () => {
// clear the callback function of this subscriber
if (!this.eventObject[eventName]) {
console.warn(`Event "${eventName}" no longer exists`);
return;
}
if (!this.eventObject[eventName][id]) {
console.warn(`Callback with id "${id}" not found for event "${eventName}"`);
return;
}
delete this.eventObject[eventName][id];
// If this event has no subscribers, also clear the entire event object
// if (Object.keys(this.eventObject[eventName]).length === 0) {
// delete this.eventObject[eventName];
// }
};
return { unSubscribe };
}
// clear event
clear(eventName) {
// If no event name is provided, all events are cleared by default
if (!eventName) {
this.eventObject = {};
return;
}
// clear the specified event
delete this.eventObject[eventName];
}
}
const eventBus = new EventBus()
export default eventBus

View File

@@ -2,9 +2,6 @@
"folders": [
{
"path": "."
},
{
"path": "../../../Seafile/Designs/Code/Python/CommonCode"
}
]
}

BIN
SearchScratch/out.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

View File

@@ -0,0 +1,40 @@
from pprint import pprint
import pickle
import os
with open('/home/thebears/Web/Nuggets/SearchInterface/SearchBackend/crap.p','rb') as ff:
cc = pickle.load(ff)
tstamp, folder_scores = cc[1],cc[0]
for i in range(100):
target_tstamp = tstamp + i
matching_file = None
next_file = None
for video_file in folder_scores['videos']:
start_time = video_file['start_time']
end_time = video_file['end_time']
if target_tstamp > start_time and target_tstamp < end_time:
matching_file = video_file
if start_time > target_tstamp and next_file is None:
next_file = video_file
if matching_file is not None:
fname = matching_file['file_name']
offset = target_tstamp - matching_file['start_time']
else:
fname = 'None Found'
offset = -1
if next_file is not None:
fname = next_file['file_name']
offset = 0
web_name = 'media/'+os.path.basename(fname)
ret_val = dict(full_path = fname, path=web_name, timeoffset = offset)
pprint(ret_val)
pprint('-----------------------------------')

View File

@@ -0,0 +1,65 @@
import sys, os
sys.path.append("/home/thebears/Web/Nuggets/SearchInterface/SearchUtil")
sys.path.append("/home/thebears/Web/Nuggets/SearchInterface/VectorService/util")
import embed_scores as ES
import numpy as np
import time
from CommonCode import kwq
# %%
from CommonCode.video_meta import FTPVide
o
video_path = '/home/thebears/temp/dog.mp4'
prompt = 'hello'
video_embeds = ES.get_embeddings_for_a_file(video_path)
prompt_embeds = ES.get_query_vector(prompt)
video_norm_embeds = FTPVideo.vec_norm(video_embeds['embeds'])
prompt_norm_embed = FTPVideo.vec_norm(prompt_embeds)
scores = np.dot(video_norm_embeds, prompt_norm_embed.T).squeeze().tolist()
ff = FTPVideo(file_path, ignore_filename = True)
res = ff.embeddings
results = np.asarray([res['frame_offsets'], scores])
results.T.tolist()
# %%
def get_embed_cache_file_search_path(file_path):
return os.path.splitext(file_path)[0]+'.oclip_embeds.npz'
file_search_path = get_embed_cache_file_search_path(file_path)
force_score = False
llvec = None
if os.path.exists(file_search_path):
llvec = np.load(file_search_path)
frs = llvec['frame_numbers']
if set(np.unique(np.diff(frs))) != {1}:
force_score = True
llvec = None
if not os.path.exists(file_search_path) or force_score:
kwq.publish(kwq.TOPICS.enter_60_videos_embed_priority, file_path, {'push_to_db':False, 'frame_interval':1, 'force_score':force_score})
if llvec is None:
for i in range(120):
print('waiting')
if os.path.exists(file_search_path):
print('Found embedding path!')
llvec = np.load(file_search_path)
break
else:
time.sleep(1)

View File

@@ -2,8 +2,11 @@ import sys, os
sys.path.append("/home/thebears/Web/Nuggets/SearchInterface/SearchUtil")
sys.path.append("/home/thebears/Web/Nuggets/SearchInterface/VectorService/util")
import embed_scores as ES
cd = '/srv/ftp_tcc/leopards1/2025/09/13/'
# %%
#cd = '/srv/ftp_tcc/leopards1/2025/09/25/'
cd = '/mnt/hdd_24tb_1/videos/ftp/leopards1/2025/09/24'
xx = ES.get_vector_representation(cd)
g
o = ES.calculate_embedding_score_in_folder(cd, 0.1, query='Two cats');
# %%
@@ -11,3 +14,15 @@ from CommonCode.video_meta import FTPVideo
f='/srv/ftp_tcc/leopards1/2025/09/13/Leopards1_00_20250913135952.mp4'
c = FTPVideo(f)
c.embeddings
# %%
cd = '/srv/ftp_tcc/leopards1/2025/08/15'
o = ES.calculate_embedding_score_in_folder(cd, 0.1, query='Two cats');
# %%
cd = '/srv/ftp_tcc/leopards1/2025/10/01'
vecreo = ES.get_vector_representation(cd, force_compute = True)
print(vecreo)
# %%
o = ES.calculate_embedding_score_in_folder(cd, 0.1, query='Two cats');
# %%

View File

@@ -1,11 +1,14 @@
from CommonCode.video_meta import FTPVideo
from CommonCode.settings import get_logger
from CommonCode import kwq
import logging
import json
import datetime as dt
import functools
import requests
import numpy as np
import time
from pqdm.processes import pqdm
from multiprocessing import Pool
import os
@@ -15,53 +18,118 @@ import redis
from hashlib import md5
r = redis.Redis(host='localhost', port=6379, db=15)
logger = get_logger(__name__,'/var/log/vector_search_logs/util_embed_scores', stdout=True, systemd=False, level = logging.INFO)
r = redis.Redis(host="localhost", port=6379, db=15)
logger = get_logger(
__name__,
"/var/log/vector_search_logs/util_embed_scores",
stdout=True,
systemd=False,
level=logging.INFO,
)
def get_embed_cache_file_search_path(file_path):
return os.path.splitext(file_path)[0] + ".oclip_embeds.npz"
def get_embeddings_for_a_file(file_path, frame_interval=1):
if not os.path.exists(file_path):
return {"Error": f"No file exists: {file_path}"}
file_search_path = get_embed_cache_file_search_path(file_path)
force_score = False
llvec = None
logger.error(f"GETTING EMBEDDINGS FOR A FILE {file_path}")
if os.path.exists(file_search_path):
llvec = np.load(file_search_path)
frs = llvec["frame_numbers"]
if set(np.unique(np.diff(frs))) != {frame_interval}:
force_score = True
llvec = None
if not os.path.exists(file_search_path) or force_score:
kwq.publish(
kwq.TOPICS.enter_60_videos_embed_priority,
file_path,
{
"push_to_db": False,
"frame_interval": frame_interval,
"force_score": force_score,
},
)
if llvec is None:
for i in range(120):
print("waiting")
if os.path.exists(file_search_path):
print("Found embedding path!")
llvec = np.load(file_search_path)
break
else:
time.sleep(1)
return llvec
def get_matching_file_for_tstamp(target_tstamp, folder_scores):
matching_file = None
for video_file in folder_scores['videos']:
start_time = video_file['start_time']
end_time = video_file['end_time']
for video_file in folder_scores["videos"]:
start_time = video_file["start_time"]
end_time = video_file["end_time"]
if target_tstamp > start_time and target_tstamp < end_time:
matching_file = video_file
if matching_file is not None:
fname = matching_file['file_name']
offset = target_tstamp - matching_file['start_time']
fname = matching_file["file_name"]
offset = target_tstamp - matching_file["start_time"]
else:
fname = 'None Found'
fname = "None Found"
offset = -1
web_name = 'media/'+os.path.basename(fname)
return dict(full_path = fname, path=web_name, timeoffset = offset)
web_name = "media/" + os.path.basename(fname)
return dict(full_path=fname, path=web_name, timeoffset=offset)
def get_vec_rep_file_loc(c_dir):
vec_rep_file = os.path.join(c_dir, 'vec_rep.npz')
vec_rep_file = os.path.join(c_dir, "vec_rep.npz")
return vec_rep_file
def get_vector_representation(c_dir, force_compute = False, redis_key = 'compute_log'):
message = {'task':'VECTOR_CALC_IN_FOLDER_START', 'when': str(c_dir), 'time': dt.datetime.now().timestamp()}
def get_vector_representation(c_dir, force_compute=False, redis_key="compute_log"):
message = {
"task": "VECTOR_CALC_IN_FOLDER_START",
"when": str(c_dir),
"time": dt.datetime.now().timestamp(),
}
r.rpush(redis_key, json.dumps(message))
vec_rep_file = get_vec_rep_file_loc(c_dir)
if os.path.exists(vec_rep_file) and not force_compute:
try:
result = dict(np.load(vec_rep_file))
message = {'task':'VECTOR_CALC_IN_FOLDER_DONE', 'when': str(c_dir), 'time': dt.datetime.now().timestamp(), 'precomputed':True}
result = dict(np.load(vec_rep_file, allow_pickle = True))
if result['embeds'].ndim == 0:
result['embeds'] = result['embeds'].tolist()
message = {
"task": "VECTOR_CALC_IN_FOLDER_DONE",
"when": str(c_dir),
"time": dt.datetime.now().timestamp(),
"precomputed": True,
}
r.rpush(redis_key, json.dumps(message))
return result
except:
os.remove(vec_rep_file)
ff = list()
for root, dirs, files in os.walk(c_dir):
for f in files:
if f.endswith('.mp4') and '_reduced' not in f:
if f.endswith(".mp4") and "_reduced" not in f:
ff.append(os.path.join(root, f))
videos = list()
@@ -76,165 +144,265 @@ def get_vector_representation(c_dir, force_compute = False, redis_key = 'compute
all_source = list()
all_tstamps = list()
enu = 0
ts_e = 0
id_e = 0
for idx, x in enumerate(sorted_videos):
try:
hh = x.embeddings
except Exception as e:
hh = None
if hh is not None:
n_emb = FTPVideo.vec_norm(hh['embeds'])
all_cat.append(n_emb)
all_idx.append( enu * np.ones(n_emb.shape[0], dtype=np.int64) )
all_source.append(x.real_path)
all_tstamps.append( [x.timestamp() for x in hh['frame_time']])
enu +=1
enu += 1
if hh.get('embeds',None) is not None:
n_emb = FTPVideo.vec_norm(hh["embeds"])
all_cat.append(n_emb)
ts_e+= n_emb.shape[-2]
arr_app = (enu-1) * np.ones(n_emb.shape[-2], dtype=np.int64)
all_idx.append(arr_app)
id_e+= len(arr_app)
all_tstamps.append([x.timestamp() for x in hh["frame_time"]])
message = {'task':'VECTOR_CALC_IN_FOLDER_BUMP', 'when': c_dir, 'progress': idx+1, 'how_many': len(sorted_videos), 'time': dt.datetime.now().timestamp()}
message = {
"task": "VECTOR_CALC_IN_FOLDER_BUMP",
"when": c_dir,
"progress": idx + 1,
"how_many": len(sorted_videos),
"time": dt.datetime.now().timestamp(),
}
r.rpush(redis_key, json.dumps(message))
if len(all_cat) == 0:
return []
all_embeds = np.vstack(all_cat)
all_embeds = FTPVideo.vec_norm(all_embeds)
return None
# all_embeds = np.vstack(all_cat)
all_embeds = {idx:x for idx,x in enumerate(all_cat)}
all_idces = np.hstack(all_idx)
all_times = np.hstack(all_tstamps)
np.savez(vec_rep_file, embeds = all_embeds, idces= all_idces, timestamps = all_times, source_files = all_source)
message = {'task':'VECTOR_CALC_IN_FOLDER_DONE', 'when': str(c_dir), 'time': dt.datetime.now().timestamp()}
np.savez(
vec_rep_file,
embeds=all_embeds,
idces=all_idces,
timestamps=all_times,
source_files=all_source,
)
message = {
"task": "VECTOR_CALC_IN_FOLDER_DONE",
"when": str(c_dir),
"time": dt.datetime.now().timestamp(),
}
r.rpush(redis_key, json.dumps(message))
return dict( embeds = all_embeds, idces= all_idces, timestamps = all_times, source_files = all_source)
return dict(
embeds=all_embeds,
idces=all_idces,
timestamps=all_times,
source_files=all_source,
)
def get_scores_embedding_c_dir(c_dir, query_vector, redis_key = 'compute_log'):
def get_scores_embedding_c_dir(c_dir, query_vector, redis_key="compute_log"):
query_scores = None
vec_rep = get_vector_representation(c_dir, redis_key=redis_key)
query_scores = (query_vector @ vec_rep['embeds'].T).squeeze()
if isinstance(vec_rep['embeds'], dict):
vec_rep['embeds'] = [y for x,y in vec_rep['embeds'].items()]
if vec_rep is None:
return None, None
if isinstance(vec_rep['embeds'], list):
q_scores = list()
for emb in vec_rep['embeds']:
d = len(query_vector)
similarity = (query_vector @ emb.reshape(-1, d).T).reshape(emb.shape[:-1])
if similarity.ndim == 2:
scores = np.max(similarity,axis=0)
else:
scores = similarity
q_scores.append(scores)
query_scores = np.hstack(q_scores)
else:
query_scores = (query_vector @ vec_rep["embeds"].T).squeeze()
return vec_rep, query_scores
@functools.lru_cache
def get_clip_scores(video_path: str, prompt: str):
if not os.path.exists(get_embed_cache_file_search_path(video_path)):
get_embeddings_for_a_file(video_path)
ff = FTPVideo(video_path, ignore_filename=True)
res = ff.embeddings
prompt_embeds = get_query_vector(prompt)
video_norm_embeds = FTPVideo.vec_norm(res["embeds"])
prompt_norm_embed = FTPVideo.vec_norm(prompt_embeds)
scores = ( prompt_norm_embed @ video_norm_embeds.T).squeeze().tolist()
results = np.asarray([res["frame_offsets"], scores])
return results
@functools.lru_cache
def get_query_vector(query):
vec_form = requests.get('http://192.168.1.242:53004/encode',params={'query':query}).json()['vector'][0]
vec_form = requests.get(
"http://192.168.1.242:53004/encode", params={"query": query}
).json()["vector"][0]
vec_search = np.asarray(vec_form)
query_vector = FTPVideo.vec_norm(vec_search[None,:])
query_vector = FTPVideo.vec_norm(vec_search[None, :])
return query_vector
def calculate_embedding_score_in_folders(c_dirs, threshold, query = None, query_vector = None, redis_key = 'compute_log'):
def calculate_embedding_score_in_folders(
c_dirs, threshold, query=None, query_vector=None, redis_key="compute_log"
):
result_list = list()
query_vector = None
if query_vector is None:
query_vector = get_query_vector(query)
# kwargs = [{'c_dir':x, 'threshold':threshold, 'query': query} for x in c_dirs]
# kwargs = [{'c_dir':x, 'threshold':threshold, 'query': query} for x in c_dirs]
args = [(x, threshold, query, None, logger, redis_key) for x in c_dirs]
# logger.info(f"CALCULATING FOR {args}")
with Pool(processes=8) as pool:
out = pool.starmap(calculate_embedding_score_in_folder, args)
# logger.info(f"DONE CALCULATING FOR {args}")
cache_files = list();
for x, cache_file_loc in out:
try:
result_list.extend(x['videos'])
cache_files.append(cache_file_loc);
except Exception as e:
print(e, x)
cache_files = list()
out = [x for x in out if x is not None]
return {'videos':result_list, 'cache_file_locs': cache_files}
for result in out:
try:
x, cache_file_loc = result
result_list.extend(x["videos"])
cache_files.append(cache_file_loc)
except Exception as e:
print(e)
return {"videos": result_list, "cache_file_locs": cache_files}
def collapse_scores_to_maxmin_avg(folder_scores):
result = list()
for c_data in folder_scores['videos']:
for c_data in folder_scores["videos"]:
new_d = c_data.copy()
scores = new_d['embed_scores']['score']
scores = new_d["embed_scores"]["score"]
max_score = max(scores)
min_score = min(scores)
max_score_idx = scores.index(max_score)
min_score_idx = scores.index(min_score)
max_score_time = new_d['embed_scores']['time'][max_score_idx]
min_score_time = new_d['embed_scores']['time'][min_score_idx]
new_d['embed_scores']['score'] = [min_score, max_score, max_score_time, min_score_time]
new_d['embed_scores']['time'] = max(new_d['embed_scores']['time'])
max_score_time = new_d["embed_scores"]["time"][max_score_idx]
min_score_time = new_d["embed_scores"]["time"][min_score_idx]
new_d["embed_scores"]["score"] = [
min_score,
max_score,
max_score_time,
min_score_time,
]
new_d["embed_scores"]["time"] = max(new_d["embed_scores"]["time"])
result.append(new_d)
return result
# c_data = {'file_name': str(s_file), 'start_time':start_time, 'end_time':end_time, 'embed_scores':{'time':frame_time, 'score':embed_scores}}
# video_json_info.append(c_data)
# to_write = {'source_files': vec_rep['source_files'], 'videos': video_json_info}
# with open(cache_file_loc, 'wb') as f:
# logger.info(f"WRITING EMBEDDING SCORE TO CACHE {cache_file_loc}")
# pickle.dump(to_write, f)
# pickle.dump(to_write, f)
def calculate_embedding_score_in_folder(og_dir, threshold, query = None, query_vector = None, logger = logger, redis_key = 'compute_log'):
message = {'task':'SCORE_CALC_IN_FOLDER_START', 'when': str(og_dir), 'time': dt.datetime.now().timestamp()}
r.rpush(redis_key, json.dumps(message))
def calculate_embedding_score_in_folder(
og_dir,
threshold,
query=None,
query_vector=None,
logger=logger,
redis_key="compute_log",
):
message = {
"task": "SCORE_CALC_IN_FOLDER_START",
"when": str(og_dir),
"time": dt.datetime.now().timestamp(),
}
r.rpush(redis_key, json.dumps(message))
if query_vector is None:
query_vector = get_query_vector(query)
candidate_dirs = list()
candidate_dirs.append(og_dir)
candidate_dirs.append(og_dir.replace('/srv/ftp_tcc','/mnt/hdd_24tb_1/videos/ftp'))
candidate_dirs.append(og_dir.replace('/srv/ftp','/mnt/hdd_24tb_1/videos/ftp'))
candidate_dirs.append(og_dir.replace("/srv/ftp_tcc", "/mnt/hdd_24tb_1/videos/ftp"))
candidate_dirs.append(og_dir.replace("/srv/ftp", "/mnt/hdd_24tb_1/videos/ftp"))
c_dir = None
for candidate in candidate_dirs:
if os.path.exists(candidate):
c_dir = candidate
break
if len([x for x in os.listdir(candidate) if x.endswith(".mp4")]) > 5:
c_dir = candidate
break
if c_dir is None:
return []
vec_cache_str = md5(query_vector).hexdigest()
cache_file_loc = os.path.join(c_dir, 'embedding_scores@'+str(threshold)+'@'+vec_cache_str+'.pkl')
cache_file_loc = os.path.join(
c_dir, "embedding_scores@" + str(threshold) + "@" + vec_cache_str + ".pkl"
)
if os.path.exists(cache_file_loc):
logger.info(f"TRYING TO LOAD CACHE {cache_file_loc}")
try:
with open(cache_file_loc, 'rb') as f:
with open(cache_file_loc, "rb") as f:
video_json_info = pickle.load(f)
files_in_cache = {os.path.splitext(os.path.basename(x))[0] for x in video_json_info.get('source_files',[])}
files_in_cache = {
os.path.splitext(os.path.basename(x))[0]
for x in video_json_info.get("source_files", [])
}
lsd_dir = os.listdir(c_dir)
files_on_disk = {x.split(".")[0] for x in lsd_dir if x.endswith('oclip_embeds.npz')}
files_on_disk = {
x.split(".")[0] for x in lsd_dir if x.endswith("oclip_embeds.npz")
}
if files_on_disk == files_in_cache:
logger.info(f"LOADED EMBEDDING SCORE FROM CACHE {cache_file_loc}")
message = {'task':'SCORE_CALC_IN_FOLDER_DONE', 'when': str(c_dir), 'time': dt.datetime.now().timestamp(), 'precomputed': True}
r.rpush(redis_key, json.dumps(message))
message = {
"task": "SCORE_CALC_IN_FOLDER_DONE",
"when": str(c_dir),
"time": dt.datetime.now().timestamp(),
"precomputed": True,
}
r.rpush(redis_key, json.dumps(message))
return (video_json_info, cache_file_loc)
else:
logger.info(f"CACHE FILE IS OLD, DELETING VEC REP FILE AND RECREATING {cache_file_loc}")
os.remove( get_vec_rep_file_loc(c_dir))
logger.info(
f"CACHE FILE IS OLD, DELETING VEC REP FILE AND RECREATING {cache_file_loc}"
)
os.remove(get_vec_rep_file_loc(c_dir))
except Exception as e:
logger.info(f"CACHE FILE IS CORRUPT, RECREATING {cache_file_loc} {e}")
os.remove(cache_file_loc)
pass
# vec_rep = get_vector_representation(c_dir, redis_key = redis_key)
vec_rep, query_scores = get_scores_embedding_c_dir(c_dir, tuple(query_vector.tolist()[0]), redis_key = redis_key)
vec_rep, query_scores = get_scores_embedding_c_dir(
c_dir, tuple(query_vector.tolist()[0]), redis_key=redis_key
)
if vec_rep is None:
return
video_json_info = list()
idces_keep = np.where(query_scores > threshold)[0]
video_id = vec_rep['idces'][idces_keep]
video_id = vec_rep["idces"][idces_keep]
videos_that_match = np.unique(video_id)
# subset_timestampsF = list()
@@ -244,67 +412,82 @@ def calculate_embedding_score_in_folder(og_dir, threshold, query = None, query_v
# max_idces = idces_entry[-1]
# subset_timestampsF.append( [ vec_rep['timestamps'][min_idces], vec_rep['timestamps'][max_idces]])
id_extract_video_level = np.where(np.isin(vec_rep['idces'], videos_that_match))[0]
idces_split = np.where(np.diff(vec_rep['idces'][id_extract_video_level]) !=0)[0] + 1
subset_timestampsF = np.split(vec_rep['timestamps'][id_extract_video_level], idces_split)
id_extract_video_level = np.where(np.isin(vec_rep["idces"], videos_that_match))[0]
idces_split = (
np.where(np.diff(vec_rep["idces"][id_extract_video_level]) != 0)[0] + 1
)
subset_timestampsF = np.split(
vec_rep["timestamps"][id_extract_video_level], idces_split
)
for subset_t in subset_timestampsF:
if len(subset_t) == 0:
continue
min_t = min(subset_t)
max_t = max(subset_t)
idces_curr = np.where(np.logical_and(vec_rep['timestamps'] > min_t , vec_rep['timestamps'] < max_t))[0]
idces_curr = np.where(
np.logical_and(vec_rep["timestamps"] > min_t, vec_rep["timestamps"] < max_t)
)[0]
if len(idces_curr) == 0:
continue
unq_vids = np.unique(vec_rep['idces'][idces_curr])
subset_idx = np.where(np.isin(vec_rep['idces'],unq_vids))[0]
subset_idces = vec_rep['idces'][subset_idx]
subset_timestamps = vec_rep['timestamps'][subset_idx]
unq_vids = np.unique(vec_rep["idces"][idces_curr])
subset_idx = np.where(np.isin(vec_rep["idces"], unq_vids))[0]
subset_idces = vec_rep["idces"][subset_idx]
subset_timestamps = vec_rep["timestamps"][subset_idx]
subset_scores = query_scores[subset_idx]
idx_split = np.where(np.diff(vec_rep['idces'][subset_idx]) !=0)[0]+1
idx_split = np.where(np.diff(vec_rep["idces"][subset_idx]) != 0)[0] + 1
split_idces = np.split(subset_idces, idx_split)
split_timestamps = np.split(subset_timestamps, idx_split)
split_scores = np.split(subset_scores, idx_split)
split_files = [vec_rep['source_files'][x[0]] for x in split_idces]
for s_file, s_scores, s_tstamps, s_idces in zip(split_files, split_scores, split_timestamps, split_idces):
split_files = [vec_rep["source_files"][x[0]] for x in split_idces]
for s_file, s_scores, s_tstamps, s_idces in zip(
split_files, split_scores, split_timestamps, split_idces
):
start_time = float(min(s_tstamps))
end_time = float(max(s_tstamps))
frame_time = (s_tstamps - start_time).tolist()
embed_scores = s_scores.tolist()
c_data = {'file_name': str(s_file), 'start_time':start_time, 'end_time':end_time, 'embed_scores':{'time':frame_time, 'score':embed_scores}}
c_data = {
"file_name": str(s_file),
"start_time": start_time,
"end_time": end_time,
"embed_scores": {"time": frame_time, "score": embed_scores},
}
video_json_info.append(c_data)
message = {'task':'SCORE_CALC_IN_FOLDER_DONE', 'when': str(c_dir), 'time': dt.datetime.now().timestamp()}
r.rpush(redis_key, json.dumps(message))
to_write = {'source_files': vec_rep['source_files'], 'videos': video_json_info}
with open(cache_file_loc, 'wb') as f:
message = {
"task": "SCORE_CALC_IN_FOLDER_DONE",
"when": str(c_dir),
"time": dt.datetime.now().timestamp(),
}
r.rpush(redis_key, json.dumps(message))
to_write = {"source_files": vec_rep["source_files"], "videos": video_json_info}
with open(cache_file_loc, "wb") as f:
logger.info(f"WRITING EMBEDDING SCORE TO CACHE {cache_file_loc}")
pickle.dump(to_write, f)
pickle.dump(to_write, f)
logger.info(f"SAVED EMBEDDING SCORE TO CACHE {cache_file_loc}")
return (to_write, cache_file_loc)
def get_matching_file_given_filename(web_name, folder_scores):
file_name = None
for x in folder_scores['videos']:
if x['file_name'].endswith(web_name):
file_name = x['file_name']
for x in folder_scores["videos"]:
if x["file_name"].endswith(web_name):
file_name = x["file_name"]
candidate_files = list()
candidate_files.append(file_name)
candidate_files.append(file_name.replace('/srv/ftp_tcc','/mnt/hdd_24tb_1/videos/ftp'))
candidate_files.append(file_name.replace('/srv/ftp','/mnt/hdd_24tb_1/videos/ftp'))
candidate_files.append(
file_name.replace("/srv/ftp_tcc", "/mnt/hdd_24tb_1/videos/ftp")
)
candidate_files.append(file_name.replace("/srv/ftp", "/mnt/hdd_24tb_1/videos/ftp"))
file_name = None
for candidate in candidate_files:
@@ -312,30 +495,27 @@ def get_matching_file_given_filename(web_name, folder_scores):
file_name = candidate
break
return file_name
# c_dirs = ['/mnt/hdd_24tb_1/videos/ftp/leopards2/2025/08/26','/srv/ftp_tcc/leopards1/2025/08/27','/srv/ftp_tcc/leopards1/2025/08/28','/srv/ftp_tcc/leopards1/2025/08/29']
# op = calculate_embedding_score_in_folders( tuple(c_dirs), 0.10, query = 'A cat and human')
#c_dirs = ['/mnt/hdd_24tb_1/videos/ftp/leopards2/2025/08/26','/srv/ftp_tcc/leopards1/2025/08/27','/srv/ftp_tcc/leopards1/2025/08/28','/srv/ftp_tcc/leopards1/2025/08/29']
#op = calculate_embedding_score_in_folders( tuple(c_dirs), 0.10, query = 'A cat and human')
def add_breaks_between_videos(op, threshold_to_split_seconds = 30*60): # 30 minutes):
def add_breaks_between_videos(op, threshold_to_split_seconds=30 * 60): # 30 minutes):
ranges = list()
for vids in op['videos']:
ranges.append( (vids['start_time'], vids['end_time']) )
for vids in op["videos"]:
ranges.append((vids["start_time"], vids["end_time"]))
breaks = list()
for idx in range(len(ranges)-1):
for idx in range(len(ranges) - 1):
current_range = ranges[idx]
next_range = ranges[idx+1]
next_range = ranges[idx + 1]
end_now = current_range[1]
start_next = next_range[0]
if (start_next - end_now) > threshold_to_split_seconds:
breaks.append((end_now, start_next))
return breaks

View File

@@ -0,0 +1,27 @@
from util import embed_scores as ES
query_vector = ES.get_query_vector('A cat grooming their tail')
c_dir = '/srv/ftp_tcc/leopards1/2026/02/28'
vpath = '/srv/ftp_tcc/leopards2/2026/02/28/Leopards2_00_20260228210054.mp4'
from CommonCode.video_meta import FTPVideo
#vec_rep = ES.get_vector_representation('/srv/ftp_tcc/leopards2/2026/02/28')
#if isinstance(vec_rep['embeds'], dict):
# vec_rep['embeds'] = [y for x,y in vec_rep['embeds'].items()]
# %%
vec_rep = ES.get_vector_representation(c_dir)
vec_rep['idces'].shape
sum([x.shape[-2] for _,x in vec_rep['embeds'].items()])
# %%
#ou = ES.calculate_embedding_score_in_folder(
ou = ES.calculate_embedding_score_in_folder(c_dir, threshold=0.04, query = 'A cat grooming')
# %%
vec_rep, query_scores = ES.get_scores_embedding_c_dir(c_dir, query_vector.squeeze())
# results = ES.get_clip_scores(video_path, prompt)
# %%
import pickle
with open('/home/thebears/crap.p','rb') as ff:
ou = pickle.load(ff)

View File

@@ -1,11 +1,25 @@
import uvicorn.protocols.http.httptools_impl
# orig_data_received = uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.data_received
# def debug_data_received(self, data):
# try:
# print("RAW INVALID DATA RECEIVED: %r", data)
# except Exception:
# pass
# return orig_data_received(self, data)
# uvicorn.protocols.http.httptools_impl.HttpToolsProtocol.data_received = debug_data_received
from typing import Union, Optional, List
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
from fastapi import FastAPI, Request, Depends
from CommonCode.settings import get_logger
from CommonCode.video_meta import FTPVideo
import logging
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
import os
import numpy as np
from fastapi import FastAPI, Request, status
import sys
import json
import time
@@ -20,15 +34,54 @@ session_manager = SessionManager(
interface=RedisSessionInterface(redis.from_url("redis://localhost"))
)
logger = get_logger(__name__,'/var/log/vector_search_logs/main_embed_scores', stdout=True, systemd=False, level = logging.INFO)
r = redis.Redis(host='localhost', port=6379, db=15)
logger = get_logger(
__name__,
"/var/log/vector_search_logs/main_embed_scores",
stdout=True,
systemd=False,
level=logging.INFO,
)
r = redis.Redis(host="localhost", port=6379, db=15)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
exc_str = f"{exc}".replace("\n", " ").replace(" ", " ")
logging.error(f"{request}: {exc_str}")
content = {"status_code": 10422, "message": exc_str, "data": None}
return JSONResponse(
content=content, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
)
@app.get("/video_embeddings")
def get_video_embeddings(video_path: str):
llvec = ES.get_embeddings_for_a_file(video_path)
if llvec is not None:
llvec = dict(llvec)
llvec["embeds"] = llvec["embeds"].tolist()
llvec["frame_numbers"] = llvec["frame_numbers"].tolist()
return llvec
@app.get("/prompt_embedding")
def get_prompt_embedding(prompt: str):
return ES.get_query_vector(prompt)
@app.get("/match_scores")
def get_clip_scores(video_path: str, prompt: str):
results = ES.get_clip_scores(video_path, prompt)
return results.T.tolist()
class VideosPostRequest(BaseModel):
query: str = "A cat and a human"
threshold: float = 0.10
c_dirs: Optional[List[str]] = None
task_id: str = 'compute_log'
task_id: str = "compute_log"
@app.post("/videos.json")
async def videos_json(
@@ -58,30 +111,27 @@ async def videos_json(
"/srv/ftp_tcc/leopards1/2025/09/08",
"/srv/ftp_tcc/leopards1/2025/09/09",
"/srv/ftp_tcc/leopards1/2025/09/10",
"/srv/ftp_tcc/leopards1/2025/09/11",
"/srv/ftp_tcc/leopards1/2025/09/11",
]
# print(','.join([str(x) for x in c_dirs]))
# message = {'task':'SCHEDULED','when':[str(x) for x in c_dirs], 'time':time.time()}
# r.rpush(task_id, json.dumps(message))?
for x in c_dirs:
message = {'task':'QUEUEING', 'when': str(x), 'time': time.time()}
message = {"task": "QUEUEING", "when": str(x), "time": time.time()}
r.rpush(task_id, json.dumps(message))
folder_scores = ES.calculate_embedding_score_in_folders(
tuple(c_dirs), threshold=threshold, query=query, redis_key = task_id)
tuple(c_dirs), threshold=threshold, query=query, redis_key=task_id
)
# if p_hits != ES.calculate_embedding_score_in_folders.cache_info().hits:
# logger.info("FROM CACHE")
# else:pp
# logger.info("COMPUTED FROM SCRATCH")
folder_scores["breaks"] = ES.add_breaks_between_videos(folder_scores)
folder_scores['videos'] = ES.collapse_scores_to_maxmin_avg(folder_scores)
folder_scores["videos"] = ES.collapse_scores_to_maxmin_avg(folder_scores)
return folder_scores