map and chainage calculation

This commit is contained in:
2026-07-07 22:42:47 +05:30
parent 05ce1f2cdb
commit 308d6bc129
17 changed files with 4178 additions and 48 deletions

View File

@@ -166,6 +166,45 @@ pass back. All Phase 3 routes require `Authorization: Bearer <token>`.
- `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 |

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Set<number>>(new Set());
const [members, setMembers] = useState<Member[]>([]);
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<RoadType[]>([]);
const [rtAssign, setRtAssign] = useState("");
// All ml_support users (admin only) — the pool the members picker chooses from.
const [users, setUsers] = useState<UserRow[]>([]);
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() {
</section>
</div>
{/* Per-video table */}
{/* Per-video table / map */}
<section className="panel">
<div className="panel-head">
<h3>Videos ({videos.length})</h3>
<h3>
Videos ({videos.length})
<span className="viewtabs videos-toggle">
<button className={videoView === "table" ? "active" : ""} onClick={() => setVideoView("table")}>Table</button>
<button className={videoView === "map" ? "active" : ""} onClick={() => setVideoView("map")}>Map</button>
</span>
</h3>
<div className="panel-actions">
{isAdmin && projectId != null && (
<FoldersPicker projectId={projectId} onChange={() => loadProject(projectId)} />
)}
{isAdmin && (
{isAdmin && videoView === "table" && (
<span className="assign-bar">
<span className="dim">{selected.size} selected </span>
<select value={assignee} onChange={(e) => setAssignee(e.target.value)} title="Assign to a project member (or unassign)">
@@ -454,7 +492,14 @@ export function App() {
{members.map((m) => <option key={m.username} value={m.username}>{m.username}</option>)}
</select>
<button className="btn" disabled={busy || selected.size === 0 || (assignee !== "" && members.length === 0)} onClick={assignSelected}>Assign</button>
{members.length === 0 && <span className="dim"> add members in Admin first</span>}
<select value={rtAssign} onChange={(e) => setRtAssign(e.target.value)} title="Set the road type of the selected videos (or clear it)">
<option value="">(clear road type)</option>
{roadTypes.map((r) => <option key={r.name} value={r.name}>{r.name}</option>)}
</select>
<button className="btn" disabled={busy || selected.size === 0} onClick={applyRoadType}
title={rtAssign ? "Apply the picked road type to the selected videos" : "Remove the road type from the selected videos"}>
{rtAssign ? "Set road" : "Clear road"}
</button>
</span>
)}
{projectId != null && videos.some((v) => v.annotation_count > 0) && (
@@ -464,13 +509,18 @@ export function App() {
)}
</div>
</div>
{videoView === "map" && projectId != null && (
<MapView projectId={projectId} isAdmin={isAdmin} onChanged={() => loadProject(projectId)} />
)}
{videoView === "table" && (
<div className="table-scroll">
<table>
<thead>
<tr>
{isAdmin && <th><input type="checkbox" title="select all"
checked={selected.size > 0 && selected.size === videos.length}
onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} /></th>}
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Annotator</th><th>Annotations</th>
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Road · Chainage</th><th>Annotator</th><th>Annotations</th>
<th>Review</th><th>Verify time</th><th>Resolution</th><th>Annotated</th><th>Remarks</th>
</tr>
</thead>
@@ -482,7 +532,7 @@ export function App() {
<td className="dim mono" title={v.rel_path}>{subfolder(v.rel_path) || "—"}</td>
<td className="mono">{v.file_name}</td>
<td>
<span className={`badge ${v.workflow_status}`}>{v.workflow_status}</span>
<span className={`badge ${v.workflow_status}`}>{statusLabel(v.workflow_status)}</span>
{v.ignored && <span className="badge ignored-tag" title={v.ignored_by ? `ignored by ${v.ignored_by}` : "ignored"}>ignored</span>}
{isAdmin && (
<button className="ignore-btn" onClick={() => toggleIgnore(v)}
@@ -500,6 +550,22 @@ export function App() {
<div className="claim-line" title={`last push ${fmtWhen(v.completed_at)}`}> <b>{v.verifiers || v.completed_by}</b> · {fmtWhen(v.completed_at)}</div>
) : null}
</td>
<td>
{v.road_type ? <span className="badge road-tag">{v.road_type}</span> : <span className="dim"></span>}
<div className="claim-line" title={
v.sr_chainage_start_m != null
? `sr (GPS-measured): ${fmtChainageRange(v.sr_chainage_start_m, v.sr_chainage_end_m)}`
: v.gps_status === "ok" ? "GPS scanned — chainage not calibrated yet"
: v.gps_status === "error" ? `GPS scan failed: ${v.gps_error ?? ""}`
: v.gps_status === "none" ? "no GPS metadata in this video"
: "GPS scan pending"
}>
{v.chainage_start_m != null
? fmtChainageRange(v.chainage_start_m, v.chainage_end_m)
: <span className="dim">{v.gps_status === "ok" ? "gps ✓" : v.gps_status === "pending" ? "gps …" : `gps ${v.gps_status}`}</span>}
</div>
<GpsPoints v={v} />
</td>
<td>{v.verifiers || v.primary_annotator || (v.assigned_to ? <span className="dim">assigned: {v.assigned_to}</span> : "—")}</td>
<td>
{v.annotation_count > 0
@@ -543,10 +609,22 @@ export function App() {
</td>
</tr>
))}
{videos.length === 0 && <tr><td colSpan={isAdmin ? 12 : 11} className="dim">no videos ingested</td></tr>}
{videos.length === 0 && <tr><td colSpan={isAdmin ? 13 : 12} className="dim">no videos ingested</td></tr>}
</tbody>
</table>
</div>
)}
</section>
{/* Chainage calibration + road-type labels (admin) */}
{isAdmin && projectId != null && (
<ChainagePanel
projectId={projectId}
videos={videos}
roadTypes={roadTypes}
onChanged={() => loadProject(projectId)}
/>
)}
</>
)}
</div>
@@ -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 (
<div className="claim-line gps-pts">
{v.gps_sample_count} gps pts
{expected != null && <span className="dim"> / ~{expected}</span>}
{(off || bigGap) && (
<button
className="gps-warn"
onClick={() => setOpen((o) => !o)}
title="Fix count doesn't match the video duration — click to see how the gaps are handled"
>
</button>
)}
{open && (off || bigGap) && (
<div className="gps-explain" onClick={() => setOpen(false)}>
{diff < 0 || (!off && bigGap) ? (
<>
Expected ~{expected ?? "?"} fixes (1 per second of video), found {v.gps_sample_count}
{bigGap && <> largest dropout <b>{v.gps_max_gap_s!.toFixed(0)} s</b></>}.
Positions inside a dropout are <b>time-interpolated</b>: 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{" "}
<b>start</b> is stitched from the previous video via GPS timestamps when possible;
a missing <b>end</b> 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}
</div>
)}
</div>
);
}
function Card({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className={`card ${tone ?? ""}`}>

View File

@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<name>chainage points — sample</name>
<!-- One Placemark per survey point.
name = the chainage at that point: "12+400" (12 km + 400 m) or "12.4" (km)
coordinates = lon,lat[,alt] (KML order: longitude first!)
The dashboard reads the name (or description) and the Point coordinates.
LineStrings/paths are ignored — only Point placemarks import. -->
<Placemark><name>0+000</name><Point><coordinates>78.185718,21.105570,0</coordinates></Point></Placemark>
<Placemark><name>5+000</name><Point><coordinates>78.158000,21.092045,0</coordinates></Point></Placemark>
<Placemark><name>10+000</name><Point><coordinates>78.123885,21.091447,0</coordinates></Point></Placemark>
</Document></kml>
`;
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<ChainagePoint[]>([]);
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<KmlRow[] | null>(null);
const [kmlUnit, setKmlUnit] = useState<"km" | "m">("km");
const [kmlSkipped, setKmlSkipped] = useState(0);
const kmlFileRef = useRef<HTMLInputElement>(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<CalibrationSummary>) => {
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 (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; road types</h3>
<button className="btn" onClick={() => setOpen(true)}>Open </button>
</div>
</section>
);
}
return (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; road types</h3>
<button className="btn" onClick={() => setOpen(false)}>Close </button>
</div>
{error && <div className="error"> {error}</div>}
{/* Road-type labels (project-wide) */}
<div className="chainage-block">
<div className="dim block-label">Road types (assign them to selected videos in the table below)</div>
<div className="member-chips">
{roadTypes.map((r) => (
<span key={r.name} className="member-chip">
{r.name} <span className="dim">({r.video_count})</span>
<button className="chip-x" disabled={busy} onClick={() => removeRoadType(r.name)} title="Remove label">×</button>
</span>
))}
<input
className="chip-input"
value={newType}
onChange={(e) => setNewType(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }}
placeholder="Add: LHS / RHS / MCW…"
/>
<button className="btn" disabled={busy || !newType.trim()} onClick={addRoadType}>Add</button>
</div>
</div>
{/* Calibration group + the videos it covers */}
<div className="chainage-block form-row">
<label className="dim">Calibration group</label>
<select value={group} onChange={(e) => setGroup(e.target.value)}>
<option value="">(all videos)</option>
{roadTypes.map((r) => (
<option key={r.name} value={r.name}>{r.name}</option>
))}
</select>
<span className="dim">
{groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s)
</span>
</div>
<div className="grid2">
{/* Control points */}
<div className="chainage-block">
<div className="dim block-label">Client calibration points (lat/lon chainage)</div>
<table>
<thead>
<tr><th>Chainage</th><th>Lat, Lon</th><th>Group</th><th>Note</th><th></th></tr>
</thead>
<tbody>
{groupPoints.map((p) => (
<tr key={p.id}>
<td>{fmtChainage(p.chainage_m)}</td>
<td className="mono">{p.lat.toFixed(6)}, {p.lon.toFixed(6)}</td>
<td className="dim">{p.road_type || "any"}</td>
<td className="dim">{p.note || "—"}</td>
<td><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
</tr>
))}
{groupPoints.length === 0 && (
<tr><td colSpan={5} className="dim">no points yet add them, or use the quick assign</td></tr>
)}
</tbody>
</table>
<div className="form-row">
<input value={latlon} onChange={(e) => setLatlon(e.target.value)} placeholder="lat, lon" style={{ width: 190 }} />
<input value={pointKm} onChange={(e) => setPointKm(e.target.value)} placeholder="chainage (km)" style={{ width: 110 }} />
<input value={pointNote} onChange={(e) => setPointNote(e.target.value)} placeholder="note (optional)" style={{ width: 140 }} />
<button className="btn" disabled={busy} onClick={addPoint}>Add point</button>
<input
ref={kmlFileRef}
type="file"
accept=".kml,.kmz,.xml"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onKmlFile(f); }}
/>
<button className="btn" disabled={busy} onClick={() => kmlFileRef.current?.click()}
title="Upload a KML/KMZ of survey points — chainage read from each placemark's name (12+400 or a number)">
Import KML/KMZ
</button>
<button className="btn" onClick={downloadSampleKml}
title="Download the template your client's points file should follow">
Sample KML
</button>
</div>
{kmlRows && (
<div className="kml-preview">
<div className="form-row">
<b>{kmlRows.filter((r) => r.include).length}</b>
<span className="dim">of {kmlRows.length} points will import into group "{group || "any"}"
{kmlSkipped > 0 && ` · ${kmlSkipped} non-point placemark(s) skipped`}</span>
<span className="dim">· values in names are</span>
<select value={kmlUnit} onChange={(e) => setKmlUnitAndReparse(e.target.value as "km" | "m")}>
<option value="km">km</option>
<option value="m">meters</option>
</select>
<span className="dim">("12+400" always = 12 km + 400 m)</span>
</div>
<div className="kml-preview-list">
<table>
<thead>
<tr><th></th><th>Placemark</th><th>Lat, Lon</th><th>Chainage (km)</th></tr>
</thead>
<tbody>
{kmlRows.map((r, i) => (
<tr key={i} className={r.include ? undefined : "row-ignored"}>
<td><input type="checkbox" checked={r.include}
onChange={() => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, include: !x.include } : x))} /></td>
<td className="mono">{r.name || <span className="dim">(unnamed)</span>}</td>
<td className="mono dim">{r.lat.toFixed(6)}, {r.lon.toFixed(6)}</td>
<td><input value={r.km} style={{ width: 90 }} placeholder="?"
onChange={(e) => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, km: e.target.value, include: true } : x))} /></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-row">
<button className="btn primary" disabled={busy} onClick={importKml}>
Import {kmlRows.filter((r) => r.include).length} point(s)
</button>
<button className="btn" disabled={busy} onClick={() => setKmlRows(null)}>Cancel</button>
</div>
</div>
)}
</div>
{/* Quick assign + recompute */}
<div className="chainage-block">
<div className="dim block-label">
Two-point quick assign client gives just first &amp; last chainage; the error is
distributed over the whole route
</div>
<div className="form-row">
<label className="dim">Route starts at</label>
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} style={{ width: 90 }} /> km
<label className="dim">ends at</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} placeholder="49.5" style={{ width: 90 }} /> km
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
Assign &amp; compute
</button>
</div>
<div className="form-row" style={{ marginTop: 10 }}>
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
title="Re-run the calibration with the points above">
Recompute from points
</button>
<span className="dim">orders the {groupVideos.length} video(s) into a route, snaps the points, distributes the error</span>
</div>
</div>
</div>
{/* Result summary */}
{summary && (
<div className="chainage-block">
<div className="block-label">
Route <b>{(summary.route_len_m / 1000).toFixed(3)} km</b> (GPS-measured)
· {summary.points_used} point(s) used
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
{summary.reversed && <span className="dim"> · direction auto-reversed</span>}
</div>
{summary.warnings.length > 0 && (
<ul className="chainage-warnings">
{summary.warnings.map((w, i) => <li key={i}> {w}</li>)}
</ul>
)}
{summary.spans.length > 0 && (
<table>
<thead>
<tr><th>Span (client)</th><th>GPS length</th><th>Client length</th><th>Scale</th></tr>
</thead>
<tbody>
{summary.spans.map((sp, i) => (
<tr key={i} className={Math.abs(sp.scale - 1) > 0.05 ? "span-warn" : undefined}>
<td>{fmtChainage(sp.from_chainage_m)} {fmtChainage(sp.to_chainage_m)}</td>
<td>{(sp.gps_len_m / 1000).toFixed(3)} km</td>
<td>{(sp.client_len_m / 1000).toFixed(3)} km</td>
<td>×{sp.scale.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
)}
<table style={{ marginTop: 8 }}>
<thead>
<tr><th>#</th><th>Video</th><th>Chainage (client)</th><th>SR chainage (GPS)</th><th></th></tr>
</thead>
<tbody>
{summary.videos.map((v) => (
<tr key={v.id}>
<td className="dim">{v.seq + 1}</td>
<td className="mono">{v.file_name}</td>
<td>{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}</td>
<td className="dim">{fmtChainageRange(v.sr_start_m, v.sr_end_m)}</td>
<td className="dim">{v.flipped && (
<span title="This footage was filmed driving toward DECREASING chainage (e.g. the return carriageway), so frame 0 sits at the higher value — chainage runs high → low through the video. Handled automatically everywhere (per-annotation chainage included). If about half a group is flipped, you probably have both directions mixed in one group — split them by road type.">
flipped
</span>
)}</td>
</tr>
))}
</tbody>
</table>
{summary.skipped.length > 0 && (
<div className="dim" style={{ marginTop: 6 }}>
Skipped (no GPS): {summary.skipped.join(", ")}
</div>
)}
</div>
)}
</section>
);
}

692
dashboard/src/MapView.tsx Normal file
View File

@@ -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<string, string> = {
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<MapVideo[]>([]);
const [points, setPoints] = useState<ChainagePoint[]>([]);
const [colorBy, setColorBy] = useState<"video" | "status">("video");
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [thread, setThread] = useState<Remark[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null);
const mapDiv = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const trackLayer = useRef<L.LayerGroup | null>(null);
const pointLayer = useRef<L.LayerGroup | null>(null);
const correctionLayer = useRef<L.LayerGroup | null>(null);
const searchLayer = useRef<L.LayerGroup | null>(null);
const fitted = useRef(false);
// Per-video cumulative track distances, computed lazily for hover/search readouts.
const cumCache = useRef(new Map<number, number[]>());
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<string, number> = {};
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<unknown>, 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 (
<div className="mapview">
<div className="map-toolbar">
<div className="map-search">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name · chainage 12+400 · lat, lon…"
/>
{matches.length > 0 && (
<div className="map-search-results">
{matches.map((mt, i) => (
<button
key={mt.v ? `v${mt.v.id}` : `ll${i}`}
onClick={() => {
if (mt.ll) gotoLatLng(mt.ll, mt.v);
else if (mt.v && mt.ch != null) gotoChainage(mt.v, mt.ch);
else if (mt.v) select(mt.v);
setSearch("");
}}
>
{mt.v === null ? (
<>📍 <span className="mono">{mt.ll![0]}, {mt.ll![1]}</span> <span className="dim">· go to location</span></>
) : (
<>
<span className="mono">{mt.v.file_name}</span>
{mt.ch != null ? (
// Same chainage can exist on LHS, RHS AND a ramp — one row per group.
<span className="dim"> · <b>{mt.v.road_type || "no group"}</b> · contains {(mt.ch / 1000).toFixed(3)} km</span>
) : mt.away != null ? (
<span className="dim"> · {mt.away.toFixed(0)} m away{mt.v.road_type ? ` · ${mt.v.road_type}` : ""}</span>
) : mt.v.track ? (
<span className="dim"> · {statusLabel(mapStatus(mt.v))}</span>
) : (
<span className="err-tag"> · no GPS</span>
)}
</>
)}
</button>
))}
</div>
)}
</div>
<div className="map-colorby">
<button className={colorBy === "video" ? "active" : ""} onClick={() => setColorBy("video")}>
Color by video
</button>
<button className={colorBy === "status" ? "active" : ""} onClick={() => setColorBy("status")}>
Color by status
</button>
</div>
<a className="btn" href={tracksKmlUrl(projectId)} download
title="All tracks (status colors) + calibration points — opens in Google Earth">
KML
</a>
{colorBy === "status" && (
<div className="map-chips">
{STATUS_ORDER.filter((s) => (statusCounts[s] ?? 0) > 0).map((s) => (
<button
key={s}
className={`map-chip${hidden.has(s) ? " off" : ""}`}
style={{ borderColor: STATUS_COLORS[s], color: hidden.has(s) ? undefined : STATUS_COLORS[s] }}
onClick={() => toggleChip(s)}
title={hidden.has(s) ? "show" : "hide"}
>
{statusLabel(s)} ({statusCounts[s]})
</button>
))}
</div>
)}
</div>
{error && <div className="error"> {error}</div>}
<div className="map-body">
<div ref={mapDiv} className="map-canvas" />
<div className="map-side">
{selected ? (
<div className="map-details">
<div className="map-details-head">
<b className="mono">{selected.file_name}</b>
<button className="btn" onClick={() => select(null)}></button>
</div>
<div className="map-kv">
<span className="dim">Folder</span>
<span className="mono">{subfolder(selected.rel_path) || "—"}</span>
<span className="dim">Status</span>
<span>
<span className={`badge ${selected.workflow_status}`}>{statusLabel(selected.workflow_status)}</span>
{selected.ignored && <span className="badge ignored-tag">ignored</span>}
</span>
<span className="dim">Road type</span>
<span>{selected.road_type || "—"}</span>
<span className="dim">Chainage</span>
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
<span className="dim">SR chainage</span>
<span title="GPS-measured (immutable)">
{fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)}
</span>
<span className="dim">GPS</span>
<span>
{selected.gps_status === "ok" ? (
<>
{((selected.gps_len_m ?? 0) / 1000).toFixed(2)} km
{selected.gps_sample_count != null && <span className="dim"> · {selected.gps_sample_count} fixes</span>}
{(selected.gps_max_gap_s ?? 0) > 5 && (
<span className="err-tag" title="Largest dropout between GPS fixes — positions in the gap are time-interpolated">
{" "}· gap {selected.gps_max_gap_s!.toFixed(0)}s
</span>
)}
</>
) : (
selected.gps_status
)}
{selected.gps_error && <span className="err-tag" title={selected.gps_error}> </span>}
{selected.corrected && (
<div className="dim corrected-note" title={selected.gps_corrected_note ?? ""}>
{selected.gps_corrected_by === "auto" ? "auto-corrected" : `corrected by ${selected.gps_corrected_by}`}
{selected.gps_corrected_note ? `: ${selected.gps_corrected_note}` : ""}
</div>
)}
</span>
<span className="dim">Annotations</span>
<span>{selected.annotation_count}</span>
</div>
<div className="map-actions">
{isAdmin && (
<button className="btn" disabled={busy} onClick={() => toggleIgnore(selected)}>
{selected.ignored ? "Un-ignore" : "Ignore"}
</button>
)}
<button
className={`hand-btn${selected.hand_raised ? " raised" : ""}`}
disabled={busy}
onClick={() => toggleHand(selected)}
title={selected.hand_raised ? "Lower hand" : "Raise hand"}
>
</button>
{isAdmin && (
<button className="btn" disabled={busy} onClick={() => rescan(selected)} title="Queue a fresh exiftool scan">
Re-scan GPS
</button>
)}
{isAdmin && selected.gps_status === "ok" && !correction && (
<button className="btn" disabled={busy} onClick={() => previewCorrection(selected)}
title="Viterbi outlier correction — preview, then keep or discard">
GPS correction
</button>
)}
{isAdmin && selected.corrected && !correction && (
<button className="btn" disabled={busy} onClick={() => revertCorrection(selected)}
title="Drop the kept correction, back to the raw track">
Revert to raw
</button>
)}
</div>
{correction && correction.videoId === selected.id && (
<div className="map-correction">
<div>
<b>{correction.result.replaced}</b> of {correction.result.points} points corrected ·{" "}
{(correction.result.len_before_m / 1000).toFixed(2)} {" "}
{(correction.result.len_after_m / 1000).toFixed(2)} km
</div>
{correction.result.note && <div className="dim">{correction.result.note}</div>}
<div className="dim">red = original · green = corrected</div>
<div className="map-actions">
<button className="btn primary" disabled={busy} onClick={() => keepCorrection(selected)}>
Keep correction
</button>
<button className="btn" disabled={busy} onClick={discardCorrection}>
Discard
</button>
</div>
</div>
)}
<div className="map-remarks">
<div className="dim">Remarks ({thread.length})</div>
<div className="remark-thread">
{thread.length === 0 && <div className="dim">No remarks yet.</div>}
{thread.map((r, i) => (
<div key={i} className="remark-line">
<b>{r.username}:</b> {r.body}
</div>
))}
</div>
<div className="remark-add">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addRemark(selected);
}}
placeholder="Add a remark…"
/>
<button className="btn primary" disabled={busy || !draft.trim()} onClick={() => addRemark(selected)}>
Add
</button>
</div>
</div>
</div>
) : (
<div className="map-hint dim">
Click a track (or search) to see a video's details, ignore it or add a remark.
<br />
{withTrack.length} of {videos.length} videos have a GPS track.
</div>
)}
{/* Requirement: videos without metadata must be listed visibly. */}
<div className="map-nometa">
<div className="map-nometa-head">
Without GPS metadata <b>{noMeta.length}</b>
{pendingScan.length > 0 && <span className="dim"> · {pendingScan.length} awaiting scan</span>}
{isAdmin && (
<button className="btn mini" disabled={busy} style={{ marginLeft: 8 }}
title="Queue a fresh scan of EVERY video (background, one at a time). Needed once to enable start-stitching on videos scanned before the upgrade."
onClick={() => {
if (window.confirm("Re-scan GPS metadata for every video in this project? The sweep works through them in the background."))
void act(() => api.gpsRescanAll(projectId));
}}>
all
</button>
)}
</div>
<div className="map-nometa-list">
{noMeta.map((v) => (
<div key={v.id} className="map-nometa-row">
<button className="map-nometa-name mono" onClick={() => select(v)} title={v.rel_path}>
{v.file_name}
</button>
{v.gps_status === "error" ? (
<span className="err-tag" title={v.gps_error ?? "scan error"}>error</span>
) : (
<span className="dim">none</span>
)}
{isAdmin && (
<button className="btn mini" disabled={busy} onClick={() => rescan(v)} title="Queue a fresh scan">
</button>
)}
</div>
))}
{noMeta.length === 0 && <div className="dim">every scanned video has GPS </div>}
</div>
</div>
</div>
</div>
</div>
);
}
function subfolder(rel: string): string {
const i = rel.lastIndexOf("/");
return i >= 0 ? rel.slice(0, i) : "";
}

