>>(
+ "read_video_polylines_json",
+ { path: picked },
+ );
+ setMetadataByVideo(map);
+ const keys = Object.keys(map);
+ // Concatenated fallback for callers that don't yet pass a video.
+ const flat: Array<[number, number]> = [];
+ for (const k of keys) flat.push(...map[k]);
+ setMetadataTrack(flat);
+ // Recenter the map on the metadata bbox so the user can actually see
+ // what they just imported (otherwise the polylines may sit outside the
+ // current viewport and look "missing").
+ if (flat.length > 0) {
+ let minLng = Infinity, maxLng = -Infinity;
+ let minLat = Infinity, maxLat = -Infinity;
+ for (const [lng, lat] of flat) {
+ if (lng < minLng) minLng = lng;
+ if (lng > maxLng) maxLng = lng;
+ if (lat < minLat) minLat = lat;
+ if (lat > maxLat) maxLat = lat;
+ }
+ setViewport({
+ center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2],
+ zoom: 12,
+ });
+ }
+ setStatus(
+ `Loaded metadata for ${keys.length} video(s). Toggle "Metadata polyline" in Layers if it's off. Snap & direction now use each asset's matching video track.`,
+ );
+ } catch (e) {
+ setStatus(`Video metadata import failed: ${e}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
async function handleImport(kind: "fixed" | "range") {
if (busy) return;
setBusy(true);
@@ -1838,6 +2083,25 @@ function AppShell({
scopePolygons={scopePolygons}
osmRoads={osmRoads}
metadataTrack={metadataTrack}
+ metadataTracks={metadataByVideo}
+ selectedMetadataVideo={selectedMetadataVideo}
+ gotoPin={gotoPin}
+ markedVertices={markedVertices}
+ onVertexShiftClick={(idx) => {
+ setMarkedVertices((prev) => {
+ const next = new Set(prev);
+ if (next.has(idx)) next.delete(idx);
+ else next.add(idx);
+ return next;
+ });
+ }}
+ onMetadataLineClick={(name) => {
+ setSelectedMetadataVideo((cur) => {
+ const next = cur === name ? null : name;
+ setStatus(next === null ? "" : name);
+ return next;
+ });
+ }}
layerVisibility={layerVisibility}
selectedAsset={selectedAsset}
onSelect={(a) => {
@@ -1928,6 +2192,25 @@ function AppShell({
scopePolygons={scopePolygons}
osmRoads={osmRoads}
metadataTrack={metadataTrack}
+ metadataTracks={metadataByVideo}
+ selectedMetadataVideo={selectedMetadataVideo}
+ gotoPin={gotoPin}
+ markedVertices={markedVertices}
+ onVertexShiftClick={(idx) => {
+ setMarkedVertices((prev) => {
+ const next = new Set(prev);
+ if (next.has(idx)) next.delete(idx);
+ else next.add(idx);
+ return next;
+ });
+ }}
+ onMetadataLineClick={(name) => {
+ setSelectedMetadataVideo((cur) => {
+ const next = cur === name ? null : name;
+ setStatus(next === null ? "" : name);
+ return next;
+ });
+ }}
layerVisibility={layerVisibility}
selectedAsset={selectedAsset}
onSelect={(a) => {
@@ -2148,7 +2431,12 @@ function AppShell({
}}
/>
- {allVideoNames
+ {Array.from(
+ new Set(
+ allVideoNames.concat(Object.keys(metadataByVideo)),
+ ),
+ )
+ .sort()
.filter((v) =>
videoSearch.trim() === ""
? true
@@ -2176,6 +2464,25 @@ function AppShell({
{v}
{n}
+
))}
@@ -2378,9 +2685,22 @@ function AppShell({
handleImportScope();
}}
disabled={busy}
+ title="Import a KML scope polygon or polyline (geometric only)."
>
KML scope…
+