diff --git a/README.md b/README.md index aca5be4..cd7d088 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,45 @@ pass back. All Phase 3 routes require `Authorization: Bearer `. - `POST /api/videos/:id/push` — body = export doc + `verify_time_ms`; replace annotations, mark `verified`, record verifier + time, clear claim +### Map / GPS / road_type / chainage + +The server image bundles exiftool; a serial background sweep extracts each video's GPS +track (`gps_status`: `pending → ok | none | error`). The raw track is immutable — +a kept "Viterbi" correction lives beside it and is preferred by the map + chainage. +`road_type` + the four chainage values are injected into every claim/export doc and +re-stamped on push (server-authoritative). **Every annotation** additionally gets its +**frame-exact** `lat`/`lon`/`chainage_m`/`sr_chainage_m` (range annotations also +`end_*`): frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS +samples — so it gets the halfway position/chainage, not a second/video boundary. +Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist in +`chainage_calibrations` so this works at any time after a recompute. + +- `GET /api/projects/:id/map` — per-video map rows: track (≤400 pts), status, + road_type, chainage, `gps_status` (member/admin) +- `GET /api/projects/:id/tracks.kml?token=` — all tracks as KML LineStrings colored by + status + calibration points (Google Earth) +- `POST /api/videos/:id/gps/rescan` — re-queue a video for the exiftool sweep (admin) +- `POST /api/projects/:id/gps/rescan_all` — re-queue every video (admin; background) +- `POST /api/videos/:id/gps/correct` `{action: preview|keep|discard}` — spike smoothing + + missing-start stitch from the previous file (by GPS + timestamp, duration-guarded); the scan sweep applies + the same pipeline automatically as 'auto' (raw never + changes; discard blocks re-auto-correction) (admin) +- `GET/POST /api/projects/:id/road_types` — list / replace label set (`{names}`, admin) +- `POST /api/projects/:id/road_type_assign` `{video_ids, road_type}` — bulk-assign ('' clears, admin) +- `GET/POST /api/projects/:id/chainage/points` — client calibration points (POST admin: + `{lat, lon, chainage_m, note?, road_type?}`) +- `POST /api/projects/:id/chainage/points/bulk` `{points: […]}` — transactional bulk insert; + the dashboard's **KML import** uses this (placemark + names like `12+400` or `12.4` become chainage) (admin) +- `DELETE /api/chainage/points/:id` — remove a point (admin) +- `POST /api/projects/:id/chainage/recompute` `{video_ids, road_type}` — order the videos into a + route, snap the points, distribute the client-vs-GPS error + piecewise-linearly; writes `chainage_*` (client) + + `sr_chainage_*` (measured, immutable) + `route_seq` (admin) +- `POST /api/projects/:id/chainage/quick` `{video_ids, start_m, end_m, road_type}` — the + two-point case: points at the route ends + same recompute (admin) + ### Env | var | default | meaning | diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 8da55bf..3da2bc3 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,10 +8,13 @@ "name": "central-dashboard", "version": "0.1.0", "dependencies": { + "fflate": "^0.8.3", + "leaflet": "^1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -1241,6 +1244,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1467,6 +1487,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1524,6 +1550,12 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index a974f26..7c0a290 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,10 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "fflate": "^0.8.3", + "leaflet": "^1.9.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index ac48a10..76009b3 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,13 +1,16 @@ import { useCallback, useEffect, useState } from "react"; import { - api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, passDeltas, setAuthToken, - type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow, + api, exportProjectUrl, exportVideoUrl, fmtChainageRange, fmtDuration, fmtWhen, passDeltas, + setAuthToken, statusLabel, + type Activity, type Member, type ProjectSummary, type RoadType, type Stats, type UserRow, type VideoRow, } from "./api"; import { RemarkCell } from "./RemarkCell"; import { AdminPanel } from "./AdminPanel"; import { MembersPicker } from "./MembersPicker"; import { FoldersPicker } from "./FoldersPicker"; import { SignIn } from "./SignIn"; +import { MapView } from "./MapView"; +import { ChainagePanel } from "./ChainagePanel"; const TOKEN_KEY = "central-token"; const POLL_MS = 10_000; @@ -88,6 +91,10 @@ export function App() { const [selected, setSelected] = useState>(new Set()); const [members, setMembers] = useState([]); const [assignee, setAssignee] = useState(""); + // Videos panel: table ↔ map toggle + road types (labels for assignment/calibration). + const [videoView, setVideoView] = useState<"table" | "map">("table"); + const [roadTypes, setRoadTypes] = useState([]); + const [rtAssign, setRtAssign] = useState(""); // All ml_support users (admin only) — the pool the members picker chooses from. const [users, setUsers] = useState([]); const mlUsers = users.filter((u) => u.role === "ml_support"); @@ -102,14 +109,16 @@ export function App() { const loadProject = useCallback(async (id: number) => { try { - const [s, v, a, m] = await Promise.all([ + const [s, v, a, m, rt] = await Promise.all([ api.stats(id), api.videos(id), api.activity(id), api.members(id).catch(() => [] as Member[]), + api.roadTypes(id).catch(() => [] as RoadType[]), ]); setStats(s); setVideos(v); setActivity(a); setMembers(m); + setRoadTypes(rt); setError(null); } catch (e) { setError(String(e)); @@ -142,6 +151,29 @@ export function App() { } }, [isAdmin, selected, assignee, projectId, loadProject]); + // Bulk-assign the picked road type to the selected videos (admin; "" clears). + const applyRoadType = useCallback(async () => { + if (!isAdmin || selected.size === 0 || projectId == null) return; + setBusy(true); + try { + await api.assignRoadType(projectId, [...selected], rtAssign); + setSelected(new Set()); + await loadProject(projectId); + setError(null); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }, [isAdmin, selected, rtAssign, projectId, loadProject]); + + // A deleted road-type label can leave a stale dropdown selection: the select + // LOOKS like "(clear road type)" but still sends the dead label, which the server + // rejects — making un-assign impossible. Snap back to '' whenever that happens. + useEffect(() => { + if (rtAssign !== "" && !roadTypes.some((r) => r.name === rtAssign)) setRtAssign(""); + }, [roadTypes, rtAssign]); + useEffect(() => { if (auth) void loadProjects(); }, [auth, loadProjects]); useEffect(() => { @@ -438,15 +470,21 @@ export function App() { - {/* Per-video table */} + {/* Per-video table / map */}
-

Videos ({videos.length})

+

+ Videos ({videos.length}) + + + + +

