diff --git a/tests/test_channel_of.py b/tests/test_channel_of.py index 3b21287..8fc2ab1 100644 --- a/tests/test_channel_of.py +++ b/tests/test_channel_of.py @@ -18,6 +18,14 @@ def test_channel_of_rear_variants(): def test_channel_of_interior(): assert naming.channel_of("I") == "interior" + assert naming.channel_of("PI") == "interior" # parking interior + + +def test_channel_of_tele(): + assert naming.channel_of("T") == "tele" + assert naming.channel_of("PT") == "tele" # parking tele + assert naming.channel_of("ET") == "tele" # event tele + assert naming.channel_of("t") == "tele" # case-insensitive def test_channel_of_unknown_and_empty(): @@ -27,8 +35,11 @@ def test_channel_of_unknown_and_empty(): def test_channel_order_and_labels(): - assert naming.CHANNEL_ORDER == ["front", "rear", "interior", "other"] + assert naming.CHANNEL_ORDER == [ + "front", "rear", "tele", "interior", "other", + ] assert naming.CHANNEL_LABELS["front"] == "Front" assert naming.CHANNEL_LABELS["rear"] == "Rear" + assert naming.CHANNEL_LABELS["tele"] == "Tele" assert naming.CHANNEL_LABELS["interior"] == "Interior" assert naming.CHANNEL_LABELS["other"] == "Other" diff --git a/tests/test_export_types.py b/tests/test_export_types.py new file mode 100644 index 0000000..2bc305e --- /dev/null +++ b/tests/test_export_types.py @@ -0,0 +1,57 @@ +"""Validation gates for the third-camera export job types. + +Two layers must both accept a type before a job can run: the +route's pydantic pattern (web/routers/exports.py) and the +worker's enqueue() allowlist (web/services/exporter.py). A type +listed in one but not the other 422s or errors at runtime — pin +them together. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pydantic import ValidationError + +from web.db import Database +from web.routers.exports import CreateExport, Segment +from web.services.exporter import ExportWorker + +NEW_TYPES = ("join_tele", "join_interior", "pip_tele", "pip_interior") +OLD_TYPES = ("join_front", "join_rear", "pip", "pip_rear") + + +def test_route_model_accepts_all_types(): + for t in OLD_TYPES + NEW_TYPES + ("timeline",): + assert CreateExport(type=t).type == t + + +def test_route_model_rejects_unknown_type(): + with pytest.raises(ValidationError): + CreateExport(type="join_bogus") + + +def test_segment_accepts_tele_and_interior_channels(): + for ch in ("front", "rear", "tele", "interior", "other"): + seg = Segment(channel=ch, start_ts=0.0, end_ts=1.0) + assert seg.channel == ch + with pytest.raises(ValidationError): + Segment(channel="bogus", start_ts=0.0, end_ts=1.0) + + +def test_enqueue_allowlist_accepts_new_types( + tmp_path: Path, monkeypatch, +): + monkeypatch.setattr( + "web.services.exporter.ffmpeg_available", lambda: True, + ) + db = Database(str(tmp_path / "test.db")) + worker = ExportWorker( + db=db, provider=MagicMock(), broadcast=MagicMock(), + ) + for t in OLD_TYPES + NEW_TYPES: + job_id = worker.enqueue(t, [1, 2]) + assert isinstance(job_id, int) + with pytest.raises(ValueError): + worker.enqueue("join_bogus", [1]) diff --git a/tests/test_exporter_pair_clips.py b/tests/test_exporter_pair_clips.py new file mode 100644 index 0000000..6c93acd --- /dev/null +++ b/tests/test_exporter_pair_clips.py @@ -0,0 +1,82 @@ +"""Tests for ExportWorker._pair_clips slot dispatch. + +The pairer groups same-capture clips by (timestamp, event_type) +and assigns each clip a slot from its trailing camera letter: +F → front, T → tele, I → interior, everything else → rear. +``required`` names the slots a group must have to count — PiP +front+rear needs both classic slots; pip_tele / pip_interior +need front plus the third camera. +""" +from __future__ import annotations + +from web.services.exporter import ExportWorker + + +def _clip(camera: str, ts: int = 1000, event: str = "normal") -> dict: + return { + "camera": camera, + "timestamp": ts, + "event_type": event, + "path": f"/x/{ts}_{camera}.MP4", + } + + +def test_front_rear_pairing_unchanged(): + pairs = ExportWorker._pair_clips( + [_clip("F"), _clip("R")], + ) + assert len(pairs) == 1 + (_, p), = pairs + assert p["front"]["camera"] == "F" + assert p["rear"]["camera"] == "R" + + +def test_triplet_keeps_all_three_slots(): + pairs = ExportWorker._pair_clips( + [_clip("F"), _clip("R"), _clip("T")], + ) + (_, p), = pairs + assert set(p) == {"front", "rear", "tele"} + + +def test_front_tele_pair_for_pip_tele(): + # A front+tele selection has no rear — the default + # front+rear requirement drops it, the pip_tele one keeps it. + clips = [_clip("F"), _clip("T")] + assert ExportWorker._pair_clips(clips) == [] + pairs = ExportWorker._pair_clips( + clips, required=("front", "tele"), + ) + assert len(pairs) == 1 + + +def test_front_interior_pair_for_pip_interior(): + clips = [_clip("F"), _clip("I")] + pairs = ExportWorker._pair_clips( + clips, required=("front", "interior"), + ) + assert len(pairs) == 1 + (_, p), = pairs + assert p["interior"]["camera"] == "I" + + +def test_tele_only_never_pairs(): + assert ExportWorker._pair_clips( + [_clip("T")], required=("front", "tele"), + ) == [] + + +def test_parking_prefixes_assign_correct_slots(): + pairs = ExportWorker._pair_clips( + [_clip("PF", event="parking"), _clip("PT", event="parking")], + required=("front", "tele"), + ) + (_, p), = pairs + assert p["front"]["camera"] == "PF" + assert p["tele"]["camera"] == "PT" + + +def test_rear_tele_without_front_never_pairs(): + assert ExportWorker._pair_clips( + [_clip("R"), _clip("T")], required=("front", "tele"), + ) == [] diff --git a/tests/test_importer.py b/tests/test_importer.py index 9c4802d..09a16ab 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -13,6 +13,12 @@ def test_classify_event_type(): assert importer.classify_event_type("F", "DCIM/Movie/RO/X.MP4") == "ro" # RO wins even for a parking-named clip living under /RO/. assert importer.classify_event_type("PF", "Movie/RO/X.MP4") == "ro" + # Third cameras: telephoto (T) and interior (I) classify the + # same way as front/rear. + assert importer.classify_event_type("T", "DCIM/Movie/X.MP4") == "normal" + assert importer.classify_event_type("PT", "DCIM/Parking/X.MP4") == "parking" + assert importer.classify_event_type("I", "DCIM/Movie/X.MP4") == "normal" + assert importer.classify_event_type("PI", "Movie/RO/X.MP4") == "ro" def test_scan_source_recurses_and_sorts_newest_first(tmp_path: Path): @@ -20,22 +26,26 @@ def test_scan_source_recurses_and_sorts_newest_first(tmp_path: Path): root = tmp_path / "card" (root / "DCIM" / "Movie").mkdir(parents=True) (root / "DCIM" / "Movie" / "RO").mkdir() - # Recognised: one older normal front, one newer locked front. + # Recognised: one older normal front, one newer locked front, + # and a telephoto clip from a 3-camera model. (root / "DCIM" / "Movie" / "2026_0101_080000_0001F.MP4").write_bytes(b"a" * 10) (root / "DCIM" / "Movie" / "RO" / "2026_0102_090000_0002F.MP4").write_bytes(b"b" * 20) + (root / "DCIM" / "Movie" / "2026_0101_081000_0003T.MP4").write_bytes(b"t" * 7) # Junk: ignored + reported. (root / "DCIM" / "Movie" / "notes.txt").write_text("hi") # Matches the filename regex but has an impossible date -> bad_timestamp. (root / "DCIM" / "Movie" / "2026_1399_250000_0009F.MP4").write_bytes(b"c" * 5) manifest = importer.scan_source(str(root)) - assert manifest.total_bytes == 30 + assert manifest.total_bytes == 37 assert [it.basename for it in manifest.items] == [ "2026_0102_090000_0002F.MP4", # newest first + "2026_0101_081000_0003T.MP4", "2026_0101_080000_0001F.MP4", ] assert manifest.items[0].event_type == "ro" - assert manifest.items[1].event_type == "normal" + assert manifest.items[1].event_type == "normal" # the T clip + assert manifest.items[2].event_type == "normal" skipped = {s["name"]: s["reason"] for s in manifest.skipped} assert skipped["notes.txt"] == "not_recognised" assert skipped["2026_1399_250000_0009F.MP4"] == "bad_timestamp" diff --git a/tests/test_naming.py b/tests/test_naming.py index 056850d..fb73ddb 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -54,7 +54,10 @@ def test_input_order_does_not_matter() -> None: def test_each_label_passes_through() -> None: clips = [_clip(_ts(2024, 3, 15, 14, 30))] - for label in ("front", "rear", "pip-front", "pip-rear"): + for label in ( + "front", "rear", "tele", "interior", + "pip-front", "pip-rear", "pip-tele", "pip-interior", + ): assert build_basename(clips, label).endswith(f"_{label}_1clip") @@ -75,6 +78,18 @@ def test_export_download_name_maps_type_and_adds_ext() -> None: assert export_download_name("pip_rear", clips, 7) == ( "2024-03-15_1430-1502_pip-rear_2clips.mp4" ) + assert export_download_name("join_tele", clips, 7) == ( + "2024-03-15_1430-1502_tele_2clips.mp4" + ) + assert export_download_name("join_interior", clips, 7) == ( + "2024-03-15_1430-1502_interior_2clips.mp4" + ) + assert export_download_name("pip_tele", clips, 7) == ( + "2024-03-15_1430-1502_pip-tele_2clips.mp4" + ) + assert export_download_name("pip_interior", clips, 7) == ( + "2024-03-15_1430-1502_pip-interior_2clips.mp4" + ) def test_export_download_name_falls_back_when_no_clips() -> None: diff --git a/tests/test_pip_filter.py b/tests/test_pip_filter.py index ced0fad..dd198d6 100644 --- a/tests/test_pip_filter.py +++ b/tests/test_pip_filter.py @@ -72,3 +72,21 @@ def test_front_main_is_the_default() -> None: assert _pip_filter_complex("top_right") == ( _pip_filter_complex("top_right", main="front") ) + + +# Tele-main / interior-main: the partner clip is ffmpeg input 1 +# (the input swap happens in _pip's argv construction, not here), +# so any non-front ``main`` yields the same graph as rear-main — +# partner fullscreen, front (input 0) scaled to the inset. + + +def test_tele_main_matches_rear_main_graph() -> None: + assert _pip_filter_complex("top_right", main="tele") == ( + _pip_filter_complex("top_right", main="rear") + ) + + +def test_interior_main_matches_rear_main_graph() -> None: + assert _pip_filter_complex("bottom_left", main="interior") == ( + _pip_filter_complex("bottom_left", main="rear") + ) diff --git a/tests/test_queue_filename_parsing.py b/tests/test_queue_filename_parsing.py new file mode 100644 index 0000000..cf92cfc --- /dev/null +++ b/tests/test_queue_filename_parsing.py @@ -0,0 +1,50 @@ +"""Tests for the queue's filename → camera / event-type parsers. + +Viofo filenames end ``…NNNNN[PE]?[FRTI].MP4`` — the trailing +letter is the camera (F=front, R=rear, T=telephoto, I=interior) +and the optional P/E prefix encodes parking/event clips. +3-channel models pair F+R with either T or I. +""" +from __future__ import annotations + +from web.services.queue import ( + _camera_from_filename, + _event_from_filename, +) + + +def test_camera_front_rear(): + assert _camera_from_filename("2026_0513_172152_000120F.MP4") == "F" + assert _camera_from_filename("2026_0513_172152_000121R.MP4") == "R" + assert _camera_from_filename("2026_0513_172152_000122PF.MP4") == "F" + assert _camera_from_filename("2026_0513_172152_000123ER.MP4") == "R" + + +def test_camera_tele(): + assert _camera_from_filename("2026_0513_172152_000124T.MP4") == "T" + assert _camera_from_filename("2026_0513_172152_000125PT.MP4") == "T" + assert _camera_from_filename("2026_0513_172152_000126ET.MP4") == "T" + + +def test_camera_interior(): + assert _camera_from_filename("2026_0109_143514_000487I.MP4") == "I" + assert _camera_from_filename("2026_0109_145126_000520PI.MP4") == "I" + assert _camera_from_filename("2026_0109_145126_000521EI.MP4") == "I" + + +def test_camera_case_insensitive(): + assert _camera_from_filename("2026_0513_172152_000124t.mp4") == "T" + + +def test_camera_unknown_letter_rejected(): + assert _camera_from_filename("2026_0513_172152_000124X.MP4") is None + assert _camera_from_filename("notes.txt") is None + + +def test_event_type_for_third_cameras(): + assert _event_from_filename("2026_0513_172152_000124T.MP4") == "normal" + assert _event_from_filename("2026_0513_172152_000125PT.MP4") == "parking" + assert _event_from_filename("2026_0513_172152_000126ET.MP4") == "event" + assert _event_from_filename("2026_0109_143514_000487I.MP4") == "normal" + assert _event_from_filename("2026_0109_145126_000520PI.MP4") == "parking" + assert _event_from_filename("2026_0109_145126_000521EI.MP4") == "event" diff --git a/tests/test_timeline_endpoint.py b/tests/test_timeline_endpoint.py index fa4ec8d..4fbbf41 100644 --- a/tests/test_timeline_endpoint.py +++ b/tests/test_timeline_endpoint.py @@ -92,6 +92,33 @@ def test_timeline_day_mode_channels_clips_bounds(logged_in_client): assert body["gps"] is None +def test_timeline_third_camera_channels(logged_in_client): + """Tele and interior clips each get their own channel, in + CHANNEL_ORDER after rear. A real device has only one of the + two, but the endpoint must order any mix it finds — e.g. an + archive that spans both a telephoto and an interior setup.""" + app = logged_in_client.app + _insert_clip(app, 1, 1_717_312_440, "F", 60.0) + _insert_clip(app, 2, 1_717_312_440, "R", 60.0) + _insert_clip(app, 3, 1_717_312_440, "T", 60.0) + _insert_clip(app, 4, 1_717_312_440, "I", 60.0) + + r = logged_in_client.get("/api/archive/timeline?date=2026-06-02") + assert r.status_code == 200 + body = r.json() + assert [ch["key"] for ch in body["channels"]] == [ + "front", "rear", "tele", "interior", + ] + labels = {ch["key"]: ch["label"] for ch in body["channels"]} + assert labels["tele"] == "Tele" + assert labels["interior"] == "Interior" + by_channel = {} + for c in body["clips"]: + by_channel.setdefault(c["channel"], []).append(c) + assert len(by_channel["tele"]) == 1 + assert len(by_channel["interior"]) == 1 + + def test_timeline_journey_mode_windows_clips(logged_in_client, monkeypatch): app = logged_in_client.app _insert_clip(app, 1, 1_717_312_440, "F", 60.0) 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))" diff --git a/web/static/app.js b/web/static/app.js index 53e4bef..a1fd9a2 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} -