From 0c9538fa9415bb118972cabecfe9b0e2e5c04115 Mon Sep 17 00:00:00 2001 From: Jusii Date: Thu, 11 Jun 2026 15:33:39 +0300 Subject: [PATCH 1/4] feat: first-class third-camera (telephoto T / interior I) support in backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viofo 3-channel models record a third file per capture alongside front (F) and rear (R): telephoto (…T.MP4, e.g. A329) or interior (…I.MP4, e.g. A139/A229 3CH). These downloaded fine but were invisible downstream: the scanner glob skipped them, the filename parsers returned None, and the archive pairer filed any non-F camera under "rear". - queue.py: camera/event regexes [FR] -> [FRTI] - _archive.py: scanner glob _*[FR].MP4 -> _*[FRTI].MP4 - naming.py: channel map gains T->tele (I->interior was already pre-wired); CHANNEL_ORDER/LABELS gain tele; export labels for the four new job types - archive.py day pairer: explicit slot dispatch (F/T/I, else rear) and tele/interior slots in the response - exporter.py: enqueue allowlist + join/pip dispatch for join_tele, join_interior, pip_tele, pip_interior; _pair_clips takes a required-slots tuple; _pip takes the partner slot. Front stays ffmpeg input 0 in every PiP variant — it carries the mic audio and default stream selection picks it; the filter helper needs no logic change since any non-front main already yields partner-fullscreen + front-inset - exports.py: route patterns accept the new types and the tele segment channel (interior was already accepted) The timeline editor needs no changes: channels are data-driven via channel_of(), and timeline.js renders whatever channel list the server sends. --- viofosync_lib/_archive.py | 6 ++- web/routers/archive.py | 22 +++++++--- web/routers/exports.py | 7 ++- web/services/exporter.py | 92 +++++++++++++++++++++++++++------------ web/services/naming.py | 23 +++++++--- web/services/queue.py | 10 +++-- 6 files changed, 112 insertions(+), 48 deletions(-) diff --git a/viofosync_lib/_archive.py b/viofosync_lib/_archive.py index 2a39c6f..c495c8f 100644 --- a/viofosync_lib/_archive.py +++ b/viofosync_lib/_archive.py @@ -30,11 +30,13 @@ "yearly": "[0-9][0-9][0-9][0-9]", } -# Downloaded recording filename glob pattern. +# Downloaded recording filename glob pattern. The trailing +# letter is the camera: F=front, R=rear, T=telephoto, +# I=interior. downloaded_filename_glob = ( "[0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9]" "_[0-9][0-9][0-9][0-9][0-9][0-9]" - "_*[FR].MP4" + "_*[FRTI].MP4" ) # Downloaded recording filename regular expression. diff --git a/web/routers/archive.py b/web/routers/archive.py index af76c10..2e06ba7 100644 --- a/web/routers/archive.py +++ b/web/routers/archive.py @@ -197,13 +197,19 @@ def _in_range(ts: int) -> bool: pass return True - # Pair front+rear by (timestamp, event_type). Viofo's F and R - # from one capture share a timestamp but get consecutive - # sequence numbers, so keying on sequence wouldn't pair them. + # Group cameras by (timestamp, event_type). Viofo clips from + # one capture share a timestamp but get consecutive sequence + # numbers, so keying on sequence wouldn't pair them. # event_type keeps parking (PF/PR) separate from normal (F/R). # Slot is picked from the last letter so PF/EF still = front. + # 3-channel models add either T (telephoto) or I (interior) + # alongside F+R. pairs: dict[tuple[int, str], dict] = defaultdict( - lambda: {"front": None, "rear": None, "sequence": None} + lambda: { + "front": None, "rear": None, + "tele": None, "interior": None, + "sequence": None, + } ) for r in rows: if not _in_range(r["timestamp"]): @@ -211,10 +217,12 @@ def _in_range(ts: int) -> bool: cam = (r["camera"] or "").upper() kind = r["event_type"] or "normal" key = (r["timestamp"], kind) - slot = "front" if cam.endswith("F") else "rear" + slot = {"F": "front", "T": "tele", "I": "interior"}.get( + cam[-1:], "rear" + ) pairs[key][slot] = dict(r) # Prefer the front sequence number for the pair; fall - # back to the rear's if there's no front clip. + # back to any other camera's if there's no front clip. if slot == "front" or pairs[key]["sequence"] is None: pairs[key]["sequence"] = r["sequence"] @@ -228,6 +236,8 @@ def _in_range(ts: int) -> bool: "iso": _dt.datetime.fromtimestamp(ts).isoformat(), "front": pair["front"], "rear": pair["rear"], + "tele": pair["tele"], + "interior": pair["interior"], }) return {"date": date, "clips": clips} diff --git a/web/routers/exports.py b/web/routers/exports.py index 1d53bbc..7c090ed 100644 --- a/web/routers/exports.py +++ b/web/routers/exports.py @@ -55,14 +55,17 @@ def _resolve_default_encoder(app_state) -> str: class Segment(BaseModel): - channel: str = Field(pattern="^(front|rear|interior|other)$") + channel: str = Field(pattern="^(front|rear|tele|interior|other)$") start_ts: float end_ts: float class CreateExport(BaseModel): type: str = Field( - pattern="^(join_front|join_rear|pip|pip_rear|timeline)$" + pattern=( + "^(join_front|join_rear|join_tele|join_interior" + "|pip|pip_rear|pip_tele|pip_interior|timeline)$" + ) ) clip_ids: List[int] = [] segments: list[Segment] | None = None diff --git a/web/services/exporter.py b/web/services/exporter.py index 34fea5f..3c69735 100644 --- a/web/services/exporter.py +++ b/web/services/exporter.py @@ -391,19 +391,21 @@ def _pip_filter_complex( ) -> str: """Build the -filter_complex argument for the PiP overlay. - ffmpeg input 0 is the front clip, input 1 is the rear clip. - ``main`` chooses which is the fullscreen base layer; the other - is scaled to 1/4 size and overlaid. ``main="front"`` (default) - reproduces the original front-fullscreen behaviour. Unknown - ``position`` values fall back to ``top_right`` so a typo - doesn't break ffmpeg invocation entirely. + ffmpeg input 0 is the front clip, input 1 is the partner clip + (rear, tele or interior). ``main`` chooses which is the + fullscreen base layer; the other is scaled to 1/4 size and + overlaid. ``main="front"`` (default) reproduces the original + front-fullscreen behaviour; any other value (rear / tele / + interior) makes the partner fullscreen with the front inset. + Unknown ``position`` values fall back to ``top_right`` so a + typo doesn't break ffmpeg invocation entirely. """ coords = _PIP_OVERLAY_COORDS.get( position, _PIP_OVERLAY_COORDS["top_right"], ) - # Only "front" / "rear" are ever passed (derived from the job - # type); anything else falls through to rear-main rather than - # erroring, matching the lenient position handling above. + # ``main`` is derived from the job type (front / rear / tele / + # interior); anything that isn't "front" means partner-main, + # matching the lenient position handling above. base, inset = ("0", "1") if main == "front" else ("1", "0") if encoder == "qsv": # GPU composition: scale_qsv shrinks the inset, overlay_qsv composes @@ -617,7 +619,10 @@ def enqueue( ) -> int: if not ffmpeg_available(): raise RuntimeError("ffmpeg not installed on this host") - if job_type not in ("join_front", "join_rear", "pip", "pip_rear"): + if job_type not in ( + "join_front", "join_rear", "join_tele", "join_interior", + "pip", "pip_rear", "pip_tele", "pip_interior", + ): raise ValueError(f"unknown job type: {job_type}") if not clip_ids: raise ValueError("no clips selected") @@ -888,8 +893,14 @@ async def _run_job(self, job: dict) -> None: clips = self._fetch_clips(clip_ids) - if job["type"] in ("join_front", "join_rear"): - wanted = "F" if job["type"] == "join_front" else "R" + join_wanted = { + "join_front": "F", + "join_rear": "R", + "join_tele": "T", + "join_interior": "I", + } + if job["type"] in join_wanted: + wanted = join_wanted[job["type"]] # ``camera`` may be ``F``, ``R``, ``PF``, ``PR``, etc. # The last letter identifies the lens. selected = [ @@ -903,38 +914,57 @@ async def _run_job(self, job: dict) -> None: ) return await self._concat(job["id"], selected, out) - else: # pip / pip_rear - pairs = self._pair_clips(clips) + else: # pip / pip_rear / pip_tele / pip_interior + # The PiP partner is the non-front camera; ``main`` + # chooses which side is fullscreen. Front is always + # ffmpeg input 0 (it carries the mic audio). + partner = { + "pip_tele": "tele", + "pip_interior": "interior", + }.get(job["type"], "rear") + pairs = self._pair_clips( + clips, required=("front", partner), + ) if not pairs: self._finish( job["id"], False, - "no front+rear pairs in selection", None, + f"no front+{partner} pairs in selection", None, ) return - main = "rear" if job["type"] == "pip_rear" else "front" + main = { + "pip_rear": "rear", + "pip_tele": "tele", + "pip_interior": "interior", + }.get(job["type"], "front") await self._pip( job["id"], pairs, out, encoder, - snap.pip_position, main=main, + snap.pip_position, main=main, partner=partner, ) # ---- ffmpeg invocations ---- @staticmethod - def _pair_clips(clips: List[dict]): - # Viofo gives F and R from the same capture identical - # timestamps but consecutive sequences, so key on - # (timestamp, event_type) and pick the slot from the - # trailing letter of ``camera`` (handles PF/PR too). + def _pair_clips( + clips: List[dict], + required: tuple = ("front", "rear"), + ): + # Viofo gives same-capture clips identical timestamps but + # consecutive sequences, so key on (timestamp, event_type) + # and pick the slot from the trailing letter of ``camera`` + # (handles PF/PR/PT/PI too). ``required`` names the slots + # a group must have to count as a complete pair. pairs: dict[tuple[int, str], dict] = {} for c in clips: cam = (c["camera"] or "").upper() kind = c.get("event_type") or "normal" key = (c["timestamp"], kind) - slot = "front" if cam.endswith("F") else "rear" + slot = {"F": "front", "T": "tele", "I": "interior"}.get( + cam[-1:], "rear" + ) pairs.setdefault(key, {})[slot] = c return [ p for p in sorted(pairs.items()) - if "front" in p[1] and "rear" in p[1] + if all(s in p[1] for s in required) ] async def _concat( @@ -986,9 +1016,15 @@ async def _pip( encoder: str = "software", position: str = "top_right", main: str = "front", + partner: str = "rear", ) -> None: """One ffmpeg per pair into a temp dir, then concat. + ``partner`` names the non-front slot in each pair (rear, + tele or interior). Front is always ffmpeg input 0 — it + carries the mic audio, and ``-c:a copy`` with no explicit + ``-map`` makes ffmpeg's default stream selection pick it. + Broadcasts segment-level progress so the UI shows something meaningful even though each segment is a separate ffmpeg invocation.""" @@ -1000,9 +1036,9 @@ async def _pip( for i, (_, p) in enumerate(pairs): # Probe this segment's duration so the inner # pump() can emit fine-grained progress instead - # of sitting silent for minutes. Front and rear of a - # Viofo pair share a duration, so the front clip is a - # fine reference even for rear-main (pip_rear). + # of sitting silent for minutes. All cameras of a + # Viofo capture share a duration, so the front clip + # is a fine reference even for partner-main jobs. seg_dur = await self._probe_total( [p["front"]] ) @@ -1026,7 +1062,7 @@ async def _pip( *_hw_decode_args(encoder), "-i", p["front"]["path"], *_hw_decode_args(encoder), - "-i", p["rear"]["path"], + "-i", p[partner]["path"], # _with_upload appends hwupload for VAAPI only; for QSV # the filter already yields GPU surfaces, so it's a no-op. "-filter_complex", diff --git a/web/services/naming.py b/web/services/naming.py index e131315..248b8d0 100644 --- a/web/services/naming.py +++ b/web/services/naming.py @@ -24,8 +24,12 @@ LABEL_FOR_TYPE = { "join_front": "front", "join_rear": "rear", - "pip": "pip-front", # front-main PiP - "pip_rear": "pip-rear", # rear-main PiP + "join_tele": "tele", + "join_interior": "interior", + "pip": "pip-front", # front-main PiP + "pip_rear": "pip-rear", # rear-main PiP + "pip_tele": "pip-tele", # tele-main + front inset + "pip_interior": "pip-interior", # interior-main + front inset } @@ -91,16 +95,23 @@ def export_download_name( # --- Timeline camera channels ------------------------------------------- # The lens is the trailing letter of a clip's ``camera`` code: -# F / PF (parking) / EF (event) -> front; R / PR -> rear; a future -# interior lens is I. Anything else falls back to "other" so an +# F / PF (parking) / EF (event) -> front; R / PR -> rear; +# T -> telephoto; I -> interior. 3-channel models pair F+R with +# either T or I. Anything else falls back to "other" so an # unexpected code still gets its own track rather than vanishing. -_CHANNEL_FOR_LETTER = {"F": "front", "R": "rear", "I": "interior"} +_CHANNEL_FOR_LETTER = { + "F": "front", + "R": "rear", + "T": "tele", + "I": "interior", +} # Stable display order for channel tracks, and human labels. -CHANNEL_ORDER = ["front", "rear", "interior", "other"] +CHANNEL_ORDER = ["front", "rear", "tele", "interior", "other"] CHANNEL_LABELS = { "front": "Front", "rear": "Rear", + "tele": "Tele", "interior": "Interior", "other": "Other", } diff --git a/web/services/queue.py b/web/services/queue.py index 7b7f810..33d6226 100644 --- a/web/services/queue.py +++ b/web/services/queue.py @@ -164,10 +164,12 @@ def reconcile( def _camera_from_filename(filename: str) -> Optional[str]: # Handles both ``…_0001F.MP4`` and ``…_0001PF.MP4`` / # ``…_0001EF.MP4`` — the optional prefix letter encodes the - # event type (P=parking, E=event). + # event type (P=parking, E=event). The camera letter is + # F=front, R=rear, T=telephoto, I=interior — 3-channel + # models pair F+R with either T or I. import re as _re m = _re.match( - r"^\d{4}_\d{4}_\d{6}_\d+[PE]?([FR])\.MP4$", + r"^\d{4}_\d{4}_\d{6}_\d+[PE]?([FRTI])\.MP4$", filename, _re.IGNORECASE, ) @@ -177,7 +179,7 @@ def _camera_from_filename(filename: str) -> Optional[str]: def _event_from_filename(filename: str) -> Optional[str]: import re as _re m = _re.match( - r"^\d{4}_\d{4}_\d{6}_\d+([PE])?[FR]\.MP4$", + r"^\d{4}_\d{4}_\d{6}_\d+([PE])?[FRTI]\.MP4$", filename, _re.IGNORECASE, ) @@ -190,7 +192,7 @@ def _event_from_filename(filename: str) -> Optional[str]: # SQL expressions for deriving camera / event type straight # from the filename. Used for filtering so we don't depend on # historical rows having ``camera`` / ``event_type`` populated. -# Filenames end in ``…NNNNN[PE]?[FR].MP4`` — the camera letter +# Filenames end in ``…NNNNN[PE]?[FRTI].MP4`` — the camera letter # is the character immediately before ``.MP4``, and the byte # before that is either a digit (normal) or P/E. _CAM_SQL = "upper(substr(filename, -5, 1))" From c1eef55b96c7cdb7c0b818e5b910a2254d349631 Mon Sep 17 00:00:00 2001 From: Jusii Date: Thu, 11 Jun 2026 15:33:54 +0300 Subject: [PATCH 2/4] feat(ui): render third-camera thumbs, exports, badges and modal cycle - Day cards render a tele/interior thumb only when the pair has one; the thumbs grid auto-fits 2 or 3 columns (80px floor) so 2-camera days are pixel-identical - Selection tracks tele/interior clip ids; Originals / Join / PiP button groups for the third camera stay hidden until the selection contains that camera, so 2-camera setups never see them - Modal viewer: the binary F<->R toggle becomes a cycle over cameras present at the current timestamp (F->R->T/I), still on the F key; single-camera timestamps keep the toggle disabled - Queue badge labels Tele / Interior; shared kind-T/kind-I badge color (magenta, clear of the ok/rear/accent/warn/err hues) - Import dropzone hint mentions telephoto & interior --- web/static/app.js | 142 +++++++++++++++++++++++++++++++++--------- web/static/index.html | 33 +++++++++- web/static/styles.css | 15 ++++- 3 files changed, 158 insertions(+), 32 deletions(-) diff --git a/web/static/app.js b/web/static/app.js index 53e4bef..07e4ea8 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -19,7 +19,7 @@ const state = { queueSelected: new Set(),// filenames ticked filters: { driving: true, parking: true, ro: true }, showMaps: localStorage.getItem("vfs.showMaps") !== "0", - archiveSelected: new Map(), // pair_id → { ts, front, rear } + archiveSelected: new Map(), // pair_id → { ts, front, rear, tele, interior } archiveExpanded: new Set(), // open archive day keys ("YYYY-MM-DD"); persists // open days across in-app navigation (re-render) map: null, @@ -983,7 +983,11 @@ function renderClipPair(pair) { ${kindBadge} -
${thumb(pair.front, "F")}${thumb(pair.rear, "R")}
+
${thumb(pair.front, "F")}${thumb(pair.rear, "R")}${ + // Third camera (telephoto or interior) renders only when + // present so 2-camera days keep their familiar two columns. + pair.tele ? thumb(pair.tele, "T") : "" + }${pair.interior ? thumb(pair.interior, "I") : ""}
`; el.querySelectorAll(".thumb img").forEach((img) => { img.addEventListener("click", (e) => { @@ -1000,14 +1004,16 @@ function renderClipPair(pair) { if (state.archiveSelected.has(pairId)) cb.checked = true; cb.addEventListener("change", () => { if (cb.checked) { - const front = el.querySelector('.thumb[data-camera="F"]'); - const rear = el.querySelector('.thumb[data-camera="R"]'); + const clipId = (cam) => { + const t = el.querySelector(`.thumb[data-camera="${cam}"]`); + return t && t.dataset.clipId ? Number(t.dataset.clipId) : null; + }; state.archiveSelected.set(pairId, { ts: Number(el.dataset.ts), - front: front && front.dataset.clipId - ? Number(front.dataset.clipId) : null, - rear: rear && rear.dataset.clipId - ? Number(rear.dataset.clipId) : null, + front: clipId("F"), + rear: clipId("R"), + tele: clipId("T"), + interior: clipId("I"), }); } else { state.archiveSelected.delete(pairId); @@ -1085,10 +1091,15 @@ function updateArchiveActions() { bar.classList.add("has-selection"); } let fronts = 0, rears = 0, both = 0; + let teles = 0, interiors = 0, frontTele = 0, frontInterior = 0; for (const v of state.archiveSelected.values()) { if (v.front) fronts++; if (v.rear) rears++; if (v.front && v.rear) both++; + if (v.tele) teles++; + if (v.interior) interiors++; + if (v.front && v.tele) frontTele++; + if (v.front && v.interior) frontInterior++; } const hasFront = fronts > 0, hasRear = rears > 0, hasPair = both > 0; document.getElementById("dl-orig-front").disabled = !hasFront; @@ -1097,6 +1108,28 @@ function updateArchiveActions() { document.getElementById("export-join-rear").disabled = !hasRear; document.getElementById("export-pip-front").disabled = !hasPair; document.getElementById("export-pip-rear").disabled = !hasPair; + // Third-camera (tele / interior) actions: the whole button group + // stays hidden unless that camera appears in the day's data, so + // 2-camera setups see the original action bar unchanged. + const camGroup = (cam, present, dl, join, pip, pipOk) => { + const group = document.getElementById(`actions-${cam}`); + if (!group) return; + group.hidden = !present; + if (!present) return; + document.getElementById(dl).disabled = !present; + document.getElementById(join).disabled = !present; + document.getElementById(pip).disabled = !pipOk; + }; + camGroup( + "tele", teles > 0, + "dl-orig-tele", "export-join-tele", "export-pip-tele", + frontTele > 0, + ); + camGroup( + "interior", interiors > 0, + "dl-orig-interior", "export-join-interior", + "export-pip-interior", frontInterior > 0, + ); document.getElementById("clear-selection").disabled = n === 0; } @@ -1116,7 +1149,8 @@ function clearSelection() { } function downloadOriginals(slot) { - // slot: "front" | "rear". Download each selected original clip + // slot: "front" | "rear" | "tele" | "interior". Download each + // selected original clip // as its own file (no ZIP). The clip stream endpoint sends // Content-Disposition: attachment with the dashcam basename, so // a same-origin anchor click triggers a named download. Stagger @@ -1126,8 +1160,7 @@ function downloadOriginals(slot) { // doesn't take ages to fire. const ids = []; for (const v of state.archiveSelected.values()) { - if (slot === "front" && v.front) ids.push(v.front); - else if (slot === "rear" && v.rear) ids.push(v.rear); + if (v[slot]) ids.push(v[slot]); } if (!ids.length) return; ids.forEach((id, i) => { @@ -1148,9 +1181,18 @@ async function submitExport(type) { for (const v of state.archiveSelected.values()) { if (type === "join_front" && v.front) ids.push(v.front); else if (type === "join_rear" && v.rear) ids.push(v.rear); - else if (type === "pip" || type === "pip_rear") { + else if (type === "join_tele" && v.tele) ids.push(v.tele); + else if (type === "join_interior" && v.interior) { + ids.push(v.interior); + } else if (type === "pip" || type === "pip_rear") { if (v.front) ids.push(v.front); if (v.rear) ids.push(v.rear); + } else if (type === "pip_tele") { + if (v.front) ids.push(v.front); + if (v.tele) ids.push(v.tele); + } else if (type === "pip_interior") { + if (v.front) ids.push(v.front); + if (v.interior) ids.push(v.interior); } } if (!ids.length) return; @@ -1224,6 +1266,18 @@ document.getElementById("export-pip-front") .addEventListener("click", () => submitExport("pip")); document.getElementById("export-pip-rear") .addEventListener("click", () => submitExport("pip_rear")); +document.getElementById("dl-orig-tele") + .addEventListener("click", () => downloadOriginals("tele")); +document.getElementById("export-join-tele") + .addEventListener("click", () => submitExport("join_tele")); +document.getElementById("export-pip-tele") + .addEventListener("click", () => submitExport("pip_tele")); +document.getElementById("dl-orig-interior") + .addEventListener("click", () => downloadOriginals("interior")); +document.getElementById("export-join-interior") + .addEventListener("click", () => submitExport("join_interior")); +document.getElementById("export-pip-interior") + .addEventListener("click", () => submitExport("pip_interior")); document.getElementById("clear-selection") .addEventListener("click", clearSelection); @@ -1316,8 +1370,12 @@ function toast(message, opts = {}) { const EXPORT_TYPE_LABELS = { join_front: "Join Front", join_rear: "Join Rear", + join_tele: "Join Tele", + join_interior: "Join Interior", pip: "PiP Fr", pip_rear: "PiP Rf", + pip_tele: "PiP Tf", + pip_interior: "PiP If", timeline: "Timeline", }; @@ -1545,20 +1603,27 @@ function renderExportJobs(jobs) { // ---------- Video modal + nav ---------- +// Camera letters in modal cycle order. Only F+R plus one of T +// (telephoto) or I (interior) exist on any single device, but +// keeping all four here costs nothing — absent cameras have empty +// timelines and the cycle skips them. +const MODAL_CAMERAS = ["F", "R", "T", "I"]; + function buildCameraTimelines(scopeEl) { const root = scopeEl || document.getElementById("view-archive"); - const F = [], R = []; + const timelines = { F: [], R: [], T: [], I: [] }; root.querySelectorAll(".thumb[data-clip-id]").forEach((el) => { const entry = { id: Number(el.dataset.clipId), ts: Number(el.dataset.ts), el, }; - (el.dataset.camera === "F" ? F : R).push(entry); + (timelines[el.dataset.camera] || timelines.F).push(entry); }); - F.sort((a, b) => a.ts - b.ts); - R.sort((a, b) => a.ts - b.ts); - return { F, R }; + for (const cam of MODAL_CAMERAS) { + timelines[cam].sort((a, b) => a.ts - b.ts); + } + return timelines; } function openVideo(clipId, camera, sourceEl, opts = {}) { @@ -1613,13 +1678,30 @@ function updateModalNav() { prev.disabled = i <= 0; next.disabled = i < 0 || i >= list.length - 1; - const other = mc.camera === "F" ? "R" : "F"; const curr = list[i]; - const match = curr - ? mc.timelines[other].find((e) => e.ts === curr.ts) - : null; - toggle.disabled = !match; - toggle.textContent = other === "R" ? "Rear view" : "Front view"; + const next_ = curr ? nextCameraClip(mc, curr.ts) : null; + toggle.disabled = !next_; + if (next_) { + toggle.textContent = `${CAMERA_VIEW_LABELS[next_.camera]} view`; + } +} + +const CAMERA_VIEW_LABELS = { + F: "Front", R: "Rear", T: "Tele", I: "Interior", +}; + +// The next camera (cycling F→R→T→I→F) that has a clip at ``ts``, +// or null when the current camera is the only one — the cycle +// skips cameras with no same-timestamp clip, so a 2-cam day +// behaves exactly like the old binary toggle. +function nextCameraClip(mc, ts) { + const start = MODAL_CAMERAS.indexOf(mc.camera); + for (let step = 1; step < MODAL_CAMERAS.length; step++) { + const cam = MODAL_CAMERAS[(start + step) % MODAL_CAMERAS.length]; + const match = mc.timelines[cam].find((e) => e.ts === ts); + if (match) return { camera: cam, clip: match }; + } + return null; } function stepVideo(delta) { @@ -1635,17 +1717,16 @@ function stepVideo(delta) { function toggleVideoCamera() { const mc = state.modalClip; if (!mc) return; - const other = mc.camera === "F" ? "R" : "F"; const curr = mc.timelines[mc.camera].find((e) => e.id === mc.id); if (!curr) return; - const match = mc.timelines[other].find((e) => e.ts === curr.ts); - if (!match) return; + const next = nextCameraClip(mc, curr.ts); + if (!next) return; // Preserve current playback position and pause state so the // other camera picks up where this one was. const video = document.querySelector("#modal-body video"); const seekTo = video ? video.currentTime : 0; const autoplay = video ? !video.paused : true; - openVideo(match.id, other, match.el, { seekTo, autoplay }); + openVideo(next.clip.id, next.camera, next.clip.el, { seekTo, autoplay }); } function closeModal() { @@ -1795,7 +1876,12 @@ function renderQueue() { function renderKindBadge(it) { const cam = it.kind_camera || it.camera || ""; const evt = it.kind_event || ""; - const camLabel = cam === "F" ? "Front" : cam === "R" ? "Rear" : "?"; + const camLabel = + cam === "F" ? "Front" + : cam === "R" ? "Rear" + : cam === "T" ? "Tele" + : cam === "I" ? "Interior" + : "?"; const parts = [`${camLabel}`]; if (evt === "parking") { parts.push(`Parking`); diff --git a/web/static/index.html b/web/static/index.html index a00f6b3..5cf14b6 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -125,6 +125,33 @@

Archive

aria-label="Picture-in-picture, rear main" title="Picture-in-picture, rear main">Rf + + +