Skip to content

Commit fc0d66d

Browse files
#757 Preliminary Video Support (#758)
* #757 Viewer Panel mp4, webm and gif support * #757 Add Video Layer Type * #757 Polishing
1 parent c67d3a8 commit fc0d66d

File tree

27 files changed

+1346
-30
lines changed

27 files changed

+1346
-30
lines changed

API/Backend/Config/validate.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ const validateLayers = (config) => {
9595
// Check zooms
9696
errs = errs.concat(isValidZooms(layer));
9797
break;
98+
case "video":
99+
// Check url
100+
errs = errs.concat(isValidUrl(layer));
101+
// Check bounding box
102+
errs = errs.concat(isValidBoundingBox(layer));
103+
break;
98104
default:
99105
errs = errs.concat(
100106
err(`Unknown layer type: '${layer.type}'`, ["layers[layer].type"])
@@ -279,6 +285,62 @@ const isValidModelParams = (layer) => {
279285
return errs;
280286
};
281287

288+
const isValidBoundingBox = (layer) => {
289+
const errs = [];
290+
291+
if (!layer.boundingBox) {
292+
errs.push(
293+
err(`Layer '${layer.name}' is missing Bounding Box.`, [
294+
"layers[layer].boundingBox",
295+
])
296+
);
297+
} else if (!Array.isArray(layer.boundingBox)) {
298+
errs.push(
299+
err(`Layer '${layer.name}' has invalid Bounding Box format.`, [
300+
"layers[layer].boundingBox",
301+
])
302+
);
303+
} else if (layer.boundingBox.length !== 4) {
304+
errs.push(
305+
err(`Layer '${layer.name}' has invalid Bounding Box length. Expected 4 values (minx,miny,maxx,maxy).`, [
306+
"layers[layer].boundingBox",
307+
])
308+
);
309+
} else {
310+
// Validate each coordinate
311+
if (isNaN(parseFloat(layer.boundingBox[0]))) {
312+
errs.push(
313+
err(`Layer '${layer.name}' has invalid Bounding Box minx value.`, [
314+
"layers[layer].boundingBox",
315+
])
316+
);
317+
}
318+
if (isNaN(parseFloat(layer.boundingBox[1]))) {
319+
errs.push(
320+
err(`Layer '${layer.name}' has invalid Bounding Box miny value.`, [
321+
"layers[layer].boundingBox",
322+
])
323+
);
324+
}
325+
if (isNaN(parseFloat(layer.boundingBox[2]))) {
326+
errs.push(
327+
err(`Layer '${layer.name}' has invalid Bounding Box maxx value.`, [
328+
"layers[layer].boundingBox",
329+
])
330+
);
331+
}
332+
if (isNaN(parseFloat(layer.boundingBox[3]))) {
333+
errs.push(
334+
err(`Layer '${layer.name}' has invalid Bounding Box maxy value.`, [
335+
"layers[layer].boundingBox",
336+
])
337+
);
338+
}
339+
}
340+
341+
return errs;
342+
};
343+
282344
const hasNonHeaderWithSublayers = (config) => {
283345
const errs = [];
284346
Utils.traverseLayers(config.layers, (layer) => {
@@ -328,6 +390,10 @@ const fillInMissingFieldsWithDefaults = (layer) => {
328390
break;
329391
case "model":
330392
break;
393+
case "video":
394+
layer.style = layer.style || {};
395+
layer.style.className = layer.name.replace(/ /g, "").toLowerCase();
396+
break;
331397
default:
332398
}
333399
};

configure/src/components/Tabs/Layers/Layers.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import LanguageIcon from "@mui/icons-material/Language"; // Tile
2929
import GridViewIcon from "@mui/icons-material/GridView"; // Vector tile
3030
import ViewInArIcon from "@mui/icons-material/ViewInAr"; // Model
3131
import AirIcon from "@mui/icons-material/Air"; // Velocity
32-
import ImageIcon from '@mui/icons-material/Image'; // Image
32+
import ImageIcon from "@mui/icons-material/Image"; // Image
33+
import VideoFileIcon from "@mui/icons-material/VideoFile"; // Video
3334
import AddIcon from "@mui/icons-material/Add";
3435

3536
import VisibilityIcon from "@mui/icons-material/Visibility";
@@ -392,6 +393,10 @@ export default function Layers() {
392393
iconType = <ImageIcon fontSize="small" />;
393394
color = "#b0518f";
394395
break;
396+
case "video":
397+
iconType = <VideoFileIcon fontSize="small" />;
398+
color = "#7b2323";
399+
break;
395400
default:
396401
}
397402

configure/src/components/Tabs/Layers/Modals/LayerModal/LayerModal.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import vectorConfig from "../../../../../metaconfigs/layer-vector-config.json";
4646
import vectortileConfig from "../../../../../metaconfigs/layer-vectortile-config.json";
4747
import velocityConfig from "../../../../../metaconfigs/layer-velocity-config.json";
4848
import imageConfig from "../../../../../metaconfigs/layer-image-config.json";
49+
import videoConfig from "../../../../../metaconfigs/layer-video-config.json";
4950

5051
const useStyles = makeStyles((theme) => ({
5152
Modal: {
@@ -201,6 +202,10 @@ const LayerModal = (props) => {
201202
config = imageConfig;
202203
break;
203204

205+
case "video":
206+
config = videoConfig;
207+
break;
208+
204209
default:
205210
break;
206211
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React, { useEffect, useRef } from "react";
2+
import { makeStyles } from "@mui/styles";
3+
import { publicUrlMainSite } from "../../core/constants";
4+
import { isUrlAbsolute } from "../../core/utils";
5+
6+
const useStyles = makeStyles((theme) => ({
7+
VideoPreview: {
8+
width: "100%",
9+
height: "100%",
10+
background: theme.palette.swatches.grey[100],
11+
position: "relative",
12+
display: "flex",
13+
alignItems: "center",
14+
justifyContent: "center",
15+
border: `1px solid ${theme.palette.swatches.grey[300]}`,
16+
borderRadius: "4px",
17+
},
18+
video: {
19+
width: "100%",
20+
height: "100%",
21+
objectFit: "contain",
22+
background: "#000",
23+
},
24+
placeholder: {
25+
color: theme.palette.swatches.grey[400],
26+
fontSize: "14px",
27+
textAlign: "center",
28+
padding: "20px",
29+
},
30+
error: {
31+
color: theme.palette.swatches.red[400],
32+
fontSize: "14px",
33+
textAlign: "center",
34+
padding: "20px",
35+
},
36+
}));
37+
38+
const VideoPreview = ({ layer, configuration }) => {
39+
const videoRef = useRef(null);
40+
const c = useStyles();
41+
42+
// Handle relative URLs by prepending the public URL and mission path
43+
const getFullVideoUrl = (url) => {
44+
if (!url) return null;
45+
46+
if (isUrlAbsolute(url)) {
47+
return url;
48+
}
49+
50+
// For relative URLs, prepend publicUrlMainSite and mission path
51+
const missionPath = `Missions/${configuration?.msv?.mission || ""}`;
52+
return `${publicUrlMainSite}/${missionPath}/${url}`;
53+
};
54+
55+
const fullVideoUrl = getFullVideoUrl(layer?.url);
56+
57+
useEffect(() => {
58+
if (videoRef.current && fullVideoUrl) {
59+
// Reset video when URL changes
60+
videoRef.current.load();
61+
}
62+
}, [fullVideoUrl]);
63+
64+
const handleVideoLoaded = () => {
65+
if (videoRef.current) {
66+
// Ensure video is muted and paused for preview
67+
videoRef.current.muted = true;
68+
videoRef.current.pause();
69+
}
70+
};
71+
72+
if (!fullVideoUrl) {
73+
return (
74+
<div className={c.VideoPreview}>
75+
<div className={c.placeholder}>Enter a video URL to see preview</div>
76+
</div>
77+
);
78+
}
79+
80+
return (
81+
<div className={c.VideoPreview}>
82+
<video
83+
ref={videoRef}
84+
className={c.video}
85+
src={fullVideoUrl}
86+
muted
87+
onLoadedData={handleVideoLoaded}
88+
controls={true}
89+
>
90+
<div className={c.error}>
91+
Video format not supported or failed to load
92+
</div>
93+
</video>
94+
</div>
95+
);
96+
};
97+
98+
export default VideoPreview;

configure/src/core/Maker.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
import { isFieldRequired } from "./validators";
4444

4545
import Map from "../components/Map/Map";
46+
import VideoPreview from "../components/VideoPreview/VideoPreview";
4647
import ColorButton from "../components/ColorButton/ColorButton";
4748
import MDEditor from "@uiw/react-md-editor";
4849
import CodeMirror from "@uiw/react-codemirror";
@@ -690,13 +691,23 @@ const getComponent = (
690691
let text_array_f = getIn(directConf, com.field, []);
691692
if (text_array_f != null && typeof text_array_f.join === "function")
692693
text_array_f = text_array_f.join(",");
694+
695+
// Check if field is required and has validation error
696+
const textArrayHasError = isRequired && (text_array_f === "" || text_array_f == null || (Array.isArray(text_array_f) && text_array_f.length === 0));
697+
693698
inner = (
694699
<TextField
695700
className={c.text}
696701
label={com.name}
697702
variant="filled"
698703
size="small"
699704
disabled={disabled}
705+
required={isRequired}
706+
error={textArrayHasError}
707+
helperText={textArrayHasError ? "This field is required" : ""}
708+
FormHelperTextProps={{
709+
className: c.noMarginHelperText
710+
}}
700711
inputProps={{
701712
autoComplete: "off",
702713
}}
@@ -1442,6 +1453,12 @@ const getComponent = (
14421453
<Map layer={layer} configuration={configuration} />
14431454
</div>
14441455
);
1456+
case "videopreview":
1457+
return (
1458+
<div className={c.map} style={{ height: com.height || "200px" }}>
1459+
<VideoPreview layer={layer} configuration={configuration} />
1460+
</div>
1461+
);
14451462
default:
14461463
return null;
14471464
}

configure/src/core/validators.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,33 @@ export const validateLayer = (layer) => {
147147
}
148148
break;
149149

150+
case "video":
151+
if (!layer.url || layer.url === "" || layer.url === "undefined") {
152+
errors.push({ field: "url", message: "URL is required" });
153+
}
154+
if (!layer.boundingBox || !Array.isArray(layer.boundingBox) || layer.boundingBox.length !== 4) {
155+
errors.push({ field: "boundingBox", message: "Bounding Box is required (minx,miny,maxx,maxy)" });
156+
} else {
157+
// Validate bounding box coordinates are numbers
158+
if (isNaN(parseFloat(layer.boundingBox[0]))) {
159+
errors.push({ field: "boundingBox", message: "Bounding Box minx must be a number" });
160+
}
161+
if (isNaN(parseFloat(layer.boundingBox[1]))) {
162+
errors.push({ field: "boundingBox", message: "Bounding Box miny must be a number" });
163+
}
164+
if (isNaN(parseFloat(layer.boundingBox[2]))) {
165+
errors.push({ field: "boundingBox", message: "Bounding Box maxx must be a number" });
166+
}
167+
if (isNaN(parseFloat(layer.boundingBox[3]))) {
168+
errors.push({ field: "boundingBox", message: "Bounding Box maxy must be a number" });
169+
}
170+
}
171+
break;
172+
150173
case "header":
151174
// No additional required fields for header
152175
break;
153-
176+
154177
default:
155178
if (layer.type) {
156179
errors.push({ field: "type", message: `Unknown layer type: ${layer.type}` });

configure/src/metaconfigs/layer-data-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"tile",
2222
"vector",
2323
"vectortile",
24-
"velocity"
24+
"velocity",
25+
"video"
2526
]
2627
},
2728
{

configure/src/metaconfigs/layer-header-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"tile",
2222
"vector",
2323
"vectortile",
24-
"velocity"
24+
"velocity",
25+
"video"
2526
]
2627
},
2728
{

configure/src/metaconfigs/layer-image-config.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
"query",
2121
"tile",
2222
"vector",
23-
"vectortile"
23+
"vectortile",
24+
"velocity",
25+
"video"
2426
]
2527
},
2628
{

configure/src/metaconfigs/layer-model-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"tile",
2222
"vector",
2323
"vectortile",
24-
"velocity"
24+
"velocity",
25+
"video"
2526
]
2627
},
2728
{

0 commit comments

Comments
 (0)