{isAdmin && projectId != null && ( loadProject(projectId)} /> )} - {isAdmin && ( + {isAdmin && videoView === "table" && ( {selected.size} selected → - {members.length === 0 && — add members in Admin first} + + )} {projectId != null && videos.some((v) => v.annotation_count > 0) && ( @@ -464,13 +509,18 @@ export function App() { )}
+ {videoView === "map" && projectId != null && ( + loadProject(projectId)} /> + )} + {videoView === "table" && ( +
{isAdmin && } - + @@ -482,7 +532,7 @@ export function App() { + ))} - {videos.length === 0 && } + {videos.length === 0 && }
0 && selected.size === videos.length} onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} />#FolderFileStatusAnnotatorAnnotations#FolderFileStatusRoad · ChainageAnnotatorAnnotations ReviewVerify timeResolutionAnnotatedRemarks
{subfolder(v.rel_path) || "—"} {v.file_name} - {v.workflow_status} + {statusLabel(v.workflow_status)} {v.ignored && ignored} {isAdmin && ( + {v.road_type ? {v.road_type} : } +
+ {v.chainage_start_m != null + ? fmtChainageRange(v.chainage_start_m, v.chainage_end_m) + : {v.gps_status === "ok" ? "gps ✓" : v.gps_status === "pending" ? "gps …" : `gps ${v.gps_status}`}} +
+ +
{v.verifiers || v.primary_annotator || (v.assigned_to ? assigned: {v.assigned_to} : "—")} {v.annotation_count > 0 @@ -543,10 +609,22 @@ export function App() {
no videos ingested
no videos ingested
+
+ )}
+ + {/* Chainage calibration + road-type labels (admin) */} + {isAdmin && projectId != null && ( + loadProject(projectId)} + /> + )} )} @@ -571,6 +649,56 @@ function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][] return order.map((k) => [k, map.get(k)!]); } +/** GPS fix count vs the ~1 fix/s expected for the video's duration. A mismatch gets + * a ⚠ that expands (on click) into a plain-language explanation of how the missing/ + * extra data is handled — interpolation, start-stitching, end clamping. */ +function GpsPoints({ v }: { v: VideoRow }) { + const [open, setOpen] = useState(false); + if (v.gps_status !== "ok" || v.gps_sample_count == null) return null; + const durS = v.duration_s ?? (v.frame_count && v.fps ? v.frame_count / v.fps : null); + const expected = durS ? Math.round(durS) : null; + const diff = expected != null ? v.gps_sample_count - expected : 0; + const off = expected != null && Math.abs(diff) > Math.max(3, expected * 0.05); + const bigGap = (v.gps_max_gap_s ?? 0) > 5; + return ( +
+ {v.gps_sample_count} gps pts + {expected != null && / ~{expected}} + {(off || bigGap) && ( + + )} + {open && (off || bigGap) && ( +
setOpen(false)}> + {diff < 0 || (!off && bigGap) ? ( + <> + Expected ~{expected ?? "?"} fixes (1 per second of video), found {v.gps_sample_count} + {bigGap && <> — largest dropout {v.gps_max_gap_s!.toFixed(0)} s}. + Positions inside a dropout are time-interpolated: each frame is placed + proportionally on a straight line between the two nearest real fixes (constant + speed assumed), and its chainage follows the same interpolation. A missing{" "} + start is stitched from the previous video via GPS timestamps when possible; + a missing end clamps to the last fix. The longer the dropout, the rougher + the approximation. + + ) : diff > 0 ? ( + <> + Found {v.gps_sample_count} fixes but only ~{expected} expected — the camera wrote + more than one fix per second (or duplicated samples). Extra fixes are used as-is; + nothing is approximated. + + ) : null} +
+ )} +
+ ); +} + function Card({ label, value, tone }: { label: string; value: number; tone?: string }) { return (
diff --git a/dashboard/src/ChainagePanel.tsx b/dashboard/src/ChainagePanel.tsx new file mode 100644 index 0000000..3c506c0 --- /dev/null +++ b/dashboard/src/ChainagePanel.tsx @@ -0,0 +1,549 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { unzipSync } from "fflate"; +import { + api, fmtChainage, fmtChainageRange, + type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow, +} from "./api"; + +/** The import format clients should follow: one Point placemark per survey point, + * chainage in the name ("12+400" = 12 km + 400 m, or decimal km). */ +const SAMPLE_KML = ` + + chainage points — sample + + 0+00078.185718,21.105570,0 + 5+00078.158000,21.092045,0 + 10+00078.123885,21.091447,0 + +`; + +function downloadSampleKml() { + const url = URL.createObjectURL(new Blob([SAMPLE_KML], { type: "application/vnd.google-earth.kml+xml" })); + const a = document.createElement("a"); + a.href = url; + a.download = "chainage_points_sample.kml"; + a.click(); + URL.revokeObjectURL(url); +} + +/** One placemark parsed out of an uploaded KML, editable before import. */ +interface KmlRow { + name: string; + lat: number; + lon: number; + /** Editable chainage in km (prefilled from the placemark name/description). */ + km: string; + include: boolean; +} + +/** Chainage from free text: road notation "12+400" (km+m) always wins; otherwise the + * first number, interpreted in `unit`. Returns meters, or null when nothing parses. */ +function parseChainageText(text: string, unit: "km" | "m"): number | null { + const plus = text.match(/(\d+)\s*\+\s*(\d+(?:\.\d+)?)/); + if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]); + const num = text.match(/-?\d+(?:[.,]\d+)?/); + if (!num) return null; + const v = parseFloat(num[0].replace(",", ".")); + if (isNaN(v)) return null; + return unit === "km" ? v * 1000 : v; +} + +/** Extract Point placemarks (name + lat/lon) from a KML document. LineStrings and + * multi-coordinate placemarks are skipped (counted) — only survey points import. */ +function parseKmlPoints(text: string): { rows: { name: string; lat: number; lon: number }[]; skipped: number } { + const doc = new DOMParser().parseFromString(text, "application/xml"); + if (doc.getElementsByTagName("parsererror").length > 0) { + throw new Error("not a valid KML/XML file (KMZ must be unzipped first)"); + } + const rows: { name: string; lat: number; lon: number }[] = []; + let skipped = 0; + for (const pm of Array.from(doc.getElementsByTagNameNS("*", "Placemark"))) { + const coords = pm.getElementsByTagNameNS("*", "coordinates")[0]?.textContent?.trim() ?? ""; + const tuples = coords.split(/\s+/).filter(Boolean); + if (tuples.length !== 1) { + skipped += 1; // a LineString/Polygon (or empty) — not a survey point + continue; + } + const [lon, lat] = tuples[0].split(",").map(parseFloat); + if (isNaN(lat) || isNaN(lon)) { + skipped += 1; + continue; + } + const name = pm.getElementsByTagNameNS("*", "name")[0]?.textContent?.trim() + || pm.getElementsByTagNameNS("*", "description")[0]?.textContent?.trim() + || ""; + rows.push({ name, lat, lon }); + } + return { rows, skipped }; +} + +/** + * Admin chainage panel: manage road-type labels, enter the client's calibration + * points (lat/lon ↔ chainage), run the two-point quick assign ("first video starts + * at X, last ends at Y") or a full recompute over the current group, and read the + * resulting per-span scales + per-video chainage. `sr` (GPS-measured) chainage is + * shown but never editable — only the client-calibrated values come from points. + */ +export function ChainagePanel({ + projectId, + videos, + roadTypes, + onChanged, +}: { + projectId: number; + videos: VideoRow[]; + roadTypes: RoadType[]; + onChanged: () => void; +}) { + const [open, setOpen] = useState(false); + const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos) + const [points, setPoints] = useState([]); + const [summary, setSummary] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // add-point form + const [latlon, setLatlon] = useState(""); + const [pointKm, setPointKm] = useState(""); + const [pointNote, setPointNote] = useState(""); + // quick two-point form + const [startKm, setStartKm] = useState("0"); + const [endKm, setEndKm] = useState(""); + // road-type editor + const [newType, setNewType] = useState(""); + // KML import preview + const [kmlRows, setKmlRows] = useState(null); + const [kmlUnit, setKmlUnit] = useState<"km" | "m">("km"); + const [kmlSkipped, setKmlSkipped] = useState(0); + const kmlFileRef = useRef(null); + + const loadPoints = useCallback(async () => { + try { + setPoints(await api.chainagePoints(projectId)); + } catch (e) { + setError(String(e)); + } + }, [projectId]); + + useEffect(() => { + if (open) void loadPoints(); + }, [open, loadPoints]); + useEffect(() => setSummary(null), [projectId, group]); + + /** The route's videos = current group, ignored ones excluded. */ + const groupVideos = useMemo( + () => videos.filter((v) => !v.ignored && (group === "" || v.road_type === group)), + [videos, group], + ); + const groupPoints = useMemo( + () => points.filter((p) => p.road_type === group || p.road_type === ""), + [points, group], + ); + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(null); + try { + setSummary(await fn()); + await loadPoints(); + onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const addPoint = async () => { + const m = latlon.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*$/); + const km = parseFloat(pointKm); + if (!m || isNaN(km)) { + setError('Point needs "lat, lon" and a chainage in km.'); + return; + } + setBusy(true); + setError(null); + try { + await api.addChainagePoint(projectId, { + lat: parseFloat(m[1]), + lon: parseFloat(m[2]), + chainage_m: km * 1000, + note: pointNote.trim(), + road_type: group, + }); + setLatlon(""); + setPointKm(""); + setPointNote(""); + await loadPoints(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const deletePoint = async (id: number) => { + setBusy(true); + try { + await api.deleteChainagePoint(id); + await loadPoints(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const quick = () => { + const s = parseFloat(startKm); + const e = parseFloat(endKm); + if (isNaN(s) || isNaN(e)) { + setError("Quick assign needs start and end chainage in km."); + return; + } + void run(() => + api.chainageQuick(projectId, { + video_ids: groupVideos.map((v) => v.id), + start_m: s * 1000, + end_m: e * 1000, + road_type: group, + }), + ); + }; + + const recompute = () => + run(() => api.chainageRecompute(projectId, groupVideos.map((v) => v.id), group)); + + const addRoadType = async () => { + const name = newType.trim(); + if (!name) return; + setBusy(true); + setError(null); + try { + await api.setRoadTypes(projectId, [...roadTypes.map((r) => r.name), name]); + setNewType(""); + onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const onKmlFile = async (file: File) => { + setError(null); + try { + let text: string; + if (/\.kmz$/i.test(file.name)) { + // KMZ = zipped KML; take the first .kml entry (usually doc.kml). + const entries = unzipSync(new Uint8Array(await file.arrayBuffer())); + const key = Object.keys(entries).find((k) => k.toLowerCase() === "doc.kml") + ?? Object.keys(entries).find((k) => /\.kml$/i.test(k)); + if (!key) { + setError(`${file.name} contains no .kml document.`); + return; + } + text = new TextDecoder().decode(entries[key]); + } else { + text = await file.text(); + } + const { rows, skipped } = parseKmlPoints(text); + if (rows.length === 0) { + setError(`No point placemarks found in ${file.name}${skipped ? ` (${skipped} non-point entries skipped)` : ""}.`); + return; + } + setKmlSkipped(skipped); + setKmlRows(rows.map((r) => { + const m = parseChainageText(r.name, kmlUnit); + return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null }; + })); + } catch (e) { + setError(String(e)); + } finally { + if (kmlFileRef.current) kmlFileRef.current.value = ""; // allow re-picking the same file + } + }; + + /** Re-read every row's chainage from its placemark name under the new unit. */ + const setKmlUnitAndReparse = (u: "km" | "m") => { + setKmlUnit(u); + setKmlRows((rows) => rows?.map((r) => { + const m = parseChainageText(r.name, u); + return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null }; + }) ?? null); + }; + + const importKml = async () => { + if (!kmlRows) return; + const points = kmlRows + .filter((r) => r.include && r.km.trim() !== "" && !isNaN(parseFloat(r.km))) + .map((r) => ({ + lat: r.lat, + lon: r.lon, + chainage_m: parseFloat(r.km) * 1000, + note: r.name, + road_type: group, + })); + if (points.length === 0) { + setError("No rows selected with a valid chainage."); + return; + } + setBusy(true); + setError(null); + try { + await api.addChainagePointsBulk(projectId, points); + setKmlRows(null); + await loadPoints(); + onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const removeRoadType = async (name: string) => { + setBusy(true); + setError(null); + try { + await api.setRoadTypes(projectId, roadTypes.map((r) => r.name).filter((n) => n !== name)); + if (group === name) setGroup(""); + onChanged(); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + if (!open) { + return ( +
+
+

Chainage & road types

+ +
+
+ ); + } + + return ( +
+
+

Chainage & road types

+ +
+ + {error &&
⚠ {error}
} + + {/* Road-type labels (project-wide) */} +
+
Road types (assign them to selected videos in the table below)
+
+ {roadTypes.map((r) => ( + + {r.name} ({r.video_count}) + + + ))} + setNewType(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }} + placeholder="Add: LHS / RHS / MCW…" + /> + +
+
+ + {/* Calibration group + the videos it covers */} +
+ + + + {groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s) + +
+ +
+ {/* Control points */} +
+
Client calibration points (lat/lon ↔ chainage)
+ + + + + + {groupPoints.map((p) => ( + + + + + + + + ))} + {groupPoints.length === 0 && ( + + )} + +
ChainageLat, LonGroupNote
{fmtChainage(p.chainage_m)}{p.lat.toFixed(6)}, {p.lon.toFixed(6)}{p.road_type || "any"}{p.note || "—"}
no points yet — add them, or use the quick assign
+
+ setLatlon(e.target.value)} placeholder="lat, lon" style={{ width: 190 }} /> + setPointKm(e.target.value)} placeholder="chainage (km)" style={{ width: 110 }} /> + setPointNote(e.target.value)} placeholder="note (optional)" style={{ width: 140 }} /> + + { const f = e.target.files?.[0]; if (f) void onKmlFile(f); }} + /> + + +
+ + {kmlRows && ( +
+
+ {kmlRows.filter((r) => r.include).length} + of {kmlRows.length} points will import into group "{group || "any"}" + {kmlSkipped > 0 && ` · ${kmlSkipped} non-point placemark(s) skipped`} + · values in names are + + ("12+400" always = 12 km + 400 m) +
+
+ + + + + + {kmlRows.map((r, i) => ( + + + + + + + ))} + +
PlacemarkLat, LonChainage (km)
setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, include: !x.include } : x))} />{r.name || (unnamed)}{r.lat.toFixed(6)}, {r.lon.toFixed(6)} setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, km: e.target.value, include: true } : x))} />
+
+
+ + +
+
+ )} +
+ + {/* Quick assign + recompute */} +
+
+ Two-point quick assign — client gives just first & last chainage; the error is + distributed over the whole route +
+
+ + setStartKm(e.target.value)} style={{ width: 90 }} /> km + + setEndKm(e.target.value)} placeholder="49.5" style={{ width: 90 }} /> km + +
+
+ + orders the {groupVideos.length} video(s) into a route, snaps the points, distributes the error +
+
+
+ + {/* Result summary */} + {summary && ( +
+
+ Route {(summary.route_len_m / 1000).toFixed(3)} km (GPS-measured) + · {summary.points_used} point(s) used + {summary.points_excluded > 0 && · {summary.points_excluded} excluded} + {summary.reversed && · direction auto-reversed} +
+ {summary.warnings.length > 0 && ( +
    + {summary.warnings.map((w, i) =>
  • ⚠ {w}
  • )} +
+ )} + {summary.spans.length > 0 && ( + + + + + + {summary.spans.map((sp, i) => ( + 0.05 ? "span-warn" : undefined}> + + + + + + ))} + +
Span (client)GPS lengthClient lengthScale
{fmtChainage(sp.from_chainage_m)} → {fmtChainage(sp.to_chainage_m)}{(sp.gps_len_m / 1000).toFixed(3)} km{(sp.client_len_m / 1000).toFixed(3)} km×{sp.scale.toFixed(4)}
+ )} + + + + + + {summary.videos.map((v) => ( + + + + + + + + ))} + +
#VideoChainage (client)SR chainage (GPS)
{v.seq + 1}{v.file_name}{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}{fmtChainageRange(v.sr_start_m, v.sr_end_m)}{v.flipped && ( + + ⇄ flipped + + )}
+ {summary.skipped.length > 0 && ( +
+ Skipped (no GPS): {summary.skipped.join(", ")} +
+ )} +
+ )} +
+ ); +} diff --git a/dashboard/src/MapView.tsx b/dashboard/src/MapView.tsx new file mode 100644 index 0000000..168ddc1 --- /dev/null +++ b/dashboard/src/MapView.tsx @@ -0,0 +1,692 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { + api, fmtChainageRange, statusLabel, tracksKmlUrl, + type ChainagePoint, type CorrectResult, type MapVideo, type Remark, +} from "./api"; + +/** Colors for the "color by status" mode (chips use the same). */ +const STATUS_COLORS: Record = { + pending: "#ff5252", // annotations missing — loud on purpose + annotated: "#f5a623", // awaiting verification + assigned: "#b48cff", + "in-progress": "#6ea8ff", + verified: "#5ccf75", + "re-verified": "#2bbfa8", + ignored: "#8f95ae", +}; +const STATUS_ORDER = ["pending", "annotated", "assigned", "in-progress", "verified", "re-verified", "ignored"]; + +/** Unique per-video color: golden-angle hue spacing keeps neighbors distinct. */ +const videoColor = (id: number) => `hsl(${(id * 137.508) % 360}, 70%, 45%)`; + +/** Bucket used for coloring/filtering: ignored trumps the workflow status. */ +const mapStatus = (v: MapVideo) => (v.ignored ? "ignored" : v.workflow_status); + +const latlngs = (track: [number, number, number][]) => + track.map((p) => [p[0], p[1]] as [number, number]); + +/** Cumulative meters along a track (local flat approximation — fine for readouts). */ +function trackCum(track: [number, number, number][]): number[] { + const cum = [0]; + for (let i = 1; i < track.length; i++) { + const mLat = 110574; + const mLon = 111320 * Math.cos((track[i][0] * Math.PI) / 180); + const dy = (track[i][0] - track[i - 1][0]) * mLat; + const dx = (track[i][1] - track[i - 1][1]) * mLon; + cum.push(cum[i - 1] + Math.hypot(dx, dy)); + } + return cum; +} + +/** Project a point onto a track: meters ALONG it at the nearest spot + meters AWAY + * (perpendicular). Local flat approximation — fine for readouts and proximity. */ +function projectOnTrack( + track: [number, number, number][], + cum: number[], + lat: number, + lon: number, +): { along: number; away: number } { + let along = 0; + let away = Infinity; + const mLat = 110574; + const mLon = 111320 * Math.cos((lat * Math.PI) / 180); + const px = lon * mLon; + const py = lat * mLat; + for (let i = 0; i + 1 < track.length; i++) { + const ax = track[i][1] * mLon, ay = track[i][0] * mLat; + const bx = track[i + 1][1] * mLon, by = track[i + 1][0] * mLat; + const dx = bx - ax, dy = by - ay; + const l2 = dx * dx + dy * dy; + const t = l2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / l2)) : 0; + const d = Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + if (d < away) { + away = d; + along = cum[i] + t * (cum[i + 1] - cum[i]); + } + } + return { along, away }; +} + +/** Client chainage under the cursor: linear within the video (exact unless a + * calibration point sits mid-video). Null when the video isn't calibrated. */ +function chainageAtCursor(v: MapVideo, cum: number[], ll: L.LatLng): number | null { + if (v.chainage_start_m == null || v.chainage_end_m == null || !v.track) return null; + const len = cum[cum.length - 1]; + if (len <= 0) return null; + const d = projectOnTrack(v.track, cum, ll.lat, ll.lng).along; + return v.chainage_start_m + (v.chainage_end_m - v.chainage_start_m) * (d / len); +} + +/** Position on the track at a given client chainage (inverse of the above). */ +function pointAtChainage(v: MapVideo, cum: number[], m: number): [number, number] | null { + if (v.chainage_start_m == null || v.chainage_end_m == null || !v.track) return null; + const span = v.chainage_end_m - v.chainage_start_m; + if (span === 0) return null; + const f = (m - v.chainage_start_m) / span; + if (f < 0 || f > 1) return null; + const d = f * cum[cum.length - 1]; + let i = 1; + while (i < cum.length - 1 && cum[i] < d) i++; + const f2 = cum[i] > cum[i - 1] ? (d - cum[i - 1]) / (cum[i] - cum[i - 1]) : 0; + return [ + v.track[i - 1][0] + (v.track[i][0] - v.track[i - 1][0]) * f2, + v.track[i - 1][1] + (v.track[i][1] - v.track[i - 1][1]) * f2, + ]; +} + +/** "12+400" (km+m) or "12.4"/"12.4 km" → meters; null when the query isn't a chainage. */ +function parseChainageQuery(q: string): number | null { + const t = q.trim(); + const plus = t.match(/^(\d+)\s*\+\s*(\d+(?:\.\d+)?)$/); + if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]); + if (/^\d+(?:\.\d+)?\s*(km)?$/i.test(t)) return parseFloat(t) * 1000; + return null; +} + +/** "21.1055, 78.1857" → [lat, lon]; null when the query isn't a coordinate pair. */ +function parseLatLngQuery(q: string): [number, number] | null { + const m = q.trim().match(/^(-?\d{1,3}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)$/); + if (!m) return null; + const lat = parseFloat(m[1]); + const lon = parseFloat(m[2]); + if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + return [lat, lon]; +} + +/** One search-result entry: a video hit, or (v = null) a bare "go to location". */ +type SearchMatch = { v: MapVideo | null; ch?: number; ll?: [number, number]; away?: number }; + +/** + * Map view of the project's videos: each GPS track is a polyline (unique color per + * video, or status colors with filter chips), with search, a details card + * (ignore / remarks / raise hand / re-scan / Viterbi correction), calibration-point + * markers, and the "videos without GPS metadata" panel. + */ +export function MapView({ + projectId, + isAdmin, + onChanged, +}: { + projectId: number; + isAdmin: boolean; + onChanged: () => void; // refresh the table/stats after ignore etc. +}) { + const [videos, setVideos] = useState([]); + const [points, setPoints] = useState([]); + const [colorBy, setColorBy] = useState<"video" | "status">("video"); + const [hidden, setHidden] = useState>(new Set()); + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const [thread, setThread] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null); + + const mapDiv = useRef(null); + const mapRef = useRef(null); + const trackLayer = useRef(null); + const pointLayer = useRef(null); + const correctionLayer = useRef(null); + const searchLayer = useRef(null); + const fitted = useRef(false); + // Per-video cumulative track distances, computed lazily for hover/search readouts. + const cumCache = useRef(new Map()); + const cumFor = (v: MapVideo): number[] | null => { + if (!v.track || v.track.length < 2) return null; + let c = cumCache.current.get(v.id); + if (!c) { + c = trackCum(v.track); + cumCache.current.set(v.id, c); + } + return c; + }; + // Refs so Leaflet click handlers (bound at draw time) see current state. + const correctionRef = useRef(correction); + correctionRef.current = correction; + + const load = useCallback(async () => { + try { + const [v, p] = await Promise.all([ + api.map(projectId), + api.chainagePoints(projectId).catch(() => [] as ChainagePoint[]), + ]); + setVideos(v); + setPoints(p); + setError(null); + } catch (e) { + setError(String(e)); + } + }, [projectId]); + + useEffect(() => { + fitted.current = false; + setSelectedId(null); + setCorrection(null); + void load(); + const t = setInterval(() => { + // Don't yank the rug while a correction preview is on screen. + if (!correctionRef.current) void load(); + }, 30_000); + return () => clearInterval(t); + }, [load]); + + // ---- Leaflet init (once) ---- + useEffect(() => { + if (!mapDiv.current || mapRef.current) return; + const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5 }); + // OSM raster tiles (internet). The basemap is an optional layer — tracks render + // fine even when tiles can't load (offline VM). + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }).addTo(map); + trackLayer.current = L.layerGroup().addTo(map); + pointLayer.current = L.layerGroup().addTo(map); + correctionLayer.current = L.layerGroup().addTo(map); + searchLayer.current = L.layerGroup().addTo(map); + mapRef.current = map; + setTimeout(() => map.invalidateSize(), 0); + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + useEffect(() => cumCache.current.clear(), [videos]); + + const withTrack = useMemo(() => videos.filter((v) => v.track && v.track.length >= 2), [videos]); + const noMeta = useMemo(() => videos.filter((v) => v.gps_status === "none" || v.gps_status === "error"), [videos]); + const pendingScan = useMemo(() => videos.filter((v) => v.gps_status === "pending"), [videos]); + + const statusCounts = useMemo(() => { + const c: Record = {}; + for (const v of withTrack) c[mapStatus(v)] = (c[mapStatus(v)] ?? 0) + 1; + return c; + }, [withTrack]); + + const visible = useMemo( + () => withTrack.filter((v) => !hidden.has(mapStatus(v))), + [withTrack, hidden], + ); + + // Search matches by name, by chainage ("12+400" / "12.4" — EVERY calibrated group + // containing it: LHS, RHS and a ramp can each match the same chainage), or by + // coordinates ("21.1055, 78.1857" — nearby tracks + a bare go-to-location entry). + const matches = useMemo(() => { + const q = search.trim(); + if (!q) return [] as SearchMatch[]; + const ll = parseLatLngQuery(q); + if (ll) { + const near: SearchMatch[] = videos + .filter((v) => v.track && v.track.length >= 2) + .map((v) => ({ v, ll, away: projectOnTrack(v.track!, cumFor(v)!, ll[0], ll[1]).away })) + .filter((m) => (m.away ?? Infinity) <= 300) + .sort((a, b) => a.away! - b.away!) + .slice(0, 10); + return [{ v: null, ll } as SearchMatch, ...near]; + } + const m = parseChainageQuery(q); + if (m != null) { + return videos + .filter((v) => { + if (v.chainage_start_m == null || v.chainage_end_m == null) return false; + const lo = Math.min(v.chainage_start_m, v.chainage_end_m); + const hi = Math.max(v.chainage_start_m, v.chainage_end_m); + return m >= lo && m <= hi; + }) + .sort((a, b) => a.road_type.localeCompare(b.road_type) || a.file_name.localeCompare(b.file_name)) + .slice(0, 12) + .map((v): SearchMatch => ({ v, ch: m })); + } + const lq = q.toLowerCase(); + return videos + .filter((v) => v.file_name.toLowerCase().includes(lq) || v.rel_path.toLowerCase().includes(lq)) + .slice(0, 12) + .map((v): SearchMatch => ({ v })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [videos, search]); + + const selected = videos.find((v) => v.id === selectedId) ?? null; + + const select = useCallback((v: MapVideo | null) => { + setSelectedId(v?.id ?? null); + setThread([]); + setCorrection(null); + searchLayer.current?.clearLayers(); + if (v) { + api.remarks(v.id).then(setThread).catch(() => setThread([])); + if (v.track && v.track.length >= 2 && mapRef.current) { + mapRef.current.fitBounds(L.latLngBounds(latlngs(v.track)), { padding: [40, 40], maxZoom: 16 }); + } + } + }, []); + + /** Chainage search hit: select the video AND pin the exact spot on its track. */ + const gotoChainage = useCallback((v: MapVideo, m: number) => { + select(v); + const cum = cumFor(v); + const pt = cum ? pointAtChainage(v, cum, m) : null; + if (pt && mapRef.current && searchLayer.current) { + L.circleMarker(pt, { radius: 9, color: "#ff3b3b", weight: 3, fillOpacity: 0.15 }) + .bindTooltip(`${(m / 1000).toFixed(3)} km`, { permanent: true, direction: "top" }) + .addTo(searchLayer.current); + mapRef.current.setView(pt, 16); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [select]); + + /** Coordinate search hit: pin the location (and select the nearby video, if any). */ + const gotoLatLng = useCallback((ll: [number, number], v: MapVideo | null) => { + select(v); // also clears the previous search pin + if (mapRef.current && searchLayer.current) { + L.circleMarker(ll, { radius: 9, color: "#ff3b3b", weight: 3, fillOpacity: 0.15 }) + .bindTooltip(`${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}`, { permanent: true, direction: "top" }) + .addTo(searchLayer.current); + mapRef.current.setView(ll, 16); + } + }, [select]); + + // ---- draw tracks ---- + useEffect(() => { + const layer = trackLayer.current; + const map = mapRef.current; + if (!layer || !map) return; + layer.clearLayers(); + for (const v of visible) { + if (correction && correction.videoId === v.id) continue; // overlay owns this one + const sel = v.id === selectedId; + const color = colorBy === "video" ? videoColor(v.id) : STATUS_COLORS[mapStatus(v)] ?? "#999"; + const line = L.polyline(latlngs(v.track!), { + color, + weight: sel ? 6 : 3, + opacity: sel ? 1 : 0.8, + dashArray: v.ignored ? "6 6" : undefined, + }); + line.bindTooltip(`${v.file_name} · ${statusLabel(mapStatus(v))}`, { sticky: true }); + const cum = cumFor(v); + if (cum && v.chainage_start_m != null) { + // Live chainage readout under the cursor. + line.on("mousemove", (e: L.LeafletMouseEvent) => { + const ch = chainageAtCursor(v, cum, e.latlng); + line.setTooltipContent( + `${v.file_name} · ${statusLabel(mapStatus(v))}${ch != null ? ` · ${(ch / 1000).toFixed(3)} km` : ""}`, + ); + }); + } + line.on("click", () => select(v)); + line.addTo(layer); + if (sel) line.bringToFront(); + } + if (!fitted.current && visible.length > 0) { + const all = visible.flatMap((v) => latlngs(v.track!)); + map.fitBounds(L.latLngBounds(all), { padding: [30, 30] }); + fitted.current = true; + } + }, [visible, colorBy, selectedId, correction, select]); + + // ---- draw calibration points ---- + useEffect(() => { + const layer = pointLayer.current; + if (!layer) return; + layer.clearLayers(); + for (const p of points) { + L.circleMarker([p.lat, p.lon], { + radius: 6, + color: "#f5c838", + fillColor: "#f5c838", + fillOpacity: 0.9, + }) + .bindTooltip( + `chainage ${(p.chainage_m / 1000).toFixed(3)} km${p.road_type ? ` · ${p.road_type}` : ""}${p.note ? ` · ${p.note}` : ""}`, + ) + .addTo(layer); + } + }, [points]); + + // ---- draw correction overlay (original red vs corrected green) ---- + useEffect(() => { + const layer = correctionLayer.current; + if (!layer) return; + layer.clearLayers(); + if (!correction) return; + L.polyline(latlngs(correction.result.original), { color: "#ff5252", weight: 3, opacity: 0.9 }) + .bindTooltip("original GPS") + .addTo(layer); + L.polyline(latlngs(correction.result.corrected), { color: "#5ccf75", weight: 4, opacity: 0.9 }) + .bindTooltip("corrected") + .addTo(layer); + if (mapRef.current) { + mapRef.current.fitBounds(L.latLngBounds(latlngs(correction.result.original)), { padding: [40, 40] }); + } + }, [correction]); + + // ---- actions ---- + const act = async (fn: () => Promise, reload = true) => { + setBusy(true); + setError(null); + try { + await fn(); + if (reload) { + await load(); + onChanged(); + } + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const toggleIgnore = (v: MapVideo) => act(() => api.setIgnore(v.id, !v.ignored)); + const toggleHand = (v: MapVideo) => act(() => api.setHand(v.id, !v.hand_raised)); + const rescan = (v: MapVideo) => act(() => api.gpsRescan(v.id)); + const addRemark = (v: MapVideo) => + act(async () => { + const body = draft.trim(); + if (!body) return; + setThread(await api.addRemark(v.id, body)); + setDraft(""); + }, false); + + const previewCorrection = (v: MapVideo) => + act(async () => { + const result = await api.gpsCorrect(v.id, "preview"); + setCorrection({ videoId: v.id, result }); + }, false); + const keepCorrection = (v: MapVideo) => + act(async () => { + await api.gpsCorrect(v.id, "keep"); + setCorrection(null); + }); + const discardCorrection = () => setCorrection(null); + const revertCorrection = (v: MapVideo) => act(() => api.gpsCorrect(v.id, "discard")); + + const toggleChip = (s: string) => + setHidden((h) => { + const n = new Set(h); + n.has(s) ? n.delete(s) : n.add(s); + return n; + }); + + return ( +
+
+
+ setSearch(e.target.value)} + placeholder="Search name · chainage 12+400 · lat, lon…" + /> + {matches.length > 0 && ( +
+ {matches.map((mt, i) => ( + + ))} +
+ )} +
+
+ + +
+ + ⬇ KML + + {colorBy === "status" && ( +
+ {STATUS_ORDER.filter((s) => (statusCounts[s] ?? 0) > 0).map((s) => ( + + ))} +
+ )} +
+ + {error &&
⚠ {error}
} + +
+
+
+ {selected ? ( +
+
+ {selected.file_name} + +
+
+ Folder + {subfolder(selected.rel_path) || "—"} + Status + + {statusLabel(selected.workflow_status)} + {selected.ignored && ignored} + + Road type + {selected.road_type || "—"} + Chainage + {fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)} + SR chainage + + {fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)} + + GPS + + {selected.gps_status === "ok" ? ( + <> + {((selected.gps_len_m ?? 0) / 1000).toFixed(2)} km + {selected.gps_sample_count != null && · {selected.gps_sample_count} fixes} + {(selected.gps_max_gap_s ?? 0) > 5 && ( + + {" "}· gap {selected.gps_max_gap_s!.toFixed(0)}s ⚠ + + )} + + ) : ( + selected.gps_status + )} + {selected.gps_error && } + {selected.corrected && ( +
+ {selected.gps_corrected_by === "auto" ? "auto-corrected" : `corrected by ${selected.gps_corrected_by}`} + {selected.gps_corrected_note ? `: ${selected.gps_corrected_note}` : ""} +
+ )} +
+ Annotations + {selected.annotation_count} +
+ +
+ {isAdmin && ( + + )} + + {isAdmin && ( + + )} + {isAdmin && selected.gps_status === "ok" && !correction && ( + + )} + {isAdmin && selected.corrected && !correction && ( + + )} +
+ + {correction && correction.videoId === selected.id && ( +
+
+ {correction.result.replaced} of {correction.result.points} points corrected ·{" "} + {(correction.result.len_before_m / 1000).toFixed(2)} →{" "} + {(correction.result.len_after_m / 1000).toFixed(2)} km +
+ {correction.result.note &&
{correction.result.note}
} +
red = original · green = corrected
+
+ + +
+
+ )} + +
+
Remarks ({thread.length})
+
+ {thread.length === 0 &&
No remarks yet.
} + {thread.map((r, i) => ( +
+ {r.username}: {r.body} +
+ ))} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addRemark(selected); + }} + placeholder="Add a remark…" + /> + +
+
+
+ ) : ( +
+ Click a track (or search) to see a video's details, ignore it or add a remark. +
+ {withTrack.length} of {videos.length} videos have a GPS track. +
+ )} + + {/* Requirement: videos without metadata must be listed visibly. */} +
+
+ Without GPS metadata {noMeta.length} + {pendingScan.length > 0 && · {pendingScan.length} awaiting scan} + {isAdmin && ( + + )} +
+
+ {noMeta.map((v) => ( +
+ + {v.gps_status === "error" ? ( + error + ) : ( + none + )} + {isAdmin && ( + + )} +
+ ))} + {noMeta.length === 0 &&
every scanned video has GPS ✓
} +
+
+
+
+
+ ); +} + +function subfolder(rel: string): string { + const i = rel.lastIndexOf("/"); + return i >= 0 ? rel.slice(0, i) : ""; +} diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 5a25a78..1be4479 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -84,6 +84,111 @@ export interface VideoRow { ignored_by: string | null; remarks: Remark[]; passes: Pass[]; // per-pass annotation-count history (push order) + road_type: string; // '' = unassigned + gps_status: string; // pending | ok | none | error + gps_len_m: number | null; + gps_error: string | null; + gps_sample_count: number | null; // raw fixes found (≈1/s expected) + gps_max_gap_s: number | null; // biggest dropout between fixes + duration_s: number | null; // real container duration from the scan + chainage_start_m: number | null; // client-calibrated + chainage_end_m: number | null; + sr_chainage_start_m: number | null; // GPS-measured (immutable) + sr_chainage_end_m: number | null; + route_seq: number | null; +} + +/** One video as the map sees it. `track` = [[lat, lon, t_s], …] (corrected when a + * correction was kept, else raw); null until the GPS sweep has scanned it. */ +export interface MapVideo { + id: number; + file_name: string; + rel_path: string; + status: string; + workflow_status: string; + ignored: boolean; + hand_raised: boolean; + assigned_to: string | null; + claimed_by: string | null; + annotation_count: number; + road_type: string; + gps_status: string; + gps_len_m: number | null; + gps_error: string | null; + chainage_start_m: number | null; + chainage_end_m: number | null; + sr_chainage_start_m: number | null; + sr_chainage_end_m: number | null; + route_seq: number | null; + remark_count: number; + track: [number, number, number][] | null; + corrected: boolean; + gps_corrected_by: string | null; // 'auto' or a username + gps_corrected_note: string | null; + gps_sample_count: number | null; // raw fixes found by the scan + gps_max_gap_s: number | null; // biggest dropout between fixes +} + +export interface RoadType { + name: string; + video_count: number; +} + +export interface ChainagePoint { + id: number; + lat: number; + lon: number; + chainage_m: number; + note: string; + road_type: string; + created_by: string; + created_at: string; +} + +export interface SpanRow { + from_chainage_m: number; + to_chainage_m: number; + gps_len_m: number; + client_len_m: number; + scale: number; +} + +export interface VideoChainageRow { + id: number; + file_name: string; + seq: number; + flipped: boolean; + sr_start_m: number; + sr_end_m: number; + chainage_start_m: number; + chainage_end_m: number; +} + +export interface CalibrationSummary { + route_len_m: number; + reversed: boolean; + points_used: number; + points_excluded: number; + spans: SpanRow[]; + warnings: string[]; + videos: VideoChainageRow[]; + skipped: string[]; +} + +/** Result of the GPS correction preview/keep — original + corrected tracks + stats. + * The pipeline = spike removal + (when a temporal predecessor exists) a + * missing-start stitch that re-bases the clock to the true video start. */ +export interface CorrectResult { + action: string; + points: number; + replaced: number; + stitched_from: string | null; // predecessor file the start was borrowed from + shift_s: number; // seconds of GPS missing at the start (clock re-based by this) + note: string; + len_before_m: number; + len_after_m: number; + original: [number, number, number][]; + corrected: [number, number, number][]; } export interface LeaderRow { @@ -186,6 +291,9 @@ export const exportVideoUrl = (id: number) => `${BASE}/videos/${id}/export?token=${encodeURIComponent(authToken)}`; export const exportProjectUrl = (id: number) => `${BASE}/projects/${id}/export?token=${encodeURIComponent(authToken)}`; +/** All video tracks (colored by status) + calibration points as a Google-Earth KML. */ +export const tracksKmlUrl = (id: number) => + `${BASE}/projects/${id}/tracks.kml?token=${encodeURIComponent(authToken)}`; function authHeaders(token?: string): Record { const t = token ?? authToken; @@ -288,8 +396,64 @@ export const api = { folders: (projectId: number) => get(`/projects/${projectId}/folders`), setFolders: (projectId: number, folders: string[], token?: string) => post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token), + // ---- Map view + GPS pipeline + road_type + chainage ---- + map: (projectId: number) => get(`/projects/${projectId}/map`), + remarks: (videoId: number) => get(`/videos/${videoId}/remarks`), + // Queue a fresh exiftool scan for a video / for every scanned video (admin). + gpsRescan: (videoId: number) => post<{ queued: boolean }>(`/videos/${videoId}/gps/rescan`), + gpsRescanAll: (projectId: number) => post<{ queued: number }>(`/projects/${projectId}/gps/rescan_all`), + // "Viterbi" correction: preview (nothing saved) / keep (persist) / discard (revert). + gpsCorrect: (videoId: number, action: "preview" | "keep" | "discard") => + post(`/videos/${videoId}/gps/correct`, { action }), + // Road types: admin-defined labels + bulk assignment to selected videos. + roadTypes: (projectId: number) => get(`/projects/${projectId}/road_types`), + setRoadTypes: (projectId: number, names: string[]) => + post<{ road_types: number }>(`/projects/${projectId}/road_types`, { names }), + assignRoadType: (projectId: number, video_ids: number[], road_type: string) => + post<{ assigned: number }>(`/projects/${projectId}/road_type_assign`, { video_ids, road_type }), + // Chainage: client calibration points + the recompute/quick-assign actions. + chainagePoints: (projectId: number) => get(`/projects/${projectId}/chainage/points`), + addChainagePoint: ( + projectId: number, + p: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }, + ) => post(`/projects/${projectId}/chainage/points`, p), + // Bulk insert (KML import): one transaction, all-or-nothing. + addChainagePointsBulk: ( + projectId: number, + points: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }[], + ) => post<{ added: number }>(`/projects/${projectId}/chainage/points/bulk`, { points }), + deleteChainagePoint: (pointId: number) => del<{ deleted: boolean }>(`/chainage/points/${pointId}`), + chainageRecompute: (projectId: number, video_ids: number[], road_type: string) => + post(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }), + chainageQuick: ( + projectId: number, + body: { video_ids: number[]; start_m: number; end_m: number; road_type: string }, + ) => post(`/projects/${projectId}/chainage/quick`, body), }; +/** Display label for a workflow/status value. `annotated` really means "has + * annotations, nobody verified them yet"; `pending` means the video has no + * annotations at all — admins need to see that plainly. Display-only: the DB/API + * keep the raw values. */ +export function statusLabel(s: string): string { + if (s === "annotated") return "awaiting verification"; + if (s === "pending") return "no annotations"; + return s; +} + +/** Chainage in decimal km ("12.345 km"); em-dash when not computed yet. */ +export function fmtChainage(m: number | null | undefined): string { + if (m == null || isNaN(m)) return "—"; + return `${(m / 1000).toFixed(3)} km`; +} + +/** "12.400 → 13.750 km" for a start/end pair (direction preserved — a flipped video + * legitimately runs high→low); em-dash when not computed. */ +export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string { + if (a == null || b == null) return "—"; + return `${(a / 1000).toFixed(3)} → ${(b / 1000).toFixed(3)} km`; +} + /** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, … * Skips legacy passes that have no recorded count. The first pass is "verify", * subsequent ones "re-verify". `total` is the running annotation count after the pass. */ diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 47cd450..673e00f 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -98,10 +98,14 @@ tbody tr:hover { background: rgba(255,255,255,0.02); } padding: 6px 8px; font-family: ui-monospace, Menlo, monospace; font-size: 12px; word-break: break-all; flex: 1; } -/* Remark thread cell (video table) */ -.remark-col { max-width: 360px; } +/* Remark thread cell (video table) — needs a guaranteed floor so the crowded table + can't crush it; the table itself scrolls horizontally instead (.table-scroll). */ +.remark-col { max-width: 360px; min-width: 150px; } .remark-collapsed { background: none; border: none; color: var(--fg, #dce3f5); cursor: pointer; - text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center; max-width: 340px; } + text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center; + max-width: 340px; white-space: nowrap; } +/* Wide tables scroll inside their panel instead of squeezing columns off the edge. */ +.table-scroll { overflow-x: auto; } .remark-collapsed:hover { filter: brightness(1.2); } .remark-badge { background: var(--bg0); border: 1px solid var(--border); border-radius: 10px; padding: 1px 7px; font-size: 11px; white-space: nowrap; } @@ -247,6 +251,83 @@ tr.rank-top { background: rgba(245,200,56,0.08); } line-height: 1; padding: 0 2px; } .chip-x:hover { filter: brightness(1.2); } +/* ---- Map view ---- */ +.videos-toggle { margin-left: 12px; vertical-align: middle; } +.mapview { display: flex; flex-direction: column; gap: 10px; } +.map-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.map-search { position: relative; } +.map-search input { background: var(--bg2); color: var(--text); border: 1px solid var(--border); + border-radius: 6px; padding: 7px 10px; font-size: 13px; width: 260px; } +.map-search input:focus { outline: none; border-color: var(--info); } +.map-search-results { position: absolute; top: calc(100% + 4px); left: 0; z-index: 1000; width: 340px; + background: var(--bg1); border: 1px solid var(--border); border-radius: 8px; + box-shadow: 0 10px 30px rgba(0,0,0,0.45); overflow: hidden; } +.map-search-results button { display: block; width: 100%; text-align: left; background: none; border: none; + color: var(--text); padding: 7px 10px; cursor: pointer; font-size: 12px; } +.map-search-results button:hover { background: var(--bg2); } +.map-colorby { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.map-colorby button { background: var(--bg1); color: var(--dim); border: none; padding: 6px 12px; + cursor: pointer; font-size: 12px; } +.map-colorby button.active { background: var(--bg2); color: var(--text); font-weight: 600; } +.map-chips { display: flex; gap: 6px; flex-wrap: wrap; } +.map-chip { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; + padding: 3px 10px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--text); } +.map-chip.off { opacity: 0.35; text-decoration: line-through; } +.map-body { display: grid; grid-template-columns: 1fr 320px; gap: 12px; } +@media (max-width: 900px) { .map-body { grid-template-columns: 1fr; } } +.map-canvas { height: 560px; border: 1px solid var(--border); border-radius: 10px; + background: var(--bg2); z-index: 0; } +.map-side { display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow: auto; } +.map-hint { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; + padding: 12px; font-size: 12px; } +.map-details { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; + padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } +.map-details-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.map-details-head b { word-break: break-all; font-size: 12px; } +.map-kv { display: grid; grid-template-columns: 92px 1fr; gap: 3px 8px; font-size: 12px; } +.map-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.map-correction { background: rgba(92,207,117,0.08); border: 1px solid rgba(92,207,117,0.4); + border-radius: 8px; padding: 8px 10px; font-size: 12px; display: flex; flex-direction: column; gap: 6px; } +.map-remarks { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.map-nometa { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; } +.map-nometa-head { font-size: 12px; margin-bottom: 6px; } +.map-nometa-head b { color: var(--warn); } +.map-nometa-list { display: flex; flex-direction: column; gap: 3px; max-height: 200px; overflow: auto; } +.map-nometa-row { display: flex; align-items: center; gap: 8px; font-size: 12px; } +.map-nometa-name { background: none; border: none; color: var(--text); cursor: pointer; padding: 0; + font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } +.map-nometa-name:hover { color: var(--info); } +.btn.mini { padding: 1px 7px; font-size: 11px; } +/* Leaflet dark-theme nudges */ +.leaflet-container { font: inherit; } +.leaflet-container a.leaflet-attribution-flag { display: none !important; } + +/* ---- Road type + chainage ---- */ +.badge.road-tag { background: rgba(245,200,56,0.16); color: var(--accent); } +.chainage-block { margin-top: 12px; } +.block-label { font-size: 12px; margin-bottom: 6px; } +.chainage-block input, .chainage-block select { background: var(--bg2); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 12px; } +.chainage-block input:focus, .chainage-block select:focus { outline: none; border-color: var(--info); } +.chip-input { width: 150px; } +.chainage-warnings { margin: 6px 0; padding-left: 18px; color: #f3d3a0; font-size: 12px; } +tr.span-warn { background: rgba(245,166,35,0.10); } +.kml-preview { margin-top: 10px; border: 1px solid var(--border); border-radius: 8px; + padding: 10px 12px; background: var(--bg2); display: flex; flex-direction: column; gap: 8px; } +.kml-preview-list { max-height: 240px; overflow: auto; } + +/* GPS fix-count line + the tap-to-explain data-quality warning (video table) */ +.gps-pts { position: relative; } +.gps-warn { background: rgba(245,166,35,0.18); border: 1px solid var(--warn); color: var(--warn); + border-radius: 8px; cursor: pointer; font-size: 11px; line-height: 1; padding: 1px 5px; margin-left: 5px; } +.gps-warn:hover { filter: brightness(1.2); } +.gps-explain { position: absolute; z-index: 40; margin-top: 4px; width: 340px; white-space: normal; + background: var(--bg1); border: 1px solid var(--warn); border-radius: 8px; padding: 9px 11px; + font-size: 12px; line-height: 1.45; box-shadow: 0 10px 30px rgba(0,0,0,0.45); cursor: pointer; } +.gps-explain b { color: var(--warn); } +/* Map details: correction attribution line */ +.corrected-note { font-size: 11px; margin-top: 2px; white-space: normal; } + /* Storage panel */ .storage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } .storage-grid > div { display: flex; flex-direction: column; gap: 2px; background: var(--bg2); diff --git a/dashboard/tsconfig.tsbuildinfo b/dashboard/tsconfig.tsbuildinfo index 57736cb..e6c21f5 100644 --- a/dashboard/tsconfig.tsbuildinfo +++ b/dashboard/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/FoldersPicker.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/ChainagePanel.tsx","./src/FoldersPicker.tsx","./src/MapView.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/db/init.sql b/db/init.sql index 52fca7c..3d5d39a 100644 --- a/db/init.sql +++ b/db/init.sql @@ -222,3 +222,90 @@ CREATE TABLE IF NOT EXISTS project_folders ( folder TEXT NOT NULL, -- directory part of rel_path ('' = project root) PRIMARY KEY (project_id, folder) ); + +-- --------------------------------------------------------------------------- +-- GPS pipeline (map view + chainage). A background sweep extracts each video's +-- GPS track with exiftool (dashcam Doc{N} samples), one video at a time: +-- gps_status: 'pending' → 'ok' (track stored) | 'none' (no GPS metadata) +-- | 'error' (exiftool/mount failure, see gps_error) +-- The RAW track is immutable once scanned; a kept "Viterbi" correction goes to +-- gps_track_corrected (map/chainage prefer it, raw is never modified). +-- Track format: [[lat, lon, t_s], ...] (t_s = seconds from first sample). +-- --------------------------------------------------------------------------- +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_status TEXT NOT NULL DEFAULT 'pending'; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_track JSONB; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_len_m DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_error TEXT; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_scanned_at TIMESTAMPTZ; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_track_corrected JSONB; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_by TEXT; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_at TIMESTAMPTZ; +-- Human-readable summary of what the correction did ("2 spikes · start stitched …"). +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_note TEXT; +-- Data-quality + continuity metadata from the raw scan: absolute epoch of the +-- first/last GPS fix (lets consecutive files stitch across a missing start), +-- raw sample count and the largest inter-fix gap (dropout visibility). +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_start_epoch DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_end_epoch DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_sample_count INTEGER; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_max_gap_s DOUBLE PRECISION; +-- Real media duration read from the container by the exiftool scan. Load-bearing for +-- the stitch guard: an inter-file gap is only "missing start" when the track covers +-- LESS than the file's duration — otherwise the camera simply wasn't recording. +ALTER TABLE videos ADD COLUMN IF NOT EXISTS duration_s DOUBLE PRECISION; +CREATE INDEX IF NOT EXISTS idx_videos_gps ON videos(gps_status) WHERE gps_status = 'pending'; + +-- --------------------------------------------------------------------------- +-- Road types: admin-defined per-project labels (LHS/RHS/MCW/Servicelane, …) +-- assigned to videos. Injected into the export/claim JSON as `road_type` and +-- re-stamped on push (server-authoritative). +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS project_road_types ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + PRIMARY KEY (project_id, name) +); +ALTER TABLE videos ADD COLUMN IF NOT EXISTS road_type TEXT NOT NULL DEFAULT ''; + +-- --------------------------------------------------------------------------- +-- Chainage (linear referencing with piecewise-linear calibration). +-- chainage_points = admin-entered client calibration pairs (lat/lon ↔ chainage). +-- Optional road_type tag scopes a point to one calibration group (LHS vs RHS are +-- separate routes); '' = usable by any group. +-- Per video: +-- sr_chainage_* = ACTUAL measured position along the GPS route (immutable, +-- regenerated only from GPS — never admin-editable) +-- chainage_* = client-calibrated values (error distributed between +-- consecutive control points) +-- route_seq = position of the video along the calibrated route +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS chainage_points ( + id SERIAL PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + chainage_m DOUBLE PRECISION NOT NULL, + note TEXT NOT NULL DEFAULT '', + road_type TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(project_id); +ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_start_m DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_end_m DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_end_m DOUBLE PRECISION; +ALTER TABLE videos ADD COLUMN IF NOT EXISTS route_seq INTEGER; + +-- The piecewise-linear calibration itself (per project + group), persisted at +-- recompute time as sorted [s_m, chainage_m] anchors. Needed to evaluate chainage at +-- ANY route position later — e.g. frame-exact per-annotation chainage in exports. +CREATE TABLE IF NOT EXISTS chainage_calibrations ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + road_type TEXT NOT NULL DEFAULT '', + anchors JSONB NOT NULL, + route_len_m DOUBLE PRECISION NOT NULL DEFAULT 0, + computed_by TEXT NOT NULL DEFAULT '', + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, road_type) +); diff --git a/docker-compose.yml b/docker-compose.yml index 455f7a4..b59bf64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: db: + restart: unless-stopped image: postgres:16-alpine environment: POSTGRES_USER: central @@ -17,6 +18,7 @@ services: retries: 10 server: + restart: unless-stopped build: context: . dockerfile: server/Dockerfile @@ -51,6 +53,7 @@ services: - "8080:8080" dashboard: + restart: unless-stopped build: ./dashboard environment: # The dashboard calls the API through nginx's /api proxy, so no host needed. diff --git a/server/Dockerfile b/server/Dockerfile index bb7d697..ee8490c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,7 +10,9 @@ RUN cargo build --release FROM debian:bookworm-slim # nfs-common provides mount.nfs for `nfs`-kind projects (mounted on demand at sync/download time). +# libimage-exiftool-perl provides exiftool for the GPS-track extraction sweep (map/chainage). RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates nfs-common \ + libimage-exiftool-perl \ && rm -rf /var/lib/apt/lists/* COPY --from=build /build/server/target/release/central-server /usr/local/bin/central-server ENV BIND_ADDR=0.0.0.0:8080 diff --git a/server/src/chainage.rs b/server/src/chainage.rs new file mode 100644 index 0000000..1afd963 --- /dev/null +++ b/server/src/chainage.rs @@ -0,0 +1,1057 @@ +//! Chainage: linear referencing with piecewise-linear calibration (the same math as +//! ArcGIS "calibrate routes"). +//! +//! The admin selects/filters a set of videos (typically one road_type group — LHS and +//! RHS are separate routes), the server: +//! 1. orders them into a route by greedy nearest-endpoint chaining (flipping videos +//! whose track runs opposite the chain), +//! 2. concatenates the GPS tracks (corrected preferred) into one polyline with +//! cumulative measured distance s ∈ [0, L] — each video's span on that axis IS its +//! `sr_chainage` (immutable: derived only from GPS, never admin-edited), +//! 3. snaps every admin-entered client calibration point (lat/lon ↔ chainage) onto +//! the polyline (excluding points > SNAP_MAX_M away), +//! 4. interpolates client chainage piecewise-linearly between consecutive control +//! points — distributing the client-vs-GPS error proportionally within each span — +//! and extrapolates beyond the outermost points with the adjacent span's scale, +//! 5. writes `chainage_start/end` (client) + `sr_chainage_start/end` (measured) + +//! `route_seq` per video. +//! +//! The "first video starts at 0, last ends at 50 km" quick-assign is just the +//! two-control-point case: it creates points at the route's ends and runs the same +//! recompute — one mechanism, no special-case math. + +use crate::auth::AuthUser; +use crate::gps::{dist_m, parse_track, wgs84_m_per_deg, Pt}; +use crate::{err, ApiResult, AppState}; +use axum::{ + extract::{Path as AxPath, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Control points farther than this from the route are excluded (bad client data). +const SNAP_MAX_M: f64 = 150.0; +/// Spans stretched/compressed beyond ±this fraction are flagged as suspicious. +const SPAN_SCALE_WARN: f64 = 0.05; + +// ---- route building ---- + +struct VideoTrack { + id: i32, + file_name: String, + track: Vec, +} + +pub struct RouteVideo { + pub id: i32, + pub file_name: String, + pub seq: i32, + pub flipped: bool, + /// Measured route position of the video's PLAYBACK start (frame 0) / end. + /// For a flipped video sr_start > sr_end — the footage runs against the route. + pub sr_start_m: f64, + pub sr_end_m: f64, +} + +struct Route { + pts: Vec<(f64, f64)>, // concatenated, route-oriented (lat, lon) + cum: Vec, // cumulative meters at each point (gaps included) + videos: Vec, +} + +impl Route { + fn len_m(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Mirror the route (s → L − s): used when the client's chainage runs opposite + /// to the direction the chain was built in. + fn reverse(&mut self) { + let l = self.len_m(); + self.pts.reverse(); + self.cum = self.cum.iter().rev().map(|s| l - s).collect(); + let n = self.videos.len() as i32; + for v in &mut self.videos { + v.seq = n - 1 - v.seq; + v.flipped = !v.flipped; + v.sr_start_m = l - v.sr_start_m; + v.sr_end_m = l - v.sr_end_m; + } + self.videos.sort_by_key(|v| v.seq); + } +} + +/// Order videos into a chain: start from the endpoint farthest from the centroid of +/// all endpoints (a route extremity), then repeatedly append the unused video whose +/// nearer endpoint is closest to the chain's current end, flipping it when it is +/// entered at its last point. Returns (track index, flipped) in route order. +fn order_route(tracks: &[VideoTrack]) -> Vec<(usize, bool)> { + let n = tracks.len(); + if n == 1 { + return vec![(0, false)]; + } + let ends: Vec<((f64, f64), (f64, f64))> = tracks + .iter() + .map(|t| { + let a = (t.track[0][0], t.track[0][1]); + let b = (t.track[t.track.len() - 1][0], t.track[t.track.len() - 1][1]); + (a, b) + }) + .collect(); + + let (mut clat, mut clon) = (0.0, 0.0); + for (a, b) in &ends { + clat += a.0 + b.0; + clon += a.1 + b.1; + } + clat /= (2 * n) as f64; + clon /= (2 * n) as f64; + + // (video, start at its last point?, distance from centroid) + let mut start = (0usize, false, -1.0f64); + for (i, (a, b)) in ends.iter().enumerate() { + let da = dist_m(clat, clon, a.0, a.1); + let db = dist_m(clat, clon, b.0, b.1); + if da > start.2 { + start = (i, false, da); + } + if db > start.2 { + start = (i, true, db); + } + } + + // flipped = the route enters this video at its LAST track point. + let mut order = vec![(start.0, start.1)]; + let mut used = vec![false; n]; + used[start.0] = true; + let mut cur = if start.1 { ends[start.0].0 } else { ends[start.0].1 }; + + while order.len() < n { + let (mut bi, mut bflip, mut bd) = (usize::MAX, false, f64::INFINITY); + for i in 0..n { + if used[i] { + continue; + } + let da = dist_m(cur.0, cur.1, ends[i].0 .0, ends[i].0 .1); + let db = dist_m(cur.0, cur.1, ends[i].1 .0, ends[i].1 .1); + if da < bd { + bd = da; + bi = i; + bflip = false; + } + if db < bd { + bd = db; + bi = i; + bflip = true; + } + } + used[bi] = true; + order.push((bi, bflip)); + cur = if bflip { ends[bi].0 } else { ends[bi].1 }; + } + order +} + +/// Concatenate the ordered tracks into one measured polyline. Straight-line gaps +/// between consecutive videos count toward s (the honest linear-reference position; +/// control points falling in a gap still snap sensibly). +fn build_route(tracks: &[VideoTrack]) -> Route { + let order = order_route(tracks); + let mut route = Route { pts: Vec::new(), cum: Vec::new(), videos: Vec::new() }; + let mut s = 0.0; + for (seq, (idx, flipped)) in order.iter().enumerate() { + let t = &tracks[*idx]; + let pts: Vec<&Pt> = if *flipped { + t.track.iter().rev().collect() + } else { + t.track.iter().collect() + }; + let mut s_entry = 0.0; + for (k, p) in pts.into_iter().enumerate() { + if let Some(last) = route.pts.last() { + s += dist_m(last.0, last.1, p[0], p[1]); + } + route.pts.push((p[0], p[1])); + route.cum.push(s); + if k == 0 { + s_entry = s; + } + } + let s_exit = s; + route.videos.push(RouteVideo { + id: t.id, + file_name: t.file_name.clone(), + seq: seq as i32, + flipped: *flipped, + sr_start_m: if *flipped { s_exit } else { s_entry }, + sr_end_m: if *flipped { s_entry } else { s_exit }, + }); + } + route +} + +/// Data-quality warnings from the built route: large inter-video gaps (chainage +/// across a gap is measured as a straight line — real road curvature is unmeasured, +/// so everything beyond it drifts unless a calibration point sits on each side) and +/// (near-)stationary videos (parked/idling footage spans ~zero chainage). +fn route_quality_warnings(route: &Route) -> Vec { + const STATIONARY_M: f64 = 30.0; + const GAP_WARN_M: f64 = 1000.0; + let mut out = Vec::new(); + let mut vids: Vec<&RouteVideo> = route.videos.iter().collect(); + vids.sort_by_key(|v| v.seq); + for v in &vids { + let span = (v.sr_end_m - v.sr_start_m).abs(); + if span < STATIONARY_M { + out.push(format!( + "{} barely moves ({span:.0} m) — parked/idling footage? its chainage span is ~zero", + v.file_name + )); + } + } + for w in vids.windows(2) { + let gap = w[1].sr_start_m.min(w[1].sr_end_m) - w[0].sr_start_m.max(w[0].sr_end_m); + if gap > GAP_WARN_M { + out.push(format!( + "{:.1} km gap between {} and {} — counted as a straight line; add a calibration point on each side of the gap or chainage beyond it will drift", + gap / 1000.0, w[0].file_name, w[1].file_name + )); + } + } + out +} + +/// Project (lat, lon) onto the nearest route segment → (s along the route, +/// perpendicular distance in meters). Local flat projection using the WGS84 +/// per-degree factors at the point's latitude — same meter as `dist_m`. +fn snap(route: &Route, lat: f64, lon: f64) -> (f64, f64) { + let (m_lat, m_lon) = wgs84_m_per_deg(lat); + let (px, py) = (lon * m_lon, lat * m_lat); + let mut best = (0.0, f64::INFINITY); + for i in 0..route.pts.len().saturating_sub(1) { + let (ax, ay) = (route.pts[i].1 * m_lon, route.pts[i].0 * m_lat); + let (bx, by) = (route.pts[i + 1].1 * m_lon, route.pts[i + 1].0 * m_lat); + let (dx, dy) = (bx - ax, by - ay); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (qx, qy) = (ax + t * dx, ay + t * dy); + let d = ((px - qx).powi(2) + (py - qy).powi(2)).sqrt(); + if d < best.1 { + best = (route.cum[i] + t * (route.cum[i + 1] - route.cum[i]), d); + } + } + best +} + +// ---- calibration ---- + +/// Piecewise-linear map from measured s to client chainage, anchored at the snapped +/// control points. Within each span the client-vs-GPS error is distributed +/// proportionally; outside the outermost anchors the adjacent span's scale +/// extrapolates. Persisted per (project, group) in `chainage_calibrations` so any +/// route position — e.g. an annotation's exact frame — can be evaluated later. +pub struct Calib { + anchors: Vec<(f64, f64)>, // (s, chainage), sorted by s, len >= 2 +} + +impl Calib { + pub fn chainage(&self, s: f64) -> f64 { + let a = &self.anchors; + let i = match a.iter().position(|&(si, _)| s < si) { + Some(0) => 0, // before the first anchor → first span's scale + Some(k) => k - 1, // inside span k-1 + None => a.len() - 2, // after the last anchor → last span's scale + }; + let (s0, c0) = a[i]; + let (s1, c1) = a[i + 1]; + c0 + (s - s0) * (c1 - c0) / (s1 - s0) + } + + fn to_value(&self) -> Value { + Value::Array(self.anchors.iter().map(|(s, c)| serde_json::json!([s, c])).collect()) + } + + /// Rebuild from the stored `[[s_m, chainage_m], …]` JSONB. None if malformed/short. + pub fn from_value(v: &Value) -> Option { + let anchors: Vec<(f64, f64)> = v + .as_array()? + .iter() + .filter_map(|p| { + let p = p.as_array()?; + Some((p.first()?.as_f64()?, p.get(1)?.as_f64()?)) + }) + .collect(); + (anchors.len() >= 2).then_some(Calib { anchors }) + } +} + +// ---- frame-exact geo (per-annotation stamping) ---- + +/// Frame-exact interpolation for one video: position, measured route position (sr) +/// and client chainage at ANY frame. An annotation on frame 15 of a 30 fps video is +/// t = 0.5 s — halfway between two 1 Hz GPS samples — so it gets the halfway +/// lat/lon and the halfway chainage, not the second's (or the video's) endpoints. +pub struct VideoGeo { + track: Vec, + cum: Vec, // cumulative meters at each sample + fps: f64, + sr_start: Option, + /// +1 when the video plays along the route direction, −1 when flipped. + dir: f64, + calib: Option, +} + +/// One interpolated point: position + (when calibrated) route measure and chainage. +pub struct GeoAt { + pub lat: f64, + pub lon: f64, + pub sr_m: Option, + pub chainage_m: Option, +} + +impl VideoGeo { + /// Build from raw column values. None when the video has no usable track or fps. + pub fn new( + track: Option<&Value>, + fps: Option, + sr_start: Option, + sr_end: Option, + calib: Option, + ) -> Option { + let track = track.map(parse_track).unwrap_or_default(); + let fps = fps.unwrap_or(0.0); + if track.len() < 2 || fps <= 0.0 { + return None; + } + let mut cum = Vec::with_capacity(track.len()); + cum.push(0.0); + for i in 1..track.len() { + cum.push(cum[i - 1] + dist_m(track[i - 1][0], track[i - 1][1], track[i][0], track[i][1])); + } + let dir = match (sr_start, sr_end) { + (Some(a), Some(b)) if b < a => -1.0, + _ => 1.0, + }; + Some(VideoGeo { track, cum, fps, sr_start, dir, calib }) + } + + /// Interpolate at a frame (clamped to the track's time range). + pub fn at_frame(&self, frame: f64) -> GeoAt { + let t = (frame / self.fps).clamp(self.track[0][2], self.track[self.track.len() - 1][2]); + let hi = self.track.partition_point(|p| p[2] <= t).min(self.track.len() - 1).max(1); + let (a, b) = (&self.track[hi - 1], &self.track[hi]); + let f = if b[2] > a[2] { ((t - a[2]) / (b[2] - a[2])).clamp(0.0, 1.0) } else { 0.0 }; + let lat = a[0] + (b[0] - a[0]) * f; + let lon = a[1] + (b[1] - a[1]) * f; + let d = self.cum[hi - 1] + (self.cum[hi] - self.cum[hi - 1]) * f; + let sr_m = self.sr_start.map(|s0| s0 + self.dir * d); + let chainage_m = match (&self.calib, sr_m) { + (Some(c), Some(s)) => Some(c.chainage(s)), + _ => None, + }; + GeoAt { lat, lon, sr_m, chainage_m } + } +} + +/// Load a VideoGeo for one video (claim/push path — a single extra query pair). +pub async fn video_geo(db: &sqlx::PgPool, video_id: i32) -> Option { + #[allow(clippy::type_complexity)] + let row: Option<(Option, Option, Option, Option, String, i32)> = sqlx::query_as( + r#"SELECT COALESCE(gps_track_corrected, gps_track), fps, + sr_chainage_start_m, sr_chainage_end_m, road_type, project_id + FROM videos WHERE id=$1"#, + ) + .bind(video_id) + .fetch_optional(db) + .await + .ok() + .flatten(); + let (track, fps, sr_s, sr_e, road_type, project_id) = row?; + let calib = load_calibration(db, project_id, &road_type).await; + VideoGeo::new(track.as_ref(), fps, sr_s, sr_e, calib) +} + +/// The calibration for a video's group: exact road_type match preferred, else the +/// untagged ('') project-wide one. +pub async fn load_calibration(db: &sqlx::PgPool, project_id: i32, road_type: &str) -> Option { + let v: Option = sqlx::query_scalar( + r#"SELECT anchors FROM chainage_calibrations + WHERE project_id=$1 AND (road_type=$2 OR road_type='') + ORDER BY (road_type=$2) DESC LIMIT 1"#, + ) + .bind(project_id) + .bind(road_type) + .fetch_optional(db) + .await + .ok() + .flatten(); + v.as_ref().and_then(Calib::from_value) +} + +fn round3(x: f64) -> f64 { + (x * 1000.0).round() / 1000.0 +} +fn round6(x: f64) -> f64 { + (x * 1e6).round() / 1e6 +} + +/// Stamp every annotation in an export doc with its frame-exact geo: `lat`, `lon`, +/// `sr_chainage_m` (measured) and `chainage_m` (client-calibrated) — range +/// annotations additionally get `end_*` values at their end frame. Server- +/// authoritative: applied on claim, re-applied on push and in both exports, so the +/// values always reflect the CURRENT calibration. +pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) { + let Some(obj) = doc.as_object_mut() else { return }; + let stamp = |m: &mut serde_json::Map, frame_key: &str, prefix: &str| { + let Some(frame) = m.get(frame_key).and_then(|v| v.as_f64()) else { return }; + let g = geo.at_frame(frame); + m.insert(format!("{prefix}lat"), serde_json::json!(round6(g.lat))); + m.insert(format!("{prefix}lon"), serde_json::json!(round6(g.lon))); + m.insert(format!("{prefix}sr_chainage_m"), serde_json::json!(g.sr_m.map(round3))); + m.insert(format!("{prefix}chainage_m"), serde_json::json!(g.chainage_m.map(round3))); + }; + if let Some(arr) = obj.get_mut("fixed_annotations").and_then(|v| v.as_array_mut()) { + for a in arr { + if let Some(m) = a.as_object_mut() { + stamp(m, "frame_number", ""); + } + } + } + if let Some(arr) = obj.get_mut("range_annotations").and_then(|v| v.as_array_mut()) { + for a in arr { + if let Some(m) = a.as_object_mut() { + stamp(m, "start_frame", ""); + stamp(m, "end_frame", "end_"); + } + } + } +} + +// ---- API shapes ---- + +#[derive(Serialize, sqlx::FromRow)] +pub struct ChainagePoint { + pub id: i32, + pub lat: f64, + pub lon: f64, + pub chainage_m: f64, + pub note: String, + pub road_type: String, + pub created_by: String, + pub created_at: chrono::DateTime, +} + +#[derive(Deserialize)] +pub struct NewPointRequest { + pub lat: f64, + pub lon: f64, + pub chainage_m: f64, + #[serde(default)] + pub note: String, + /// Calibration group this point belongs to ('' = usable by any group). + #[serde(default)] + pub road_type: String, +} + +#[derive(Deserialize)] +pub struct RecomputeRequest { + /// The route's videos — the admin's current filter/selection (e.g. one road_type). + pub video_ids: Vec, + /// Calibration group: uses control points tagged with this road_type or untagged. + #[serde(default)] + pub road_type: String, +} + +#[derive(Deserialize)] +pub struct QuickAssignRequest { + pub video_ids: Vec, + /// Client chainage at the route's first coordinate / last coordinate (meters). + pub start_m: f64, + pub end_m: f64, + #[serde(default)] + pub road_type: String, +} + +#[derive(Serialize)] +pub struct SpanRow { + pub from_chainage_m: f64, + pub to_chainage_m: f64, + pub gps_len_m: f64, + pub client_len_m: f64, + pub scale: f64, +} + +#[derive(Serialize)] +pub struct VideoChainageRow { + pub id: i32, + pub file_name: String, + pub seq: i32, + pub flipped: bool, + pub sr_start_m: f64, + pub sr_end_m: f64, + pub chainage_start_m: f64, + pub chainage_end_m: f64, +} + +#[derive(Serialize)] +pub struct CalibrationSummary { + pub route_len_m: f64, + pub reversed: bool, + pub points_used: usize, + pub points_excluded: usize, + pub spans: Vec, + pub warnings: Vec, + pub videos: Vec, + /// Selected videos without a usable GPS track (chainage cleared, excluded). + pub skipped: Vec, +} + +// ---- endpoints ---- + +/// `GET /api/projects/:id/chainage/points` — the project's calibration points +/// (admin or member). +pub async fn list_points( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult> { + crate::require_project_access(&s, &user, id).await?; + let rows = sqlx::query_as::<_, ChainagePoint>( + r#"SELECT id, lat, lon, chainage_m, note, road_type, created_by, created_at + FROM chainage_points WHERE project_id=$1 ORDER BY road_type, chainage_m"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/projects/:id/chainage/points` — add a client calibration point (admin). +pub async fn add_point( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + if !(-90.0..=90.0).contains(&req.lat) || !(-180.0..=180.0).contains(&req.lon) { + return Err((StatusCode::BAD_REQUEST, "invalid lat/lon".into())); + } + let row = sqlx::query_as::<_, ChainagePoint>( + r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7) + RETURNING id, lat, lon, chainage_m, note, road_type, created_by, created_at"#, + ) + .bind(id) + .bind(req.lat) + .bind(req.lon) + .bind(req.chainage_m) + .bind(req.note.trim()) + .bind(req.road_type.trim()) + .bind(&user.username) + .fetch_one(&s.db) + .await + .map_err(err)?; + crate::admin::audit(&s.db, &user.username, "chainage_point_added", + json!({ "project_id": id, "chainage_m": req.chainage_m })).await; + Ok(Json(row)) +} + +#[derive(Deserialize)] +pub struct BulkPointsRequest { + pub points: Vec, +} + +/// `POST /api/projects/:id/chainage/points/bulk {points}` — insert many calibration +/// points in one transaction (the KML-import path). All-or-nothing. Admin. +pub async fn add_points_bulk( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + if req.points.is_empty() { + return Err((StatusCode::BAD_REQUEST, "no points to import".into())); + } + if req.points.len() > 2000 { + return Err((StatusCode::BAD_REQUEST, "too many points (max 2000)".into())); + } + for p in &req.points { + if !(-90.0..=90.0).contains(&p.lat) || !(-180.0..=180.0).contains(&p.lon) { + return Err((StatusCode::BAD_REQUEST, format!("invalid lat/lon ({}, {})", p.lat, p.lon))); + } + } + let mut tx = s.db.begin().await.map_err(err)?; + for p in &req.points { + sqlx::query( + r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7)"#, + ) + .bind(id) + .bind(p.lat) + .bind(p.lon) + .bind(p.chainage_m) + .bind(p.note.trim()) + .bind(p.road_type.trim()) + .bind(&user.username) + .execute(&mut *tx) + .await + .map_err(err)?; + } + tx.commit().await.map_err(err)?; + crate::admin::audit(&s.db, &user.username, "chainage_points_imported", + json!({ "project_id": id, "count": req.points.len() })).await; + Ok(Json(json!({ "added": req.points.len() }))) +} + +/// `DELETE /api/chainage/points/:id` — remove a calibration point (admin). +pub async fn delete_point( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult { + user.require_admin()?; + let deleted: Option = + sqlx::query_scalar("DELETE FROM chainage_points WHERE id=$1 RETURNING id") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if deleted.is_none() { + return Err((StatusCode::NOT_FOUND, "point not found".into())); + } + Ok(Json(json!({ "deleted": true }))) +} + +/// `POST /api/projects/:id/chainage/recompute {video_ids, road_type}` — order the +/// selected videos into a route, calibrate against the control points, persist +/// per-video chainage (admin). +pub async fn recompute( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let summary = run_recompute(&s, id, &req.video_ids, req.road_type.trim(), &user).await?; + Ok(Json(summary)) +} + +/// `POST /api/projects/:id/chainage/quick {video_ids, start_m, end_m, road_type}` — +/// the "first video starts at X, last ends at Y" flow: creates two control points at +/// the route's ends (replacing previous quick points for the group) and recomputes. +pub async fn quick_assign( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let road_type = req.road_type.trim().to_string(); + let tracks = fetch_tracks(&s, id, &req.video_ids).await?.0; + if tracks.is_empty() { + return Err((StatusCode::BAD_REQUEST, "none of the selected videos has a GPS track yet".into())); + } + let route = build_route(&tracks); + let first = route.pts.first().copied().unwrap(); + let last = route.pts.last().copied().unwrap(); + + // Replace any previous quick points for this group, then add the fresh pair. + sqlx::query( + "DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND note IN ('quick: route start','quick: route end')", + ) + .bind(id) + .bind(&road_type) + .execute(&s.db) + .await + .map_err(err)?; + for (pt, chainage, note) in [(first, req.start_m, "quick: route start"), (last, req.end_m, "quick: route end")] { + sqlx::query( + r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7)"#, + ) + .bind(id) + .bind(pt.0) + .bind(pt.1) + .bind(chainage) + .bind(note) + .bind(&road_type) + .bind(&user.username) + .execute(&s.db) + .await + .map_err(err)?; + } + + let summary = run_recompute(&s, id, &req.video_ids, &road_type, &user).await?; + Ok(Json(summary)) +} + +/// Fetch the selected videos' effective tracks (corrected preferred). Returns +/// (usable tracks, file names of selected videos without GPS). +async fn fetch_tracks( + s: &AppState, + project_id: i32, + video_ids: &[i32], +) -> Result<(Vec, Vec<(i32, String)>), (StatusCode, String)> { + if video_ids.is_empty() { + return Err((StatusCode::BAD_REQUEST, "no videos selected".into())); + } + let rows: Vec<(i32, String, Option)> = sqlx::query_as( + r#"SELECT id, file_name, COALESCE(gps_track_corrected, gps_track) + FROM videos WHERE project_id=$1 AND id = ANY($2)"#, + ) + .bind(project_id) + .bind(video_ids) + .fetch_all(&s.db) + .await + .map_err(err)?; + if rows.is_empty() { + return Err((StatusCode::BAD_REQUEST, "selected videos not found in this project".into())); + } + let mut tracks = Vec::new(); + let mut skipped = Vec::new(); + for (id, file_name, track) in rows { + let pts = track.as_ref().map(|v| parse_track(v)).unwrap_or_default(); + if pts.len() >= 2 { + tracks.push(VideoTrack { id, file_name, track: pts }); + } else { + skipped.push((id, file_name)); + } + } + Ok((tracks, skipped)) +} + +async fn run_recompute( + s: &AppState, + project_id: i32, + video_ids: &[i32], + road_type: &str, + user: &AuthUser, +) -> Result { + let (tracks, skipped) = fetch_tracks(s, project_id, video_ids).await?; + if tracks.is_empty() { + return Err((StatusCode::BAD_REQUEST, "none of the selected videos has a GPS track yet".into())); + } + + let mut warnings: Vec = Vec::new(); + let mut route = build_route(&tracks); + warnings.extend(route_quality_warnings(&route)); + + // Control points for this calibration group (tagged with it, or untagged). + let points: Vec<(i32, f64, f64, f64)> = sqlx::query_as( + r#"SELECT id, lat, lon, chainage_m FROM chainage_points + WHERE project_id=$1 AND (road_type=$2 OR road_type='') + ORDER BY chainage_m"#, + ) + .bind(project_id) + .bind(road_type) + .fetch_all(&s.db) + .await + .map_err(err)?; + if points.len() < 2 { + return Err(( + StatusCode::BAD_REQUEST, + "need at least two calibration points (or use the two-point quick assign)".into(), + )); + } + + // Snap each point to the route; drop the ones that don't belong to it. + let mut anchors: Vec<(f64, f64)> = Vec::new(); // (s, chainage) + let mut excluded = 0usize; + for (pid, lat, lon, chainage_m) in &points { + let (sv, d) = snap(&route, *lat, *lon); + if d > SNAP_MAX_M { + excluded += 1; + warnings.push(format!( + "point #{pid} ({:.3} km) is {:.0} m from the route — excluded", + chainage_m / 1000.0, d + )); + } else { + anchors.push((sv, *chainage_m)); + } + } + if anchors.len() < 2 { + return Err(( + StatusCode::BAD_REQUEST, + format!("fewer than two calibration points lie on this route ({})", warnings.join("; ")), + )); + } + + anchors.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + // If client chainage decreases along the chain, the route was built backwards + // relative to the client's direction — mirror it and re-snap the anchors. + let reversed = anchors.last().unwrap().1 < anchors.first().unwrap().1; + if reversed { + let l = route.len_m(); + route.reverse(); + for a in &mut anchors { + a.0 = l - a.0; + } + anchors.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + warnings.push("route direction reversed to match the client's chainage direction".into()); + } + + // Collapse anchors that snapped onto (nearly) the same spot — zero-length spans + // would divide by zero. + anchors.dedup_by(|b, a| (b.0 - a.0).abs() < 1.0); + if anchors.len() < 2 { + return Err((StatusCode::BAD_REQUEST, "calibration points collapse onto one route position".into())); + } + + // Span summary + sanity flags. + let mut spans = Vec::new(); + for w in anchors.windows(2) { + let (s0, c0) = w[0]; + let (s1, c1) = w[1]; + let gps_len = s1 - s0; + let client_len = c1 - c0; + let scale = client_len / gps_len; + if scale <= 0.0 { + warnings.push(format!( + "span {:.3} → {:.3} km: client chainage does not increase along the route (scale {:.2})", + c0 / 1000.0, c1 / 1000.0, scale + )); + } else if (scale - 1.0).abs() > SPAN_SCALE_WARN { + warnings.push(format!( + "span {:.3} → {:.3} km stretched ×{:.3} (GPS {:.0} m vs client {:.0} m) — check the points", + c0 / 1000.0, c1 / 1000.0, scale, gps_len, client_len + )); + } + spans.push(SpanRow { + from_chainage_m: c0, + to_chainage_m: c1, + gps_len_m: gps_len, + client_len_m: client_len, + scale, + }); + } + + let calib = Calib { anchors }; + + // Persist: calibrated videos get chainage + sr + seq; GPS-less selections are + // cleared (they're not on the route). + let mut out_videos = Vec::new(); + let mut tx = s.db.begin().await.map_err(err)?; + for v in &route.videos { + let ch_start = calib.chainage(v.sr_start_m); + let ch_end = calib.chainage(v.sr_end_m); + sqlx::query( + r#"UPDATE videos SET sr_chainage_start_m=$2, sr_chainage_end_m=$3, + chainage_start_m=$4, chainage_end_m=$5, route_seq=$6 WHERE id=$1"#, + ) + .bind(v.id) + .bind(v.sr_start_m) + .bind(v.sr_end_m) + .bind(ch_start) + .bind(ch_end) + .bind(v.seq) + .execute(&mut *tx) + .await + .map_err(err)?; + out_videos.push(VideoChainageRow { + id: v.id, + file_name: v.file_name.clone(), + seq: v.seq, + flipped: v.flipped, + sr_start_m: v.sr_start_m, + sr_end_m: v.sr_end_m, + chainage_start_m: ch_start, + chainage_end_m: ch_end, + }); + } + for (id, name) in &skipped { + sqlx::query( + r#"UPDATE videos SET sr_chainage_start_m=NULL, sr_chainage_end_m=NULL, + chainage_start_m=NULL, chainage_end_m=NULL, route_seq=NULL WHERE id=$1"#, + ) + .bind(id) + .execute(&mut *tx) + .await + .map_err(err)?; + warnings.push(format!("{name} has no GPS track — excluded from the route, chainage cleared")); + } + // Persist the calibration itself so chainage(s) can be evaluated later at ANY + // route position (frame-exact per-annotation stamping, exports). + sqlx::query( + r#"INSERT INTO chainage_calibrations (project_id, road_type, anchors, route_len_m, computed_by) + VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (project_id, road_type) DO UPDATE SET + anchors=EXCLUDED.anchors, route_len_m=EXCLUDED.route_len_m, + computed_by=EXCLUDED.computed_by, computed_at=now()"#, + ) + .bind(project_id) + .bind(road_type) + .bind(calib.to_value()) + .bind(route.len_m()) + .bind(&user.username) + .execute(&mut *tx) + .await + .map_err(err)?; + tx.commit().await.map_err(err)?; + + crate::admin::audit(&s.db, &user.username, "chainage_recomputed", + json!({ "project_id": project_id, "road_type": road_type, + "videos": out_videos.len(), "points_used": calib.anchors.len() })).await; + + Ok(CalibrationSummary { + route_len_m: route.len_m(), + reversed, + points_used: calib.anchors.len(), + points_excluded: excluded, + spans, + warnings, + videos: out_videos, + skipped: skipped.into_iter().map(|(_, n)| n).collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ~11.1 m per 0.0001° lon at the equator; tracks run west→east along lat 0. + fn seg(id: i32, lon_from: f64, lon_to: f64, n: usize) -> VideoTrack { + let track = (0..n) + .map(|i| { + let f = i as f64 / (n - 1) as f64; + [0.0, lon_from + (lon_to - lon_from) * f, i as f64] + }) + .collect(); + VideoTrack { id, file_name: format!("v{id}.mp4"), track } + } + + #[test] + fn route_orders_shuffled_and_flipped_videos() { + // Three consecutive 1.112 km segments, given shuffled and with v2 reversed. + let v1 = seg(1, 0.00, 0.01, 50); + let v2 = seg(2, 0.02, 0.01, 50); // reversed on purpose + let v3 = seg(3, 0.02, 0.03, 50); + let route = build_route(&[v3, v2, v1]); + let order: Vec<(i32, bool)> = route.videos.iter().map(|v| (v.id, v.flipped)).collect(); + // Either direction of the whole chain is a valid route. + assert!( + order == vec![(1, false), (2, true), (3, false)] + || order == vec![(3, true), (2, false), (1, true)], + "unexpected order: {order:?}" + ); + // Total ≈ 3 × 1112 m. + assert!((route.len_m() - 3.0 * 1112.0).abs() < 30.0, "len {}", route.len_m()); + } + + #[test] + fn two_point_calibration_distributes_error() { + // GPS says ~3.3 km; client says 0 → 3000 m: every meter scales by the same factor. + let tracks = vec![seg(1, 0.00, 0.01, 50), seg(2, 0.01, 0.02, 50), seg(3, 0.02, 0.03, 50)]; + let route = build_route(&tracks); + let l = route.len_m(); + let calib = Calib { anchors: vec![(0.0, 0.0), (l, 3000.0)] }; + assert!((calib.chainage(0.0) - 0.0).abs() < 1e-6); + assert!((calib.chainage(l) - 3000.0).abs() < 1e-6); + // Midpoint maps to half the client length regardless of the GPS error. + assert!((calib.chainage(l / 2.0) - 1500.0).abs() < 1e-6); + // Per-video ends are proportional. + let v2 = route.videos.iter().find(|v| v.id == 2).unwrap(); + let c_start = calib.chainage(v2.sr_start_m); + assert!((c_start - 1000.0).abs() < 15.0, "v2 start {c_start}"); + } + + #[test] + fn multi_point_calibration_scales_each_span_independently() { + // One 2.224 km track; client points at s=0 → 0 m, mid → 1000 m, end → 2200 m. + let tracks = vec![seg(1, 0.0, 0.02, 200)]; + let route = build_route(&tracks); + let l = route.len_m(); + let calib = Calib { anchors: vec![(0.0, 0.0), (l / 2.0, 1000.0), (l, 2200.0)] }; + // First span compresses (GPS 1112 m → client 1000 m), second stretches. + assert!((calib.chainage(l / 4.0) - 500.0).abs() < 1.0); + assert!((calib.chainage(3.0 * l / 4.0) - 1600.0).abs() < 1.0); + // Extrapolation beyond the last anchor uses the last span's scale. + let beyond = calib.chainage(l + 100.0); + let last_scale = 1200.0 / (l / 2.0); + assert!((beyond - (2200.0 + 100.0 * last_scale)).abs() < 1.0); + } + + #[test] + fn snap_measures_perpendicular_distance() { + let tracks = vec![seg(1, 0.0, 0.01, 100)]; + let route = build_route(&tracks); + // A point 0.001° (~110 m) north of the line's midpoint. + let (sv, d) = snap(&route, 0.001, 0.005); + assert!((d - 110.0).abs() < 5.0, "d {d}"); + assert!((sv - route.len_m() / 2.0).abs() < 15.0, "s {sv}"); + } + + #[test] + fn route_quality_flags_gaps_and_stationary_videos() { + // Two segments with a ~5.5 km hole between them + one parked clip. + let a = seg(1, 0.00, 0.01, 50); + let b = seg(2, 0.06, 0.07, 50); + let mut parked = seg(3, 0.0701, 0.0701, 50); // all samples at one spot + for p in parked.track.iter_mut() { + p[1] = 0.0701; + } + let route = build_route(&[a, b, parked]); + let warns = route_quality_warnings(&route); + assert!(warns.iter().any(|w| w.contains("gap between")), "{warns:?}"); + assert!(warns.iter().any(|w| w.contains("barely moves")), "{warns:?}"); + } + + #[test] + fn frame_exact_interpolation() { + // 1 Hz samples ~22.7 m apart at the equator; a 30 fps video. Frame 15 is + // t = 0.5 s — halfway between samples 0 and 1 in position AND chainage. + let t = seg(1, 0.0, 0.01, 50); + let track_val = crate::gps::track_value(&t.track); + let calib = Calib { anchors: vec![(0.0, 0.0), (1113.0, 900.0)] }; + let geo = VideoGeo::new(Some(&track_val), Some(30.0), Some(0.0), Some(1113.0), Some(calib)).unwrap(); + let g = geo.at_frame(15.0); + let step_lon = 0.01 / 49.0; + assert!((g.lon - step_lon / 2.0).abs() < 1e-6, "lon {}", g.lon); + let d = g.sr_m.unwrap(); + assert!((d - 11.36).abs() < 0.2, "sr {d}"); // half of one ~22.7 m hop + let ch = g.chainage_m.unwrap(); + assert!((ch - d * 900.0 / 1113.0).abs() < 0.1, "ch {ch}"); + // Flipped video (sr_start > sr_end): sr decreases as the video plays. + let geo_f = VideoGeo::new(Some(&track_val), Some(30.0), Some(1113.0), Some(0.0), None).unwrap(); + assert!((geo_f.at_frame(15.0).sr_m.unwrap() - (1113.0 - d)).abs() < 0.2); + // Frames beyond the GPS range clamp to the track's ends. + assert!(geo.at_frame(1e9).sr_m.unwrap() <= 1113.5); + } + + #[test] + fn stamp_walks_fixed_and_range_annotations() { + let t = seg(1, 0.0, 0.01, 50); + let track_val = crate::gps::track_value(&t.track); + let geo = VideoGeo::new(Some(&track_val), Some(30.0), Some(0.0), Some(1113.0), + Some(Calib { anchors: vec![(0.0, 0.0), (1113.0, 1000.0)] })).unwrap(); + let mut doc = serde_json::json!({ + "fixed_annotations": [{ "label": "pothole", "frame_number": 30 }], + "range_annotations": [{ "label": "crack", "start_frame": 0, "end_frame": 60 }] + }); + stamp_annotation_geo(&mut doc, &geo); + let f = &doc["fixed_annotations"][0]; + assert!(f["chainage_m"].as_f64().unwrap() > 15.0); + assert!(f["lat"].as_f64().unwrap().abs() < 1e-6); + let r = &doc["range_annotations"][0]; + assert!(r["chainage_m"].as_f64().unwrap() < r["end_chainage_m"].as_f64().unwrap()); + } + + #[test] + fn reverse_mirrors_positions() { + let tracks = vec![seg(1, 0.0, 0.01, 50), seg(2, 0.01, 0.02, 50)]; + let mut route = build_route(&tracks); + let l = route.len_m(); + let v1_start = route.videos.iter().find(|v| v.id == 1).unwrap().sr_start_m; + route.reverse(); + assert!((route.len_m() - l).abs() < 1e-9); + let v1 = route.videos.iter().find(|v| v.id == 1).unwrap(); + assert!((v1.sr_start_m - (l - v1_start)).abs() < 1e-9); + // cum stays sorted ascending after the mirror + assert!(route.cum.windows(2).all(|w| w[1] >= w[0] - 1e-9)); + } +} diff --git a/server/src/gps.rs b/server/src/gps.rs new file mode 100644 index 0000000..6dfafc6 --- /dev/null +++ b/server/src/gps.rs @@ -0,0 +1,888 @@ +//! GPS pipeline: exiftool track extraction (background sweep), shared track math, +//! and the Stage-1 "Viterbi" smoother with its preview/keep/discard endpoint. +//! +//! Track representation everywhere: `[[lat, lon, t_s], …]` (t_s = seconds since the +//! first GPS sample). The RAW extracted track (`videos.gps_track`) is immutable once +//! scanned; a kept correction lives in `gps_track_corrected` and is preferred by the +//! map + chainage. Raw is never modified — discarding a correction restores raw. +//! +//! Lifecycle (`videos.gps_status`): 'pending' → 'ok' | 'none' (no GPS metadata) | +//! 'error' (exiftool/mount failure, message in `gps_error`). The sweep processes ONE +//! video at a time (exiftool -ee reads the whole file over NFS — never hammer the NAS). + +use crate::auth::AuthUser; +use crate::{err, ApiResult, AppState}; +use axum::{ + extract::{Path as AxPath, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::time::Duration; + +/// One GPS sample: [lat, lon, t_s]. +pub type Pt = [f64; 3]; + +/// Max samples stored per track (1 Hz ⇒ ~83 min of video at full fidelity). +const MAX_TRACK_PTS: usize = 5000; +/// Hard deadline for one exiftool scan (-ee reads the whole file over NFS). +const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(600); +/// Sweep pacing: idle poll when nothing is pending / pause between videos / back-off +/// after a failure (so an unreachable NAS isn't probed in a hot loop). +const SWEEP_IDLE: Duration = Duration::from_secs(60); +const SWEEP_BETWEEN: Duration = Duration::from_millis(500); +const SWEEP_AFTER_ERROR: Duration = Duration::from_secs(5); + +// ---- track math (shared with chainage) ---- + +/// WGS84 ellipsoid: semi-major axis + first eccentricity squared. +const WGS84_A: f64 = 6_378_137.0; +const WGS84_E2: f64 = 0.006_694_379_990_14; + +/// Meters per degree of latitude / longitude at a given latitude, from the WGS84 +/// meridian (M) and prime-vertical (N) radii. Distance and snapping both use this, +/// so every meter in the system is measured the same way. +pub fn wgs84_m_per_deg(lat_deg: f64) -> (f64, f64) { + let phi = lat_deg.to_radians(); + let w = (1.0 - WGS84_E2 * phi.sin().powi(2)).sqrt(); + let m = WGS84_A * (1.0 - WGS84_E2) / (w * w * w); + let n = WGS84_A / w; + let rad = std::f64::consts::PI / 180.0; + (m * rad, n * phi.cos() * rad) +} + +/// WGS84 local-ellipsoid distance between two nearby points. Essentially exact at +/// GPS sample spacing (meters–tens of meters) and <2e-5 relative for spans up to +/// ~100 km — unlike spherical haversine, whose 0.1–0.3% systematic bias would land +/// directly in sr_chainage. (Not valid across the ±180° meridian — irrelevant here.) +pub fn dist_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + let (mlat, mlon) = wgs84_m_per_deg((lat1 + lat2) * 0.5); + let dy = (lat2 - lat1) * mlat; + let dx = (lon2 - lon1) * mlon; + (dx * dx + dy * dy).sqrt() +} + +pub fn polyline_len_m(track: &[Pt]) -> f64 { + track + .windows(2) + .map(|w| dist_m(w[0][0], w[0][1], w[1][0], w[1][1])) + .sum() +} + +/// Parse a stored JSONB track (`[[lat,lon,t_s],…]`) back into points. Tolerant: +/// skips malformed entries. +pub fn parse_track(v: &Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|p| { + let p = p.as_array()?; + Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2).and_then(|t| t.as_f64()).unwrap_or(0.0)]) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Serialize a track for storage/transport, rounding to keep the JSON small +/// (1e-6 deg ≈ 0.1 m; 0.1 s time resolution). +pub fn track_value(track: &[Pt]) -> Value { + Value::Array( + track + .iter() + .map(|p| { + json!([ + (p[0] * 1e6).round() / 1e6, + (p[1] * 1e6).round() / 1e6, + (p[2] * 10.0).round() / 10.0 + ]) + }) + .collect(), + ) +} + +/// Cap a track at `max` points (uniform stride, always keeping the last point). +pub fn downsample(track: Vec, max: usize) -> Vec { + if track.len() <= max || max < 2 { + return track; + } + let stride = track.len().div_ceil(max); + let last = *track.last().unwrap(); + let mut out: Vec = track.into_iter().step_by(stride).collect(); + if out.last() != Some(&last) { + out.push(last); + } + out +} + +// ---- exiftool extraction ---- + +/// What one exiftool scan yields: the GPS samples (t_s relative to the first fix), +/// the first fix's absolute epoch (None when the camera wrote no GPSDateTime), the +/// container's media duration in seconds, and the video frame rate. Empty samples = +/// no GPS metadata. Duration + fps let us do frame→position math on raw NAS videos +/// that were never annotated (no sibling JSON ⇒ no fps in our DB otherwise). +struct ScanResult { + samples: Vec, + t0_abs: Option, + duration_s: Option, + fps: Option, +} + +/// Run exiftool over a video and collect its embedded GPS time-series (Doc{N} +/// groups, the format dashcams/DJI write at ~1 Hz). Same flags as the proven +/// desktop extractor, plus container Duration + VideoFrameRate. +async fn run_exiftool(path: &std::path::Path) -> Result { + let child = tokio::process::Command::new("exiftool") + .args([ + "-ee", "-api", "LargeFileSupport=1", "-G3", "-json", "-n", + "-GPSLatitude", "-GPSLongitude", "-GPSDateTime", "-Duration", "-VideoFrameRate", + ]) + .arg(path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("failed to run exiftool (is it installed?): {e}"))?; + + let output = match tokio::time::timeout(EXIFTOOL_TIMEOUT, child.wait_with_output()).await { + Ok(r) => r.map_err(|e| format!("exiftool failed: {e}"))?, + // kill_on_drop terminates the scan when the timeout drops the child. + Err(_) => return Err("exiftool timed out (file too slow to read?)".into()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: Value = serde_json::from_str(stdout.trim()).map_err(|_| { + let se = String::from_utf8_lossy(&output.stderr); + let msg: String = se.trim().chars().take(300).collect(); + if msg.is_empty() { + "exiftool produced no output".to_string() + } else { + format!("exiftool: {msg}") + } + })?; + + let obj = parsed + .as_array() + .and_then(|a| a.first()) + .and_then(|v| v.as_object()) + .ok_or_else(|| "exiftool output was not a non-empty array".to_string())?; + + // exiftool reports per-file problems inline (e.g. "Error: File is empty"). + if let Some((_, e)) = obj.iter().find(|(k, _)| *k == "Error" || k.ends_with(":Error")) { + if let Some(msg) = e.as_str() { + return Err(format!("exiftool: {msg}")); + } + } + + // Numeric field across any -G3 group (container + per-track may each report one); + // `reduce` picks the max, which is the sensible choice for both Duration and fps. + let numeric = |name: &str| -> Option { + obj.iter() + .filter(|(k, _)| k.as_str() == name || k.ends_with(&format!(":{name}"))) + .filter_map(|(_, v)| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .reduce(f64::max) + }; + let duration = numeric("Duration"); + let fps = numeric("VideoFrameRate").filter(|f| *f > 0.0); + + let (samples, t0_abs) = collect_samples(obj); + Ok(ScanResult { samples, t0_abs, duration_s: duration, fps }) +} + +/// Group `Doc{N}:GPSLatitude/GPSLongitude/GPSDateTime` keys into ordered samples +/// with relative timestamps (falls back to 1 Hz when GPSDateTime is missing). +/// Also returns the first fix's ABSOLUTE epoch — GPS time is UTC on every camera, +/// so consecutive files can be matched by timestamp (start-of-video stitching). +fn collect_samples(obj: &serde_json::Map) -> (Vec, Option) { + #[derive(Default)] + struct Partial { + lat: Option, + lon: Option, + t: Option, + } + + let mut by_doc: BTreeMap = BTreeMap::new(); + for (k, v) in obj { + let Some((doc_idx, field)) = split_doc_key(k) else { continue }; + let entry = by_doc.entry(doc_idx).or_default(); + match field { + "GPSLatitude" => entry.lat = v.as_f64(), + "GPSLongitude" => entry.lon = v.as_f64(), + "GPSDateTime" => entry.t = v.as_str().and_then(parse_gps_datetime), + _ => {} + } + } + + let mut samples: Vec = Vec::with_capacity(by_doc.len()); + let mut t0: Option = None; + let mut t0_abs: Option = None; + for (idx, p) in by_doc { + let (Some(lat), Some(lon)) = (p.lat, p.lon) else { continue }; + if t0.is_none() { + t0_abs = p.t; // real epoch only when the camera stamped it + } + let t_abs = p.t.unwrap_or(idx as f64); + let t_rel = t_abs - *t0.get_or_insert(t_abs); + samples.push([lat, lon, t_rel]); + } + (samples, t0_abs) +} + +fn split_doc_key(k: &str) -> Option<(u32, &str)> { + let (group, field) = k.split_once(':')?; + let idx: u32 = group.strip_prefix("Doc")?.parse().ok()?; + Some((idx, field)) +} + +/// "2025:08:02 09:33:31Z" → seconds since epoch (approx; only relative deltas are +/// used). Civil-to-days via Howard Hinnant's algorithm — no chrono parse needed for +/// exiftool's colon-separated dates. +fn parse_gps_datetime(s: &str) -> Option { + let s = s.trim_end_matches('Z').trim(); + let (date, time) = s.split_once(' ')?; + let mut dp = date.split(':'); + let y: i64 = dp.next()?.parse().ok()?; + let mo: i64 = dp.next()?.parse().ok()?; + let d: i64 = dp.next()?.parse().ok()?; + let mut tp = time.split(':'); + let h: i64 = tp.next()?.parse().ok()?; + let mi: i64 = tp.next()?.parse().ok()?; + let sec: f64 = tp.next()?.parse().ok()?; + let y_adj = if mo <= 2 { y - 1 } else { y }; + let era = y_adj.div_euclid(400); + let yoe = y_adj - era * 400; + let m = if mo > 2 { mo - 3 } else { mo + 9 }; + let doy = (153 * m + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + Some(days as f64 * 86400.0 + (h * 3600 + mi * 60) as f64 + sec) +} + +// ---- background sweep ---- + +/// Forever-loop: pick ONE pending video, scan it, store the result, repeat. +/// Serial by design (requirement: throttle — never scan the NAS in parallel). +pub async fn sweep(state: AppState) { + loop { + let next: Option<(i32, String, String, String)> = sqlx::query_as( + r#"SELECT v.id, v.rel_path, p.source_path, p.source_kind + FROM videos v JOIN projects p ON v.project_id = p.id + WHERE v.gps_status = 'pending' + ORDER BY v.id LIMIT 1"#, + ) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + let Some((id, rel_path, source_path, source_kind)) = next else { + tokio::time::sleep(SWEEP_IDLE).await; + continue; + }; + + let ok = scan_one(&state, id, &rel_path, &source_path, &source_kind).await; + tokio::time::sleep(if ok { SWEEP_BETWEEN } else { SWEEP_AFTER_ERROR }).await; + } +} + +/// Scan a single video and persist the outcome. Returns false on error (the video +/// is marked 'error' either way, so the sweep never re-picks it until a re-scan). +async fn scan_one(state: &AppState, id: i32, rel_path: &str, source_path: &str, source_kind: &str) -> bool { + let (sp, kind, host, opts) = + (source_path.to_string(), source_kind.to_string(), state.nfs_host.clone(), state.nfs_opts.clone()); + let dir = match tokio::task::spawn_blocking(move || crate::nfs::resolve_dir(&sp, &kind, &host, &opts)).await { + Ok(Ok(d)) => d, + Ok(Err(e)) => return mark_error(&state.db, id, &e).await, + Err(e) => return mark_error(&state.db, id, &format!("mount task failed: {e}")).await, + }; + + match run_exiftool(&dir.join(rel_path)).await { + Ok(ScanResult { samples, t0_abs, duration_s, fps }) if samples.len() >= 2 => { + // Data-quality stats are measured BEFORE the storage cap: the raw fix + // count and the largest inter-fix gap (dropouts) must reflect reality. + let sample_count = samples.len() as i32; + let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max); + let end_epoch = t0_abs.map(|t0| t0 + samples.last().unwrap()[2]); + let samples = downsample(samples, MAX_TRACK_PTS); + let len = polyline_len_m(&samples); + let res = sqlx::query( + r#"UPDATE videos SET gps_status='ok', gps_track=$2, gps_len_m=$3, gps_error=NULL, + gps_scanned_at=now(), + gps_start_epoch=$4, gps_end_epoch=$5, gps_sample_count=$6, gps_max_gap_s=$7, + duration_s=$8, + -- fill fps for raw NAS videos (no sibling JSON) so frame→position + -- math works; never clobber a JSON/desktop-derived fps. + fps=COALESCE(fps, $9), + -- a fresh raw track invalidates any correction derived from the old one + gps_track_corrected=NULL, gps_corrected_by=NULL, gps_corrected_at=NULL, + gps_corrected_note=NULL + WHERE id=$1"#, + ) + .bind(id) + .bind(track_value(&samples)) + .bind(len) + .bind(t0_abs) + .bind(end_epoch) + .bind(sample_count) + .bind(max_gap) + .bind(duration_s) + .bind(fps) + .execute(&state.db) + .await; + match res { + Ok(_) => { + tracing::info!("gps: video {id} ok — {sample_count} samples, {len:.0} m, max gap {max_gap:.0}s"); + // Auto-correct this video (spikes + start stitch), then its + // temporal successor — its start stitch may only now be possible. + auto_correct(&state.db, id).await; + if let Some(end) = end_epoch { + let succ: Option = sqlx::query_scalar( + r#"SELECT v.id FROM videos v, videos me + WHERE me.id=$1 AND v.id<>$1 AND v.project_id=me.project_id + AND v.gps_status='ok' AND v.gps_start_epoch IS NOT NULL + AND v.gps_start_epoch > $2 AND v.gps_start_epoch - $2 <= $3 + AND (CASE WHEN v.rel_path LIKE '%/%' THEN regexp_replace(v.rel_path,'/[^/]*$','') ELSE '' END) + = (CASE WHEN me.rel_path LIKE '%/%' THEN regexp_replace(me.rel_path,'/[^/]*$','') ELSE '' END) + ORDER BY v.gps_start_epoch LIMIT 1"#, + ) + .bind(id) + .bind(end) + .bind(STITCH_MAX_GAP_S) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + if let Some(succ) = succ { + auto_correct(&state.db, succ).await; + } + } + true + } + Err(e) => mark_error(&state.db, id, &format!("db write failed: {e}")).await, + } + } + Ok(_) => { + let _ = sqlx::query( + "UPDATE videos SET gps_status='none', gps_error=NULL, gps_scanned_at=now() WHERE id=$1", + ) + .bind(id) + .execute(&state.db) + .await; + tracing::info!("gps: video {id} has no GPS metadata"); + true + } + Err(e) => mark_error(&state.db, id, &e).await, + } +} + +async fn mark_error(db: &sqlx::PgPool, id: i32, msg: &str) -> bool { + tracing::warn!("gps: video {id} scan failed: {msg}"); + let _ = sqlx::query( + "UPDATE videos SET gps_status='error', gps_error=$2, gps_scanned_at=now() WHERE id=$1", + ) + .bind(id) + .bind(msg) + .execute(db) + .await; + false +} + +// ---- Stage-1 "Viterbi" smoother ---- + +/// Speed gate: hops implying more than this are implausible (≈160 km/h). +const MAX_SPEED_MPS: f64 = 45.0; +/// Cost of dropping (re-interpolating) one sample. An outlier is dropped when +/// keeping it costs more than this. +const DROP_PENALTY: f64 = 30.0; +/// Max consecutive dropped samples + 1 (DP transition window). +const VITERBI_WINDOW: usize = 12; + +/// Min-cost keep/drop path over the samples (Viterbi over a keep-lattice): +/// transition cost between consecutive KEPT samples grows quadratically with the +/// implied speed above `MAX_SPEED_MPS`; every dropped sample costs `DROP_PENALTY`. +/// Dropped samples are re-interpolated (by time) between their kept neighbors — +/// principled outlier rejection with no road network. Returns the corrected track +/// (same length + timestamps as the input) and how many samples were replaced. +pub fn viterbi_smooth(track: &[Pt]) -> (Vec, usize) { + let n = track.len(); + if n < 3 { + return (track.to_vec(), 0); + } + + let trans_cost = |a: &Pt, b: &Pt| -> f64 { + let dt = (b[2] - a[2]).max(0.5); + let v = dist_m(a[0], a[1], b[0], b[1]) / dt; + let excess = (v - MAX_SPEED_MPS).max(0.0); + (excess / 5.0).powi(2) + }; + + // cost[i] = min cost of a path whose last kept sample is i. + let mut cost = vec![f64::INFINITY; n]; + let mut prev = vec![usize::MAX; n]; + for i in 0..n { + if i <= VITERBI_WINDOW { + // Start the path at i, paying for the dropped leading samples. + cost[i] = DROP_PENALTY * i as f64; + } + for j in i.saturating_sub(VITERBI_WINDOW)..i { + if cost[j].is_finite() { + let c = cost[j] + DROP_PENALTY * (i - j - 1) as f64 + trans_cost(&track[j], &track[i]); + if c < cost[i] { + cost[i] = c; + prev[i] = j; + } + } + } + } + + // End the path at the sample minimizing total cost incl. dropped trailing ones. + let mut end = n - 1; + let mut best = f64::INFINITY; + for i in n.saturating_sub(VITERBI_WINDOW + 1)..n { + let c = cost[i] + DROP_PENALTY * (n - 1 - i) as f64; + if c < best { + best = c; + end = i; + } + } + + let mut kept = vec![false; n]; + let mut i = end; + loop { + kept[i] = true; + if prev[i] == usize::MAX { + break; + } + i = prev[i]; + } + + // Rebuild: kept samples stay; dropped ones are re-interpolated between the + // nearest kept neighbors (clamped to the first/last kept at the edges). + let kept_idx: Vec = (0..n).filter(|&k| kept[k]).collect(); + let mut out = track.to_vec(); + let mut replaced = 0usize; + for k in 0..n { + if kept[k] { + continue; + } + let after = kept_idx.partition_point(|&ki| ki < k); + let (lo, hi) = match (after.checked_sub(1).map(|a| kept_idx[a]), kept_idx.get(after)) { + (Some(lo), Some(&hi)) => (lo, hi), + (None, Some(&hi)) => (hi, hi), + (Some(lo), None) => (lo, lo), + (None, None) => continue, // unreachable: at least one sample is kept + }; + let (a, b) = (&track[lo], &track[hi]); + let f = if b[2] > a[2] { ((track[k][2] - a[2]) / (b[2] - a[2])).clamp(0.0, 1.0) } else { 0.0 }; + out[k] = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, track[k][2]]; + replaced += 1; + } + (out, replaced) +} + +// ---- auto-correction: spikes + start-of-video stitching ---- + +/// Two files are one continuous recording only when the GPS gap between them is at +/// most this many seconds. +const STITCH_MAX_GAP_S: f64 = 180.0; +/// Gaps at or below this are the normal ~1 Hz cadence across a file boundary — +/// nothing is missing. +const STITCH_MIN_GAP_S: f64 = 2.0; + +/// Decide the time-shift for a missing-start stitch. `gap_s` = seconds between the +/// predecessor's last fix and this video's first fix; `span_s` = this track's own +/// first→last duration; `duration_s` = the video's real length when known +/// (frame_count / fps); `jump_m` = distance from the predecessor's last fix to this +/// video's first fix. Returns the seconds this track starts late (its samples are +/// shifted by this), or None when nothing should be stitched. +fn plan_stitch(gap_s: f64, span_s: f64, duration_s: Option, jump_m: f64) -> Option { + if gap_s <= STITCH_MIN_GAP_S || gap_s > STITCH_MAX_GAP_S { + return None; // healthy rollover, or not a continuous recording + } + if jump_m / gap_s > MAX_SPEED_MPS { + return None; // the vehicle can't have covered the jump — different session + } + let mut shift = gap_s - 1.0; // one normal sample period isn't missing data + if let Some(dur) = duration_s { + // The track already covers the whole file ⇒ the gap was the PREDECESSOR's + // problem (missing end), not ours — shifting would misalign every frame. + let missing = dur - span_s; + if missing < STITCH_MIN_GAP_S { + return None; + } + shift = shift.min(missing); + } + (shift > 0.5).then_some(shift) +} + +/// The unified correction result: Viterbi spike removal + (when a temporal +/// predecessor exists in the same folder) a missing-start stitch that re-bases the +/// clock so t = 0 is the actual video start. Raw is never modified. +pub struct CorrectionOutcome { + pub raw: Vec, + pub corrected: Vec, + pub replaced: usize, + pub stitched_from: Option, + pub shift_s: f64, + pub note: String, +} + +impl CorrectionOutcome { + pub fn changed(&self) -> bool { + self.replaced > 0 || self.stitched_from.is_some() + } +} + +/// Compute (but do not store) the correction for a video. Errors when the video has +/// no usable raw track. +pub async fn compute_correction(db: &sqlx::PgPool, video_id: i32) -> Result { + #[allow(clippy::type_complexity)] + let row: Option<(Option, Option, Option, Option, Option, i32, String)> = sqlx::query_as( + r#"SELECT gps_track, gps_start_epoch, fps, frame_count, duration_s, project_id, rel_path + FROM videos WHERE id=$1 AND gps_status='ok'"#, + ) + .bind(video_id) + .fetch_optional(db) + .await + .map_err(|e| e.to_string())?; + let Some((track, start_epoch, fps, frame_count, duration_s, project_id, rel_path)) = row else { + return Err("video has no GPS track".into()); + }; + let raw = track.as_ref().map(parse_track).unwrap_or_default(); + if raw.len() < 2 { + return Err("video has no GPS track".into()); + } + + let (mut corrected, replaced) = viterbi_smooth(&raw); + let mut notes: Vec = Vec::new(); + if replaced > 0 { + notes.push(format!("{replaced} spike(s) re-interpolated")); + } + + // Missing-start stitch: the nearest earlier-ending video in the same folder, + // matched by absolute GPS time (UTC on every camera — no timezone games). + let mut stitched_from = None; + let mut shift_s = 0.0; + if let Some(start_epoch) = start_epoch { + let folder = match rel_path.rfind('/') { + Some(i) => rel_path[..i].to_string(), + None => String::new(), + }; + let prev: Option<(String, f64, Value)> = sqlx::query_as( + r#"SELECT file_name, gps_end_epoch, gps_track FROM videos + WHERE project_id=$1 AND id<>$2 AND gps_track IS NOT NULL AND gps_end_epoch IS NOT NULL + AND gps_end_epoch < $3 AND $3 - gps_end_epoch <= $4 + AND (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path,'/[^/]*$','') ELSE '' END) = $5 + ORDER BY gps_end_epoch DESC LIMIT 1"#, + ) + .bind(project_id) + .bind(video_id) + .bind(start_epoch) + .bind(STITCH_MAX_GAP_S) + .bind(&folder) + .fetch_optional(db) + .await + .map_err(|e| e.to_string())?; + if let Some((prev_name, prev_end, prev_track)) = prev { + // Borrow from the predecessor's RAW track (clean provenance — its own + // correction may already contain stitched points). + let prev_pts = parse_track(&prev_track); + if let Some(anchor) = prev_pts.last() { + let gap = start_epoch - prev_end; + let span = corrected.last().unwrap()[2] - corrected[0][2]; + // Container duration (from the scan) first; the annotation-doc + // fps/frame_count only as a fallback for pre-upgrade rows. + let duration = duration_s.or(match (fps, frame_count) { + (Some(f), Some(n)) if f > 0.0 => Some(n as f64 / f), + _ => None, + }); + let jump = dist_m(anchor[0], anchor[1], corrected[0][0], corrected[0][1]); + if let Some(shift) = plan_stitch(gap, span, duration, jump) { + for p in corrected.iter_mut() { + p[2] += shift; + } + corrected.insert(0, [anchor[0], anchor[1], 0.0]); + shift_s = shift; + notes.push(format!( + "start stitched from {prev_name} (first {shift:.1}s of GPS were missing — clock re-based)" + )); + stitched_from = Some(prev_name); + } + } + } + } + + Ok(CorrectionOutcome { + raw, + corrected, + replaced, + stitched_from, + shift_s, + note: notes.join(" · "), + }) +} + +/// Compute + store the correction as 'auto' — but never over a human decision: +/// only when the video is untouched or its current correction is itself 'auto'. +/// A no-op correction clears a stale auto one. Best-effort (sweep path). +pub async fn auto_correct(db: &sqlx::PgPool, video_id: i32) { + let eligible: Option = sqlx::query_scalar( + "SELECT (gps_corrected_by IS NULL OR gps_corrected_by='auto') FROM videos WHERE id=$1 AND gps_status='ok'", + ) + .bind(video_id) + .fetch_optional(db) + .await + .ok() + .flatten(); + if eligible != Some(true) { + return; + } + match compute_correction(db, video_id).await { + Ok(o) if o.changed() => { + let len = polyline_len_m(&o.corrected); + let res = sqlx::query( + r#"UPDATE videos SET gps_track_corrected=$2, gps_corrected_by='auto', + gps_corrected_at=now(), gps_corrected_note=$3, gps_len_m=$4 WHERE id=$1"#, + ) + .bind(video_id) + .bind(track_value(&o.corrected)) + .bind(&o.note) + .bind(len) + .execute(db) + .await; + match res { + Ok(_) => tracing::info!("gps: video {video_id} auto-corrected — {}", o.note), + Err(e) => tracing::warn!("gps: video {video_id} auto-correct store failed: {e}"), + } + } + Ok(o) => { + // Nothing to fix (anymore) — drop a stale auto correction if present. + let _ = sqlx::query( + r#"UPDATE videos SET gps_track_corrected=NULL, gps_corrected_by=NULL, + gps_corrected_at=NULL, gps_corrected_note=NULL, gps_len_m=$2 + WHERE id=$1 AND gps_corrected_by='auto'"#, + ) + .bind(video_id) + .bind(polyline_len_m(&o.raw)) + .execute(db) + .await; + } + Err(e) => tracing::debug!("gps: video {video_id} auto-correct skipped: {e}"), + } +} + +// ---- endpoints ---- + +/// `POST /api/videos/:id/gps/rescan` — queue a video for a fresh metadata scan +/// (admin). The sweep picks it up; any previous error is cleared. +pub async fn rescan(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult { + user.require_admin()?; + let updated: Option = + sqlx::query_scalar("UPDATE videos SET gps_status='pending', gps_error=NULL WHERE id=$1 RETURNING id") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if updated.is_none() { + return Err((StatusCode::NOT_FOUND, "video not found".into())); + } + Ok(Json(json!({ "queued": true }))) +} + +#[derive(Deserialize)] +pub struct CorrectRequest { + /// 'preview' (compute, don't save) | 'keep' (save corrected) | 'discard' (revert to raw). + pub action: String, +} + +/// `POST /api/videos/:id/gps/correct {action}` — the keep-or-discard correction flow +/// (admin), running the SAME pipeline the scan sweep auto-applies (spike removal + +/// missing-start stitch). Preview returns original + corrected tracks so the map can +/// overlay them; keep persists (attributed to the admin); discard reverts to raw AND +/// tombstones the video so the sweep won't re-auto-correct it (a re-scan resets that). +pub async fn correct( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let exists: Option = sqlx::query_scalar("SELECT gps_status FROM videos WHERE id=$1") + .bind(id) + .fetch_optional(&s.db) + .await + .map_err(err)?; + let Some(gps_status) = exists else { + return Err((StatusCode::NOT_FOUND, "video not found".into())); + }; + if gps_status != "ok" { + return Err((StatusCode::BAD_REQUEST, "video has no GPS track".into())); + } + + match req.action.as_str() { + "discard" => { + let raw_track: Option = sqlx::query_scalar("SELECT gps_track FROM videos WHERE id=$1") + .bind(id) + .fetch_one(&s.db) + .await + .map_err(err)?; + let len = raw_track.as_ref().map(|t| polyline_len_m(&parse_track(t))).unwrap_or_default(); + // Tombstone: gps_corrected_by keeps the admin's name with a NULL track, + // so auto_correct (which only touches NULL/'auto') leaves this video raw. + sqlx::query( + r#"UPDATE videos SET gps_track_corrected=NULL, + gps_corrected_by=$2, gps_corrected_at=now(), + gps_corrected_note='reverted to raw — auto-correction off (re-scan to reset)', + gps_len_m=$3 + WHERE id=$1"#, + ) + .bind(id) + .bind(&user.username) + .bind(len) + .execute(&s.db) + .await + .map_err(err)?; + Ok(Json(json!({ "action": "discard", "len_m": len }))) + } + "preview" | "keep" => { + let o = compute_correction(&s.db, id).await.map_err(|e| (StatusCode::BAD_REQUEST, e))?; + let len_before = polyline_len_m(&o.raw); + let len_after = polyline_len_m(&o.corrected); + if req.action == "keep" { + sqlx::query( + r#"UPDATE videos SET gps_track_corrected=$2, gps_corrected_by=$3, + gps_corrected_at=now(), gps_corrected_note=$4, gps_len_m=$5 WHERE id=$1"#, + ) + .bind(id) + .bind(track_value(&o.corrected)) + .bind(&user.username) + .bind(&o.note) + .bind(len_after) + .execute(&s.db) + .await + .map_err(err)?; + crate::worker::log_event(&s.db, id, &user.username, "gps_corrected", + json!({ "replaced": o.replaced, "stitched": o.stitched_from.is_some() })).await; + } + Ok(Json(json!({ + "action": req.action, + "points": o.raw.len(), + "replaced": o.replaced, + "stitched_from": o.stitched_from, + "shift_s": o.shift_s, + "note": o.note, + "len_before_m": len_before, + "len_after_m": len_after, + "original": track_value(&o.raw), + "corrected": track_value(&o.corrected), + }))) + } + _ => Err((StatusCode::BAD_REQUEST, "action must be preview, keep or discard".into())), + } +} + +/// `POST /api/projects/:id/gps/rescan_all` — queue EVERY already-scanned video in the +/// project for a fresh scan (admin). Needed once after upgrading: pre-upgrade scans +/// have no absolute timestamps, so stitching/data-quality stats only exist after a +/// re-scan. The serial sweep works through them in the background. +pub async fn rescan_all(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult { + user.require_admin()?; + let n = sqlx::query( + "UPDATE videos SET gps_status='pending', gps_error=NULL WHERE project_id=$1 AND gps_status <> 'pending'", + ) + .bind(id) + .execute(&s.db) + .await + .map_err(err)? + .rows_affected(); + crate::admin::audit(&s.db, &user.username, "gps_rescan_all", json!({ "project_id": id, "queued": n })).await; + Ok(Json(json!({ "queued": n }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A straight east-west track at the equator: 1 pt/s, ~11.1 m apart (40 km/h). + fn straight(n: usize) -> Vec { + (0..n).map(|i| [0.0, i as f64 * 0.0001, i as f64]).collect() + } + + #[test] + fn smoother_removes_a_jump() { + let mut t = straight(60); + // one wild point 0.01° (~1.1 km) off the line + t[30][0] = 0.01; + let (fixed, replaced) = viterbi_smooth(&t); + assert_eq!(replaced, 1); + assert!(fixed[30][0].abs() < 1e-6, "outlier not re-interpolated: {}", fixed[30][0]); + // everything else untouched + assert_eq!(fixed[29], t[29]); + assert_eq!(fixed[31], t[31]); + } + + #[test] + fn smoother_keeps_a_clean_track() { + let t = straight(60); + let (fixed, replaced) = viterbi_smooth(&t); + assert_eq!(replaced, 0); + assert_eq!(fixed, t); + } + + #[test] + fn smoother_removes_a_burst() { + let mut t = straight(80); + for k in 40..44 { + t[k][0] = 0.02; // 2.2 km off for 4 consecutive samples + } + let (fixed, replaced) = viterbi_smooth(&t); + assert_eq!(replaced, 4); + for k in 40..44 { + assert!(fixed[k][0].abs() < 1e-6); + } + } + + #[test] + fn downsample_caps_and_keeps_last() { + let t = straight(12000); + let d = downsample(t.clone(), 5000); + assert!(d.len() <= 5001); + assert_eq!(d.last(), t.last()); + } + + #[test] + fn stitch_planning_rules() { + // Healthy 1 Hz rollover (1 s gap) → nothing missing. + assert_eq!(plan_stitch(1.0, 299.0, Some(300.0), 20.0), None); + // 9 s gap, duration unknown → shift 8 s. + assert_eq!(plan_stitch(9.0, 291.0, None, 100.0), Some(8.0)); + // 9 s gap but the track already covers the whole file → the PREDECESSOR was + // missing its end; shifting us would misalign every frame. + assert_eq!(plan_stitch(9.0, 299.0, Some(300.0), 100.0), None); + // Duration caps the shift: 20 s gap but only 5 s missing from this file. + assert_eq!(plan_stitch(20.0, 295.0, Some(300.0), 100.0), Some(5.0)); + // Teleport guard: 5 km jump in 9 s can't be the same vehicle. + assert_eq!(plan_stitch(9.0, 291.0, None, 5000.0), None); + // Different session: 10 minutes apart. + assert_eq!(plan_stitch(600.0, 291.0, None, 100.0), None); + } + + #[test] + fn wgs84_distance_matches_reference_arcs() { + // Textbook WGS84 arc lengths: 1° latitude ≈ 110.574 km (equator) / + // 111.132 km (45°); 1° longitude at the equator ≈ 111.320 km. + assert!((dist_m(0.0, 0.0, 1.0, 0.0) - 110_574.0).abs() < 200.0); + assert!((dist_m(0.0, 0.0, 0.0, 1.0) - 111_320.0).abs() < 30.0); + assert!((dist_m(44.5, 0.0, 45.5, 0.0) - 111_132.0).abs() < 200.0); + } + + #[test] + fn track_roundtrip() { + let t = straight(10); + let v = track_value(&t); + let back = parse_track(&v); + assert_eq!(back.len(), 10); + assert!((back[9][1] - t[9][1]).abs() < 1e-5); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 31a7e1c..1eb99a6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,5 +1,7 @@ mod admin; mod auth; +mod chainage; +mod gps; mod ingest; mod models; mod nfs; @@ -41,6 +43,17 @@ const FOLDER_FILTER: &str = " AND (NOT EXISTS (SELECT 1 FROM project_folders pf OR (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path, '/[^/]*$', '') ELSE '' END) \ IN (SELECT folder FROM project_folders WHERE project_id = $1)) "; +/// SQL fragment: the derived lifecycle label for the UI badge. References the +/// unaliased `videos` table; shared by the video list and the map endpoint. +const WORKFLOW_STATUS_SQL: &str = "CASE + WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress' + WHEN videos.status = 'verified' THEN + CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2 + THEN 're-verified' ELSE 'verified' END + WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned' + ELSE videos.status + END"; + #[derive(Clone)] pub struct AppState { pub db: PgPool, @@ -117,6 +130,10 @@ async fn main() -> anyhow::Result<()> { let state = AppState { db, ingest_dir, admin_token, lease_secs, nfs_host, nfs_opts }; + // GPS pipeline: serial background sweep extracting each pending video's track + // with exiftool (one at a time — never hammers the NAS). + tokio::spawn(gps::sweep(state.clone())); + // Phase 3: background task that auto-releases expired claims back to the pool. { let db = state.db.clone(); @@ -180,6 +197,19 @@ async fn main() -> anyhow::Result<()> { .route("/api/projects/:id/members/:username", delete(admin::remove_member)) .route("/api/projects/:id/timer", post(project_timer)) .route("/api/projects/:id/folders", get(list_folders).post(set_folders)) + // Map view + GPS pipeline + road_type + chainage. + .route("/api/projects/:id/map", get(map_data)) + .route("/api/projects/:id/tracks.kml", get(export_tracks_kml)) + .route("/api/projects/:id/road_types", get(list_road_types).post(set_road_types)) + .route("/api/projects/:id/road_type_assign", post(assign_road_type)) + .route("/api/videos/:id/gps/rescan", post(gps::rescan)) + .route("/api/projects/:id/gps/rescan_all", post(gps::rescan_all)) + .route("/api/videos/:id/gps/correct", post(gps::correct)) + .route("/api/projects/:id/chainage/points", get(chainage::list_points).post(chainage::add_point)) + .route("/api/projects/:id/chainage/points/bulk", post(chainage::add_points_bulk)) + .route("/api/chainage/points/:id", delete(chainage::delete_point)) + .route("/api/projects/:id/chainage/recompute", post(chainage::recompute)) + .route("/api/projects/:id/chainage/quick", post(chainage::quick_assign)) // Phase 3 admin (token-gated). .route("/api/admin/storage", get(storage_info)) .route("/api/users", get(admin::list_users).post(admin::create_user)) @@ -387,6 +417,247 @@ async fn set_folders( Ok(Json(json!({ "folders": req.folders.len() }))) } +/// Max polyline points per video in the map payload. Full-resolution tracks stay in +/// the DB for chainage math; the map only needs the shape. +const MAP_MAX_PTS: usize = 400; + +/// `GET /api/projects/:id/map` — everything the map needs per video: GPS track +/// (corrected preferred), status/workflow labels, road_type + chainage. Videos with +/// `gps_status` none/error feed the "videos without metadata" panel. Folder-aware. +async fn map_data(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult> { + require_project_access(&s, &user, id).await?; + let q = format!( + r#"SELECT id, file_name, rel_path, status, {WORKFLOW_STATUS_SQL} AS workflow_status, + ignored, hand_raised, assigned_to, claimed_by, annotation_count, + road_type, gps_status, gps_len_m, gps_error, + chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m, route_seq, + COALESCE((SELECT count(*) FROM video_remarks r WHERE r.video_id = videos.id), 0) AS remark_count, + COALESCE(gps_track_corrected, gps_track) AS track, + (gps_track_corrected IS NOT NULL) AS corrected, + gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s + FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"# + ); + let mut rows = sqlx::query_as::<_, MapVideo>(&q) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + // Downsample tracks for transport — hundreds of videos × 5k points would be tens + // of MB; ~400 points per video keeps the payload in the low single-digit MB. + for r in rows.iter_mut() { + if let Some(t) = r.track.take() { + let pts = gps::parse_track(&t); + r.track = Some(gps::track_value(&gps::downsample(pts, MAP_MAX_PTS))); + } + } + Ok(Json(rows)) +} + +/// `GET /api/projects/:id/road_types` — the project's road-type labels + how many +/// videos carry each (admin or member). +async fn list_road_types(State(s): State, user: AuthUser, AxPath(id): AxPath) -> ApiResult> { + require_project_access(&s, &user, id).await?; + let rows = sqlx::query_as::<_, RoadTypeRow>( + r#"SELECT rt.name, COALESCE(c.cnt, 0) AS video_count + FROM project_road_types rt + LEFT JOIN (SELECT road_type, count(*)::bigint AS cnt + FROM videos WHERE project_id=$1 GROUP BY road_type) c + ON c.road_type = rt.name + WHERE rt.project_id=$1 ORDER BY rt.name"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + +/// `POST /api/projects/:id/road_types {names}` — replace the label set (admin). +/// Videos keep whatever label they already carry; removing a name never rewrites videos. +async fn set_road_types( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let names: Vec = req + .names + .iter() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) + .collect(); + let mut tx = s.db.begin().await.map_err(err)?; + sqlx::query("DELETE FROM project_road_types WHERE project_id=$1") + .bind(id) + .execute(&mut *tx) + .await + .map_err(err)?; + for n in &names { + sqlx::query("INSERT INTO project_road_types (project_id, name) VALUES ($1,$2) ON CONFLICT DO NOTHING") + .bind(id) + .bind(n) + .execute(&mut *tx) + .await + .map_err(err)?; + } + tx.commit().await.map_err(err)?; + admin::audit(&s.db, &user.username, "road_types_updated", json!({ "project_id": id, "count": names.len() })).await; + Ok(Json(json!({ "road_types": names.len() }))) +} + +/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign a +/// road type to the selected videos (admin; empty road_type clears it). +async fn assign_road_type( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + if req.video_ids.is_empty() { + return Err((StatusCode::BAD_REQUEST, "no videos selected".into())); + } + let rt = req.road_type.trim().to_string(); + if !rt.is_empty() { + let known: Option = + sqlx::query_scalar("SELECT 1 FROM project_road_types WHERE project_id=$1 AND name=$2") + .bind(id) + .bind(&rt) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if known.is_none() { + return Err((StatusCode::BAD_REQUEST, format!("'{rt}' is not a road type of this project"))); + } + } + let n = sqlx::query("UPDATE videos SET road_type=$3 WHERE project_id=$1 AND id = ANY($2)") + .bind(id) + .bind(&req.video_ids) + .bind(&rt) + .execute(&s.db) + .await + .map_err(err)? + .rows_affected(); + admin::audit(&s.db, &user.username, "road_type_assigned", + json!({ "project_id": id, "road_type": rt, "count": n })).await; + Ok(Json(json!({ "assigned": n, "road_type": rt }))) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// KML line color (aabbggrr) per workflow status — same buckets as the map view. +fn kml_status_color(status: &str) -> &'static str { + match status { + "pending" => "ff5252ff", + "annotated" => "ff23a6f5", + "assigned" => "ffff8cb4", + "in-progress" => "ffffa86e", + "verified" => "ff75cf5c", + "re-verified" => "ffa8bf2b", + _ => "ffae958f", // ignored / unknown → gray + } +} + +const KML_STATUSES: &[&str] = + &["pending", "annotated", "assigned", "in-progress", "verified", "re-verified", "ignored"]; + +/// `GET /api/projects/:id/tracks.kml?token=` — every scanned video's track as a KML +/// LineString colored by status (+ the calibration points), for Google Earth / +/// client hand-off. Auth via `?token=` (file download). +async fn export_tracks_kml( + State(s): State, + Query(q): Query, + AxPath(id): AxPath, +) -> Result { + if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() { + return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into())); + } + let vq = format!( + r#"SELECT file_name, {WORKFLOW_STATUS_SQL} AS workflow_status, ignored, road_type, + chainage_start_m, chainage_end_m, COALESCE(gps_track_corrected, gps_track) AS track + FROM videos WHERE project_id=$1 AND COALESCE(gps_track_corrected, gps_track) IS NOT NULL + {FOLDER_FILTER} ORDER BY file_name"# + ); + #[allow(clippy::type_complexity)] + let rows: Vec<(String, String, bool, String, Option, Option, serde_json::Value)> = + sqlx::query_as(&vq).bind(id).fetch_all(&s.db).await.map_err(err)?; + let points: Vec<(f64, f64, f64, String, String)> = sqlx::query_as( + "SELECT lat, lon, chainage_m, note, road_type FROM chainage_points WHERE project_id=$1 ORDER BY chainage_m", + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + + let mut kml = String::with_capacity(1 << 20); + kml.push_str("\n\n"); + kml.push_str(&format!("project {id} — video tracks\n")); + for st in KML_STATUSES { + kml.push_str(&format!( + "\n", + kml_status_color(st) + )); + } + kml.push_str("Videos\n"); + for (file_name, workflow_status, ignored, road_type, ch_s, ch_e, track) in rows { + let status = if ignored { "ignored".to_string() } else { workflow_status }; + let pts = gps::downsample(gps::parse_track(&track), 1000); + if pts.len() < 2 { + continue; + } + let mut desc = format!("status: {status}"); + if !road_type.is_empty() { + desc.push_str(&format!(" · road: {road_type}")); + } + if let (Some(a), Some(b)) = (ch_s, ch_e) { + desc.push_str(&format!(" · chainage {:.3} → {:.3} km", a / 1000.0, b / 1000.0)); + } + kml.push_str(&format!( + "{}{}#st-{}1", + xml_escape(&file_name), xml_escape(&desc), status + )); + for p in &pts { + kml.push_str(&format!("{},{},0 ", p[1], p[0])); + } + kml.push_str("\n"); + } + kml.push_str("\nCalibration points\n"); + for (lat, lon, chainage_m, note, road_type) in points { + let name = format!("{:.3} km{}", chainage_m / 1000.0, if road_type.is_empty() { String::new() } else { format!(" ({road_type})") }); + kml.push_str(&format!( + "{}{}{lon},{lat},0\n", + xml_escape(&name), xml_escape(¬e) + )); + } + kml.push_str("\n\n"); + + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.google-earth.kml+xml") + .header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"project_{id}_tracks.kml\"")) + .body(Body::from(kml)) + .map_err(err) +} + +/// Server-authoritative wire-format metadata: inject/overwrite `road_type` + chainage +/// keys at the top level of an export doc. Applied on claim (pull), push (store) and +/// both export endpoints, so a worker's old client can never drop them. +pub fn stamp_road_meta( + doc: &mut serde_json::Value, + road_type: &str, + chainage: (Option, Option, Option, Option), +) { + if let Some(obj) = doc.as_object_mut() { + obj.insert("road_type".into(), json!(road_type)); + obj.insert("chainage_start_m".into(), json!(chainage.0)); + obj.insert("chainage_end_m".into(), json!(chainage.1)); + obj.insert("sr_chainage_start_m".into(), json!(chainage.2)); + obj.insert("sr_chainage_end_m".into(), json!(chainage.3)); + } +} + async fn connect_with_retry(url: &str, attempts: u32) -> anyhow::Result { let mut last = None; for i in 1..=attempts { @@ -538,14 +809,9 @@ async fn list_videos(State(s): State, user: AuthUser, AxPath(id): AxPa WHERE video_id = videos.id AND event='push' GROUP BY username) s), '') AS verifiers, assigned_to, -- derived lifecycle label for the UI badge - CASE - WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress' - WHEN videos.status = 'verified' THEN - CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2 - THEN 're-verified' ELSE 'verified' END - WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned' - ELSE videos.status - END AS workflow_status, + {WORKFLOW_STATUS_SQL} AS workflow_status, + road_type, gps_status, gps_len_m, gps_error, gps_sample_count, gps_max_gap_s, duration_s, + chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m, route_seq, hand_raised, hand_raised_by, hand_raised_at, ignored, ignored_by, COALESCE((SELECT json_agg(json_build_object('username', r.username, 'body', r.body, 'created_at', r.created_at) ORDER BY r.created_at) @@ -598,19 +864,29 @@ async fn export_video(State(s): State, Query(q): Query, Ax if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() { return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into())); } - let row: Option<(String, String, Option)> = - sqlx::query_as("SELECT file_name, rel_path, raw_json FROM videos WHERE id=$1") + #[allow(clippy::type_complexity)] + let row: Option<(String, String, Option, String, Option, Option, Option, Option)> = + sqlx::query_as( + r#"SELECT file_name, rel_path, raw_json, road_type, + chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m + FROM videos WHERE id=$1"#, + ) .bind(id) .fetch_optional(&s.db) .await .map_err(err)?; - let (file_name, rel_path, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; + let (file_name, rel_path, raw_json, road_type, ch_s, ch_e, sr_s, sr_e) = + row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; // Embed the video identity inside the doc so the file's name/path travel with it. - let doc = ensure_video_block( + let mut doc = ensure_video_block( raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })), &file_name, &rel_path, ); + stamp_road_meta(&mut doc, &road_type, (ch_s, ch_e, sr_s, sr_e)); + if let Some(geo) = chainage::video_geo(&s.db, id).await { + chainage::stamp_annotation_geo(&mut doc, &geo); + } let body = serde_json::to_vec_pretty(&doc).map_err(err)?; let stem = file_name.rsplit_once('.').map(|(a, _)| a).unwrap_or(&file_name); let fname = format!("{stem}_annotations.json"); @@ -627,26 +903,31 @@ async fn export_project(State(s): State, Query(q): Query, if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() { return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into())); } - let rows: Vec<(i32, String, String, Option)> = sqlx::query_as( - "SELECT id, file_name, rel_path, raw_json FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path", + #[allow(clippy::type_complexity)] + let rows: Vec<(i32, String, String, Option, String, Option, Option, Option, Option)> = sqlx::query_as( + r#"SELECT id, file_name, rel_path, raw_json, road_type, + chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m + FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path"#, ) .bind(id) .fetch_all(&s.db) .await .map_err(err)?; - let videos: Vec = rows - .into_iter() - .map(|(vid, fname, rel, raw)| { - // file_name first (struct order) AND embedded in the annotations doc, so the - // name is both immediately visible per entry and self-contained with the data. - let annotations = ensure_video_block( - raw.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })), - &fname, - &rel, - ); - ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations } - }) - .collect(); + let mut videos: Vec = Vec::with_capacity(rows.len()); + for (vid, fname, rel, raw, road_type, ch_s, ch_e, sr_s, sr_e) in rows { + // file_name first (struct order) AND embedded in the annotations doc, so the + // name is both immediately visible per entry and self-contained with the data. + let mut annotations = ensure_video_block( + raw.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })), + &fname, + &rel, + ); + stamp_road_meta(&mut annotations, &road_type, (ch_s, ch_e, sr_s, sr_e)); + if let Some(geo) = chainage::video_geo(&s.db, vid).await { + chainage::stamp_annotation_geo(&mut annotations, &geo); + } + videos.push(ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations }); + } let doc = json!({ "project_id": id, "video_count": videos.len(), "videos": videos }); let body = serde_json::to_vec_pretty(&doc).map_err(err)?; Response::builder() diff --git a/server/src/models.rs b/server/src/models.rs index 1a65ccf..4394ee3 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -148,6 +148,24 @@ pub struct VideoRow { // pending | annotated | assigned | in-progress | verified | re-verified. pub assigned_to: Option, pub workflow_status: String, + // Road type label ('' = unassigned) — injected into the export/claim JSON. + pub road_type: String, + // GPS pipeline: pending | ok | none | error (+ measured track length / last error). + pub gps_status: String, + pub gps_len_m: Option, + pub gps_error: Option, + // Data quality: raw fix count + biggest dropout — the table compares the count + // against the video duration (≈1 fix/s) and flags mismatches. + pub gps_sample_count: Option, + pub gps_max_gap_s: Option, + // Real container duration from the scan (raw NAS videos have no fps/frame_count). + pub duration_s: Option, + // Client-calibrated chainage + the immutable GPS-measured (sr) chainage, meters. + pub chainage_start_m: Option, + pub chainage_end_m: Option, + pub sr_chainage_start_m: Option, + pub sr_chainage_end_m: Option, + pub route_seq: Option, // "Raise hand" signal — anyone may raise (to start a conversation) or lower it. pub hand_raised: bool, pub hand_raised_by: Option, @@ -163,6 +181,61 @@ pub struct VideoRow { pub passes: serde_json::Value, } +/// One row of `GET /api/projects/:id/map` — a video as the map sees it. `track` is +/// the (transport-downsampled) effective polyline: corrected when kept, else raw; +/// None until the GPS sweep has scanned the video (or when it has no metadata). +#[derive(Serialize, sqlx::FromRow)] +pub struct MapVideo { + pub id: i32, + pub file_name: String, + pub rel_path: String, + pub status: String, + pub workflow_status: String, + pub ignored: bool, + pub hand_raised: bool, + pub assigned_to: Option, + pub claimed_by: Option, + pub annotation_count: i32, + pub road_type: String, + pub gps_status: String, + pub gps_len_m: Option, + pub gps_error: Option, + pub chainage_start_m: Option, + pub chainage_end_m: Option, + pub sr_chainage_start_m: Option, + pub sr_chainage_end_m: Option, + pub route_seq: Option, + pub remark_count: i64, + pub track: Option, + pub corrected: bool, + // Who/what produced the correction ('auto' or a username) + its summary note. + pub gps_corrected_by: Option, + pub gps_corrected_note: Option, + // Data quality from the raw scan: fix count + biggest dropout between fixes. + pub gps_sample_count: Option, + pub gps_max_gap_s: Option, +} + +/// `GET /api/projects/:id/road_types` — a label + how many videos carry it. +#[derive(Serialize, sqlx::FromRow)] +pub struct RoadTypeRow { + pub name: String, + pub video_count: i64, +} + +/// `POST /api/projects/:id/road_types {names}` — replace the label set (admin). +#[derive(Deserialize)] +pub struct RoadTypesRequest { + pub names: Vec, +} + +/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign. +#[derive(Deserialize)] +pub struct RoadTypeAssignRequest { + pub video_ids: Vec, + pub road_type: String, // '' = clear +} + /// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video. #[derive(Deserialize)] pub struct HandRequest { diff --git a/server/src/worker.rs b/server/src/worker.rs index c918fc5..9ceafa4 100644 --- a/server/src/worker.rs +++ b/server/src/worker.rs @@ -52,14 +52,16 @@ pub async fn claim( user: AuthUser, AxPath(id): AxPath, ) -> ApiResult { - let row: Option<(i32, String, String, Option, Option, Option, Option, chrono::DateTime, Option)> = + #[allow(clippy::type_complexity)] + let row: Option<(i32, String, String, Option, Option, Option, Option, chrono::DateTime, Option, String, Option, Option, Option, Option)> = sqlx::query_as( r#" UPDATE videos SET claimed_by=$2, claimed_at=now(), lease_expires_at=now() + ($3::int * interval '1 second') WHERE id=$1 AND (claimed_by IS NULL OR claimed_by=$2 OR lease_expires_at < now()) - RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json + RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json, + road_type, chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m "#, ) .bind(id) @@ -69,7 +71,8 @@ pub async fn claim( .await .map_err(err)?; - let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json)) = row + let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json, + road_type, ch_s, ch_e, sr_s, sr_e)) = row else { // Distinguish "doesn't exist" from "not claimable". let exists: Option = sqlx::query_scalar("SELECT id FROM videos WHERE id=$1") @@ -88,6 +91,21 @@ pub async fn claim( log_event(&s.db, video_id, &user.username, "claim", json!({})).await; + // Inject the server-authoritative road_type + chainage into the doc the worker + // pulls (only when a doc exists — a pending video legitimately has none; push + // re-stamps regardless, so the metadata always persists). Each annotation also + // gets its frame-exact lat/lon + chainage. + let annotations = match raw_json { + Some(mut doc) => { + crate::stamp_road_meta(&mut doc, &road_type, (ch_s, ch_e, sr_s, sr_e)); + if let Some(geo) = crate::chainage::video_geo(&s.db, video_id).await { + crate::chainage::stamp_annotation_geo(&mut doc, &geo); + } + doc + } + None => Value::Null, + }; + Ok(Json(ClaimResponse { video_id, file_name, @@ -98,7 +116,7 @@ pub async fn claim( frame_count, lease_expires_at, download_url: format!("/api/videos/{video_id}/download"), - annotations: raw_json.unwrap_or(Value::Null), + annotations, })) } @@ -223,13 +241,18 @@ pub async fn push( // lease lapsed (auto-released, or re-claimed by someone else) the worker must // re-claim before pushing — their local work is never lost, it just can't clobber a // claim they no longer hold. Admins may always push. - let row: Option<(Option, Option>)> = - sqlx::query_as("SELECT claimed_by, lease_expires_at FROM videos WHERE id=$1") + #[allow(clippy::type_complexity)] + let row: Option<(Option, Option>, String, Option, Option, Option, Option)> = + sqlx::query_as( + r#"SELECT claimed_by, lease_expires_at, road_type, + chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m + FROM videos WHERE id=$1"#, + ) .bind(id) .fetch_optional(&s.db) .await .map_err(err)?; - let (claimed_by, lease_expires_at) = + let (claimed_by, lease_expires_at, road_type, ch_s, ch_e, sr_s, sr_e) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; if !user.is_admin() { let holds_active_claim = claimed_by.as_deref() == Some(user.username.as_str()) @@ -250,6 +273,34 @@ pub async fn push( let doc: ExportDoc = serde_json::from_value(body.clone()) .map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid export doc: {e}")))?; + // Adopt the doc's fps/frame_count/resolution BEFORE stamping. A raw (from-scratch) + // NAS video has no fps in our DB — but the desktop decoded the real file, so its + // fps is authoritative. Without this, per-annotation lat/lon/chainage can't be + // computed (frame→time needs fps) and the geo silently wouldn't be written. + // COALESCE(doc, existing) so a doc that omits a field never wipes it. + if let Some(v) = doc.video.as_ref() { + let _ = sqlx::query( + r#"UPDATE videos SET fps=COALESCE($2, fps), frame_count=COALESCE($3, frame_count), + width=COALESCE($4, width), height=COALESCE($5, height) WHERE id=$1"#, + ) + .bind(id) + .bind(v.fps) + .bind(v.frame_count.map(|x| x as i32)) + .bind(v.width.map(|x| x as i32)) + .bind(v.height.map(|x| x as i32)) + .execute(&s.db) + .await; + } + + // Re-stamp the server-authoritative road_type + chainage onto the stored doc — + // whatever the worker's client sent (or dropped), the server's values win. Every + // annotation gets its frame-exact lat/lon + chainage too (from the fps just set). + let mut body = body; + crate::stamp_road_meta(&mut body, &road_type, (ch_s, ch_e, sr_s, sr_e)); + if let Some(geo) = crate::chainage::video_geo(&s.db, id).await { + crate::chainage::stamp_annotation_geo(&mut body, &geo); + } + let ann_count = (doc.fixed_annotations.len() + doc.range_annotations.len()) as i32; // Recompute the video-level annotator fields from the pushed doc (same idea as