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