View File

@@ -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<string, string> {
const t = token ?? authToken;
@@ -288,8 +396,64 @@ export const api = {
folders: (projectId: number) => get<Folder[]>(`/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<MapVideo[]>(`/projects/${projectId}/map`),
remarks: (videoId: number) => get<Remark[]>(`/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<CorrectResult>(`/videos/${videoId}/gps/correct`, { action }),
// Road types: admin-defined labels + bulk assignment to selected videos.
roadTypes: (projectId: number) => get<RoadType[]>(`/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<ChainagePoint[]>(`/projects/${projectId}/chainage/points`),
addChainagePoint: (
projectId: number,
p: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string },
) => post<ChainagePoint>(`/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<CalibrationSummary>(`/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<CalibrationSummary>(`/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. */

View File

@@ -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);

View File

@@ -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"}
{"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"}

View File

@@ -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)
);

View File

@@ -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.

View File

@@ -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

1057
server/src/chainage.rs Normal file

File diff suppressed because it is too large Load Diff

888
server/src/gps.rs Normal file
View File

@@ -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 (meterstens of meters) and <2e-5 relative for spans up to
/// ~100 km — unlike spherical haversine, whose 0.10.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<Pt> {
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<Pt>, max: usize) -> Vec<Pt> {
if track.len() <= max || max < 2 {
return track;
}
let stride = track.len().div_ceil(max);
let last = *track.last().unwrap();
let mut out: Vec<Pt> = 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<Pt>,
t0_abs: Option<f64>,
duration_s: Option<f64>,
fps: Option<f64>,
}
/// 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<ScanResult, String> {
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<f64> {
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<String, Value>) -> (Vec<Pt>, Option<f64>) {
#[derive(Default)]
struct Partial {
lat: Option<f64>,
lon: Option<f64>,
t: Option<f64>,
}
let mut by_doc: BTreeMap<u32, Partial> = 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<Pt> = Vec::with_capacity(by_doc.len());
let mut t0: Option<f64> = None;
let mut t0_abs: Option<f64> = 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<f64> {
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<i32> = 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<Pt>, 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<usize> = (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<f64>, jump_m: f64) -> Option<f64> {
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<Pt>,
pub corrected: Vec<Pt>,
pub replaced: usize,
pub stitched_from: Option<String>,
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<CorrectionOutcome, String> {
#[allow(clippy::type_complexity)]
let row: Option<(Option<Value>, Option<f64>, Option<f64>, Option<i32>, Option<f64>, 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<String> = 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<bool> = 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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Value> {
user.require_admin()?;
let updated: Option<i32> =
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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(req): Json<CorrectRequest>,
) -> ApiResult<Value> {
user.require_admin()?;
let exists: Option<String> = 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<Value> = 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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Value> {
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<Pt> {
(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);
}
}

View File

@@ -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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<MapVideo>> {
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<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<RoadTypeRow>> {
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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(req): Json<RoadTypesRequest>,
) -> ApiResult<serde_json::Value> {
user.require_admin()?;
let names: Vec<String> = 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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(req): Json<RoadTypeAssignRequest>,
) -> ApiResult<serde_json::Value> {
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<i32> =
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// 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<AppState>,
Query(q): Query<TokenQuery>,
AxPath(id): AxPath<i32>,
) -> Result<Response, (StatusCode, String)> {
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<f64>, Option<f64>, 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>\n");
kml.push_str(&format!("<name>project {id} — video tracks</name>\n"));
for st in KML_STATUSES {
kml.push_str(&format!(
"<Style id=\"st-{st}\"><LineStyle><color>{}</color><width>4</width></LineStyle></Style>\n",
kml_status_color(st)
));
}
kml.push_str("<Folder><name>Videos</name>\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!(
"<Placemark><name>{}</name><description>{}</description><styleUrl>#st-{}</styleUrl><LineString><tessellate>1</tessellate><coordinates>",
xml_escape(&file_name), xml_escape(&desc), status
));
for p in &pts {
kml.push_str(&format!("{},{},0 ", p[1], p[0]));
}
kml.push_str("</coordinates></LineString></Placemark>\n");
}
kml.push_str("</Folder>\n<Folder><name>Calibration points</name>\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!(
"<Placemark><name>{}</name><description>{}</description><Point><coordinates>{lon},{lat},0</coordinates></Point></Placemark>\n",
xml_escape(&name), xml_escape(&note)
));
}
kml.push_str("</Folder>\n</Document></kml>\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<f64>, Option<f64>, Option<f64>, Option<f64>),
) {
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<PgPool> {
let mut last = None;
for i in 1..=attempts {
@@ -538,14 +809,9 @@ async fn list_videos(State(s): State<AppState>, 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<AppState>, Query(q): Query<TokenQuery>, 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<serde_json::Value>)> =
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<serde_json::Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
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<AppState>, Query(q): Query<TokenQuery>,
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<serde_json::Value>)> = 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<serde_json::Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> = 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<ExportedVideo> = 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<ExportedVideo> = 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()

View File

@@ -148,6 +148,24 @@ pub struct VideoRow {
// pending | annotated | assigned | in-progress | verified | re-verified.
pub assigned_to: Option<String>,
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<f64>,
pub gps_error: Option<String>,
// 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<i32>,
pub gps_max_gap_s: Option<f64>,
// Real container duration from the scan (raw NAS videos have no fps/frame_count).
pub duration_s: Option<f64>,
// Client-calibrated chainage + the immutable GPS-measured (sr) chainage, meters.
pub chainage_start_m: Option<f64>,
pub chainage_end_m: Option<f64>,
pub sr_chainage_start_m: Option<f64>,
pub sr_chainage_end_m: Option<f64>,
pub route_seq: Option<i32>,
// "Raise hand" signal — anyone may raise (to start a conversation) or lower it.
pub hand_raised: bool,
pub hand_raised_by: Option<String>,
@@ -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<String>,
pub claimed_by: Option<String>,
pub annotation_count: i32,
pub road_type: String,
pub gps_status: String,
pub gps_len_m: Option<f64>,
pub gps_error: Option<String>,
pub chainage_start_m: Option<f64>,
pub chainage_end_m: Option<f64>,
pub sr_chainage_start_m: Option<f64>,
pub sr_chainage_end_m: Option<f64>,
pub route_seq: Option<i32>,
pub remark_count: i64,
pub track: Option<Value>,
pub corrected: bool,
// Who/what produced the correction ('auto' or a username) + its summary note.
pub gps_corrected_by: Option<String>,
pub gps_corrected_note: Option<String>,
// Data quality from the raw scan: fix count + biggest dropout between fixes.
pub gps_sample_count: Option<i32>,
pub gps_max_gap_s: Option<f64>,
}
/// `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<String>,
}
/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign.
#[derive(Deserialize)]
pub struct RoadTypeAssignRequest {
pub video_ids: Vec<i32>,
pub road_type: String, // '' = clear
}
/// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video.
#[derive(Deserialize)]
pub struct HandRequest {

View File

@@ -52,14 +52,16 @@ pub async fn claim(
user: AuthUser,
AxPath(id): AxPath<i32>,
) -> ApiResult<ClaimResponse> {
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>)> =
#[allow(clippy::type_complexity)]
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
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<i32> = 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<String>, Option<chrono::DateTime<chrono::Utc>>)> =
sqlx::query_as("SELECT claimed_by, lease_expires_at FROM videos WHERE id=$1")
#[allow(clippy::type_complexity)]
let row: Option<(Option<String>, Option<chrono::DateTime<chrono::Utc>>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
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