YACWC
This commit is contained in:
75
SearchFrontend/search_ui/package-lock.json
generated
75
SearchFrontend/search_ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
31
SearchFrontend/search_ui/src/Explorer.css
Normal file
31
SearchFrontend/search_ui/src/Explorer.css
Normal 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;
|
||||
}
|
||||
80
SearchFrontend/search_ui/src/Explorer.jsx
Normal file
80
SearchFrontend/search_ui/src/Explorer.jsx
Normal 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;
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
76
SearchFrontend/search_ui/src/components/Login.css
Normal file
76
SearchFrontend/search_ui/src/components/Login.css
Normal 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;
|
||||
}
|
||||
70
SearchFrontend/search_ui/src/components/Login.jsx
Normal file
70
SearchFrontend/search_ui/src/components/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
36
SearchFrontend/search_ui/src/components/TimelineStacked.css
Normal file
36
SearchFrontend/search_ui/src/components/TimelineStacked.css
Normal 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);
|
||||
}
|
||||
152
SearchFrontend/search_ui/src/components/TimelineStacked.jsx
Normal file
152
SearchFrontend/search_ui/src/components/TimelineStacked.jsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
import videojs from "video.js";
|
||||
|
||||
|
||||
|
||||
function VideoPlayerMultiple()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export default VideoPlayerMultiple
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
129
SearchFrontend/search_ui/src/components/VideoUploadDropzone.jsx
Normal file
129
SearchFrontend/search_ui/src/components/VideoUploadDropzone.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
118
SearchFrontend/search_ui/src/util/EventBus.jsx
Normal file
118
SearchFrontend/search_ui/src/util/EventBus.jsx
Normal 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
|
||||
Reference in New Issue
Block a user