diff --git a/tests/test_cameras.py b/tests/test_cameras.py new file mode 100644 index 0000000..116d846 --- /dev/null +++ b/tests/test_cameras.py @@ -0,0 +1,121 @@ +"""Camera registry invariants + derivation pins. + +The registry (viofosync_lib/cameras.py) is the single source of +truth; naming.py derives the export-type tables from it. These +tests pin the derived values to the exact literals that used to +be hardcoded, so a registry edit that silently changes behavior +fails here first. +""" +from __future__ import annotations + +from viofosync_lib._archive import downloaded_filename_glob +from viofosync_lib.cameras import ( + CAMERA_LETTERS, + CAMERAS, + CHANNEL_FOR_LETTER, + pair_slot_of, +) +from web.services import naming + + +def test_registry_invariants(): + letters = [c.letter for c in CAMERAS] + channels = [c.channel for c in CAMERAS] + assert len(set(letters)) == len(letters), "duplicate letter" + assert len(set(channels)) == len(channels), "duplicate channel" + assert all(len(c.letter) == 1 and c.letter.isupper() for c in CAMERAS) + assert "other" not in channels, '"other" is the fallback, not a camera' + + +def test_letters_and_front_rear_positions(): + # Front and rear are load-bearing: front is ffmpeg input 0 / + # audio source, and both always render in the archive grid. + assert CAMERA_LETTERS == "FRTI" + assert CAMERAS[0].channel == "front" + assert CAMERAS[1].channel == "rear" + + +def test_channel_for_letter_matches_registry(): + assert CHANNEL_FOR_LETTER == { + "F": "front", "R": "rear", "T": "tele", "I": "interior", + } + + +def test_pair_slot_of_rear_fallback(): + # channel_of sends unknowns to "other"; the pairers' slot + # helper keeps their historical rear fallback instead. + assert pair_slot_of("F") == "front" + assert pair_slot_of("PT") == "tele" + assert pair_slot_of("X") == "rear" + assert pair_slot_of("") == "rear" + assert pair_slot_of(None) == "rear" + + +def test_glob_derivation(): + assert downloaded_filename_glob.endswith(f"_*[{CAMERA_LETTERS}].MP4") + + +def test_js_mirror_matches_registry(): + """app.js hand-mirrors the registry (no bundler, so it can't + import it). Extract the JS CAMERAS table and compare, so the + two can't drift apart silently.""" + import re + from pathlib import Path + + repo = Path(__file__).resolve().parents[1] + js = (repo / "web" / "static" / "app.js").read_text() + rows = re.findall( + r'\{\s*letter:\s*"(\w)",\s*channel:\s*"(\w+)",\s*' + r'label:\s*"(\w+)"\s*\}', + js, + ) + assert rows == [ + (c.letter, c.channel, c.label) for c in CAMERAS + ], "web/static/app.js CAMERAS table is out of sync with the registry" + + +# --- Derived export-type tables pin the pre-registry literals ---- + + +def test_export_job_types(): + assert set(naming.EXPORT_JOB_TYPES) == { + "join_front", "join_rear", "join_tele", "join_interior", + "pip", "pip_rear", "pip_tele", "pip_interior", + } + + +def test_join_letter_for_type(): + assert naming.JOIN_LETTER_FOR_TYPE == { + "join_front": "F", + "join_rear": "R", + "join_tele": "T", + "join_interior": "I", + } + + +def test_pip_partner_and_main_for_type(): + assert naming.PIP_PARTNER_FOR_TYPE == { + "pip": "rear", + "pip_rear": "rear", + "pip_tele": "tele", + "pip_interior": "interior", + } + assert naming.PIP_MAIN_FOR_TYPE == { + "pip": "front", + "pip_rear": "rear", + "pip_tele": "tele", + "pip_interior": "interior", + } + + +def test_label_for_type(): + assert naming.LABEL_FOR_TYPE == { + "join_front": "front", + "join_rear": "rear", + "join_tele": "tele", + "join_interior": "interior", + "pip": "pip-front", + "pip_rear": "pip-rear", + "pip_tele": "pip-tele", + "pip_interior": "pip-interior", + } diff --git a/viofosync_lib/__init__.py b/viofosync_lib/__init__.py index c30c58c..a133dcb 100644 --- a/viofosync_lib/__init__.py +++ b/viofosync_lib/__init__.py @@ -7,6 +7,13 @@ - :mod:`viofosync_lib._protocol` — HTTP API to the dashcam (XML listing, HTML scrape, byte downloader) - :mod:`viofosync_lib._gpx` — MP4 atom parsing + GPX generation + +plus one deliberately public submodule: + +- :mod:`viofosync_lib.cameras` — the camera registry (which + lenses exist and how a filename's trailing letter maps to one). + Imported directly (``from viofosync_lib.cameras import …``) by + both this package and the web layer. """ from __future__ import annotations diff --git a/viofosync_lib/_archive.py b/viofosync_lib/_archive.py index c495c8f..3c754a6 100644 --- a/viofosync_lib/_archive.py +++ b/viofosync_lib/_archive.py @@ -13,6 +13,8 @@ import re from collections import namedtuple +from .cameras import CAMERA_LETTERS + logger = logging.getLogger("viofosync_lib.archive") # Recording namedtuple matching Viofo's file information. @@ -31,12 +33,11 @@ } # Downloaded recording filename glob pattern. The trailing -# letter is the camera: F=front, R=rear, T=telephoto, -# I=interior. +# letter is the camera (see cameras.py for the registry). 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]" - "_*[FRTI].MP4" + f"_*[{CAMERA_LETTERS}].MP4" ) # Downloaded recording filename regular expression. diff --git a/viofosync_lib/cameras.py b/viofosync_lib/cameras.py new file mode 100644 index 0000000..168a1cf --- /dev/null +++ b/viofosync_lib/cameras.py @@ -0,0 +1,64 @@ +"""Camera registry — the single source of truth for Viofo lenses. + +A clip's filename ends ``…NNNNN[PE]?.MP4`` where the +optional P/E prefix encodes parking/event and ```` is the +camera. 2-channel models record F+R; 3-channel models add either +T (telephoto, e.g. A329) or I (interior, e.g. A139 / A229 3CH). + +Everything camera-shaped derives from :data:`CAMERAS`: the +download/scan glob, the queue's filename regexes, timeline +channel keys and labels, archive pair slots, and the per-camera +export job types (see ``web/services/naming.py`` for the +app-level derivations). Adding a future lens means adding one +:class:`Camera` line here plus its UI strings. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Camera: + letter: str # filename suffix letter (the F in ``…0001F.MP4``) + channel: str # stable machine key: timeline channel / pair slot + label: str # human-facing label + + +# Declaration order is display order (timeline tracks, UI cycles). +CAMERAS: tuple[Camera, ...] = ( + Camera("F", "front", "Front"), + Camera("R", "rear", "Rear"), + Camera("T", "tele", "Tele"), + Camera("I", "interior", "Interior"), +) + +# "FRTI" — drops straight into regex/glob character classes. +CAMERA_LETTERS = "".join(c.letter for c in CAMERAS) + +CHANNEL_FOR_LETTER = {c.letter: c.channel for c in CAMERAS} + + +def channel_of(camera: str | None) -> str: + """Map a clip's ``camera`` code to its channel key. + + The lens is the trailing letter of the code, so parking/event + prefixes (PF, ET, PI, …) resolve to the same channel as the + bare letter. Anything unrecognised falls back to ``"other"`` + so an unexpected code still gets its own track rather than + vanishing. + """ + if not camera: + return "other" + return CHANNEL_FOR_LETTER.get(camera[-1].upper(), "other") + + +def pair_slot_of(camera: str | None) -> str: + """Like :func:`channel_of`, but with the pairers' historical + ``"rear"`` fallback: when the archive day view and the export + pairer group same-capture clips into slots, an unknown letter + has always been filed under rear rather than given its own + slot.""" + if not camera: + return "rear" + return CHANNEL_FOR_LETTER.get(camera[-1].upper(), "rear") diff --git a/web/db.py b/web/db.py index a8cd91b..010e824 100644 --- a/web/db.py +++ b/web/db.py @@ -99,7 +99,7 @@ def migrate_legacy_db_path(new_path: str) -> None: basename TEXT NOT NULL, group_name TEXT, timestamp INTEGER NOT NULL, -- unix seconds - camera TEXT NOT NULL, -- 'F' or 'R' or other + camera TEXT NOT NULL, -- registry letter (F/R/T/I), possibly P/E-prefixed sequence INTEGER NOT NULL, event_type TEXT, -- 'normal'|'parking'|'ro' size_bytes INTEGER, @@ -136,7 +136,7 @@ def migrate_legacy_db_path(new_path: str) -> None: CREATE TABLE IF NOT EXISTS export_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, -- join_front|join_rear|pip|pip_rear|timeline + type TEXT NOT NULL, -- join_/pip_ per camera (see naming.EXPORT_JOB_TYPES) or timeline clip_ids TEXT NOT NULL, -- JSON array state TEXT NOT NULL, -- queued|running|done|failed|cancelled progress REAL NOT NULL DEFAULT 0.0, diff --git a/web/routers/archive.py b/web/routers/archive.py index 2e06ba7..d0e6072 100644 --- a/web/routers/archive.py +++ b/web/routers/archive.py @@ -24,7 +24,13 @@ from ..services import durations, filmstrip, route_cache, scanner, thumbs from ..services import tasks as _tasks from ..services import gps as gps_service -from ..services.naming import CHANNEL_LABELS, CHANNEL_ORDER, channel_of +from ..services.naming import ( + CAMERAS, + CHANNEL_LABELS, + CHANNEL_ORDER, + channel_of, + pair_slot_of, +) log = logging.getLogger("viofosync.archive") @@ -201,25 +207,18 @@ def _in_range(ts: int) -> bool: # 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. + # Slot is the registry channel of the last letter, so PF/EF + # still = front. + slots = [c.channel for c in CAMERAS] pairs: dict[tuple[int, str], dict] = defaultdict( - lambda: { - "front": None, "rear": None, - "tele": None, "interior": None, - "sequence": None, - } + lambda: dict.fromkeys([*slots, "sequence"]) ) for r in rows: if not _in_range(r["timestamp"]): continue - cam = (r["camera"] or "").upper() kind = r["event_type"] or "normal" key = (r["timestamp"], kind) - slot = {"F": "front", "T": "tele", "I": "interior"}.get( - cam[-1:], "rear" - ) + slot = pair_slot_of(r["camera"]) pairs[key][slot] = dict(r) # Prefer the front sequence number for the pair; fall # back to any other camera's if there's no front clip. @@ -234,10 +233,7 @@ def _in_range(ts: int) -> bool: "sequence": pair["sequence"], "event_type": kind, "iso": _dt.datetime.fromtimestamp(ts).isoformat(), - "front": pair["front"], - "rear": pair["rear"], - "tele": pair["tele"], - "interior": pair["interior"], + **{s: pair[s] for s in slots}, }) return {"date": date, "clips": clips} diff --git a/web/routers/exports.py b/web/routers/exports.py index 7c090ed..697e074 100644 --- a/web/routers/exports.py +++ b/web/routers/exports.py @@ -12,7 +12,12 @@ from ..auth import require_csrf, require_session from ..services import export_preview -from ..services.naming import export_download_name, parse_clip_ids +from ..services.naming import ( + CHANNEL_ORDER, + EXPORT_JOB_TYPES, + export_download_name, + parse_clip_ids, +) # 1x1 transparent PNG — served when a preview can't be produced (job not done, # unknown, or generation failed), so the degrades cleanly. @@ -55,17 +60,14 @@ def _resolve_default_encoder(app_state) -> str: class Segment(BaseModel): - channel: str = Field(pattern="^(front|rear|tele|interior|other)$") + channel: str = Field(pattern=f"^({'|'.join(CHANNEL_ORDER)})$") start_ts: float end_ts: float class CreateExport(BaseModel): type: str = Field( - pattern=( - "^(join_front|join_rear|join_tele|join_interior" - "|pip|pip_rear|pip_tele|pip_interior|timeline)$" - ) + pattern=f"^({'|'.join((*EXPORT_JOB_TYPES, 'timeline'))})$" ) clip_ids: List[int] = [] segments: list[Segment] | None = None diff --git a/web/services/exporter.py b/web/services/exporter.py index 3c69735..ad9dd45 100644 --- a/web/services/exporter.py +++ b/web/services/exporter.py @@ -6,13 +6,16 @@ frontend can show a progress bar via the same WebSocket the downloader uses. -Job types: - * ``join_front`` — concat demuxer on front clips only - * ``join_rear`` — same for rear - * ``pip`` — picture-in-picture: front fullscreen + - rear inset. Requires paired clips. - * ``pip_rear`` — picture-in-picture with rear fullscreen + - front inset. Requires paired clips. +Job types (derived per camera from the registry — see +``naming.EXPORT_JOB_TYPES``): + * ``join_`` — concat demuxer on that camera's clips + (``join_front``, ``join_rear``, ``join_tele``, + ``join_interior``) + * ``pip`` — picture-in-picture: front fullscreen + + rear inset. Requires paired clips. + * ``pip_`` — picture-in-picture with that camera + fullscreen + front inset. Requires + paired clips. Outputs land in ``$RECORDINGS/.exports/{job_id}.mp4`` and are served by the archive router via a standard ``FileResponse``. @@ -46,7 +49,14 @@ class _ExportCancelled(Exception): from ..settings import SettingsProvider from . import durations, export_preview from . import tasks as _tasks -from .naming import channel_of +from .naming import ( + EXPORT_JOB_TYPES, + JOIN_LETTER_FOR_TYPE, + PIP_MAIN_FOR_TYPE, + PIP_PARTNER_FOR_TYPE, + channel_of, + pair_slot_of, +) log = logging.getLogger("viofosync.exporter") @@ -619,10 +629,7 @@ def enqueue( ) -> int: if not ffmpeg_available(): raise RuntimeError("ffmpeg not installed on this host") - if job_type not in ( - "join_front", "join_rear", "join_tele", "join_interior", - "pip", "pip_rear", "pip_tele", "pip_interior", - ): + if job_type not in EXPORT_JOB_TYPES: raise ValueError(f"unknown job type: {job_type}") if not clip_ids: raise ValueError("no clips selected") @@ -893,14 +900,8 @@ async def _run_job(self, job: dict) -> None: clips = self._fetch_clips(clip_ids) - 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"]] + if job["type"] in JOIN_LETTER_FOR_TYPE: + wanted = JOIN_LETTER_FOR_TYPE[job["type"]] # ``camera`` may be ``F``, ``R``, ``PF``, ``PR``, etc. # The last letter identifies the lens. selected = [ @@ -918,10 +919,7 @@ async def _run_job(self, job: dict) -> None: # 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") + partner = PIP_PARTNER_FOR_TYPE[job["type"]] pairs = self._pair_clips( clips, required=("front", partner), ) @@ -931,11 +929,7 @@ async def _run_job(self, job: dict) -> None: f"no front+{partner} pairs in selection", None, ) return - main = { - "pip_rear": "rear", - "pip_tele": "tele", - "pip_interior": "interior", - }.get(job["type"], "front") + main = PIP_MAIN_FOR_TYPE[job["type"]] await self._pip( job["id"], pairs, out, encoder, snap.pip_position, main=main, partner=partner, @@ -955,13 +949,9 @@ def _pair_clips( # 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 = {"F": "front", "T": "tele", "I": "interior"}.get( - cam[-1:], "rear" - ) - pairs.setdefault(key, {})[slot] = c + pairs.setdefault(key, {})[pair_slot_of(c["camera"])] = c return [ p for p in sorted(pairs.items()) if all(s in p[1] for s in required) diff --git a/web/services/naming.py b/web/services/naming.py index 248b8d0..ab94155 100644 --- a/web/services/naming.py +++ b/web/services/naming.py @@ -1,18 +1,23 @@ -"""Derive sensible download filenames for joined/PiP exports. - -Pure functions — no DB or HTTP. ``build_basename`` turns a set of -clips plus a camera label into a stem like -``2024-03-15_1430-1502_front_4clips`` (date + time-range + camera -+ clip count). ``export_download_name`` maps an export job type to -a label and appends ``.mp4``, falling back to the legacy -``viofosync_export_{id}.mp4`` when the source clips are gone -(retention) or the type is unknown. - -(Original, un-joined clips are downloaded individually and keep -their dashcam basenames — they don't go through this module.) - -Timestamps are unix seconds formatted in local time, matching how -the archive UI renders clip times (web/routers/archive.py). +"""Camera facade for the web layer + export filename derivation. + +Pure functions — no DB or HTTP. Two roles: + +1. The web app's view of the camera registry + (viofosync_lib/cameras.py): re-exports plus the derived + per-camera export job types (``join_``, ``pip``/ + ``pip_``) with their letter/partner/main tables, and + the timeline channel order/labels. + +2. Download filenames for joined/PiP exports: ``build_basename`` + turns a set of clips plus a camera label into a stem like + ``2024-03-15_1430-1502_front_4clips``; ``export_download_name`` + maps an export job type to a label and appends ``.mp4``, + falling back to the legacy ``viofosync_export_{id}.mp4`` when + the source clips are gone (retention) or the type is unknown. + (Original, un-joined clips keep their dashcam basenames — they + don't go through this module.) Timestamps are unix seconds + formatted in local time, matching how the archive UI renders + clip times (web/routers/archive.py). """ from __future__ import annotations @@ -20,16 +25,45 @@ import json as _json from typing import List +from viofosync_lib.cameras import ( # noqa: F401 — re-exported + CAMERAS, + channel_of, + pair_slot_of, +) + +# --- Per-camera export job types, derived from the registry ---------- +# +# Join types exist for every camera (``join_front`` … ``join_interior``). +# PiP types pair the front camera with one partner: the legacy ``pip`` +# is front-main + rear inset; ``pip_`` makes the partner +# fullscreen with the front inset. Adding a camera in +# viofosync_lib/cameras.py extends all of these automatically. + +JOIN_LETTER_FOR_TYPE = { + f"join_{c.channel}": c.letter for c in CAMERAS +} + +_PARTNERS = [c.channel for c in CAMERAS if c.channel != "front"] + +# job type -> the non-front slot it pairs with +PIP_PARTNER_FOR_TYPE = {"pip": "rear"} | { + f"pip_{ch}": ch for ch in _PARTNERS +} + +# job type -> which side is fullscreen +PIP_MAIN_FOR_TYPE = {"pip": "front"} | { + f"pip_{ch}": ch for ch in _PARTNERS +} + +# Everything enqueue()/the route accept, except "timeline" which has +# its own entry point. +EXPORT_JOB_TYPES = (*JOIN_LETTER_FOR_TYPE, *PIP_PARTNER_FOR_TYPE) + # Export job type -> camera label used in the filename. LABEL_FOR_TYPE = { - "join_front": "front", - "join_rear": "rear", - "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 + f"join_{c.channel}": c.channel for c in CAMERAS +} | {"pip": "pip-front"} | { + f"pip_{ch}": f"pip-{ch}" for ch in _PARTNERS } @@ -94,31 +128,10 @@ 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; -# 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", - "T": "tele", - "I": "interior", -} - -# Stable display order for channel tracks, and human labels. -CHANNEL_ORDER = ["front", "rear", "tele", "interior", "other"] -CHANNEL_LABELS = { - "front": "Front", - "rear": "Rear", - "tele": "Tele", - "interior": "Interior", +# Channel keys/labels come straight from the registry; "other" is the +# fallback channel_of() uses for unrecognised codes. channel_of itself +# lives in viofosync_lib.cameras and is re-exported above. +CHANNEL_ORDER = [c.channel for c in CAMERAS] + ["other"] +CHANNEL_LABELS = {c.channel: c.label for c in CAMERAS} | { "other": "Other", } - - -def channel_of(camera: str | None) -> str: - """Map a clip's ``camera`` code to a timeline channel key.""" - if not camera: - return "other" - return _CHANNEL_FOR_LETTER.get(camera[-1].upper(), "other") diff --git a/web/services/queue.py b/web/services/queue.py index 021278c..a8f4544 100644 --- a/web/services/queue.py +++ b/web/services/queue.py @@ -22,6 +22,8 @@ from dataclasses import dataclass from typing import Iterable, List, Optional +from viofosync_lib.cameras import CAMERA_LETTERS + from ..db import Database @@ -182,12 +184,11 @@ 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). The camera letter is - # F=front, R=rear, T=telephoto, I=interior — 3-channel - # models pair F+R with either T or I. + # event type (P=parking, E=event); the camera letter set + # comes from the registry. import re as _re m = _re.match( - r"^\d{4}_\d{4}_\d{6}_\d+[PE]?([FRTI])\.MP4$", + rf"^\d{{4}}_\d{{4}}_\d{{6}}_\d+[PE]?([{CAMERA_LETTERS}])\.MP4$", filename, _re.IGNORECASE, ) @@ -197,7 +198,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])?[FRTI]\.MP4$", + rf"^\d{{4}}_\d{{4}}_\d{{6}}_\d+([PE])?[{CAMERA_LETTERS}]\.MP4$", filename, _re.IGNORECASE, ) @@ -441,7 +442,7 @@ def list_page( def _day_expr() -> str: """SQL expression for the YYYY-MM-DD day key derived from - the filename (``YYYY_MMDD_HHMMSS_NN[FR].MP4``). Uses the + the filename (``YYYY_MMDD_HHMMSS_NN.MP4``). Uses the filename rather than ``recorded_at`` so grouping is consistent even for rows missing a timestamp.""" return ( @@ -558,7 +559,7 @@ def list_day_items( ) -> List[dict]: """Return all queue items for a given day (``YYYY-MM-DD``), newest recording first. Filenames start with - ``YYYY_MMDD_HHMMSS_NN[FR]`` so a plain text DESC sort gives + ``YYYY_MMDD_HHMMSS_NN`` so a plain text DESC sort gives reverse time-of-day order with front/rear pairs adjacent. ``queue_position`` is still computed against the real download order (priority + enqueued_at) so the client can diff --git a/web/static/app.js b/web/static/app.js index a1fd9a2..61b2448 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -4,6 +4,27 @@ // All mutating calls send X-CSRF-Token. The token is returned by // /api/auth/login and refreshed via /api/auth/csrf on 403. +// Camera registry — mirror of viofosync_lib/cameras.py; keep the two +// in sync. Order = display/cycle order. ``letter`` is the filename +// suffix (and data-camera attribute), ``channel`` the API key (pair +// slots, export type suffixes, element id suffixes), ``label`` the +// human string. front+rear always exist; a 3-channel model adds one +// of the others. +const CAMERAS = [ + { letter: "F", channel: "front", label: "Front" }, + { letter: "R", channel: "rear", label: "Rear" }, + { letter: "T", channel: "tele", label: "Tele" }, + { letter: "I", channel: "interior", label: "Interior" }, +]; +const CAMERA_LABELS = Object.fromEntries( + CAMERAS.map((c) => [c.letter, c.label]), +); +// The third-camera slots whose UI (thumbs, action groups) only +// appears when the data contains them. +const EXTRA_CAMERAS = CAMERAS.filter( + (c) => c.channel !== "front" && c.channel !== "rear", +); + const state = { csrf: null, modalClip: null, // { id, camera, dayEl, timelines } @@ -983,11 +1004,14 @@ function renderClipPair(pair) { ${kindBadge} -
${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") : ""}
+
${CAMERAS.map((c) => + // front/rear always render (placeholder when missing); a + // third camera renders only when present so 2-camera days + // keep their familiar two columns. + c.channel === "front" || c.channel === "rear" + ? thumb(pair[c.channel], c.letter) + : pair[c.channel] ? thumb(pair[c.channel], c.letter) : "" + ).join("")}
`; el.querySelectorAll(".thumb img").forEach((img) => { img.addEventListener("click", (e) => { @@ -1010,10 +1034,9 @@ function renderClipPair(pair) { }; state.archiveSelected.set(pairId, { ts: Number(el.dataset.ts), - front: clipId("F"), - rear: clipId("R"), - tele: clipId("T"), - interior: clipId("I"), + ...Object.fromEntries( + CAMERAS.map((c) => [c.channel, clipId(c.letter)]), + ), }); } else { state.archiveSelected.delete(pairId); @@ -1090,47 +1113,40 @@ function updateArchiveActions() { label.textContent = `${n} selected`; bar.classList.add("has-selection"); } - let fronts = 0, rears = 0, both = 0; - let teles = 0, interiors = 0, frontTele = 0, frontInterior = 0; + // Per-camera selection counts, plus front+partner pair counts + // for the PiP buttons. + const count = Object.fromEntries(CAMERAS.map((c) => [c.channel, 0])); + const withFront = Object.fromEntries( + CAMERAS.map((c) => [c.channel, 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; + for (const c of CAMERAS) { + if (v[c.channel]) count[c.channel]++; + if (v.front && v[c.channel]) withFront[c.channel]++; + } + } + const hasFront = count.front > 0, hasRear = count.rear > 0; + const hasPair = withFront.rear > 0; document.getElementById("dl-orig-front").disabled = !hasFront; document.getElementById("dl-orig-rear").disabled = !hasRear; document.getElementById("export-join-front").disabled = !hasFront; 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 until the selection contains a clip from that - // camera, 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; + // Third-camera actions: the whole button group stays hidden until + // the selection contains a clip from that camera, so 2-camera + // setups see the original action bar unchanged. + for (const c of EXTRA_CAMERAS) { + const group = document.getElementById(`actions-${c.channel}`); + if (!group) continue; + const present = count[c.channel] > 0; 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, - ); + if (!present) continue; + document.getElementById(`dl-orig-${c.channel}`).disabled = false; + document.getElementById(`export-join-${c.channel}`).disabled = false; + document.getElementById(`export-pip-${c.channel}`).disabled = + withFront[c.channel] === 0; + } document.getElementById("clear-selection").disabled = n === 0; } @@ -1178,22 +1194,20 @@ function downloadOriginals(slot) { } async function submitExport(type) { + // join_ takes that channel's clips; the pip family takes + // front + the partner channel ("pip" is the legacy front-main + + // rear name, "pip_" makes the partner fullscreen). + const joinCh = type.startsWith("join_") ? type.slice(5) : null; + const pipCh = + type === "pip" ? "rear" + : type.startsWith("pip_") ? type.slice(4) + : null; const ids = []; 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 === "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 (joinCh && v[joinCh]) ids.push(v[joinCh]); + else if (pipCh) { 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 (v[pipCh]) ids.push(v[pipCh]); } } if (!ids.length) return; @@ -1255,30 +1269,19 @@ document.getElementById("exports-toggle").addEventListener("click", () => { setExportsPanelOpen(!open); }); -document.getElementById("dl-orig-front") - .addEventListener("click", () => downloadOriginals("front")); -document.getElementById("dl-orig-rear") - .addEventListener("click", () => downloadOriginals("rear")); -document.getElementById("export-join-front") - .addEventListener("click", () => submitExport("join_front")); -document.getElementById("export-join-rear") - .addEventListener("click", () => submitExport("join_rear")); -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")); +// One originals/join/pip button trio per camera. "pip" is the +// legacy name of the front-main job; every other camera's PiP is +// pip_. +for (const c of CAMERAS) { + const ch = c.channel; + document.getElementById(`dl-orig-${ch}`) + .addEventListener("click", () => downloadOriginals(ch)); + document.getElementById(`export-join-${ch}`) + .addEventListener("click", () => submitExport(`join_${ch}`)); + document.getElementById(`export-pip-${ch}`) + .addEventListener("click", () => + submitExport(ch === "front" ? "pip" : `pip_${ch}`)); +} document.getElementById("clear-selection") .addEventListener("click", clearSelection); @@ -1604,15 +1607,17 @@ 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"]; +// Modal camera cycle follows registry order. Only F+R plus one of +// the extras exist on any single device, but keeping them all costs +// nothing — absent cameras have empty timelines and the cycle +// skips them. +const MODAL_CAMERAS = CAMERAS.map((c) => c.letter); function buildCameraTimelines(scopeEl) { const root = scopeEl || document.getElementById("view-archive"); - const timelines = { F: [], R: [], T: [], I: [] }; + const timelines = Object.fromEntries( + MODAL_CAMERAS.map((cam) => [cam, []]), + ); root.querySelectorAll(".thumb[data-clip-id]").forEach((el) => { const entry = { id: Number(el.dataset.clipId), @@ -1683,14 +1688,10 @@ function updateModalNav() { const next_ = curr ? nextCameraClip(mc, curr.ts) : null; toggle.disabled = !next_; if (next_) { - toggle.textContent = `${CAMERA_VIEW_LABELS[next_.camera]} view`; + toggle.textContent = `${CAMERA_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 @@ -1877,12 +1878,11 @@ 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" - : cam === "T" ? "Tele" - : cam === "I" ? "Interior" - : "?"; + // hasOwn guard: cam comes from API data, and a plain-object + // lookup would resolve prototype keys ("constructor", …) to + // functions instead of "?". + const camLabel = Object.hasOwn(CAMERA_LABELS, cam) + ? CAMERA_LABELS[cam] : "?"; const parts = [`${camLabel}`]; if (evt === "parking") { parts.push(`Parking`);