From 74d056906c9568a059ffcc4906d17b73db88ff99 Mon Sep 17 00:00:00 2001
From: semantic-release
Date: Mon, 27 Jul 2026 10:37:06 +0000
Subject: [PATCH 01/44] 0.1.0
Automatically generated by python-semantic-release
---
CHANGELOG.md | 3 +++
pyproject.toml | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abadeef..672a93b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
+## v0.1.0 (2026-07-27)
+
+
## v0.1.0-b.3 (2026-07-27)
### Bug Fixes
diff --git a/pyproject.toml b/pyproject.toml
index 496df25..f73be01 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "vidxp"
-version = "0.1.0-b.3"
+version = "0.1.0"
authors = [
{ name = "Muhammad Haroon" },
{ name = "Talha Momin" },
From b6cfc4acb1b8e539db6f7ccdd7f2ac7c95f01b6f Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 17:43:35 +0500
Subject: [PATCH 02/44] feat(application): add reusable VidXP access service
---
src/vidxp/application.py | 342 ++++++++++++++++++++++++++++++++
src/vidxp/core/actor_results.py | 54 +++++
src/vidxp/core/runner.py | 12 +-
src/vidxp/core/storage.py | 26 +++
tests/test_actor_results.py | 19 ++
tests/test_application.py | 186 +++++++++++++++++
tests/test_runner.py | 11 +-
tests/test_storage.py | 14 ++
8 files changed, 661 insertions(+), 3 deletions(-)
create mode 100644 src/vidxp/application.py
create mode 100644 tests/test_application.py
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
new file mode 100644
index 0000000..7feed0c
--- /dev/null
+++ b/src/vidxp/application.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+from typing import Any, Iterable
+
+from vidxp.core.actor_results import (
+ ActorClusterSummary,
+ ActorRenderResult,
+ actor_clusters,
+ actor_detections,
+ render_actor_result,
+)
+from vidxp.core.contracts import (
+ SUPPORTED_MODALITIES,
+ IndexConfig,
+ SearchResult,
+)
+from vidxp.core.manifest import (
+ CHECKPOINT_DIRECTORY,
+ COMPLETION_FILE,
+ FAILURES_FILE,
+ MANIFEST_FILE,
+ TIMINGS_FILE,
+)
+from vidxp.core.models import (
+ INDEXING_DEPENDENCIES,
+ dependency_failures,
+ get_alignment_model,
+ get_clip_model,
+ get_embedder,
+ get_whisper_model,
+)
+from vidxp.core.runner import (
+ ProgressCallback,
+ index_video,
+ indexing_in_progress,
+ local_config_from_status,
+)
+from vidxp.core.search import search_dialogue, search_scene
+from vidxp.core.storage import IndexStorage
+from vidxp.core.video import ffmpeg_binary
+from vidxp.index_state import (
+ INDEX_STATUS_FILE,
+ INDEX_STATUS_SCHEMA,
+ IndexingInProgressError,
+ read_index_status,
+ require_ready_index,
+)
+
+
+class VidXPService:
+ """Reusable application boundary for CLI, HTTP, and other adapters."""
+
+ def __init__(
+ self,
+ index_directory: str | Path = "chroma_data",
+ *,
+ device: str | None = None,
+ ) -> None:
+ self.index_directory = Path(index_directory)
+ self.device = device
+
+ def index_status(self) -> dict[str, Any]:
+ status = read_index_status(self.index_directory)
+ if status is not None:
+ payload = dict(status)
+ else:
+ payload = {
+ "schema_version": INDEX_STATUS_SCHEMA,
+ "state": "missing",
+ "stage": "status",
+ "message": "No local video index was found.",
+ }
+ payload["index_directory"] = str(self.index_directory)
+ return payload
+
+ def active_config(self) -> tuple[IndexConfig, dict[str, Any]]:
+ status = require_ready_index(self.index_directory)
+ config = local_config_from_status(
+ status,
+ storage_directory=self.index_directory,
+ )
+ if self.device is not None:
+ config = replace(config, device=self.device)
+ return config, status
+
+ def create_index(
+ self,
+ video_path: str | Path,
+ *,
+ modalities: Iterable[str] = SUPPORTED_MODALITIES,
+ frame_stride: int = 1,
+ progress_callback: ProgressCallback | None = None,
+ ) -> dict[str, Any]:
+ selected = tuple(dict.fromkeys(str(item) for item in modalities))
+ options: dict[str, Any] = {
+ "enabled_modalities": selected,
+ "frame_stride": frame_stride,
+ "storage_directory": self.index_directory,
+ }
+ if self.device is not None:
+ options["device"] = self.device
+ config = IndexConfig.local(**options)
+ return index_video(
+ str(video_path),
+ progress_callback=progress_callback,
+ config=config,
+ )
+
+ def check_dependencies(
+ self,
+ modalities: Iterable[str] = SUPPORTED_MODALITIES,
+ ) -> dict[str, Any]:
+ selected = self._validate_modalities(modalities)
+ failures = dict(
+ dependency_failures(
+ selected,
+ needs_transcription="dialogue" in selected,
+ )
+ )
+ dependencies = []
+ for modality in selected:
+ dependencies.extend(INDEXING_DEPENDENCIES[modality])
+ if "dialogue" in selected:
+ dependencies.extend(INDEXING_DEPENDENCIES["transcription"])
+
+ checks = []
+ seen = set()
+ for dependency in dependencies:
+ if dependency.label in seen:
+ continue
+ seen.add(dependency.label)
+ error = failures.get(dependency.label)
+ checks.append(
+ {
+ "name": dependency.label,
+ "ok": error is None,
+ "error": error,
+ }
+ )
+ if "dialogue" in selected:
+ try:
+ resolved_ffmpeg = ffmpeg_binary()
+ except Exception as exc:
+ error = f"{type(exc).__name__}: {exc}"
+ failures["FFmpeg"] = error
+ checks.append(
+ {"name": "FFmpeg", "ok": False, "error": error}
+ )
+ else:
+ checks.append(
+ {
+ "name": "FFmpeg",
+ "ok": True,
+ "path": resolved_ffmpeg,
+ "error": None,
+ }
+ )
+ return {
+ "ok": not failures,
+ "modalities": list(selected),
+ "checks": checks,
+ }
+
+ def prepare_models(
+ self,
+ modalities: Iterable[str] = ("dialogue", "scene"),
+ *,
+ language: str | None = None,
+ progress_callback: ProgressCallback | None = None,
+ ) -> dict[str, Any]:
+ selected = self._validate_modalities(modalities)
+ config = IndexConfig.local(
+ enabled_modalities=selected,
+ device=self.device or "cpu",
+ storage_directory=self.index_directory,
+ )
+ failures = dependency_failures(
+ selected,
+ needs_transcription="dialogue" in selected,
+ )
+ if failures:
+ details = "; ".join(
+ f"{label}: {error}" for label, error in failures
+ )
+ raise RuntimeError(details)
+
+ prepared = []
+
+ def report(stage: str, message: str) -> None:
+ if progress_callback is not None:
+ progress_callback(
+ {
+ "state": "preparing",
+ "stage": stage,
+ "message": message,
+ }
+ )
+
+ if "dialogue" in selected:
+ report(
+ "dialogue_model",
+ f"Preparing dialogue model: {config.sentence_model}",
+ )
+ get_embedder(config.sentence_model, config.device)
+ prepared.append(config.sentence_model)
+ report(
+ "transcription_model",
+ f"Preparing transcription model: WhisperX "
+ f"{config.whisper_model}",
+ )
+ get_whisper_model(config.whisper_model, config.device)
+ prepared.append(config.whisper_model)
+ if language:
+ report(
+ "alignment_model",
+ f"Preparing the {language} alignment model.",
+ )
+ get_alignment_model(language, config.device)
+ prepared.append(f"whisperx-alignment:{language}")
+ if "scene" in selected:
+ report(
+ "scene_model",
+ f"Preparing scene model: CLIP {config.clip_model}",
+ )
+ get_clip_model(config.clip_model, config.device)
+ prepared.append(config.clip_model)
+ return {
+ "prepared": prepared,
+ "modalities": list(selected),
+ "device": config.device,
+ "language": language,
+ }
+
+ def search(
+ self,
+ modality: str,
+ query: str,
+ *,
+ top_k: int = 10,
+ ) -> SearchResult:
+ config, _ = self.active_config()
+ if modality not in config.enabled_modalities:
+ raise ValueError(
+ f"The {modality} modality is not present in this index."
+ )
+ find = {
+ "dialogue": search_dialogue,
+ "scene": search_scene,
+ }.get(modality)
+ if find is None:
+ raise ValueError(
+ "Semantic search supports dialogue and scene modalities."
+ )
+ return find(
+ query,
+ config=config,
+ top_k=top_k,
+ video_id=config.video_id,
+ )
+
+ def actor_clusters(self) -> tuple[ActorClusterSummary, ...]:
+ config, _ = self.active_config()
+ self._require_actor(config)
+ return actor_clusters(config)
+
+ def actor_detections(self, cluster_id: str) -> list[dict[str, Any]]:
+ config, _ = self.active_config()
+ self._require_actor(config)
+ return actor_detections(config, cluster_id)
+
+ def render_actor(
+ self,
+ cluster_id: str,
+ input_path: str | Path,
+ output_path: str | Path,
+ ) -> ActorRenderResult:
+ config, _ = self.active_config()
+ self._require_actor(config)
+ return render_actor_result(
+ config,
+ cluster_id,
+ input_path,
+ output_path,
+ )
+
+ def clear_index(self) -> bool:
+ if not self.index_directory.exists():
+ return False
+ base_config = IndexConfig.local(
+ storage_directory=self.index_directory,
+ )
+ if indexing_in_progress(base_config):
+ raise IndexingInProgressError(
+ f"Indexing is active for {self.index_directory}."
+ )
+
+ status = read_index_status(self.index_directory)
+ if status is not None and status.get("state") == "ready":
+ try:
+ config = local_config_from_status(
+ status,
+ storage_directory=self.index_directory,
+ )
+ except (IndexSchemaError, KeyError, TypeError, ValueError):
+ config = base_config
+ else:
+ config = base_config
+ with IndexStorage(config) as storage:
+ storage.clear()
+
+ for name in (
+ INDEX_STATUS_FILE,
+ MANIFEST_FILE,
+ TIMINGS_FILE,
+ FAILURES_FILE,
+ COMPLETION_FILE,
+ ):
+ (self.index_directory / name).unlink(missing_ok=True)
+ checkpoint_directory = self.index_directory / CHECKPOINT_DIRECTORY
+ if checkpoint_directory.is_dir():
+ for checkpoint in checkpoint_directory.glob("*.json"):
+ checkpoint.unlink()
+ try:
+ checkpoint_directory.rmdir()
+ except OSError:
+ pass
+ return True
+
+ @staticmethod
+ def _require_actor(config: IndexConfig) -> None:
+ if "actor" not in config.enabled_modalities:
+ raise ValueError("The actor modality is not present in this index.")
+
+ @staticmethod
+ def _validate_modalities(
+ modalities: Iterable[str],
+ ) -> tuple[str, ...]:
+ selected = tuple(dict.fromkeys(str(item) for item in modalities))
+ IndexConfig(enabled_modalities=selected)
+ return selected
diff --git a/src/vidxp/core/actor_results.py b/src/vidxp/core/actor_results.py
index 5bc7b27..c7b4d05 100644
--- a/src/vidxp/core/actor_results.py
+++ b/src/vidxp/core/actor_results.py
@@ -18,6 +18,60 @@ class ActorRenderResult:
detection_count: int
+@dataclass(frozen=True)
+class ActorClusterSummary:
+ cluster_id: str
+ video_id: str
+ detection_count: int
+ first_timestamp: float
+ last_timestamp: float
+
+ def to_dict(self) -> dict:
+ return {
+ "cluster_id": self.cluster_id,
+ "video_id": self.video_id,
+ "detection_count": self.detection_count,
+ "first_timestamp": self.first_timestamp,
+ "last_timestamp": self.last_timestamp,
+ }
+
+
+def actor_clusters(
+ config: IndexConfig,
+ *,
+ storage: IndexStorage | None = None,
+) -> tuple[ActorClusterSummary, ...]:
+ if config.video_id is None:
+ raise ValueError("IndexConfig.video_id is required for actor results.")
+ owns_storage = storage is None
+ active_storage = storage or IndexStorage(config)
+ try:
+ records = active_storage.actor_cluster_records(
+ video_id=config.video_id,
+ )
+ finally:
+ if owns_storage:
+ active_storage.close()
+
+ grouped: dict[str, list[dict]] = {}
+ for record in records:
+ grouped.setdefault(str(record["cluster_id"]), []).append(record)
+ return tuple(
+ ActorClusterSummary(
+ cluster_id=cluster_id,
+ video_id=config.video_id,
+ detection_count=len(cluster_records),
+ first_timestamp=min(
+ float(record["timestamp"]) for record in cluster_records
+ ),
+ last_timestamp=max(
+ float(record["timestamp"]) for record in cluster_records
+ ),
+ )
+ for cluster_id, cluster_records in sorted(grouped.items())
+ )
+
+
def actor_detections(
config: IndexConfig,
cluster_id: str,
diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py
index 21f393c..552e30b 100644
--- a/src/vidxp/core/runner.py
+++ b/src/vidxp/core/runner.py
@@ -74,7 +74,11 @@ def indexing_in_progress(config: IndexConfig | None = None) -> bool:
return _run_lock_held(active_config.run_directory)
-def local_config_from_status(status: dict[str, Any]) -> IndexConfig:
+def local_config_from_status(
+ status: dict[str, Any],
+ *,
+ storage_directory: str | Path | None = None,
+) -> IndexConfig:
summary = status.get("summary") or {}
if summary.get("index_schema_version") != INDEX_SCHEMA_VERSION:
raise IndexSchemaError(
@@ -93,9 +97,12 @@ def local_config_from_status(status: dict[str, Any]) -> IndexConfig:
"split": str(summary["split"]),
"run_id": str(summary["run_id"]),
"video_id": str(summary["video_id"]),
- "storage_directory": "chroma_data",
}
)
+ if storage_directory is not None:
+ stored["storage_directory"] = str(storage_directory)
+ else:
+ stored.setdefault("storage_directory", "chroma_data")
if "enabled_modalities" in stored:
stored["enabled_modalities"] = tuple(stored["enabled_modalities"])
if "collection_names" in stored:
@@ -542,6 +549,7 @@ def report(event: dict[str, Any]) -> None:
total=event.get("total"),
summary=event.get("summary"),
error=event.get("error"),
+ index_directory=active_config.index_directory,
)
if progress_callback is not None:
progress_callback(event)
diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py
index 3abc12d..e176104 100644
--- a/src/vidxp/core/storage.py
+++ b/src/vidxp/core/storage.py
@@ -203,6 +203,32 @@ def actor_detections(
key=lambda item: (int(item["frame_index"]), item["detection_id"]),
)
+ def actor_cluster_records(
+ self,
+ *,
+ video_id: str,
+ ) -> list[dict[str, Any]]:
+ result = self.collection("actor").get(
+ where=metadata_filter(
+ self.config,
+ video_id=video_id,
+ ),
+ include=["metadatas"],
+ )
+ records = [
+ dict(metadata)
+ for metadata in (result.get("metadatas") or [])
+ if metadata and metadata.get("cluster_id") is not None
+ ]
+ return sorted(
+ records,
+ key=lambda item: (
+ str(item["cluster_id"]),
+ int(item["frame_index"]),
+ str(item["detection_id"]),
+ ),
+ )
+
def size_bytes(self) -> int:
return directory_size(self.path)
diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py
index 0bde93b..36d7027 100644
--- a/tests/test_actor_results.py
+++ b/tests/test_actor_results.py
@@ -5,6 +5,7 @@
from vidxp.core.actor_results import (
ActorClusterNotFoundError,
+ actor_clusters,
actor_detections,
render_actor_result,
)
@@ -40,6 +41,24 @@ def test_actor_detection_metadata_is_converted_once(self):
)
storage.close.assert_not_called()
+ def test_actor_clusters_summarize_detection_ranges(self):
+ storage = Mock()
+ storage.actor_cluster_records.return_value = [
+ {"cluster_id": "1", "timestamp": 4.5},
+ {"cluster_id": "1", "timestamp": 1.5},
+ {"cluster_id": "2", "timestamp": 9.0},
+ ]
+
+ clusters = actor_clusters(self.config, storage=storage)
+
+ self.assertEqual(
+ [cluster.cluster_id for cluster in clusters],
+ ["1", "2"],
+ )
+ self.assertEqual(clusters[0].detection_count, 2)
+ self.assertEqual(clusters[0].first_timestamp, 1.5)
+ self.assertEqual(clusters[0].last_timestamp, 4.5)
+
def test_render_actor_result_rejects_an_empty_cluster(self):
storage = Mock()
storage.actor_detections.return_value = []
diff --git a/tests/test_application.py b/tests/test_application.py
new file mode 100644
index 0000000..07d16eb
--- /dev/null
+++ b/tests/test_application.py
@@ -0,0 +1,186 @@
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import Mock, patch
+
+from vidxp.application import VidXPService
+from vidxp.core.contracts import IndexConfig
+
+
+class ApplicationServiceTests(unittest.TestCase):
+ def test_missing_index_has_a_stable_status_contract(self):
+ service = VidXPService("missing-index")
+ with patch(
+ "vidxp.application.read_index_status",
+ return_value=None,
+ ):
+ status = service.index_status()
+
+ self.assertEqual(status["state"], "missing")
+ self.assertEqual(status["schema_version"], 1)
+
+ def test_active_config_uses_selected_directory_and_device(self):
+ service = VidXPService("selected-index", device="cuda")
+ ready = {"state": "ready"}
+ stored_config = IndexConfig.local(
+ video_id="video-1",
+ storage_directory="selected-index",
+ )
+ with (
+ patch(
+ "vidxp.application.require_ready_index",
+ return_value=ready,
+ ) as require,
+ patch(
+ "vidxp.application.local_config_from_status",
+ return_value=stored_config,
+ ) as restore,
+ ):
+ config, status = service.active_config()
+
+ require.assert_called_once_with(Path("selected-index"))
+ restore.assert_called_once_with(
+ ready,
+ storage_directory=Path("selected-index"),
+ )
+ self.assertEqual(config.device, "cuda")
+ self.assertIs(status, ready)
+
+ def test_search_is_a_thin_adapter_over_the_core(self):
+ service = VidXPService()
+ config = IndexConfig.local(
+ video_id="video-1",
+ enabled_modalities=("scene",),
+ )
+ expected = Mock()
+ with (
+ patch.object(
+ service,
+ "active_config",
+ return_value=(config, {}),
+ ),
+ patch(
+ "vidxp.application.search_scene",
+ return_value=expected,
+ ) as search,
+ ):
+ result = service.search("scene", "yellow taxi", top_k=7)
+
+ self.assertIs(result, expected)
+ search.assert_called_once_with(
+ "yellow taxi",
+ config=config,
+ top_k=7,
+ video_id="video-1",
+ )
+
+ def test_create_index_centralizes_storage_and_runtime_configuration(self):
+ service = VidXPService("selected-index", device="cuda")
+ with patch(
+ "vidxp.application.index_video",
+ return_value={"scene_frames": 1},
+ ) as index:
+ summary = service.create_index(
+ "video.mp4",
+ modalities=("scene",),
+ frame_stride=5,
+ )
+
+ self.assertEqual(summary, {"scene_frames": 1})
+ config = index.call_args.kwargs["config"]
+ self.assertEqual(config.storage_directory, "selected-index")
+ self.assertEqual(config.device, "cuda")
+ self.assertEqual(config.enabled_modalities, ("scene",))
+ self.assertEqual(config.frame_stride, 5)
+
+ def test_dependency_checks_return_a_transport_neutral_contract(self):
+ service = VidXPService()
+ with (
+ patch(
+ "vidxp.application.dependency_failures",
+ return_value=[("CLIP", "missing")],
+ ),
+ patch(
+ "vidxp.application.ffmpeg_binary",
+ return_value="ffmpeg",
+ ),
+ ):
+ result = service.check_dependencies(("scene",))
+
+ self.assertFalse(result["ok"])
+ failed = [check for check in result["checks"] if not check["ok"]]
+ self.assertEqual(failed, [
+ {"name": "CLIP", "ok": False, "error": "missing"}
+ ])
+
+ def test_model_preparation_reports_progress_without_cli_dependencies(self):
+ service = VidXPService(device="cuda")
+ events = []
+ with (
+ patch(
+ "vidxp.application.dependency_failures",
+ return_value=[],
+ ),
+ patch("vidxp.application.get_clip_model") as load_clip,
+ ):
+ result = service.prepare_models(
+ ("scene",),
+ progress_callback=events.append,
+ )
+
+ load_clip.assert_called_once_with("ViT-B/32", "cuda")
+ self.assertEqual(result["device"], "cuda")
+ self.assertEqual(events[0]["stage"], "scene_model")
+
+ def test_clear_removes_only_known_run_state_after_clearing_collections(self):
+ with TemporaryDirectory() as directory:
+ index_directory = Path(directory) / "index"
+ index_directory.mkdir()
+ for name in (
+ "index_status.json",
+ "manifest.json",
+ "timings.jsonl",
+ "failures.jsonl",
+ "run.complete.json",
+ ):
+ (index_directory / name).write_text("{}", encoding="utf-8")
+ unrelated = index_directory / "keep.txt"
+ unrelated.write_text("keep", encoding="utf-8")
+ checkpoint_directory = index_directory / "checkpoints"
+ checkpoint_directory.mkdir()
+ (checkpoint_directory / "one.json").write_text(
+ "{}",
+ encoding="utf-8",
+ )
+
+ storage = Mock()
+ storage.__enter__ = Mock(return_value=storage)
+ storage.__exit__ = Mock(return_value=None)
+ service = VidXPService(index_directory)
+ with (
+ patch(
+ "vidxp.application.indexing_in_progress",
+ return_value=False,
+ ),
+ patch(
+ "vidxp.application.read_index_status",
+ return_value=None,
+ ),
+ patch(
+ "vidxp.application.IndexStorage",
+ return_value=storage,
+ ),
+ ):
+ cleared = service.clear_index()
+
+ self.assertTrue(cleared)
+ storage.clear.assert_called_once_with()
+ self.assertTrue(unrelated.is_file())
+ self.assertFalse(
+ (index_directory / "index_status.json").exists()
+ )
+ self.assertFalse(checkpoint_directory.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_runner.py b/tests/test_runner.py
index e281589..d032730 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -547,13 +547,22 @@ def test_local_index_hash_is_reused_by_run_index(self):
"vidxp.core.runner.run_index",
return_value=manifest,
) as run,
- patch("vidxp.core.runner.write_index_status"),
+ patch(
+ "vidxp.core.runner.write_index_status",
+ ) as write_status,
):
index_video(str(path), config=config)
hash_source.assert_called_once()
indexed_source = run.call_args.args[0][0]
self.assertEqual(indexed_source.checksum, checksum)
+ self.assertTrue(
+ all(
+ call.kwargs["index_directory"]
+ == config.index_directory
+ for call in write_status.call_args_list
+ )
+ )
def test_manifest_and_timing_files_are_valid_json(self):
with TemporaryDirectory() as directory:
diff --git a/tests/test_storage.py b/tests/test_storage.py
index 1a25385..02fdc05 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -156,6 +156,20 @@ def test_actor_detections_are_chronologically_ordered(self):
["d1", "d3"],
)
+ def test_actor_cluster_records_are_scoped_and_ordered(self):
+ collection = FakeCollection()
+ storage = fake_storage(self.config, collection)
+
+ records = storage.actor_cluster_records(video_id="video-1")
+
+ self.assertEqual(
+ [item["detection_id"] for item in records],
+ ["d1", "d3"],
+ )
+ clauses = collection.get_options["where"]["$and"]
+ self.assertIn({"run_id": "run-1"}, clauses)
+ self.assertIn({"video_id": "video-1"}, clauses)
+
def test_actor_cluster_cleanup_remains_scoped_to_video_and_run(self):
collection = FakeCollection()
storage = fake_storage(self.config, collection)
From 9d9e0472feb1828d50e41d9aa7fea2b612f5d017 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 17:43:40 +0500
Subject: [PATCH 03/44] feat(cli): expand structured command interface
---
src/vidxp/cli.py | 332 ++++++-----------------------
src/vidxp/cli_commands/__init__.py | 1 +
src/vidxp/cli_commands/actors.py | 180 ++++++++++++++++
src/vidxp/cli_commands/compat.py | 91 ++++++++
src/vidxp/cli_commands/index.py | 138 ++++++++++++
src/vidxp/cli_commands/runtime.py | 113 ++++++++++
src/vidxp/cli_commands/search.py | 80 +++++++
src/vidxp/cli_support.py | 191 +++++++++++++++++
tests/test_cli.py | 287 +++++++++++++++++--------
9 files changed, 1059 insertions(+), 354 deletions(-)
create mode 100644 src/vidxp/cli_commands/__init__.py
create mode 100644 src/vidxp/cli_commands/actors.py
create mode 100644 src/vidxp/cli_commands/compat.py
create mode 100644 src/vidxp/cli_commands/index.py
create mode 100644 src/vidxp/cli_commands/runtime.py
create mode 100644 src/vidxp/cli_commands/search.py
create mode 100644 src/vidxp/cli_support.py
diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py
index 958b265..c85d015 100644
--- a/src/vidxp/cli.py
+++ b/src/vidxp/cli.py
@@ -1,50 +1,52 @@
from __future__ import annotations
+from pathlib import Path
from typing import Annotated
import typer
-from rich import print
from vidxp import __version__
+from vidxp.application import VidXPService
from vidxp.benchmarks.cli import app as benchmark_app
-from vidxp.core.actor_results import (
- ActorClusterNotFoundError,
- render_actor_result,
-)
-from vidxp.core.contracts import IndexConfig, IndexSchemaError
-from vidxp.core.models import (
- INDEXING_DEPENDENCIES,
- dependency_failures,
- get_alignment_model,
- get_clip_model,
- get_embedder,
- get_whisper_model,
-)
-from vidxp.core.runner import (
- index_video,
- local_config_from_status,
-)
-from vidxp.core.search import search_dialogue, search_scene
-from vidxp.core.video import ffmpeg_binary
+from vidxp.cli_commands import compat
+from vidxp.cli_commands.actors import app as actors_app
+from vidxp.cli_commands.index import app as index_app
+from vidxp.cli_commands.runtime import doctor, prepare
+from vidxp.cli_commands.search import app as search_app
+from vidxp.cli_support import CLIState, OutputFormat
+from vidxp.core.actor_results import ActorClusterNotFoundError
+from vidxp.core.contracts import IndexSchemaError
from vidxp.index_state import (
IndexingInProgressError,
IndexNotReadyError,
- require_ready_index,
)
-app = typer.Typer(no_args_is_help=True)
+app = typer.Typer(
+ no_args_is_help=True,
+ help="Index and search video by dialogue, scene, and actor.",
+)
+app.add_typer(index_app, name="index")
+app.add_typer(search_app, name="search")
+app.add_typer(actors_app, name="actors")
app.add_typer(benchmark_app, name="benchmark")
+app.command()(doctor)
+app.command()(prepare)
+app.command("videoindex", hidden=True, deprecated=True)(compat.videoindex)
+app.command("dialogue", hidden=True, deprecated=True)(compat.dialogue)
+app.command("scene", hidden=True, deprecated=True)(compat.scene)
+app.command("actor", hidden=True, deprecated=True)(compat.actor)
def _show_version(value: bool) -> None:
if value:
- print(f"VidXP {__version__}")
+ typer.echo(f"VidXP {__version__}")
raise typer.Exit()
@app.callback()
def app_options(
+ ctx: typer.Context,
version: Annotated[
bool,
typer.Option(
@@ -55,263 +57,55 @@ def app_options(
help="Show the installed VidXP version and exit.",
),
] = False,
-) -> None:
- """Index and search video by dialogue, scene, and actor."""
-
-
-def _modalities(value: str) -> tuple[str, ...]:
- modalities = tuple(
- item.strip().lower()
- for item in value.split(",")
- if item.strip()
- )
- try:
- IndexConfig(enabled_modalities=modalities)
- except ValueError as exc:
- raise typer.BadParameter(str(exc)) from exc
- return modalities
-
-
-def _active_config() -> tuple[IndexConfig, dict]:
- status = require_ready_index()
- try:
- return local_config_from_status(status), status
- except IndexSchemaError as exc:
- raise IndexNotReadyError(str(exc)) from exc
-
-
-def _require_modality(config: IndexConfig, modality: str) -> None:
- if modality not in config.enabled_modalities:
- raise IndexNotReadyError(
- f"The {modality} modality is not present in this index."
- )
-
-
-@app.command()
-def videoindex(
- path: str,
- modalities: Annotated[
- str,
- typer.Option(
- "--modalities",
- "-m",
- help="Comma-separated dialogue, scene, and actor modalities.",
- ),
- ] = "dialogue,scene,actor",
- frame_stride: Annotated[
- int,
- typer.Option(
- "--frame-stride",
- min=1,
- help=(
- "Materialize every Nth frame for scene and actor modalities."
- ),
- ),
- ] = 1,
-):
- """Index one local video, replacing the previous local index."""
-
- config = IndexConfig.local(
- enabled_modalities=_modalities(modalities),
- frame_stride=frame_stride,
- )
- last_stage = None
- last_percent = None
-
- def progress(event):
- nonlocal last_percent, last_stage
- stage = event["stage"]
- current, total = event.get("current"), event.get("total")
- percent = (
- int(current * 100 / total)
- if current is not None and total
- else None
- )
- if stage != last_stage:
- print(f"[cyan]{event['message']}[/cyan]")
- last_stage = stage
- last_percent = percent
- elif percent is not None and (
- last_percent is None or percent >= last_percent + 10
- ):
- print(f"[cyan]{event['message']} {percent}%[/cyan]")
- last_percent = percent
-
- summary = index_video(path, progress_callback=progress, config=config)
- print("[bold green]Video indexing completed successfully.[/bold green]")
- return summary
-
-
-@app.command()
-def doctor(
- modalities: Annotated[
- str,
+ index_directory: Annotated[
+ Path,
typer.Option(
- "--modalities",
- "-m",
- help="Only validate dependencies for these modalities.",
+ "--index-dir",
+ envvar="VIDXP_INDEX_DIR",
+ file_okay=False,
+ help="Local index directory.",
),
- ] = "dialogue,scene,actor",
-):
- """Validate selected indexing dependencies without downloading models."""
-
- selected = _modalities(modalities)
- failures = dict(
- dependency_failures(
- selected,
- needs_transcription="dialogue" in selected,
- )
- )
- checked_labels = []
- for modality in selected:
- for dependency in INDEXING_DEPENDENCIES[modality]:
- if dependency.label not in checked_labels:
- checked_labels.append(dependency.label)
- if "dialogue" in selected:
- for dependency in INDEXING_DEPENDENCIES["transcription"]:
- if dependency.label not in checked_labels:
- checked_labels.append(dependency.label)
-
- for label in checked_labels:
- if label in failures:
- print(f"[bold red]FAILED[/bold red] {label}: {failures[label]}")
- else:
- print(f"[green]OK[/green] {label}")
-
- if "dialogue" in selected:
- try:
- resolved_ffmpeg = ffmpeg_binary()
- print(f"[green]OK[/green] FFmpeg: {resolved_ffmpeg}")
- except Exception as exc:
- failures["FFmpeg"] = f"{type(exc).__name__}: {exc}"
- print(f"[bold red]FAILED[/bold red] FFmpeg: {failures['FFmpeg']}")
-
- if failures:
- raise typer.Exit(1)
- print("[bold green]Selected VidXP dependencies are available.[/bold green]")
-
-
-@app.command()
-def prepare(
- modalities: Annotated[
- str,
- typer.Option(
- "--modalities",
- "-m",
- help="Only prepare models for these modalities.",
- ),
- ] = "dialogue,scene",
- language: Annotated[
+ ] = Path("chroma_data"),
+ device: Annotated[
str | None,
typer.Option(
- "--language",
- "-l",
- help="Also cache the WhisperX alignment model for this language.",
+ "--device",
+ envvar="VIDXP_DEVICE",
+ help="Runtime device override, for example cpu, cuda, or mps.",
),
] = None,
-):
- """Download and cache selected runtime models before indexing."""
-
- selected = _modalities(modalities)
- config = IndexConfig.local(enabled_modalities=selected)
- try:
- failures = dependency_failures(
- selected,
- needs_transcription="dialogue" in selected,
- )
- if failures:
- details = "; ".join(
- f"{label}: {error}" for label, error in failures
- )
- raise RuntimeError(details)
- if "dialogue" in selected:
- print(f"[cyan]Preparing dialogue model: {config.sentence_model}[/cyan]")
- get_embedder(config.sentence_model, config.device)
- print(
- f"[cyan]Preparing transcription model: "
- f"WhisperX {config.whisper_model}[/cyan]"
- )
- get_whisper_model(config.whisper_model, config.device)
- if language:
- print(f"[cyan]Preparing the {language} alignment model.[/cyan]")
- get_alignment_model(language, config.device)
- if "scene" in selected:
- print(f"[cyan]Preparing scene model: CLIP {config.clip_model}[/cyan]")
- get_clip_model(config.clip_model, config.device)
- except Exception as exc:
- print(
- f"[bold red]Model preparation failed: "
- f"{type(exc).__name__}: {exc}[/bold red]"
- )
- raise typer.Exit(1) from exc
- print("[bold green]Selected VidXP runtime models are prepared.[/bold green]")
-
-
-@app.command()
-def dialogue(query: str):
- config, _ = _active_config()
- _require_modality(config, "dialogue")
- print("[green]Searching dialogue...[/green]")
- result = search_dialogue(
- query,
- config=config,
- top_k=1,
- video_id=config.video_id,
- )
- if not result.hits:
- raise IndexNotReadyError(
- "The completed index contains no searchable dialogue phrases."
- )
- print("[green]Dialogue found !!![/green]")
- timestamp = result.hits[0].start
- print(f"[bold green]{timestamp:.3f} seconds[/bold green]")
- return timestamp
-
-
-@app.command()
-def scene(query: str):
- config, _ = _active_config()
- _require_modality(config, "scene")
- print("[green]Searching scene...[/green]")
- result = search_scene(
- query,
- config=config,
- top_k=1,
- video_id=config.video_id,
+ output_format: Annotated[
+ OutputFormat,
+ typer.Option(
+ "--format",
+ envvar="VIDXP_OUTPUT_FORMAT",
+ help="Default command output format.",
+ ),
+ ] = OutputFormat.rich,
+ quiet: Annotated[
+ bool,
+ typer.Option("--quiet", "-q", help="Suppress progress output."),
+ ] = False,
+) -> None:
+ ctx.obj = CLIState(
+ service=VidXPService(index_directory, device=device),
+ output_format=output_format,
+ quiet=quiet,
)
- if not result.hits:
- raise IndexNotReadyError(
- "The completed index contains no searchable scene frames."
- )
- print("[green]Scene found...[/green]")
- timestamp = result.hits[0].start
- print(f"[bold green]{timestamp:.3f} seconds[/bold green]")
- return timestamp
-
-
-@app.command()
-def actor(cluster_id: str, input_path: str, output_path: str = "output.mp4"):
- config, _ = _active_config()
- _require_modality(config, "actor")
- try:
- render_actor_result(
- config,
- cluster_id,
- input_path,
- output_path,
- )
- except ActorClusterNotFoundError as exc:
- raise IndexNotReadyError(
- str(exc)
- ) from exc
- print(f"[green]Video saved as {output_path}[/green]")
-def main():
+def main() -> None:
try:
app()
- except (IndexNotReadyError, IndexingInProgressError) as exc:
- print(f"[bold red]{exc}[/bold red]")
+ except (
+ ActorClusterNotFoundError,
+ FileNotFoundError,
+ IndexNotReadyError,
+ IndexingInProgressError,
+ IndexSchemaError,
+ ValueError,
+ ) as exc:
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
raise SystemExit(1) from exc
diff --git a/src/vidxp/cli_commands/__init__.py b/src/vidxp/cli_commands/__init__.py
new file mode 100644
index 0000000..48c7dec
--- /dev/null
+++ b/src/vidxp/cli_commands/__init__.py
@@ -0,0 +1 @@
+"""Typer command groups for the VidXP CLI adapter."""
diff --git a/src/vidxp/cli_commands/actors.py b/src/vidxp/cli_commands/actors.py
new file mode 100644
index 0000000..25f461b
--- /dev/null
+++ b/src/vidxp/cli_commands/actors.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Annotated, Iterable
+
+import typer
+from rich.console import Console
+from rich.table import Table
+
+from vidxp.cli_support import (
+ CLIState,
+ OutputFormat,
+ effective_output_format,
+ emit_json,
+ state_from_context,
+)
+
+
+app = typer.Typer(
+ no_args_is_help=True,
+ help="Inspect and render actor clusters.",
+)
+
+
+def complete_cluster(
+ ctx: typer.Context,
+ incomplete: str,
+) -> Iterable[tuple[str, str]]:
+ state = ctx.find_root().obj
+ if not isinstance(state, CLIState):
+ return
+ try:
+ clusters = state.service.actor_clusters()
+ except Exception:
+ return
+ for cluster in clusters:
+ if cluster.cluster_id.startswith(incomplete):
+ yield (
+ cluster.cluster_id,
+ f"{cluster.detection_count} detections",
+ )
+
+
+@app.command("list")
+def actors_list(
+ ctx: typer.Context,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """List actor clusters in the selected index."""
+
+ state = state_from_context(ctx)
+ clusters = state.service.actor_clusters()
+ payload = {
+ "clusters": [cluster.to_dict() for cluster in clusters],
+ "count": len(clusters),
+ }
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(payload)
+ return
+ table = Table(title="Actor clusters")
+ table.add_column("Cluster")
+ table.add_column("Detections", justify="right")
+ table.add_column("First", justify="right")
+ table.add_column("Last", justify="right")
+ for cluster in clusters:
+ table.add_row(
+ cluster.cluster_id,
+ str(cluster.detection_count),
+ f"{cluster.first_timestamp:.3f}s",
+ f"{cluster.last_timestamp:.3f}s",
+ )
+ Console().print(table)
+
+
+@app.command("inspect")
+def actors_inspect(
+ ctx: typer.Context,
+ cluster_id: Annotated[
+ str,
+ typer.Argument(
+ autocompletion=complete_cluster,
+ help="Actor cluster identifier.",
+ ),
+ ],
+ limit: Annotated[
+ int,
+ typer.Option("--limit", min=1, help="Maximum detections to display."),
+ ] = 20,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Inspect retained detections for one actor cluster."""
+
+ state = state_from_context(ctx)
+ detections = state.service.actor_detections(cluster_id)
+ if not detections:
+ raise typer.BadParameter(f"Actor cluster {cluster_id} was not found.")
+ payload = {
+ "cluster_id": cluster_id,
+ "detection_count": len(detections),
+ "detections": detections[:limit],
+ "truncated": len(detections) > limit,
+ }
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(payload)
+ return
+ table = Table(title=f"Actor cluster {cluster_id}")
+ table.add_column("Frame", justify="right")
+ table.add_column("Timestamp", justify="right")
+ table.add_column("Detection")
+ for detection in detections[:limit]:
+ table.add_row(
+ str(detection["frame_index"]),
+ f"{float(detection['timestamp']):.3f}s",
+ str(detection["detection_id"]),
+ )
+ Console().print(table)
+ if len(detections) > limit:
+ typer.echo(f"Showing {limit} of {len(detections)} detections.")
+
+
+@app.command("render")
+def actors_render(
+ ctx: typer.Context,
+ cluster_id: Annotated[
+ str,
+ typer.Argument(
+ autocompletion=complete_cluster,
+ help="Actor cluster identifier.",
+ ),
+ ],
+ input_path: Annotated[
+ Path,
+ typer.Argument(
+ exists=True,
+ dir_okay=False,
+ readable=True,
+ resolve_path=True,
+ help="Source video used to create the active index.",
+ ),
+ ],
+ output_path: Annotated[
+ Path,
+ typer.Option(
+ "--output",
+ "-o",
+ dir_okay=False,
+ help="Rendered video destination.",
+ ),
+ ] = Path("output.mp4"),
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Render one actor cluster as a result video."""
+
+ state = state_from_context(ctx)
+ result = state.service.render_actor(
+ cluster_id,
+ input_path,
+ output_path,
+ )
+ payload = {
+ "cluster_id": cluster_id,
+ "output_path": str(result.output_path),
+ "detection_count": result.detection_count,
+ }
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(payload)
+ else:
+ typer.secho(
+ f"Video saved as {result.output_path}",
+ fg=typer.colors.GREEN,
+ )
diff --git a/src/vidxp/cli_commands/compat.py b/src/vidxp/cli_commands/compat.py
new file mode 100644
index 0000000..9b3315b
--- /dev/null
+++ b/src/vidxp/cli_commands/compat.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Annotated
+
+import typer
+
+from vidxp.cli_commands.index import create_index
+from vidxp.cli_support import legacy_modalities, state_from_context
+
+
+def videoindex(
+ ctx: typer.Context,
+ path: Annotated[
+ Path,
+ typer.Argument(exists=True, dir_okay=False, readable=True),
+ ],
+ modalities: Annotated[
+ str,
+ typer.Option(
+ "--modalities",
+ "-m",
+ help="Comma-separated dialogue, scene, and actor modalities.",
+ ),
+ ] = "dialogue,scene,actor",
+ frame_stride: Annotated[
+ int,
+ typer.Option("--frame-stride", min=1),
+ ] = 1,
+) -> None:
+ create_index(
+ state_from_context(ctx),
+ path,
+ modalities=legacy_modalities(modalities),
+ frame_stride=frame_stride,
+ )
+
+
+def dialogue(
+ ctx: typer.Context,
+ query: str,
+) -> float | None:
+ result = state_from_context(ctx).service.search(
+ "dialogue",
+ query,
+ top_k=1,
+ )
+ if not result.hits:
+ typer.echo("No dialogue matches found.")
+ return None
+ timestamp = result.hits[0].start
+ typer.secho(f"{timestamp:.3f} seconds", fg=typer.colors.GREEN, bold=True)
+ return timestamp
+
+
+def scene(
+ ctx: typer.Context,
+ query: str,
+) -> float | None:
+ result = state_from_context(ctx).service.search(
+ "scene",
+ query,
+ top_k=1,
+ )
+ if not result.hits:
+ typer.echo("No scene matches found.")
+ return None
+ timestamp = result.hits[0].start
+ typer.secho(f"{timestamp:.3f} seconds", fg=typer.colors.GREEN, bold=True)
+ return timestamp
+
+
+def actor(
+ ctx: typer.Context,
+ cluster_id: str,
+ input_path: Annotated[
+ Path,
+ typer.Argument(exists=True, dir_okay=False, readable=True),
+ ],
+ output_path: Path = Path("output.mp4"),
+) -> None:
+ state = state_from_context(ctx)
+ result = state.service.render_actor(
+ cluster_id,
+ input_path,
+ output_path,
+ )
+ typer.secho(
+ f"Video saved as {result.output_path}",
+ fg=typer.colors.GREEN,
+ )
diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py
new file mode 100644
index 0000000..32743c4
--- /dev/null
+++ b/src/vidxp/cli_commands/index.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Annotated, Iterable
+
+import typer
+
+from vidxp.cli_support import (
+ CLIState,
+ IndexProgress,
+ Modality,
+ OutputFormat,
+ effective_output_format,
+ emit_json,
+ emit_status,
+ selected_modalities,
+ state_from_context,
+)
+
+
+app = typer.Typer(no_args_is_help=True, help="Manage a local video index.")
+
+
+def create_index(
+ state: CLIState,
+ path: Path,
+ *,
+ modalities: Iterable[str],
+ frame_stride: int,
+) -> dict:
+ show_progress = (
+ not state.quiet and state.output_format == OutputFormat.rich
+ )
+ with IndexProgress(show_progress) as progress:
+ summary = state.service.create_index(
+ path,
+ modalities=modalities,
+ frame_stride=frame_stride,
+ progress_callback=progress.update,
+ )
+ if state.output_format == OutputFormat.json:
+ emit_json(summary)
+ else:
+ typer.secho(
+ "Video indexing completed successfully.",
+ fg=typer.colors.GREEN,
+ bold=True,
+ )
+ return summary
+
+
+@app.command("create")
+def index_create(
+ ctx: typer.Context,
+ path: Annotated[
+ Path,
+ typer.Argument(
+ exists=True,
+ dir_okay=False,
+ readable=True,
+ resolve_path=True,
+ help="Local video file to index.",
+ ),
+ ],
+ modalities: Annotated[
+ list[Modality] | None,
+ typer.Option(
+ "--modality",
+ "-m",
+ help="Modality to index; repeat to select more than one.",
+ ),
+ ] = None,
+ frame_stride: Annotated[
+ int,
+ typer.Option(
+ "--frame-stride",
+ min=1,
+ help="Materialize every Nth frame for visual modalities.",
+ ),
+ ] = 1,
+) -> None:
+ """Create or replace a local index for one video."""
+
+ state = state_from_context(ctx)
+ create_index(
+ state,
+ path,
+ modalities=selected_modalities(modalities),
+ frame_stride=frame_stride,
+ )
+
+
+@app.command("status")
+def index_status(
+ ctx: typer.Context,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Show the state and source of the selected index."""
+
+ state = state_from_context(ctx)
+ emit_status(
+ state.service.index_status(),
+ output_format=effective_output_format(state, json_output),
+ )
+
+
+@app.command("clear")
+def index_clear(
+ ctx: typer.Context,
+ yes: Annotated[
+ bool,
+ typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
+ ] = False,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Clear indexed records and VidXP run state."""
+
+ state = state_from_context(ctx)
+ if not yes:
+ typer.confirm(
+ f"Clear the local index at {state.service.index_directory}?",
+ abort=True,
+ )
+ cleared = state.service.clear_index()
+ payload = {
+ "cleared": cleared,
+ "index_directory": str(state.service.index_directory),
+ }
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(payload)
+ else:
+ typer.echo("Index cleared." if cleared else "No index was found.")
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
new file mode 100644
index 0000000..06b9b9f
--- /dev/null
+++ b/src/vidxp/cli_commands/runtime.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from typing import Annotated
+
+import typer
+
+from vidxp.cli_support import (
+ OutputFormat,
+ effective_output_format,
+ emit_json,
+ legacy_modalities,
+ state_from_context,
+)
+
+
+def doctor(
+ ctx: typer.Context,
+ modalities: Annotated[
+ str,
+ typer.Option(
+ "--modalities",
+ "-m",
+ help="Only validate dependencies for these modalities.",
+ ),
+ ] = "dialogue,scene,actor",
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Validate selected indexing dependencies without downloading models."""
+
+ selected = legacy_modalities(modalities)
+ state = state_from_context(ctx)
+ result = state.service.check_dependencies(selected)
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(result)
+ else:
+ for check in result["checks"]:
+ if check["ok"]:
+ detail = f": {check['path']}" if check.get("path") else ""
+ typer.secho(
+ f"OK {check['name']}{detail}",
+ fg=typer.colors.GREEN,
+ )
+ else:
+ typer.secho(
+ f"FAILED {check['name']}: {check['error']}",
+ fg=typer.colors.RED,
+ )
+ if not result["ok"]:
+ raise typer.Exit(1)
+ if effective_output_format(state, json_output) == OutputFormat.rich:
+ typer.secho(
+ "Selected VidXP dependencies are available.",
+ fg=typer.colors.GREEN,
+ bold=True,
+ )
+
+
+def prepare(
+ ctx: typer.Context,
+ modalities: Annotated[
+ str,
+ typer.Option(
+ "--modalities",
+ "-m",
+ help="Only prepare models for these modalities.",
+ ),
+ ] = "dialogue,scene",
+ language: Annotated[
+ str | None,
+ typer.Option(
+ "--language",
+ "-l",
+ help="Also cache the WhisperX alignment model for this language.",
+ ),
+ ] = None,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Download and cache selected runtime models before indexing."""
+
+ selected = legacy_modalities(modalities)
+ state = state_from_context(ctx)
+ try:
+ result = state.service.prepare_models(
+ selected,
+ language=language,
+ progress_callback=(
+ None
+ if state.quiet
+ or effective_output_format(state, json_output)
+ == OutputFormat.json
+ else lambda event: typer.echo(event["message"])
+ ),
+ )
+ except Exception as exc:
+ typer.secho(
+ f"Model preparation failed: {type(exc).__name__}: {exc}",
+ fg=typer.colors.RED,
+ )
+ raise typer.Exit(1) from exc
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(result)
+ else:
+ typer.secho(
+ "Selected VidXP runtime models are prepared.",
+ fg=typer.colors.GREEN,
+ bold=True,
+ )
diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py
new file mode 100644
index 0000000..18491fe
--- /dev/null
+++ b/src/vidxp/cli_commands/search.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from typing import Annotated
+
+import typer
+
+from vidxp.cli_support import (
+ CLIState,
+ effective_output_format,
+ emit_search,
+ state_from_context,
+)
+from vidxp.core.contracts import SearchResult
+
+
+app = typer.Typer(no_args_is_help=True, help="Search the active index.")
+
+
+def run_search(
+ state: CLIState,
+ modality: str,
+ query: str,
+ *,
+ top_k: int,
+ json_output: bool,
+) -> SearchResult:
+ result = state.service.search(modality, query, top_k=top_k)
+ emit_search(
+ result,
+ output_format=effective_output_format(state, json_output),
+ )
+ return result
+
+
+@app.command("dialogue")
+def search_dialogue_command(
+ ctx: typer.Context,
+ query: Annotated[str, typer.Argument(help="Dialogue text to find.")],
+ top_k: Annotated[
+ int,
+ typer.Option("--top-k", "-k", min=1, help="Maximum ranked hits."),
+ ] = 10,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Find ranked intervals matching spoken dialogue."""
+
+ run_search(
+ state_from_context(ctx),
+ "dialogue",
+ query,
+ top_k=top_k,
+ json_output=json_output,
+ )
+
+
+@app.command("scene")
+def search_scene_command(
+ ctx: typer.Context,
+ query: Annotated[str, typer.Argument(help="Visual description to find.")],
+ top_k: Annotated[
+ int,
+ typer.Option("--top-k", "-k", min=1, help="Maximum ranked hits."),
+ ] = 10,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Find ranked intervals matching a visual description."""
+
+ run_search(
+ state_from_context(ctx),
+ "scene",
+ query,
+ top_k=top_k,
+ json_output=json_output,
+ )
diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py
new file mode 100644
index 0000000..4322ec7
--- /dev/null
+++ b/src/vidxp/cli_support.py
@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, Iterable
+
+import typer
+from rich.console import Console
+from rich.progress import (
+ BarColumn,
+ Progress,
+ SpinnerColumn,
+ TaskProgressColumn,
+ TextColumn,
+)
+from rich.table import Table
+
+from vidxp.application import VidXPService
+from vidxp.core.contracts import (
+ SUPPORTED_MODALITIES,
+ IndexConfig,
+ SearchResult,
+)
+
+
+class OutputFormat(str, Enum):
+ rich = "rich"
+ json = "json"
+
+
+class Modality(str, Enum):
+ dialogue = "dialogue"
+ scene = "scene"
+ actor = "actor"
+
+
+@dataclass
+class CLIState:
+ service: VidXPService
+ output_format: OutputFormat = OutputFormat.rich
+ quiet: bool = False
+
+
+def state_from_context(ctx: typer.Context) -> CLIState:
+ state = ctx.ensure_object(CLIState)
+ if not isinstance(state, CLIState):
+ raise RuntimeError("VidXP CLI state was not initialized.")
+ return state
+
+
+def effective_output_format(
+ state: CLIState,
+ json_output: bool,
+) -> OutputFormat:
+ return OutputFormat.json if json_output else state.output_format
+
+
+def emit_json(payload: Any) -> None:
+ typer.echo(
+ json.dumps(
+ payload,
+ ensure_ascii=False,
+ indent=2,
+ sort_keys=True,
+ )
+ )
+
+
+def emit_search(
+ result: SearchResult,
+ *,
+ output_format: OutputFormat,
+) -> None:
+ if output_format == OutputFormat.json:
+ emit_json(result.to_dict())
+ return
+ if not result.hits:
+ typer.echo(f"No {result.modality} matches found.")
+ return
+ table = Table(title=f"{result.modality.title()} search results")
+ table.add_column("Rank", justify="right")
+ table.add_column("Start", justify="right")
+ table.add_column("End", justify="right")
+ table.add_column("Score", justify="right")
+ table.add_column("Video")
+ for hit in result.hits:
+ table.add_row(
+ str(hit.rank),
+ f"{hit.start:.3f}s",
+ f"{hit.end:.3f}s",
+ f"{hit.score:.6f}",
+ hit.video_id,
+ )
+ Console().print(table)
+
+
+def emit_status(
+ status: dict[str, Any],
+ *,
+ output_format: OutputFormat,
+) -> None:
+ if output_format == OutputFormat.json:
+ emit_json(status)
+ return
+ table = Table(title="VidXP index")
+ table.add_column("Field")
+ table.add_column("Value")
+ table.add_row("State", str(status.get("state", "unknown")))
+ table.add_row("Message", str(status.get("message", "—")))
+ if updated_at := status.get("updated_at"):
+ table.add_row("Updated", str(updated_at))
+ if video := status.get("video"):
+ table.add_row(
+ "Video",
+ str(video.get("source_name") or video.get("path") or "—"),
+ )
+ summary = status.get("summary") or {}
+ if modalities := (summary.get("configuration") or {}).get(
+ "enabled_modalities"
+ ):
+ table.add_row("Modalities", ", ".join(map(str, modalities)))
+ Console().print(table)
+
+
+class IndexProgress:
+ def __init__(self, enabled: bool) -> None:
+ self.enabled = enabled
+ self.progress = Progress(
+ SpinnerColumn(),
+ TextColumn("{task.description}"),
+ BarColumn(),
+ TaskProgressColumn(),
+ console=Console(stderr=True),
+ )
+ self.task_id: int | None = None
+ self.stage: str | None = None
+
+ def __enter__(self) -> "IndexProgress":
+ if self.enabled:
+ self.progress.start()
+ return self
+
+ def __exit__(self, *_: Any) -> None:
+ if self.enabled:
+ self.progress.stop()
+
+ def update(self, event: dict[str, Any]) -> None:
+ if not self.enabled:
+ return
+ stage = str(event.get("stage", "indexing"))
+ total = event.get("total")
+ current = event.get("current")
+ if self.task_id is None or stage != self.stage:
+ if self.task_id is not None:
+ self.progress.update(self.task_id, completed=1, total=1)
+ self.task_id = self.progress.add_task(
+ str(event.get("message", stage.replace("_", " "))),
+ total=float(total) if total else None,
+ )
+ self.stage = stage
+ self.progress.update(
+ self.task_id,
+ description=str(event.get("message", stage.replace("_", " "))),
+ total=float(total) if total else None,
+ completed=float(current) if current is not None else None,
+ )
+
+
+def selected_modalities(
+ values: Iterable[Modality] | None,
+) -> tuple[str, ...]:
+ if values is None:
+ return SUPPORTED_MODALITIES
+ selected = tuple(dict.fromkeys(value.value for value in values))
+ if not selected:
+ raise typer.BadParameter("At least one modality is required.")
+ return selected
+
+
+def legacy_modalities(value: str) -> tuple[str, ...]:
+ selected = tuple(
+ item.strip().lower()
+ for item in value.split(",")
+ if item.strip()
+ )
+ try:
+ IndexConfig(enabled_modalities=selected)
+ except ValueError as exc:
+ raise typer.BadParameter(str(exc)) from exc
+ return selected
diff --git a/tests/test_cli.py b/tests/test_cli.py
index bcf6439..ca19657 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,10 +1,14 @@
+import json
import unittest
-from unittest.mock import patch
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import Mock, patch
from typer.testing import CliRunner
from vidxp import cli
-from vidxp.core.contracts import IndexConfig, SearchHit, SearchResult
+from vidxp.core.actor_results import ActorClusterSummary, ActorRenderResult
+from vidxp.core.contracts import SearchHit, SearchResult
def result(modality, starts):
@@ -28,103 +32,216 @@ def result(modality, starts):
)
-class CliCompatibilityTests(unittest.TestCase):
+class CliTests(unittest.TestCase):
def setUp(self):
- self.config = IndexConfig.local(video_id="video-1")
-
- def test_dialogue_command_returns_first_rich_result_timestamp(self):
- with (
- patch.object(
- cli,
- "_active_config",
- return_value=(self.config, {}),
- ),
- patch.object(
- cli,
- "search_dialogue",
- return_value=result("dialogue", [7.5, 12.0]),
- ) as search,
- patch.object(cli, "print"),
+ self.runner = CliRunner()
+ self.service = Mock()
+ self.service.index_directory = Path("chroma_data")
+ self.service.device = None
+
+ def invoke(self, arguments):
+ with patch.object(
+ cli,
+ "VidXPService",
+ return_value=self.service,
):
- timestamp = cli.dialogue("fresh bread")
+ return self.runner.invoke(cli.app, arguments)
+
+ def test_grouped_commands_are_exposed_and_compatibility_aliases_are_hidden(self):
+ response = self.invoke(["--help"])
+
+ self.assertEqual(response.exit_code, 0)
+ for command in ("index", "search", "actors", "benchmark"):
+ self.assertIn(command, response.stdout)
+ for alias in ("videoindex", "dialogue", "scene", "actor"):
+ self.assertFalse(
+ any(
+ line.lstrip("│ ").startswith(f"{alias} ")
+ for line in response.stdout.splitlines()
+ )
+ )
- self.assertEqual(timestamp, 7.5)
- search.assert_called_once_with(
+ def test_search_returns_ranked_json_and_passes_top_k(self):
+ self.service.search.return_value = result(
+ "dialogue",
+ [7.5, 12.0],
+ )
+
+ response = self.invoke(
+ [
+ "search",
+ "dialogue",
+ "fresh bread",
+ "--top-k",
+ "2",
+ "--json",
+ ]
+ )
+
+ self.assertEqual(response.exit_code, 0, response.output)
+ payload = json.loads(response.stdout)
+ self.assertEqual(
+ [hit["start"] for hit in payload["hits"]],
+ [7.5, 12.0],
+ )
+ self.service.search.assert_called_once_with(
+ "dialogue",
"fresh bread",
- config=self.config,
- top_k=1,
- video_id="video-1",
+ top_k=2,
)
- def test_scene_command_returns_first_rich_result_timestamp(self):
- with (
- patch.object(
- cli,
- "_active_config",
- return_value=(self.config, {}),
- ),
- patch.object(
- cli,
- "search_scene",
- return_value=result("scene", [3.25]),
- ),
- patch.object(cli, "print"),
- ):
- self.assertEqual(cli.scene("yellow taxi"), 3.25)
-
- def test_typer_dialogue_command_displays_first_timestamp(self):
- with (
- patch.object(
- cli,
- "_active_config",
- return_value=(self.config, {}),
- ),
- patch.object(
- cli,
- "search_dialogue",
- return_value=result("dialogue", [7.5]),
- ),
- ):
- response = CliRunner().invoke(
+ def test_global_index_directory_and_device_configure_service(self):
+ with patch.object(cli, "VidXPService") as service_type:
+ service_type.return_value.index_status.return_value = {
+ "state": "missing",
+ }
+ response = self.runner.invoke(
cli.app,
- ["dialogue", "fresh bread"],
+ [
+ "--index-dir",
+ "custom-index",
+ "--device",
+ "cuda",
+ "index",
+ "status",
+ "--json",
+ ],
)
- self.assertEqual(response.exit_code, 0)
- self.assertIn("7.500 seconds", response.stdout)
-
- def test_typer_videoindex_command_displays_progress_and_completion(self):
- def fake_index(_, progress_callback, **__):
- progress_callback(
- {
- "stage": "scene_indexing",
- "message": "Indexing sampled video frames.",
- "current": 0,
- "total": 10,
- }
- )
- progress_callback(
- {
- "stage": "scene_indexing",
- "message": "Indexing sampled video frames.",
- "current": 10,
- "total": 10,
- }
+ self.assertEqual(response.exit_code, 0, response.output)
+ service_type.assert_called_once_with(Path("custom-index"), device="cuda")
+
+ def test_index_create_uses_repeated_typed_modalities(self):
+ self.service.create_index.return_value = {"scene_frames": 10}
+ with TemporaryDirectory() as directory:
+ video = Path(directory) / "sample.mp4"
+ video.write_bytes(b"video")
+ response = self.invoke(
+ [
+ "--format",
+ "json",
+ "index",
+ "create",
+ str(video),
+ "--modality",
+ "scene",
+ "--frame-stride",
+ "5",
+ ]
)
- return {"scene_frames": 10}
- with patch.object(cli, "index_video", side_effect=fake_index):
- response = CliRunner().invoke(
- cli.app,
- ["videoindex", "sample.mp4", "--modalities", "scene"],
+ self.assertEqual(response.exit_code, 0, response.output)
+ self.assertEqual(json.loads(response.stdout), {"scene_frames": 10})
+ self.service.create_index.assert_called_once()
+ call = self.service.create_index.call_args
+ self.assertEqual(call.kwargs["modalities"], ("scene",))
+ self.assertEqual(call.kwargs["frame_stride"], 5)
+
+ def test_index_status_reports_missing_index_as_json(self):
+ self.service.index_status.return_value = {
+ "state": "missing",
+ "message": "No local video index was found.",
+ }
+
+ response = self.invoke(["index", "status", "--json"])
+
+ self.assertEqual(response.exit_code, 0, response.output)
+ self.assertEqual(json.loads(response.stdout)["state"], "missing")
+
+ def test_index_clear_requires_explicit_confirmation_for_automation(self):
+ self.service.clear_index.return_value = True
+
+ response = self.invoke(["index", "clear", "--yes", "--json"])
+
+ self.assertEqual(response.exit_code, 0, response.output)
+ self.assertTrue(json.loads(response.stdout)["cleared"])
+ self.service.clear_index.assert_called_once_with()
+
+ def test_doctor_and_prepare_use_the_reusable_service(self):
+ self.service.check_dependencies.return_value = {
+ "ok": True,
+ "modalities": ["scene"],
+ "checks": [{"name": "CLIP", "ok": True, "error": None}],
+ }
+ self.service.prepare_models.return_value = {
+ "prepared": ["ViT-B/32"],
+ "modalities": ["scene"],
+ "device": "cpu",
+ "language": None,
+ }
+
+ checked = self.invoke(
+ ["doctor", "--modalities", "scene", "--json"]
+ )
+ prepared = self.invoke(
+ ["prepare", "--modalities", "scene", "--json"]
+ )
+
+ self.assertTrue(json.loads(checked.stdout)["ok"])
+ self.assertEqual(
+ json.loads(prepared.stdout)["prepared"],
+ ["ViT-B/32"],
+ )
+ self.service.check_dependencies.assert_called_once_with(("scene",))
+ self.service.prepare_models.assert_called_once()
+
+ def test_actor_commands_expose_clusters_detections_and_rendering(self):
+ cluster = ActorClusterSummary("3", "video-1", 4, 1.0, 8.0)
+ self.service.actor_clusters.return_value = (cluster,)
+ self.service.actor_detections.return_value = [
+ {
+ "frame_index": 2,
+ "timestamp": 1.5,
+ "detection_id": "d2",
+ }
+ ]
+ self.service.render_actor.return_value = ActorRenderResult(
+ Path("actor.mp4"),
+ 4,
+ )
+
+ listed = self.invoke(["actors", "list", "--json"])
+ inspected = self.invoke(["actors", "inspect", "3", "--json"])
+ with TemporaryDirectory() as directory:
+ source = Path(directory) / "source.mp4"
+ source.write_bytes(b"video")
+ rendered = self.invoke(
+ [
+ "actors",
+ "render",
+ "3",
+ str(source),
+ "--output",
+ "actor.mp4",
+ "--json",
+ ]
)
- self.assertEqual(response.exit_code, 0)
- self.assertIn("Indexing sampled video frames.", response.stdout)
- self.assertIn("completed successfully", response.stdout)
+ self.assertEqual(json.loads(listed.stdout)["count"], 1)
+ self.assertEqual(
+ json.loads(inspected.stdout)["detection_count"],
+ 1,
+ )
+ self.assertEqual(
+ json.loads(rendered.stdout)["output_path"],
+ "actor.mp4",
+ )
+
+ def test_legacy_search_alias_still_works(self):
+ self.service.search.return_value = result("scene", [3.25])
+
+ response = self.invoke(["scene", "yellow taxi"])
+
+ self.assertEqual(response.exit_code, 0, response.output)
+ self.assertIn("3.250 seconds", response.stdout)
+ self.service.search.assert_called_once_with(
+ "scene",
+ "yellow taxi",
+ top_k=1,
+ )
def test_benchmark_commands_are_exposed(self):
- response = CliRunner().invoke(cli.app, ["benchmark", "--help"])
+ response = self.invoke(["benchmark", "--help"])
self.assertEqual(response.exit_code, 0)
self.assertIn("didemo", response.stdout)
@@ -133,7 +250,7 @@ def test_benchmark_commands_are_exposed(self):
def test_version_options_report_installed_package_version(self):
for option in ("--version", "-V"):
with self.subTest(option=option):
- response = CliRunner().invoke(cli.app, [option])
+ response = self.invoke([option])
self.assertEqual(response.exit_code, 0)
self.assertEqual(
From 6563b5a6646c7e493b97e1b3e549da4eca0d63ff Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 17:43:47 +0500
Subject: [PATCH 04/44] build: constrain Typer compatibility range
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 496df25..2e43e1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,7 +45,7 @@ dependencies = [
"sentence-transformers>=3.4,<4",
"setuptools>=68,<81",
"torch",
- "typer",
+ "typer>=0.27,<1",
"whisperx>=3.8.6,<3.9",
"clip-anytorch==2.6.0",
]
From a5e760ae2dbec378e8fa6b65caec8fd15cbbb0e8 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 17:43:47 +0500
Subject: [PATCH 05/44] docs: update canonical CLI commands
---
INSTALLATION_GUIDE.md | 7 ++++---
README.md | 13 +++++++------
docs/CONTRIBUTING.md | 4 +++-
3 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md
index 182d5e9..b1bf2de 100644
--- a/INSTALLATION_GUIDE.md
+++ b/INSTALLATION_GUIDE.md
@@ -139,7 +139,7 @@ If `vidxp prepare` was not run first, the initial indexing command downloads any
missing models before processing the video:
```bash
-vidxp videoindex samplevideo.mp4
+vidxp index create samplevideo.mp4
```
Keep the terminal and internet connection open until VidXP reports that the
@@ -167,11 +167,12 @@ directory to `PATH`, then rerun `vidxp doctor`.
### A search says the index is not ready
Wait for the active indexing command to finish. If the previous process ended
-or failed, run `vidxp videoindex` again to rebuild the incomplete local index.
+or failed, run `vidxp index create` again to rebuild the incomplete local
+index.
### The first indexing run appears slow
Check the terminal for model-download or indexing progress. Model preparation,
transcription, scene analysis, and actor detection are separate stages. Use
`vidxp prepare` before indexing or select fewer capabilities with
-`--modalities`.
+one or more `--modality` options.
diff --git a/README.md b/README.md
index 3bee6d0..02a46e7 100644
--- a/README.md
+++ b/README.md
@@ -81,24 +81,25 @@ vidxp prepare
Build an index containing dialogue, scene, and actor information:
```bash
-vidxp videoindex samplevideo.mp4
+vidxp index create samplevideo.mp4
```
Search the completed index:
```bash
-vidxp dialogue "the bread just came out of the oven"
-vidxp scene "a yellow taxi on a city street"
-vidxp actor 1 samplevideo.mp4
+vidxp search dialogue "the bread just came out of the oven"
+vidxp search scene "a yellow taxi on a city street" --top-k 5
+vidxp actors list
+vidxp actors render 1 samplevideo.mp4
```
Index only selected capabilities or sample fewer visual frames:
```bash
-vidxp videoindex samplevideo.mp4 --modalities scene --frame-stride 5
+vidxp index create samplevideo.mp4 --modality scene --frame-stride 5
```
-`--modalities` accepts any combination of `dialogue`, `scene`, and `actor`.
+Repeat `--modality` to combine `dialogue`, `scene`, and `actor`.
Run `vidxp --help` or any command followed by `--help` for the complete command
reference.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 899c763..6263fe0 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -7,6 +7,7 @@ Thanks for contributing to VidXP (Video eXPlain).
| File / path | Role |
|-------------|------|
| `src/vidxp/cli.py` | Typer commands and installed `vidxp` entry point |
+| `src/vidxp/application.py` | Reusable application boundary for CLI and future adapters |
| `src/vidxp/frontend.py` | Streamlit interface and installed `vidxp-ui` entry point |
| `src/vidxp/core/` | Indexing, retrieval, storage, models, run state, and shared contracts |
| `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls |
@@ -50,9 +51,10 @@ record the exact identifier in benchmark results.
- Indexing, retrieval, storage, metadata, and face clustering:
`src/vidxp/core/`.
+- Transport-neutral application operations: `src/vidxp/application.py`.
- Command-line behavior: `src/vidxp/cli.py`.
- Upload and search UX: `src/vidxp/frontend.py`; keep product logic in the
- shared core.
+ shared application and core modules.
- Official benchmark formats and evaluator calls: `src/vidxp/benchmarks/`.
- New dependencies: `pyproject.toml`, with the reason stated in the pull
request.
From c3205e98434056a58362ba9ebb7b45f734c90bb2 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 19:08:22 +0500
Subject: [PATCH 06/44] feat(config): add repository-aware application
interfaces
---
src/vidxp/application.py | 13 ++
src/vidxp/cli.py | 130 +++++++++--
src/vidxp/cli_commands/repositories.py | 205 +++++++++++++++++
src/vidxp/cli_commands/runtime.py | 65 ++++--
src/vidxp/cli_support.py | 3 +
src/vidxp/frontend.py | 52 +++--
src/vidxp/index_worker.py | 48 +++-
src/vidxp/repositories.py | 300 +++++++++++++++++++++++++
tests/test_cli.py | 140 +++++++++++-
tests/test_frontend.py | 112 +++------
tests/test_index_worker.py | 77 +++++--
tests/test_repositories.py | 114 ++++++++++
12 files changed, 1094 insertions(+), 165 deletions(-)
create mode 100644 src/vidxp/cli_commands/repositories.py
create mode 100644 src/vidxp/repositories.py
create mode 100644 tests/test_repositories.py
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index 7feed0c..fc00cd6 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -13,6 +13,7 @@
)
from vidxp.core.contracts import (
SUPPORTED_MODALITIES,
+ CancellationToken,
IndexConfig,
SearchResult,
)
@@ -92,6 +93,8 @@ def create_index(
modalities: Iterable[str] = SUPPORTED_MODALITIES,
frame_stride: int = 1,
progress_callback: ProgressCallback | None = None,
+ cancellation: CancellationToken | None = None,
+ source_name: str | None = None,
) -> dict[str, Any]:
selected = tuple(dict.fromkeys(str(item) for item in modalities))
options: dict[str, Any] = {
@@ -105,9 +108,19 @@ def create_index(
return index_video(
str(video_path),
progress_callback=progress_callback,
+ source_name=source_name,
config=config,
+ cancellation=cancellation,
)
+ def indexing_in_progress(self) -> bool:
+ options: dict[str, Any] = {
+ "storage_directory": self.index_directory,
+ }
+ if self.device is not None:
+ options["device"] = self.device
+ return indexing_in_progress(IndexConfig.local(**options))
+
def check_dependencies(
self,
modalities: Iterable[str] = SUPPORTED_MODALITIES,
diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py
index c85d015..25776e9 100644
--- a/src/vidxp/cli.py
+++ b/src/vidxp/cli.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+import json
+import os
+import sys
from pathlib import Path
from typing import Annotated
@@ -11,7 +14,8 @@
from vidxp.cli_commands import compat
from vidxp.cli_commands.actors import app as actors_app
from vidxp.cli_commands.index import app as index_app
-from vidxp.cli_commands.runtime import doctor, prepare
+from vidxp.cli_commands.repositories import app as repositories_app
+from vidxp.cli_commands.runtime import doctor, prepare, ui
from vidxp.cli_commands.search import app as search_app
from vidxp.cli_support import CLIState, OutputFormat
from vidxp.core.actor_results import ActorClusterNotFoundError
@@ -20,6 +24,7 @@
IndexingInProgressError,
IndexNotReadyError,
)
+from vidxp.repositories import resolve_repository
app = typer.Typer(
@@ -29,9 +34,11 @@
app.add_typer(index_app, name="index")
app.add_typer(search_app, name="search")
app.add_typer(actors_app, name="actors")
+app.add_typer(repositories_app, name="repositories")
app.add_typer(benchmark_app, name="benchmark")
app.command()(doctor)
app.command()(prepare)
+app.command()(ui)
app.command("videoindex", hidden=True, deprecated=True)(compat.videoindex)
app.command("dialogue", hidden=True, deprecated=True)(compat.dialogue)
app.command("scene", hidden=True, deprecated=True)(compat.scene)
@@ -57,21 +64,35 @@ def app_options(
help="Show the installed VidXP version and exit.",
),
] = False,
+ repository_name: Annotated[
+ str | None,
+ typer.Option(
+ "--repository",
+ "-r",
+ help="Named repository to use.",
+ ),
+ ] = None,
+ config_file: Annotated[
+ Path | None,
+ typer.Option(
+ "--config",
+ dir_okay=False,
+ help="Repository configuration file.",
+ ),
+ ] = None,
index_directory: Annotated[
- Path,
+ Path | None,
typer.Option(
"--index-dir",
- envvar="VIDXP_INDEX_DIR",
file_okay=False,
- help="Local index directory.",
+ help="Override the selected repository index directory.",
),
- ] = Path("chroma_data"),
+ ] = None,
device: Annotated[
str | None,
typer.Option(
"--device",
- envvar="VIDXP_DEVICE",
- help="Runtime device override, for example cpu, cuda, or mps.",
+ help="Override the selected repository runtime device.",
),
] = None,
output_format: Annotated[
@@ -87,26 +108,99 @@ def app_options(
typer.Option("--quiet", "-q", help="Suppress progress output."),
] = False,
) -> None:
+ registry, repository = resolve_repository(
+ registry_path=config_file,
+ name=repository_name,
+ index_directory=index_directory,
+ device=device,
+ )
ctx.obj = CLIState(
- service=VidXPService(index_directory, device=device),
+ service=VidXPService(
+ repository.index_directory,
+ device=repository.device,
+ ),
+ registry=registry,
+ repository=repository,
output_format=output_format,
quiet=quiet,
)
+def _wants_json(arguments: list[str] | None = None) -> bool:
+ values = list(sys.argv[1:] if arguments is None else arguments)
+ if "--json" in values:
+ return True
+ for index, value in enumerate(values):
+ if value == "--format" and index + 1 < len(values):
+ return values[index + 1].lower() == "json"
+ if value.startswith("--format="):
+ return value.split("=", 1)[1].lower() == "json"
+ return os.environ.get("VIDXP_OUTPUT_FORMAT", "").lower() == "json"
+
+
+def _error_message(exc: Exception) -> str:
+ formatter = getattr(exc, "format_message", None)
+ return str(formatter()) if formatter is not None else str(exc)
+
+
+def _exit_code(exc: Exception) -> int:
+ return int(getattr(exc, "exit_code", 1) or 1)
+
+
+def _emit_error(exc: Exception, *, json_output: bool) -> None:
+ message = _error_message(exc)
+ if json_output:
+ typer.echo(
+ json.dumps(
+ {
+ "ok": False,
+ "error": {
+ "type": type(exc).__name__,
+ "message": message,
+ "exit_code": _exit_code(exc),
+ },
+ },
+ ensure_ascii=False,
+ indent=2,
+ sort_keys=True,
+ ),
+ err=True,
+ )
+ elif show := getattr(exc, "show", None):
+ show(file=sys.stderr)
+ else:
+ typer.secho(message, fg=typer.colors.RED, err=True)
+
+
def main() -> None:
try:
- app()
- except (
- ActorClusterNotFoundError,
- FileNotFoundError,
- IndexNotReadyError,
- IndexingInProgressError,
- IndexSchemaError,
- ValueError,
- ) as exc:
- typer.secho(str(exc), fg=typer.colors.RED, err=True)
+ app(standalone_mode=False)
+ except typer.Exit as exc:
+ raise SystemExit(exc.exit_code) from None
+ except typer.Abort as exc:
+ _emit_error(exc, json_output=_wants_json())
raise SystemExit(1) from exc
+ except Exception as exc:
+ is_command_error = hasattr(exc, "exit_code") and hasattr(
+ exc,
+ "format_message",
+ )
+ is_expected_runtime_error = isinstance(
+ exc,
+ (
+ ActorClusterNotFoundError,
+ FileNotFoundError,
+ IndexNotReadyError,
+ IndexingInProgressError,
+ IndexSchemaError,
+ RuntimeError,
+ ValueError,
+ ),
+ )
+ if not is_command_error and not is_expected_runtime_error:
+ raise
+ _emit_error(exc, json_output=_wants_json())
+ raise SystemExit(_exit_code(exc)) from exc
if __name__ == "__main__":
diff --git a/src/vidxp/cli_commands/repositories.py b/src/vidxp/cli_commands/repositories.py
new file mode 100644
index 0000000..7e89a66
--- /dev/null
+++ b/src/vidxp/cli_commands/repositories.py
@@ -0,0 +1,205 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Annotated
+
+import typer
+from rich.console import Console
+from rich.table import Table
+
+from vidxp.cli_support import (
+ OutputFormat,
+ effective_output_format,
+ emit_json,
+ state_from_context,
+)
+
+
+app = typer.Typer(
+ no_args_is_help=True,
+ help="Manage named index repositories.",
+)
+
+
+def _emit_repository(
+ repository,
+ *,
+ output_format: OutputFormat,
+) -> None:
+ payload = repository.to_dict()
+ if output_format == OutputFormat.json:
+ emit_json(payload)
+ return
+ table = Table(title=f"Repository {repository.name}")
+ table.add_column("Field")
+ table.add_column("Value")
+ table.add_row("Index directory", str(repository.index_directory))
+ table.add_row("Device", repository.device or "default")
+ table.add_row("Configured", "yes" if repository.configured else "no")
+ Console().print(table)
+
+
+@app.command("list")
+def repositories_list(
+ ctx: typer.Context,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """List configured repositories and the active selection."""
+
+ state = state_from_context(ctx)
+ repositories = state.registry.list()
+ payload = {
+ "active_repository": state.repository.name,
+ "config_file": str(state.registry.path),
+ "repositories": [item.to_dict() for item in repositories],
+ }
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(payload)
+ return
+ table = Table(title="VidXP repositories")
+ table.add_column("Active")
+ table.add_column("Name")
+ table.add_column("Index directory")
+ table.add_column("Device")
+ for repository in repositories:
+ table.add_row(
+ "*" if repository.name == state.repository.name else "",
+ repository.name,
+ str(repository.index_directory),
+ repository.device or "default",
+ )
+ Console().print(table)
+
+
+@app.command("show")
+def repositories_show(
+ ctx: typer.Context,
+ name: Annotated[
+ str | None,
+ typer.Argument(help="Repository name; defaults to the active one."),
+ ] = None,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Show one repository configuration."""
+
+ state = state_from_context(ctx)
+ repository = (
+ state.registry.resolve(name)
+ if name is not None
+ else state.repository
+ )
+ _emit_repository(
+ repository,
+ output_format=effective_output_format(state, json_output),
+ )
+
+
+@app.command("add")
+def repositories_add(
+ ctx: typer.Context,
+ name: Annotated[str, typer.Argument(help="Repository name.")],
+ index_directory: Annotated[
+ Path,
+ typer.Option(
+ "--index-dir",
+ file_okay=False,
+ help="Local index directory managed by this repository.",
+ ),
+ ],
+ device: Annotated[
+ str | None,
+ typer.Option("--device", help="Optional repository device."),
+ ] = None,
+ replace: Annotated[
+ bool,
+ typer.Option("--replace", help="Replace an existing configuration."),
+ ] = False,
+ use: Annotated[
+ bool,
+ typer.Option("--use", help="Make this repository active."),
+ ] = False,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Add or replace a named local index repository."""
+
+ state = state_from_context(ctx)
+ repository = state.registry.add(
+ name,
+ index_directory,
+ device=device,
+ replace=replace,
+ )
+ if use:
+ repository = state.registry.use(repository.name)
+ _emit_repository(
+ repository,
+ output_format=effective_output_format(state, json_output),
+ )
+
+
+@app.command("use")
+def repositories_use(
+ ctx: typer.Context,
+ name: Annotated[str, typer.Argument(help="Repository name.")],
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Select the default repository for future commands."""
+
+ state = state_from_context(ctx)
+ repository = state.registry.use(name)
+ _emit_repository(
+ repository,
+ output_format=effective_output_format(state, json_output),
+ )
+
+
+@app.command("remove")
+def repositories_remove(
+ ctx: typer.Context,
+ name: Annotated[str, typer.Argument(help="Repository name.")],
+ yes: Annotated[
+ bool,
+ typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
+ ] = False,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+) -> None:
+ """Remove configuration without deleting indexed data."""
+
+ state = state_from_context(ctx)
+ repository = state.registry.resolve(name)
+ if not repository.configured:
+ raise typer.BadParameter(
+ "The implicit default repository has no saved configuration."
+ )
+ if not yes:
+ typer.confirm(
+ f"Remove repository configuration {repository.name!r}? "
+ "Indexed data will not be deleted.",
+ abort=True,
+ )
+ removed = state.registry.remove(repository.name)
+ payload = {
+ "removed": removed.to_dict(),
+ "index_deleted": False,
+ }
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(payload)
+ else:
+ typer.echo(
+ f"Removed {removed.name!r}; indexed data was left untouched."
+ )
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
index 06b9b9f..e74649b 100644
--- a/src/vidxp/cli_commands/runtime.py
+++ b/src/vidxp/cli_commands/runtime.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+import os
+import sys
+from pathlib import Path
from typing import Annotated
import typer
@@ -85,24 +88,17 @@ def prepare(
selected = legacy_modalities(modalities)
state = state_from_context(ctx)
- try:
- result = state.service.prepare_models(
- selected,
- language=language,
- progress_callback=(
- None
- if state.quiet
- or effective_output_format(state, json_output)
- == OutputFormat.json
- else lambda event: typer.echo(event["message"])
- ),
- )
- except Exception as exc:
- typer.secho(
- f"Model preparation failed: {type(exc).__name__}: {exc}",
- fg=typer.colors.RED,
- )
- raise typer.Exit(1) from exc
+ result = state.service.prepare_models(
+ selected,
+ language=language,
+ progress_callback=(
+ None
+ if state.quiet
+ or effective_output_format(state, json_output)
+ == OutputFormat.json
+ else lambda event: typer.echo(event["message"])
+ ),
+ )
if effective_output_format(state, json_output) == OutputFormat.json:
emit_json(result)
else:
@@ -111,3 +107,36 @@ def prepare(
fg=typer.colors.GREEN,
bold=True,
)
+
+
+def ui(ctx: typer.Context) -> None:
+ """Launch Streamlit with the selected repository configuration."""
+
+ state = state_from_context(ctx)
+ os.environ["VIDXP_CONFIG_FILE"] = str(state.registry.path)
+ os.environ["VIDXP_REPOSITORY"] = state.repository.name
+ os.environ["VIDXP_INDEX_DIR"] = str(state.service.index_directory)
+ if state.service.device is None:
+ os.environ.pop("VIDXP_DEVICE", None)
+ else:
+ os.environ["VIDXP_DEVICE"] = state.service.device
+
+ try:
+ from vidxp import frontend
+ except ModuleNotFoundError as exc:
+ if exc.name == "streamlit":
+ raise RuntimeError(
+ "The browser interface requires the frontend extra. "
+ "Install vidxp[frontend]."
+ ) from exc
+ raise
+
+ frontend.SERVICE = state.service
+ frontend.SAVED_VIDEO_PATH = (
+ state.service.index_directory / "source-video.mp4"
+ )
+ frontend.ACTOR_OUTPUT_PATH = (
+ state.service.index_directory / "actor-result.mp4"
+ )
+ sys.argv = [sys.argv[0]]
+ frontend.main()
diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py
index 4322ec7..bead501 100644
--- a/src/vidxp/cli_support.py
+++ b/src/vidxp/cli_support.py
@@ -22,6 +22,7 @@
IndexConfig,
SearchResult,
)
+from vidxp.repositories import RepositoryConfig, RepositoryRegistry
class OutputFormat(str, Enum):
@@ -38,6 +39,8 @@ class Modality(str, Enum):
@dataclass
class CLIState:
service: VidXPService
+ registry: RepositoryRegistry
+ repository: RepositoryConfig
output_format: OutputFormat = OutputFormat.rich
quiet: bool = False
diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py
index a52a622..0d49575 100644
--- a/src/vidxp/frontend.py
+++ b/src/vidxp/frontend.py
@@ -4,21 +4,27 @@
import streamlit as st
-from vidxp.core.actor_results import render_actor_result
-from vidxp.core.runner import local_config_from_status
-from vidxp.core.search import search_dialogue, search_scene
-from vidxp.index_state import (
- IndexNotReadyError,
- read_index_status,
-)
+from vidxp.application import VidXPService
+from vidxp.index_state import IndexNotReadyError
from vidxp.index_worker import (
cancel_indexing,
indexing_in_progress,
start_indexing,
)
+from vidxp.repositories import resolve_repository
+
+
+def _configured_service() -> VidXPService:
+ _, repository = resolve_repository()
+ return VidXPService(
+ repository.index_directory,
+ device=repository.device,
+ )
+
-SAVED_VIDEO_PATH = Path("video.mp4")
-ACTOR_OUTPUT_PATH = Path("output.mp4")
+SERVICE = _configured_service()
+SAVED_VIDEO_PATH = SERVICE.index_directory / "source-video.mp4"
+ACTOR_OUTPUT_PATH = SERVICE.index_directory / "actor-result.mp4"
INDEX_REQUESTED_KEY = "_vidxp_index_requested"
INDEX_ERROR_KEY = "_vidxp_index_error"
SEARCH_RESULT_KEY = "_vidxp_search_result"
@@ -76,7 +82,7 @@ def _render_index_status(status, active, uploaded_video, request_error=None):
"message": "Indexing is running.",
}
_render_progress(event)
- elif not status:
+ elif not status or status.get("state") == "missing":
st.caption("First indexing may download missing runtime model weights.")
elif status["state"] == "ready":
if _is_search_ready(status, uploaded_video):
@@ -118,6 +124,7 @@ def _request_cancellation():
def _run_indexing(uploaded_video, status):
try:
if uploaded_video is not None:
+ SAVED_VIDEO_PATH.parent.mkdir(parents=True, exist_ok=True)
SAVED_VIDEO_PATH.write_bytes(uploaded_video.getvalue())
source_name = uploaded_video.name
else:
@@ -126,7 +133,7 @@ def _run_indexing(uploaded_video, status):
if status
else SAVED_VIDEO_PATH.name
)
- start_indexing(str(SAVED_VIDEO_PATH), source_name)
+ start_indexing(str(SAVED_VIDEO_PATH), source_name, SERVICE)
except Exception as exc:
st.session_state[INDEX_ERROR_KEY] = f"{type(exc).__name__}: {exc}"
else:
@@ -138,14 +145,12 @@ def _run_indexing(uploaded_video, status):
def _run_search(search_type, query):
try:
- status = read_index_status()
- if not status or status.get("state") != "ready":
+ status = SERVICE.index_status()
+ if status.get("state") != "ready":
raise IndexNotReadyError("The video index is not ready.")
- config = local_config_from_status(status)
if search_type == "actor":
ACTOR_OUTPUT_PATH.unlink(missing_ok=True)
- render_actor_result(
- config,
+ SERVICE.render_actor(
query,
SAVED_VIDEO_PATH,
ACTOR_OUTPUT_PATH,
@@ -161,12 +166,10 @@ def _run_search(search_type, query):
"video_path": str(ACTOR_OUTPUT_PATH),
}
- finder = search_dialogue if search_type == "dialogue" else search_scene
- result = finder(
+ result = SERVICE.search(
+ search_type,
query,
- config=config,
top_k=1,
- video_id=config.video_id,
)
if not result.hits:
return {"error": f"No {search_type} match was found."}
@@ -275,13 +278,14 @@ def run():
st.set_page_config(page_title="VidXP", page_icon="🎬", layout="wide")
st.title("VidXP")
st.caption("Index and search video by dialogue, scene, and actor.")
+ st.caption(f"Index repository: {SERVICE.index_directory}")
- active = indexing_in_progress()
+ active = indexing_in_progress(SERVICE)
if not active:
st.session_state.pop(CANCEL_REQUESTED_KEY, None)
requested = st.session_state.get(INDEX_REQUESTED_KEY, False)
busy = active or requested
- status = read_index_status()
+ status = SERVICE.index_status()
video_column, workflow_column = st.columns(
[0.95, 1.05],
gap="large",
@@ -323,9 +327,9 @@ def run():
@st.fragment(run_every="1s")
def poll_index_status():
- latest_active = indexing_in_progress()
+ latest_active = indexing_in_progress(SERVICE)
_render_index_status(
- read_index_status(),
+ SERVICE.index_status(),
latest_active,
uploaded_video,
st.session_state.get(INDEX_ERROR_KEY),
diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py
index a4c3b0d..eb60361 100644
--- a/src/vidxp/index_worker.py
+++ b/src/vidxp/index_worker.py
@@ -2,42 +2,68 @@
from multiprocessing.process import BaseProcess
from threading import Lock
+from vidxp.application import VidXPService
from vidxp.core.contracts import CancellationToken
-from vidxp.core.runner import (
- index_video,
- indexing_in_progress as in_process_indexing,
-)
from vidxp.index_state import IndexingInProgressError
+from vidxp.repositories import resolve_repository
_process: BaseProcess | None = None
_cancel_event = None
_start_lock = Lock()
-def _run_indexing(path: str, source_name: str, cancel_event) -> None:
- index_video(
+def _configured_service() -> VidXPService:
+ _, repository = resolve_repository()
+ return VidXPService(
+ repository.index_directory,
+ device=repository.device,
+ )
+
+
+def _run_indexing(
+ path: str,
+ source_name: str,
+ cancel_event,
+ index_directory: str,
+ device: str | None,
+) -> None:
+ VidXPService(index_directory, device=device).create_index(
path,
source_name=source_name,
cancellation=CancellationToken(cancel_event),
)
-def indexing_in_progress() -> bool:
- return (_process is not None and _process.is_alive()) or in_process_indexing()
+def indexing_in_progress(service: VidXPService | None = None) -> bool:
+ active_service = service or _configured_service()
+ return (
+ _process is not None and _process.is_alive()
+ ) or active_service.indexing_in_progress()
-def start_indexing(path: str, source_name: str) -> None:
+def start_indexing(
+ path: str,
+ source_name: str,
+ service: VidXPService | None = None,
+) -> None:
global _cancel_event, _process
+ active_service = service or _configured_service()
with _start_lock:
- if indexing_in_progress():
+ if indexing_in_progress(active_service):
raise IndexingInProgressError("Another video is already being indexed.")
context = get_context("spawn")
_cancel_event = context.Event()
_process = context.Process(
target=_run_indexing,
- args=(path, source_name, _cancel_event),
+ args=(
+ path,
+ source_name,
+ _cancel_event,
+ str(active_service.index_directory),
+ active_service.device,
+ ),
name="vidxp-indexer",
daemon=True,
)
diff --git a/src/vidxp/repositories.py b/src/vidxp/repositories.py
new file mode 100644
index 0000000..b0e4df7
--- /dev/null
+++ b/src/vidxp/repositories.py
@@ -0,0 +1,300 @@
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+from filelock import FileLock
+
+
+REPOSITORY_SCHEMA_VERSION = 1
+DEFAULT_REPOSITORY_NAME = "default"
+DEFAULT_INDEX_DIRECTORY = Path("chroma_data")
+_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
+
+
+class RepositoryConfigError(ValueError):
+ """Raised when repository configuration is invalid or unavailable."""
+
+
+def default_config_path() -> Path:
+ configured = os.environ.get("VIDXP_CONFIG_FILE")
+ if configured:
+ return Path(configured).expanduser()
+ if sys.platform == "win32":
+ root = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming"))
+ elif sys.platform == "darwin":
+ root = Path.home() / "Library/Application Support"
+ else:
+ root = Path(
+ os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
+ )
+ return root / "vidxp" / "repositories.json"
+
+
+def _repository_name(value: str) -> str:
+ name = str(value).strip()
+ if not _NAME_PATTERN.fullmatch(name):
+ raise RepositoryConfigError(
+ "Repository names must be 1-64 characters, start with a letter "
+ "or number, and contain only letters, numbers, '.', '_', or '-'."
+ )
+ return name
+
+
+@dataclass(frozen=True)
+class RepositoryConfig:
+ name: str
+ index_directory: Path
+ device: str | None = None
+ configured: bool = True
+
+ def __post_init__(self) -> None:
+ object.__setattr__(self, "name", _repository_name(self.name))
+ object.__setattr__(
+ self,
+ "index_directory",
+ Path(self.index_directory).expanduser(),
+ )
+ if self.device is not None and not str(self.device).strip():
+ raise RepositoryConfigError("Repository device must not be empty.")
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "name": self.name,
+ "type": "local",
+ "index_directory": str(self.index_directory),
+ "device": self.device,
+ "configured": self.configured,
+ }
+
+
+class RepositoryRegistry:
+ def __init__(self, path: str | Path | None = None) -> None:
+ self.path = Path(path) if path is not None else default_config_path()
+
+ def read(self) -> dict[str, Any]:
+ return self._read_unlocked()
+
+ def _read_unlocked(self) -> dict[str, Any]:
+ if not self.path.is_file():
+ return {
+ "schema_version": REPOSITORY_SCHEMA_VERSION,
+ "active_repository": None,
+ "repositories": {},
+ }
+ try:
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise RepositoryConfigError(
+ f"Repository configuration is unreadable: {self.path}"
+ ) from exc
+ if (
+ not isinstance(payload, dict)
+ or payload.get("schema_version") != REPOSITORY_SCHEMA_VERSION
+ or not isinstance(payload.get("repositories"), dict)
+ ):
+ raise RepositoryConfigError(
+ f"Unsupported repository configuration: {self.path}"
+ )
+ self._validate_payload(payload)
+ return payload
+
+ def list(self) -> tuple[RepositoryConfig, ...]:
+ payload = self.read()
+ configured = tuple(
+ self._from_entry(name, entry)
+ for name, entry in sorted(payload["repositories"].items())
+ )
+ if any(item.name == DEFAULT_REPOSITORY_NAME for item in configured):
+ return configured
+ return (
+ RepositoryConfig(
+ DEFAULT_REPOSITORY_NAME,
+ DEFAULT_INDEX_DIRECTORY,
+ configured=False,
+ ),
+ *configured,
+ )
+
+ def resolve(self, name: str | None = None) -> RepositoryConfig:
+ payload = self.read()
+ selected = (
+ _repository_name(name)
+ if name is not None
+ else payload.get("active_repository") or DEFAULT_REPOSITORY_NAME
+ )
+ entry = payload["repositories"].get(selected)
+ if entry is not None:
+ return self._from_entry(selected, entry)
+ if selected == DEFAULT_REPOSITORY_NAME:
+ return RepositoryConfig(
+ DEFAULT_REPOSITORY_NAME,
+ DEFAULT_INDEX_DIRECTORY,
+ configured=False,
+ )
+ raise RepositoryConfigError(
+ f"Repository {selected!r} is not configured."
+ )
+
+ def add(
+ self,
+ name: str,
+ index_directory: str | Path,
+ *,
+ device: str | None = None,
+ replace: bool = False,
+ ) -> RepositoryConfig:
+ repository = RepositoryConfig(
+ name,
+ Path(index_directory).expanduser().resolve(),
+ device,
+ )
+ with self._lock():
+ payload = self._read_unlocked()
+ if repository.name in payload["repositories"] and not replace:
+ raise RepositoryConfigError(
+ f"Repository {repository.name!r} already exists."
+ )
+ payload["repositories"][repository.name] = {
+ "type": "local",
+ "index_directory": str(repository.index_directory),
+ "device": repository.device,
+ }
+ self._write_unlocked(payload)
+ return repository
+
+ def remove(self, name: str) -> RepositoryConfig:
+ selected = _repository_name(name)
+ with self._lock():
+ payload = self._read_unlocked()
+ entry = payload["repositories"].pop(selected, None)
+ if entry is None:
+ raise RepositoryConfigError(
+ f"Repository {selected!r} is not configured."
+ )
+ if payload.get("active_repository") == selected:
+ payload["active_repository"] = None
+ self._write_unlocked(payload)
+ return self._from_entry(selected, entry)
+
+ def use(self, name: str) -> RepositoryConfig:
+ selected = _repository_name(name)
+ with self._lock():
+ payload = self._read_unlocked()
+ entry = payload["repositories"].get(selected)
+ if entry is not None:
+ repository = self._from_entry(selected, entry)
+ payload["active_repository"] = repository.name
+ elif selected == DEFAULT_REPOSITORY_NAME:
+ repository = RepositoryConfig(
+ DEFAULT_REPOSITORY_NAME,
+ DEFAULT_INDEX_DIRECTORY,
+ configured=False,
+ )
+ payload["active_repository"] = None
+ else:
+ raise RepositoryConfigError(
+ f"Repository {selected!r} is not configured."
+ )
+ self._write_unlocked(payload)
+ return repository
+
+ def write(self, payload: Mapping[str, Any]) -> None:
+ with self._lock():
+ self._write_unlocked(payload)
+
+ def _write_unlocked(self, payload: Mapping[str, Any]) -> None:
+ validated = dict(payload)
+ self._validate_payload(validated)
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = self.path.with_suffix(self.path.suffix + ".tmp")
+ temporary.write_text(
+ json.dumps(
+ validated,
+ ensure_ascii=False,
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ temporary.replace(self.path)
+
+ def _lock(self) -> FileLock:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ return FileLock(str(self.path) + ".lock")
+
+ @staticmethod
+ def _from_entry(
+ name: str,
+ entry: Mapping[str, Any],
+ ) -> RepositoryConfig:
+ return RepositoryConfig(
+ name=name,
+ index_directory=Path(str(entry["index_directory"])),
+ device=(
+ str(entry["device"])
+ if entry.get("device") is not None
+ else None
+ ),
+ )
+
+ @staticmethod
+ def _validate_payload(payload: Mapping[str, Any]) -> None:
+ repositories = payload.get("repositories")
+ if not isinstance(repositories, dict):
+ raise RepositoryConfigError(
+ "Repository configuration must contain a repositories object."
+ )
+ for name, entry in repositories.items():
+ _repository_name(name)
+ if (
+ not isinstance(entry, dict)
+ or entry.get("type") != "local"
+ or not str(entry.get("index_directory", "")).strip()
+ ):
+ raise RepositoryConfigError(
+ f"Repository {name!r} has an invalid local configuration."
+ )
+ active = payload.get("active_repository")
+ if active is not None:
+ active = _repository_name(str(active))
+ if active not in repositories:
+ raise RepositoryConfigError(
+ f"Active repository {active!r} is not configured."
+ )
+
+
+def resolve_repository(
+ *,
+ registry_path: str | Path | None = None,
+ name: str | None = None,
+ index_directory: str | Path | None = None,
+ device: str | None = None,
+) -> tuple[RepositoryRegistry, RepositoryConfig]:
+ registry = RepositoryRegistry(registry_path)
+ selected_name = name or os.environ.get("VIDXP_REPOSITORY")
+ repository = registry.resolve(selected_name)
+ resolved_index = (
+ Path(index_directory)
+ if index_directory is not None
+ else Path(os.environ["VIDXP_INDEX_DIR"])
+ if os.environ.get("VIDXP_INDEX_DIR")
+ else repository.index_directory
+ )
+ resolved_device = (
+ device
+ if device is not None
+ else os.environ.get("VIDXP_DEVICE") or repository.device
+ )
+ return registry, RepositoryConfig(
+ name=repository.name,
+ index_directory=resolved_index,
+ device=resolved_device,
+ configured=repository.configured,
+ )
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ca19657..6905039 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,5 +1,9 @@
import json
+import os
+import sys
import unittest
+from contextlib import redirect_stderr
+from io import StringIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import Mock, patch
@@ -9,6 +13,7 @@
from vidxp import cli
from vidxp.core.actor_results import ActorClusterSummary, ActorRenderResult
from vidxp.core.contracts import SearchHit, SearchResult
+from vidxp.index_state import IndexNotReadyError
def result(modality, starts):
@@ -35,6 +40,11 @@ def result(modality, starts):
class CliTests(unittest.TestCase):
def setUp(self):
self.runner = CliRunner()
+ self.temporary_directory = TemporaryDirectory()
+ self.addCleanup(self.temporary_directory.cleanup)
+ self.config_file = (
+ Path(self.temporary_directory.name) / "repositories.json"
+ )
self.service = Mock()
self.service.index_directory = Path("chroma_data")
self.service.device = None
@@ -45,13 +55,23 @@ def invoke(self, arguments):
"VidXPService",
return_value=self.service,
):
- return self.runner.invoke(cli.app, arguments)
+ return self.runner.invoke(
+ cli.app,
+ ["--config", str(self.config_file), *arguments],
+ )
def test_grouped_commands_are_exposed_and_compatibility_aliases_are_hidden(self):
response = self.invoke(["--help"])
self.assertEqual(response.exit_code, 0)
- for command in ("index", "search", "actors", "benchmark"):
+ for command in (
+ "index",
+ "search",
+ "actors",
+ "repositories",
+ "benchmark",
+ "ui",
+ ):
self.assertIn(command, response.stdout)
for alias in ("videoindex", "dialogue", "scene", "actor"):
self.assertFalse(
@@ -98,6 +118,8 @@ def test_global_index_directory_and_device_configure_service(self):
response = self.runner.invoke(
cli.app,
[
+ "--config",
+ str(self.config_file),
"--index-dir",
"custom-index",
"--device",
@@ -111,6 +133,62 @@ def test_global_index_directory_and_device_configure_service(self):
self.assertEqual(response.exit_code, 0, response.output)
service_type.assert_called_once_with(Path("custom-index"), device="cuda")
+ def test_repository_commands_persist_and_select_named_indexes(self):
+ index_directory = (
+ Path(self.temporary_directory.name) / "team-index"
+ )
+ added = self.invoke(
+ [
+ "repositories",
+ "add",
+ "team",
+ "--index-dir",
+ str(index_directory),
+ "--device",
+ "cuda",
+ "--use",
+ "--json",
+ ]
+ )
+ listed = self.invoke(["repositories", "list", "--json"])
+
+ self.assertEqual(added.exit_code, 0, added.output)
+ self.assertEqual(json.loads(added.stdout)["name"], "team")
+ payload = json.loads(listed.stdout)
+ self.assertEqual(payload["active_repository"], "team")
+ configured = {
+ item["name"]: item for item in payload["repositories"]
+ }
+ self.assertEqual(
+ configured["team"]["index_directory"],
+ str(index_directory.resolve()),
+ )
+
+ def test_repository_removal_leaves_index_data_untouched(self):
+ index_directory = (
+ Path(self.temporary_directory.name) / "team-index"
+ )
+ index_directory.mkdir()
+ marker = index_directory / "keep"
+ marker.write_text("data", encoding="utf-8")
+ self.invoke(
+ [
+ "repositories",
+ "add",
+ "team",
+ "--index-dir",
+ str(index_directory),
+ ]
+ )
+
+ removed = self.invoke(
+ ["repositories", "remove", "team", "--yes", "--json"]
+ )
+
+ self.assertEqual(removed.exit_code, 0, removed.output)
+ self.assertFalse(json.loads(removed.stdout)["index_deleted"])
+ self.assertTrue(marker.is_file())
+
def test_index_create_uses_repeated_typed_modalities(self):
self.service.create_index.return_value = {"scene_frames": 10}
with TemporaryDirectory() as directory:
@@ -185,6 +263,28 @@ def test_doctor_and_prepare_use_the_reusable_service(self):
self.service.check_dependencies.assert_called_once_with(("scene",))
self.service.prepare_models.assert_called_once()
+ def test_ui_receives_the_selected_service_configuration(self):
+ self.service.index_directory = Path("selected-index")
+ self.service.device = "cuda"
+ from vidxp import frontend
+
+ with (
+ patch.dict(os.environ, {}, clear=False),
+ patch.object(frontend, "SERVICE"),
+ patch.object(frontend, "SAVED_VIDEO_PATH"),
+ patch.object(frontend, "ACTOR_OUTPUT_PATH"),
+ patch.object(frontend, "main") as launch,
+ ):
+ response = self.invoke(["ui"])
+
+ self.assertEqual(response.exit_code, 0, response.output)
+ launch.assert_called_once_with()
+ self.assertEqual(os.environ["VIDXP_DEVICE"], "cuda")
+ self.assertEqual(
+ os.environ["VIDXP_INDEX_DIR"],
+ "selected-index",
+ )
+
def test_actor_commands_expose_clusters_detections_and_rendering(self):
cluster = ActorClusterSummary("3", "video-1", 4, 1.0, 8.0)
self.service.actor_clusters.return_value = (cluster,)
@@ -258,6 +358,42 @@ def test_version_options_report_installed_package_version(self):
f"VidXP {cli.__version__}",
)
+ def test_main_emits_uniform_json_for_runtime_errors(self):
+ self.service.search.side_effect = IndexNotReadyError(
+ "Index is not ready."
+ )
+ stderr = StringIO()
+ arguments = [
+ "vidxp",
+ "--config",
+ str(self.config_file),
+ "--format",
+ "json",
+ "search",
+ "scene",
+ "yellow taxi",
+ ]
+ with (
+ patch.object(sys, "argv", arguments),
+ patch.object(
+ cli,
+ "VidXPService",
+ return_value=self.service,
+ ),
+ redirect_stderr(stderr),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ cli.main()
+
+ self.assertEqual(raised.exception.code, 1)
+ payload = json.loads(stderr.getvalue())
+ self.assertFalse(payload["ok"])
+ self.assertEqual(
+ payload["error"]["message"],
+ "Index is not ready.",
+ )
+ self.assertEqual(payload["error"]["exit_code"], 1)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index fba6ba4..8a9d7a7 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -1,12 +1,12 @@
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
-from unittest.mock import patch
+from unittest.mock import Mock, patch
from streamlit.testing.v1 import AppTest
from vidxp import frontend
-from vidxp.core.contracts import IndexConfig, SearchHit, SearchResult
+from vidxp.core.contracts import SearchHit, SearchResult
from vidxp.index_state import IndexNotReadyError
@@ -47,15 +47,15 @@ def result_for(modality, timestamp):
def frontend_harness(video_path, actor_output_path):
from pathlib import Path
from shutil import copyfile
- from unittest.mock import patch
+ from unittest.mock import Mock, patch
from vidxp import frontend
- from vidxp.core.contracts import IndexConfig, SearchHit, SearchResult
+ from vidxp.core.contracts import SearchHit, SearchResult
video_path = Path(video_path)
actor_output_path = Path(actor_output_path)
- config = IndexConfig.local(video_id="video-1")
- ready_status = {
+ service = Mock()
+ service.index_status.return_value = {
"state": "ready",
"message": "Video indexing completed successfully.",
"summary": {
@@ -86,116 +86,74 @@ def search_result(modality, timestamp):
),
)
+ def search(modality, *_args, **_kwargs):
+ return search_result(
+ modality,
+ 17.25 if modality == "dialogue" else 42.5,
+ )
+
def generate_actor_result(*_):
copyfile(video_path, actor_output_path)
+ service.search.side_effect = search
+ service.render_actor.side_effect = generate_actor_result
with (
+ patch.object(frontend, "SERVICE", service),
patch.object(frontend, "SAVED_VIDEO_PATH", video_path),
patch.object(frontend, "ACTOR_OUTPUT_PATH", actor_output_path),
patch.object(frontend, "indexing_in_progress", return_value=False),
- patch.object(frontend, "read_index_status", return_value=ready_status),
- patch.object(frontend, "local_config_from_status", return_value=config),
- patch.object(
- frontend,
- "search_scene",
- return_value=search_result("scene", 42.5),
- ),
- patch.object(
- frontend,
- "search_dialogue",
- return_value=search_result("dialogue", 17.25),
- ),
- patch.object(
- frontend,
- "render_actor_result",
- side_effect=generate_actor_result,
- ),
):
frontend.run()
class FrontendSearchTests(unittest.TestCase):
def setUp(self):
- self.config = IndexConfig.local(video_id="video-1")
- self.status_patches = (
- patch.object(frontend, "read_index_status", return_value=READY_STATUS),
- patch.object(
- frontend,
- "local_config_from_status",
- return_value=self.config,
- ),
- )
+ self.service = Mock()
+ self.service.index_status.return_value = READY_STATUS
- def test_scene_search_returns_a_renderable_rich_result(self):
- with (
- self.status_patches[0],
- self.status_patches[1],
- patch.object(
- frontend,
- "search_scene",
- return_value=result_for("scene", 12.5),
- ),
- ):
+ def test_scene_search_uses_shared_service(self):
+ self.service.search.return_value = result_for("scene", 12.5)
+ with patch.object(frontend, "SERVICE", self.service):
result = frontend._run_search("scene", "yellow taxi")
self.assertEqual(result["timestamp"], 12.5)
self.assertEqual(result["hit"]["video_id"], "video-1")
+ self.service.search.assert_called_once_with(
+ "scene",
+ "yellow taxi",
+ top_k=1,
+ )
def test_dialogue_search_returns_a_renderable_result(self):
- with (
- patch.object(frontend, "read_index_status", return_value=READY_STATUS),
- patch.object(
- frontend,
- "local_config_from_status",
- return_value=self.config,
- ),
- patch.object(
- frontend,
- "search_dialogue",
- return_value=result_for("dialogue", 8),
- ),
- ):
+ self.service.search.return_value = result_for("dialogue", 8)
+ with patch.object(frontend, "SERVICE", self.service):
result = frontend._run_search("dialogue", "fresh bread")
self.assertEqual(result["type"], "dialogue")
self.assertEqual(result["timestamp"], 8.0)
- def test_actor_search_uses_structured_detections(self):
+ def test_actor_search_uses_shared_service(self):
with TemporaryDirectory() as directory:
output_path = Path(directory) / "actor.mp4"
def generate_result(*_):
output_path.write_bytes(b"video")
+ self.service.render_actor.side_effect = generate_result
with (
+ patch.object(frontend, "SERVICE", self.service),
patch.object(frontend, "ACTOR_OUTPUT_PATH", output_path),
- patch.object(
- frontend,
- "read_index_status",
- return_value=READY_STATUS,
- ),
- patch.object(
- frontend,
- "local_config_from_status",
- return_value=self.config,
- ),
- patch.object(
- frontend,
- "render_actor_result",
- side_effect=generate_result,
- ) as renderer,
):
result = frontend._run_search("actor", "3")
- renderer.assert_called_once()
+ self.service.render_actor.assert_called_once()
self.assertEqual(result["type"], "actor")
def test_search_error_is_returned_for_persistent_rendering(self):
- with patch.object(
- frontend,
- "read_index_status",
- side_effect=IndexNotReadyError("Index is not ready."),
- ):
+ self.service.index_status.side_effect = IndexNotReadyError(
+ "Index is not ready."
+ )
+ with patch.object(frontend, "SERVICE", self.service):
result = frontend._run_search("scene", "yellow taxi")
self.assertEqual(result, {"error": "Index is not ready."})
diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py
index 9d4b18c..01c8545 100644
--- a/tests/test_index_worker.py
+++ b/tests/test_index_worker.py
@@ -1,5 +1,6 @@
import unittest
-from unittest.mock import patch
+from pathlib import Path
+from unittest.mock import Mock, patch
from vidxp import index_worker
from vidxp.index_state import IndexingInProgressError
@@ -45,45 +46,91 @@ class IndexWorkerTests(unittest.TestCase):
def setUp(self):
index_worker._process = None
index_worker._cancel_event = None
+ self.service = Mock()
+ self.service.index_directory = Path("selected-index")
+ self.service.device = "cuda"
+ self.service.indexing_in_progress.return_value = False
def tearDown(self):
index_worker._process = None
index_worker._cancel_event = None
- @patch.object(index_worker, "in_process_indexing", return_value=False)
- def test_worker_starts_indexing_in_a_separate_process(self, _):
+ def test_worker_starts_indexing_in_a_separate_process(self):
context = FakeContext()
with patch.object(index_worker, "get_context", return_value=context):
- index_worker.start_indexing("video.mp4", "source.mp4")
+ index_worker.start_indexing(
+ "video.mp4",
+ "source.mp4",
+ self.service,
+ )
self.assertTrue(context.process.is_alive())
self.assertEqual(context.process.options["name"], "vidxp-indexer")
self.assertTrue(context.process.options["daemon"])
self.assertEqual(
context.process.options["args"],
- ("video.mp4", "source.mp4", context.event),
+ (
+ "video.mp4",
+ "source.mp4",
+ context.event,
+ "selected-index",
+ "cuda",
+ ),
)
self.assertIs(context.process.options["target"], index_worker._run_indexing)
- @patch.object(index_worker, "in_process_indexing", return_value=False)
- def test_worker_rejects_a_second_indexing_run(self, _):
+ def test_worker_process_reconstructs_the_selected_service(self):
+ service = Mock()
+ with patch.object(
+ index_worker,
+ "VidXPService",
+ return_value=service,
+ ) as service_type:
+ index_worker._run_indexing(
+ "video.mp4",
+ "source.mp4",
+ FakeEvent(),
+ "selected-index",
+ "cuda",
+ )
+
+ service_type.assert_called_once_with("selected-index", device="cuda")
+ service.create_index.assert_called_once()
+ self.assertEqual(
+ service.create_index.call_args.kwargs["source_name"],
+ "source.mp4",
+ )
+
+ def test_worker_rejects_a_second_indexing_run(self):
context = FakeContext()
with patch.object(index_worker, "get_context", return_value=context):
- index_worker.start_indexing("video.mp4", "source.mp4")
+ index_worker.start_indexing(
+ "video.mp4",
+ "source.mp4",
+ self.service,
+ )
with self.assertRaises(IndexingInProgressError):
- index_worker.start_indexing("video.mp4", "source.mp4")
+ index_worker.start_indexing(
+ "video.mp4",
+ "source.mp4",
+ self.service,
+ )
+
+ def test_existing_service_run_remains_visible(self):
+ self.service.indexing_in_progress.return_value = True
- @patch.object(index_worker, "in_process_indexing", return_value=True)
- def test_existing_in_process_run_remains_visible(self, _):
- self.assertTrue(index_worker.indexing_in_progress())
+ self.assertTrue(index_worker.indexing_in_progress(self.service))
- @patch.object(index_worker, "in_process_indexing", return_value=False)
- def test_cancellation_requests_are_cooperative(self, _):
+ def test_cancellation_requests_are_cooperative(self):
context = FakeContext()
with patch.object(index_worker, "get_context", return_value=context):
- index_worker.start_indexing("video.mp4", "source.mp4")
+ index_worker.start_indexing(
+ "video.mp4",
+ "source.mp4",
+ self.service,
+ )
self.assertTrue(index_worker.cancel_indexing())
self.assertTrue(context.event.set_called)
diff --git a/tests/test_repositories.py b/tests/test_repositories.py
new file mode 100644
index 0000000..cbf80e7
--- /dev/null
+++ b/tests/test_repositories.py
@@ -0,0 +1,114 @@
+import json
+import os
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from vidxp.repositories import (
+ DEFAULT_REPOSITORY_NAME,
+ RepositoryConfigError,
+ RepositoryRegistry,
+ resolve_repository,
+)
+
+
+class RepositoryRegistryTests(unittest.TestCase):
+ def test_missing_registry_exposes_non_persistent_default(self):
+ with TemporaryDirectory() as directory:
+ registry = RepositoryRegistry(Path(directory) / "repos.json")
+
+ repositories = registry.list()
+ selected = registry.resolve()
+
+ self.assertEqual(repositories[0].name, DEFAULT_REPOSITORY_NAME)
+ self.assertFalse(repositories[0].configured)
+ self.assertEqual(selected.index_directory, Path("chroma_data"))
+
+ def test_add_use_replace_and_remove_round_trip(self):
+ with TemporaryDirectory() as directory:
+ path = Path(directory) / "repos.json"
+ registry = RepositoryRegistry(path)
+ repository = registry.add(
+ "team",
+ "indexes/team",
+ device="cuda",
+ )
+ registry.use("team")
+
+ selected = registry.resolve()
+ payload = json.loads(path.read_text(encoding="utf-8"))
+
+ self.assertEqual(selected, repository)
+ self.assertEqual(payload["active_repository"], "team")
+ with self.assertRaisesRegex(
+ RepositoryConfigError,
+ "already exists",
+ ):
+ registry.add("team", "other")
+
+ replacement = registry.add(
+ "team",
+ "indexes/replacement",
+ replace=True,
+ )
+ removed = registry.remove("team")
+
+ self.assertEqual(
+ replacement.index_directory,
+ Path("indexes/replacement").resolve(),
+ )
+ self.assertEqual(removed, replacement)
+
+ def test_remove_never_deletes_the_repository_index(self):
+ with TemporaryDirectory() as directory:
+ root = Path(directory)
+ index = root / "index"
+ index.mkdir()
+ marker = index / "keep"
+ marker.write_text("data", encoding="utf-8")
+ registry = RepositoryRegistry(root / "repos.json")
+ registry.add("team", index)
+
+ registry.remove("team")
+
+ self.assertTrue(marker.is_file())
+
+ def test_invalid_names_and_unknown_repositories_are_rejected(self):
+ with TemporaryDirectory() as directory:
+ registry = RepositoryRegistry(Path(directory) / "repos.json")
+ with self.assertRaises(RepositoryConfigError):
+ registry.add("../escape", "index")
+ with self.assertRaisesRegex(
+ RepositoryConfigError,
+ "not configured",
+ ):
+ registry.resolve("missing")
+
+ def test_explicit_and_environment_overrides_have_stable_precedence(self):
+ with TemporaryDirectory() as directory:
+ registry_path = Path(directory) / "repos.json"
+ RepositoryRegistry(registry_path).add(
+ "team",
+ "registered",
+ device="cpu",
+ )
+ environment = {
+ "VIDXP_REPOSITORY": "team",
+ "VIDXP_INDEX_DIR": "environment-index",
+ "VIDXP_DEVICE": "mps",
+ }
+ with patch.dict(os.environ, environment, clear=False):
+ _, selected = resolve_repository(
+ registry_path=registry_path,
+ index_directory="explicit-index",
+ device="cuda",
+ )
+
+ self.assertEqual(selected.name, "team")
+ self.assertEqual(selected.index_directory, Path("explicit-index"))
+ self.assertEqual(selected.device, "cuda")
+
+
+if __name__ == "__main__":
+ unittest.main()
From 1c5e85debd5af7d36214e0b8ddab9351c9700ae7 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 19:08:28 +0500
Subject: [PATCH 07/44] feat(benchmarks): honor shared device and JSON output
---
src/vidxp/benchmarks/cli.py | 32 +++++++++++++++--
src/vidxp/benchmarks/didemo.py | 2 ++
src/vidxp/benchmarks/hirest.py | 2 ++
tests/test_benchmark_cli.py | 66 ++++++++++++++++++++++++++++++++++
4 files changed, 99 insertions(+), 3 deletions(-)
create mode 100644 tests/test_benchmark_cli.py
diff --git a/src/vidxp/benchmarks/cli.py b/src/vidxp/benchmarks/cli.py
index eb77b82..fd84ca0 100644
--- a/src/vidxp/benchmarks/cli.py
+++ b/src/vidxp/benchmarks/cli.py
@@ -5,13 +5,19 @@
from typing import Annotated, Literal
import typer
-from rich import print
+from rich import print as rich_print
from vidxp.benchmarks.didemo import run_didemo
from vidxp.benchmarks.hirest import (
HIREST_DEFAULT_WINDOW_FRACTION,
run_hirest,
)
+from vidxp.cli_support import (
+ OutputFormat,
+ effective_output_format,
+ emit_json,
+ state_from_context,
+)
app = typer.Typer(help="Run official benchmark adapters.")
@@ -62,6 +68,7 @@ def _pair_file(path: Path | None) -> list[tuple[str, str]] | None:
@app.command("didemo")
def didemo_command(
+ ctx: typer.Context,
annotations: Annotated[Path, typer.Option(exists=True, dir_okay=False)],
evaluator: Annotated[Path, typer.Option(exists=True, dir_okay=False)],
media_directory: Annotated[
@@ -91,9 +98,14 @@ def didemo_command(
] = "max",
frame_stride: Annotated[int, typer.Option(min=1)] = 1,
reset: Annotated[bool, typer.Option()] = False,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
) -> None:
"""Run DiDeMo scene retrieval and its official evaluator."""
+ state = state_from_context(ctx)
metrics = run_didemo(
annotations_path=annotations,
evaluator_path=evaluator,
@@ -105,12 +117,17 @@ def didemo_command(
split=split,
chunk_pooling=chunk_pooling,
reset=reset,
+ device=state.service.device or "cpu",
)
- print(metrics)
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(metrics)
+ else:
+ rich_print(metrics)
@app.command("hirest")
def hirest_command(
+ ctx: typer.Context,
ground_truth: Annotated[Path, typer.Option(exists=True, dir_okay=False)],
categories: Annotated[Path, typer.Option(exists=True, dir_okay=False)],
evaluator: Annotated[Path, typer.Option(exists=True, dir_okay=False)],
@@ -145,9 +162,14 @@ def hirest_command(
),
] = HIREST_DEFAULT_WINDOW_FRACTION,
reset: Annotated[bool, typer.Option()] = False,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
) -> None:
"""Run HiREST released-ASR retrieval; score validation predictions."""
+ state = state_from_context(ctx)
metrics = run_hirest(
ground_truth_path=ground_truth,
categories_path=categories,
@@ -160,5 +182,9 @@ def hirest_command(
split=split,
temporal_window_fraction=temporal_window_fraction,
reset=reset,
+ device=state.service.device or "cpu",
)
- print(metrics)
+ if effective_output_format(state, json_output) == OutputFormat.json:
+ emit_json(metrics)
+ else:
+ rich_print(metrics)
diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py
index 3917555..d01f40a 100644
--- a/src/vidxp/benchmarks/didemo.py
+++ b/src/vidxp/benchmarks/didemo.py
@@ -354,6 +354,7 @@ def run_didemo(
annotation_indices: Sequence[int] | None = None,
media_overrides: Mapping[str, str | Path] | None = None,
frame_stride: int = 1,
+ device: str = "cpu",
split: Literal["validation", "test"] = "test",
chunk_pooling: Literal["max", "mean"] = "max",
reset: bool = False,
@@ -377,6 +378,7 @@ def run_didemo(
run_id=run_id,
enabled_modalities=("scene",),
frame_stride=frame_stride,
+ device=device,
output_root=output_root,
)
run_directory = config.run_directory
diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py
index 3c09d56..f7e8c5c 100644
--- a/src/vidxp/benchmarks/hirest.py
+++ b/src/vidxp/benchmarks/hirest.py
@@ -401,6 +401,7 @@ def run_hirest(
pairs: Sequence[tuple[str, str]] | None = None,
split: Literal["validation", "test"] = "test",
temporal_window_fraction: float = HIREST_DEFAULT_WINDOW_FRACTION,
+ device: str = "cpu",
reset: bool = False,
) -> dict[str, Any]:
if split not in {"validation", "test"}:
@@ -425,6 +426,7 @@ def run_hirest(
split=split,
run_id=run_id,
enabled_modalities=("dialogue",),
+ device=device,
output_root=output_root,
)
run_directory = config.run_directory
diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py
new file mode 100644
index 0000000..3e2552a
--- /dev/null
+++ b/tests/test_benchmark_cli.py
@@ -0,0 +1,66 @@
+import json
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import Mock, patch
+
+from typer.testing import CliRunner
+
+from vidxp import cli
+
+
+class BenchmarkCliTests(unittest.TestCase):
+ def test_benchmark_uses_global_device_and_json_output(self):
+ runner = CliRunner()
+ service = Mock()
+ service.index_directory = Path("chroma_data")
+ service.device = "cuda"
+ with TemporaryDirectory() as directory:
+ root = Path(directory)
+ config = root / "repositories.json"
+ annotations = root / "annotations.json"
+ evaluator = root / "evaluator.py"
+ media = root / "media"
+ annotations.write_text("[]", encoding="utf-8")
+ evaluator.write_text("", encoding="utf-8")
+ media.mkdir()
+ with (
+ patch.object(
+ cli,
+ "VidXPService",
+ return_value=service,
+ ),
+ patch(
+ "vidxp.benchmarks.cli.run_didemo",
+ return_value={"rank_at_1": 0.5},
+ ) as run,
+ ):
+ response = runner.invoke(
+ cli.app,
+ [
+ "--config",
+ str(config),
+ "--device",
+ "cuda",
+ "--format",
+ "json",
+ "benchmark",
+ "didemo",
+ "--annotations",
+ str(annotations),
+ "--evaluator",
+ str(evaluator),
+ "--media-directory",
+ str(media),
+ "--run-id",
+ "run-1",
+ ],
+ )
+
+ self.assertEqual(response.exit_code, 0, response.output)
+ self.assertEqual(json.loads(response.stdout)["rank_at_1"], 0.5)
+ self.assertEqual(run.call_args.kwargs["device"], "cuda")
+
+
+if __name__ == "__main__":
+ unittest.main()
From f5932120829426cc72d74081cd96934d766bff9f Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 19:08:28 +0500
Subject: [PATCH 08/44] docs: describe repository-aware CLI usage
---
INSTALLATION_GUIDE.md | 5 +++--
README.md | 14 ++++++++++++--
docs/CONTRIBUTING.md | 1 +
3 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md
index b1bf2de..f4b0ff3 100644
--- a/INSTALLATION_GUIDE.md
+++ b/INSTALLATION_GUIDE.md
@@ -98,8 +98,9 @@ weights. Restrict the check when diagnosing one capability:
vidxp doctor --modalities scene
```
-If the frontend extra was installed, start the interface with `vidxp-ui`. It
-runs until stopped with `Ctrl+C`.
+If the frontend extra was installed, start the interface with `vidxp ui`. The
+standalone `vidxp-ui` launcher remains available; both use the active repository
+configured by `vidxp repositories use`. They run until stopped with `Ctrl+C`.
## Prepare models
diff --git a/README.md b/README.md
index 02a46e7..5e1f467 100644
--- a/README.md
+++ b/README.md
@@ -103,15 +103,25 @@ Repeat `--modality` to combine `dialogue`, `scene`, and `actor`.
Run `vidxp --help` or any command followed by `--help` for the complete command
reference.
+Use named repositories to keep index locations and devices centrally
+configured:
+
+```bash
+vidxp repositories add team --index-dir ./indexes/team --device cuda --use
+vidxp repositories list
+```
+
## Browser interface
Install the `frontend` extra and start:
```bash
-vidxp-ui
+vidxp ui
```
-The command starts a local Streamlit server and remains active until stopped.
+`vidxp-ui` remains available as a direct launcher. Both launchers use the
+active named repository. The command starts a local Streamlit server and
+remains active until stopped.
The interface can upload a video, start or cancel indexing, restore saved
progress after a page reload, and search the capabilities available in the
completed index.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 6263fe0..0fe3194 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -8,6 +8,7 @@ Thanks for contributing to VidXP (Video eXPlain).
|-------------|------|
| `src/vidxp/cli.py` | Typer commands and installed `vidxp` entry point |
| `src/vidxp/application.py` | Reusable application boundary for CLI and future adapters |
+| `src/vidxp/repositories.py` | Persistent named local-index configuration |
| `src/vidxp/frontend.py` | Streamlit interface and installed `vidxp-ui` entry point |
| `src/vidxp/core/` | Indexing, retrieval, storage, models, run state, and shared contracts |
| `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls |
From 91e0bd0932c07a4dd88412a950a2420c0e640a6a Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 19:16:44 +0500
Subject: [PATCH 09/44] refactor(cli): remove legacy entry points
---
Dockerfile | 2 +-
INSTALLATION_GUIDE.md | 6 +-
README.md | 5 +-
docs/CONTRIBUTING.md | 4 +-
docs/benchmarking/runtime_validation.md | 4 +-
pyproject.toml | 1 -
src/vidxp/cli.py | 5 --
src/vidxp/cli_commands/compat.py | 91 -------------------------
src/vidxp/cli_commands/runtime.py | 32 +++++++--
src/vidxp/cli_support.py | 2 +-
src/vidxp/frontend.py | 10 ++-
tests/test_cli.py | 38 ++++-------
12 files changed, 59 insertions(+), 141 deletions(-)
delete mode 100644 src/vidxp/cli_commands/compat.py
diff --git a/Dockerfile b/Dockerfile
index 4e64364..c2271c0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,4 +30,4 @@ RUN python -m pip install --upgrade pip setuptools wheel \
EXPOSE 8501
-CMD ["vidxp-ui", "--server.address=0.0.0.0", "--server.port=8501"]
+CMD ["vidxp", "ui", "--host", "0.0.0.0", "--port", "8501"]
diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md
index f4b0ff3..53f6566 100644
--- a/INSTALLATION_GUIDE.md
+++ b/INSTALLATION_GUIDE.md
@@ -98,9 +98,9 @@ weights. Restrict the check when diagnosing one capability:
vidxp doctor --modalities scene
```
-If the frontend extra was installed, start the interface with `vidxp ui`. The
-standalone `vidxp-ui` launcher remains available; both use the active repository
-configured by `vidxp repositories use`. They run until stopped with `Ctrl+C`.
+If the frontend extra was installed, start the interface with `vidxp ui`. It
+uses the active repository configured by `vidxp repositories use` and runs
+until stopped with `Ctrl+C`.
## Prepare models
diff --git a/README.md b/README.md
index 5e1f467..0f3db36 100644
--- a/README.md
+++ b/README.md
@@ -119,9 +119,8 @@ Install the `frontend` extra and start:
vidxp ui
```
-`vidxp-ui` remains available as a direct launcher. Both launchers use the
-active named repository. The command starts a local Streamlit server and
-remains active until stopped.
+The command uses the active named repository, starts a local Streamlit server,
+and remains active until stopped.
The interface can upload a video, start or cancel indexing, restore saved
progress after a page reload, and search the capabilities available in the
completed index.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 0fe3194..75b34bd 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -9,7 +9,7 @@ Thanks for contributing to VidXP (Video eXPlain).
| `src/vidxp/cli.py` | Typer commands and installed `vidxp` entry point |
| `src/vidxp/application.py` | Reusable application boundary for CLI and future adapters |
| `src/vidxp/repositories.py` | Persistent named local-index configuration |
-| `src/vidxp/frontend.py` | Streamlit interface and installed `vidxp-ui` entry point |
+| `src/vidxp/frontend.py` | Streamlit interface launched by `vidxp ui` |
| `src/vidxp/core/` | Indexing, retrieval, storage, models, run state, and shared contracts |
| `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls |
| `pyproject.toml` | Package metadata and Python dependencies |
@@ -42,7 +42,7 @@ vidxp doctor
```
CLI: `vidxp --help`
-UI: `vidxp-ui`
+UI: `vidxp ui`
Models default to CPU and download into their libraries' standard caches on
first use. If a model identifier changes, update the setup documentation and
diff --git a/docs/benchmarking/runtime_validation.md b/docs/benchmarking/runtime_validation.md
index 04fbede..f18b4ae 100644
--- a/docs/benchmarking/runtime_validation.md
+++ b/docs/benchmarking/runtime_validation.md
@@ -34,7 +34,7 @@ python -m streamlit run .\src\vidxp\frontend.py
For an installed wheel, the supported command remains:
```powershell
-vidxp-ui
+vidxp ui
```
### Real execution checks
@@ -49,7 +49,7 @@ selects the older installed TestPyPI package instead.
| Check | Executed path | Observed result |
|---|---|---|
| Source Streamlit UI | Source selected through `PYTHONPATH`, real browser session | Page rendered with upload, index, status, and search controls; no import exception |
-| Built-wheel UI | Fresh wheel installed without VidXP source on its import path; `vidxp-ui` opened in a real browser session | Page rendered successfully and the Streamlit health endpoint returned `ok` |
+| Built-wheel UI | Fresh wheel installed without VidXP source on its import path; browser interface opened from the installed package | Page rendered successfully and the Streamlit health endpoint returned `ok` |
| Built-wheel CLI | Final wheel installed into an isolated target while using the validated dependency environment; wheel target placed first on `PYTHONPATH` | Import resolved inside the wheel target and `python -m vidxp --help` listed the expected commands |
| Dependency doctor | `vidxp doctor --modalities dialogue,scene,actor` | ChromaDB, MiniLM, CLIP, NumPy, OpenCV, Pillow, PyTorch, face recognition, MoviePy, WhisperX, and FFmpeg imports resolved |
| Released-transcript path | Real MiniLM encoding, Chroma writes, and top-2 dialogue search over three timestamped segments | Run completed; two hits returned; first interval was `[0.0, 2.0]`; full run/source metadata present |
diff --git a/pyproject.toml b/pyproject.toml
index 2e43e1e..5fcdb02 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,7 +60,6 @@ benchmarks = [
[project.scripts]
vidxp = "vidxp.cli:main"
-vidxp-ui = "vidxp.frontend:main"
[project.urls]
Repository = "https://github.com/grayhatdevelopers/vidxp"
diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py
index 25776e9..dd7d05d 100644
--- a/src/vidxp/cli.py
+++ b/src/vidxp/cli.py
@@ -11,7 +11,6 @@
from vidxp import __version__
from vidxp.application import VidXPService
from vidxp.benchmarks.cli import app as benchmark_app
-from vidxp.cli_commands import compat
from vidxp.cli_commands.actors import app as actors_app
from vidxp.cli_commands.index import app as index_app
from vidxp.cli_commands.repositories import app as repositories_app
@@ -39,10 +38,6 @@
app.command()(doctor)
app.command()(prepare)
app.command()(ui)
-app.command("videoindex", hidden=True, deprecated=True)(compat.videoindex)
-app.command("dialogue", hidden=True, deprecated=True)(compat.dialogue)
-app.command("scene", hidden=True, deprecated=True)(compat.scene)
-app.command("actor", hidden=True, deprecated=True)(compat.actor)
def _show_version(value: bool) -> None:
diff --git a/src/vidxp/cli_commands/compat.py b/src/vidxp/cli_commands/compat.py
deleted file mode 100644
index 9b3315b..0000000
--- a/src/vidxp/cli_commands/compat.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Annotated
-
-import typer
-
-from vidxp.cli_commands.index import create_index
-from vidxp.cli_support import legacy_modalities, state_from_context
-
-
-def videoindex(
- ctx: typer.Context,
- path: Annotated[
- Path,
- typer.Argument(exists=True, dir_okay=False, readable=True),
- ],
- modalities: Annotated[
- str,
- typer.Option(
- "--modalities",
- "-m",
- help="Comma-separated dialogue, scene, and actor modalities.",
- ),
- ] = "dialogue,scene,actor",
- frame_stride: Annotated[
- int,
- typer.Option("--frame-stride", min=1),
- ] = 1,
-) -> None:
- create_index(
- state_from_context(ctx),
- path,
- modalities=legacy_modalities(modalities),
- frame_stride=frame_stride,
- )
-
-
-def dialogue(
- ctx: typer.Context,
- query: str,
-) -> float | None:
- result = state_from_context(ctx).service.search(
- "dialogue",
- query,
- top_k=1,
- )
- if not result.hits:
- typer.echo("No dialogue matches found.")
- return None
- timestamp = result.hits[0].start
- typer.secho(f"{timestamp:.3f} seconds", fg=typer.colors.GREEN, bold=True)
- return timestamp
-
-
-def scene(
- ctx: typer.Context,
- query: str,
-) -> float | None:
- result = state_from_context(ctx).service.search(
- "scene",
- query,
- top_k=1,
- )
- if not result.hits:
- typer.echo("No scene matches found.")
- return None
- timestamp = result.hits[0].start
- typer.secho(f"{timestamp:.3f} seconds", fg=typer.colors.GREEN, bold=True)
- return timestamp
-
-
-def actor(
- ctx: typer.Context,
- cluster_id: str,
- input_path: Annotated[
- Path,
- typer.Argument(exists=True, dir_okay=False, readable=True),
- ],
- output_path: Path = Path("output.mp4"),
-) -> None:
- state = state_from_context(ctx)
- result = state.service.render_actor(
- cluster_id,
- input_path,
- output_path,
- )
- typer.secho(
- f"Video saved as {result.output_path}",
- fg=typer.colors.GREEN,
- )
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
index e74649b..309fdb7 100644
--- a/src/vidxp/cli_commands/runtime.py
+++ b/src/vidxp/cli_commands/runtime.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
-import sys
from pathlib import Path
from typing import Annotated
@@ -11,7 +10,7 @@
OutputFormat,
effective_output_format,
emit_json,
- legacy_modalities,
+ parse_modalities,
state_from_context,
)
@@ -33,7 +32,7 @@ def doctor(
) -> None:
"""Validate selected indexing dependencies without downloading models."""
- selected = legacy_modalities(modalities)
+ selected = parse_modalities(modalities)
state = state_from_context(ctx)
result = state.service.check_dependencies(selected)
if effective_output_format(state, json_output) == OutputFormat.json:
@@ -86,7 +85,7 @@ def prepare(
) -> None:
"""Download and cache selected runtime models before indexing."""
- selected = legacy_modalities(modalities)
+ selected = parse_modalities(modalities)
state = state_from_context(ctx)
result = state.service.prepare_models(
selected,
@@ -109,7 +108,22 @@ def prepare(
)
-def ui(ctx: typer.Context) -> None:
+def ui(
+ ctx: typer.Context,
+ host: Annotated[
+ str | None,
+ typer.Option("--host", help="Streamlit server address."),
+ ] = None,
+ port: Annotated[
+ int | None,
+ typer.Option(
+ "--port",
+ min=1,
+ max=65535,
+ help="Streamlit server port.",
+ ),
+ ] = None,
+) -> None:
"""Launch Streamlit with the selected repository configuration."""
state = state_from_context(ctx)
@@ -138,5 +152,9 @@ def ui(ctx: typer.Context) -> None:
frontend.ACTOR_OUTPUT_PATH = (
state.service.index_directory / "actor-result.mp4"
)
- sys.argv = [sys.argv[0]]
- frontend.main()
+ streamlit_arguments = []
+ if host is not None:
+ streamlit_arguments.append(f"--server.address={host}")
+ if port is not None:
+ streamlit_arguments.append(f"--server.port={port}")
+ frontend.main(streamlit_arguments)
diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py
index bead501..6d3106c 100644
--- a/src/vidxp/cli_support.py
+++ b/src/vidxp/cli_support.py
@@ -181,7 +181,7 @@ def selected_modalities(
return selected
-def legacy_modalities(value: str) -> tuple[str, ...]:
+def parse_modalities(value: str) -> tuple[str, ...]:
selected = tuple(
item.strip().lower()
for item in value.split(",")
diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py
index 0d49575..0ab5f2d 100644
--- a/src/vidxp/frontend.py
+++ b/src/vidxp/frontend.py
@@ -1,6 +1,7 @@
import hashlib
import sys
from pathlib import Path
+from typing import Sequence
import streamlit as st
@@ -373,10 +374,15 @@ def poll_index_status():
_run_indexing(uploaded_video, status)
-def main():
+def main(arguments: Sequence[str] = ()):
from streamlit.web import cli as streamlit_cli
- sys.argv = ["streamlit", "run", str(Path(__file__).resolve()), *sys.argv[1:]]
+ sys.argv = [
+ "streamlit",
+ "run",
+ str(Path(__file__).resolve()),
+ *arguments,
+ ]
raise SystemExit(streamlit_cli.main())
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 6905039..1288ef7 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -60,7 +60,7 @@ def invoke(self, arguments):
["--config", str(self.config_file), *arguments],
)
- def test_grouped_commands_are_exposed_and_compatibility_aliases_are_hidden(self):
+ def test_grouped_commands_are_exposed(self):
response = self.invoke(["--help"])
self.assertEqual(response.exit_code, 0)
@@ -73,13 +73,14 @@ def test_grouped_commands_are_exposed_and_compatibility_aliases_are_hidden(self)
"ui",
):
self.assertIn(command, response.stdout)
- for alias in ("videoindex", "dialogue", "scene", "actor"):
- self.assertFalse(
- any(
- line.lstrip("│ ").startswith(f"{alias} ")
- for line in response.stdout.splitlines()
- )
- )
+
+ def test_removed_legacy_commands_are_rejected(self):
+ for command in ("videoindex", "dialogue", "scene", "actor"):
+ with self.subTest(command=command):
+ response = self.invoke([command])
+
+ self.assertEqual(response.exit_code, 2)
+ self.assertIn("No such command", response.output)
def test_search_returns_ranked_json_and_passes_top_k(self):
self.service.search.return_value = result(
@@ -275,10 +276,14 @@ def test_ui_receives_the_selected_service_configuration(self):
patch.object(frontend, "ACTOR_OUTPUT_PATH"),
patch.object(frontend, "main") as launch,
):
- response = self.invoke(["ui"])
+ response = self.invoke(
+ ["ui", "--host", "0.0.0.0", "--port", "8501"]
+ )
self.assertEqual(response.exit_code, 0, response.output)
- launch.assert_called_once_with()
+ launch.assert_called_once_with(
+ ["--server.address=0.0.0.0", "--server.port=8501"]
+ )
self.assertEqual(os.environ["VIDXP_DEVICE"], "cuda")
self.assertEqual(
os.environ["VIDXP_INDEX_DIR"],
@@ -327,19 +332,6 @@ def test_actor_commands_expose_clusters_detections_and_rendering(self):
"actor.mp4",
)
- def test_legacy_search_alias_still_works(self):
- self.service.search.return_value = result("scene", [3.25])
-
- response = self.invoke(["scene", "yellow taxi"])
-
- self.assertEqual(response.exit_code, 0, response.output)
- self.assertIn("3.250 seconds", response.stdout)
- self.service.search.assert_called_once_with(
- "scene",
- "yellow taxi",
- top_k=1,
- )
-
def test_benchmark_commands_are_exposed(self):
response = self.invoke(["benchmark", "--help"])
From fba7de33c72cb6831e28fb3dd90dbb003e9eb0a9 Mon Sep 17 00:00:00 2001
From: "Saad A. Bazaz"
Date: Mon, 27 Jul 2026 21:15:21 +0500
Subject: [PATCH 10/44] chore: clean readme further
---
README.md | 24 ++++++++++++++++--------
1 file changed, 16 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 21caec9..9ae660e 100644
--- a/README.md
+++ b/README.md
@@ -14,28 +14,36 @@
VidXP is a local-first video indexing and search engine distributed as a Python
package.
-
You can use it:
+
+
+
You can use it:
- From the command line
- Through its browser interface
- As a desktop app
- As an API
+ - As an MCP, with your agents (coming soon ⚡️)
- As an indexing and retrieval layer inside another application
+
+
+
-
+
-
-
-
-
-
+
+
@@ -249,7 +257,7 @@ See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) for guidelines, maintainers, and h
## Credits
-Built by Grayhat Developers PVT Ltd in 2025. Maintained by the community.
+Built by Grayhat Developers PVT Ltd. 2026. Maintained by the community.
Email: info@grayhat.studio
From 43a17e1e585f43c6c250a7583626fed361928527 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 22:15:30 +0500
Subject: [PATCH 11/44] feat(capabilities): add registry-driven feature
architecture
---
Dockerfile | 2 +-
pyproject.toml | 39 ++-
src/vidxp/application.py | 271 ++++++++----------
src/vidxp/benchmarks/didemo.py | 5 +-
src/vidxp/benchmarks/hirest.py | 5 +-
src/vidxp/benchmarks/requirements.txt | 1 +
src/vidxp/capabilities/__init__.py | 15 +
src/vidxp/capabilities/actor/__init__.py | 1 +
.../actors.py => capabilities/actor/cli.py} | 11 +-
src/vidxp/capabilities/actor/definition.py | 93 ++++++
.../actor/indexing.py} | 6 +-
src/vidxp/capabilities/actor/operations.py | 52 ++++
src/vidxp/capabilities/actor/requirements.txt | 4 +
.../actor/results.py} | 63 ++--
src/vidxp/capabilities/contracts.py | 182 ++++++++++++
src/vidxp/capabilities/dialogue/__init__.py | 1 +
src/vidxp/capabilities/dialogue/definition.py | 119 ++++++++
.../dialogue/indexing.py} | 2 +-
src/vidxp/capabilities/dialogue/models.py | 30 ++
src/vidxp/capabilities/dialogue/operations.py | 109 +++++++
.../capabilities/dialogue/requirements.txt | 4 +
src/vidxp/capabilities/registry.py | 129 +++++++++
src/vidxp/capabilities/scene/__init__.py | 1 +
src/vidxp/capabilities/scene/definition.py | 71 +++++
src/vidxp/capabilities/scene/indexing.py | 96 +++++++
src/vidxp/capabilities/scene/models.py | 14 +
src/vidxp/capabilities/scene/operations.py | 82 ++++++
src/vidxp/capabilities/scene/requirements.txt | 6 +
src/vidxp/capabilities/schemas.py | 102 +++++++
src/vidxp/{core => capabilities}/search.py | 81 +-----
.../visual.py} | 131 +++------
src/vidxp/cli.py | 13 +-
src/vidxp/cli_commands/index.py | 3 +-
src/vidxp/cli_commands/runtime.py | 13 +-
src/vidxp/cli_commands/search.py | 86 +++---
src/vidxp/cli_support.py | 29 +-
src/vidxp/core/__init__.py | 6 +-
src/vidxp/core/contracts.py | 130 ++++-----
src/vidxp/core/indexing.py | 38 ---
src/vidxp/core/manifest.py | 23 +-
src/vidxp/core/models.py | 150 ----------
src/vidxp/core/runner.py | 192 +++++--------
src/vidxp/core/storage.py | 57 ++--
src/vidxp/requirements/frontend.txt | 1 +
tests/test_actor_results.py | 37 ++-
tests/test_application.py | 113 +++++---
tests/test_benchmarks.py | 2 +-
tests/test_capabilities.py | 121 ++++++++
tests/test_cli.py | 39 ++-
tests/test_contracts.py | 26 +-
tests/test_frontend.py | 4 +-
tests/test_indexing.py | 50 ++--
tests/test_models.py | 60 ++--
tests/test_packaging.py | 62 ++++
tests/test_runner.py | 50 ++--
tests/test_search.py | 11 +-
tests/test_storage.py | 29 +-
57 files changed, 2030 insertions(+), 1043 deletions(-)
create mode 100644 src/vidxp/benchmarks/requirements.txt
create mode 100644 src/vidxp/capabilities/__init__.py
create mode 100644 src/vidxp/capabilities/actor/__init__.py
rename src/vidxp/{cli_commands/actors.py => capabilities/actor/cli.py} (95%)
create mode 100644 src/vidxp/capabilities/actor/definition.py
rename src/vidxp/{core/indexing_actor.py => capabilities/actor/indexing.py} (96%)
create mode 100644 src/vidxp/capabilities/actor/operations.py
create mode 100644 src/vidxp/capabilities/actor/requirements.txt
rename src/vidxp/{core/actor_results.py => capabilities/actor/results.py} (74%)
create mode 100644 src/vidxp/capabilities/contracts.py
create mode 100644 src/vidxp/capabilities/dialogue/__init__.py
create mode 100644 src/vidxp/capabilities/dialogue/definition.py
rename src/vidxp/{core/indexing_dialogue.py => capabilities/dialogue/indexing.py} (99%)
create mode 100644 src/vidxp/capabilities/dialogue/models.py
create mode 100644 src/vidxp/capabilities/dialogue/operations.py
create mode 100644 src/vidxp/capabilities/dialogue/requirements.txt
create mode 100644 src/vidxp/capabilities/registry.py
create mode 100644 src/vidxp/capabilities/scene/__init__.py
create mode 100644 src/vidxp/capabilities/scene/definition.py
create mode 100644 src/vidxp/capabilities/scene/indexing.py
create mode 100644 src/vidxp/capabilities/scene/models.py
create mode 100644 src/vidxp/capabilities/scene/operations.py
create mode 100644 src/vidxp/capabilities/scene/requirements.txt
create mode 100644 src/vidxp/capabilities/schemas.py
rename src/vidxp/{core => capabilities}/search.py (68%)
rename src/vidxp/{core/indexing_visual.py => capabilities/visual.py} (72%)
delete mode 100644 src/vidxp/core/indexing.py
delete mode 100644 src/vidxp/core/models.py
create mode 100644 src/vidxp/requirements/frontend.txt
create mode 100644 tests/test_capabilities.py
create mode 100644 tests/test_packaging.py
diff --git a/Dockerfile b/Dockerfile
index c2271c0..4c1a474 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,7 @@ COPY pyproject.toml README.md LICENSE MANIFEST.in ./
COPY src ./src
RUN python -m pip install --upgrade pip setuptools wheel \
- && pip install ".[frontend]"
+ && pip install ".[all,frontend]"
EXPOSE 8501
diff --git a/pyproject.toml b/pyproject.toml
index 5fcdb02..5576e01 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,29 +33,12 @@ classifiers = [
"Topic :: Multimedia :: Video",
"Topic :: Software Development :: Libraries :: Python Modules",
]
+dynamic = ["optional-dependencies"]
dependencies = [
- "chromadb",
- "face_recognition",
"filelock>=3.13",
- "moviepy==1.0.3",
- "numpy>=2.1,<3",
- "opencv-python",
- "Pillow>=7.0.0",
+ "pydantic>=2.8,<3",
"rich",
- "sentence-transformers>=3.4,<4",
- "setuptools>=68,<81",
- "torch",
"typer>=0.27,<1",
- "whisperx>=3.8.6,<3.9",
- "clip-anytorch==2.6.0",
-]
-
-[project.optional-dependencies]
-frontend = [
- "streamlit>=1.37",
-]
-benchmarks = [
- "srt>=3.5,<4",
]
[project.scripts]
@@ -74,7 +57,23 @@ where = ["src"]
include = ["vidxp*"]
[tool.setuptools.package-data]
-vidxp = []
+vidxp = [
+ "benchmarks/requirements.txt",
+ "capabilities/*/requirements.txt",
+ "requirements/*.txt",
+]
+
+[tool.setuptools.dynamic.optional-dependencies]
+dialogue = { file = ["src/vidxp/capabilities/dialogue/requirements.txt"] }
+scene = { file = ["src/vidxp/capabilities/scene/requirements.txt"] }
+actor = { file = ["src/vidxp/capabilities/actor/requirements.txt"] }
+all = { file = [
+ "src/vidxp/capabilities/dialogue/requirements.txt",
+ "src/vidxp/capabilities/scene/requirements.txt",
+ "src/vidxp/capabilities/actor/requirements.txt",
+] }
+frontend = { file = ["src/vidxp/requirements/frontend.txt"] }
+benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] }
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index fc00cd6..e6dd0d3 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -2,20 +2,32 @@
from dataclasses import replace
from pathlib import Path
-from typing import Any, Iterable
+from typing import Any, Iterable, Mapping, cast
-from vidxp.core.actor_results import (
+from pydantic import BaseModel
+
+from vidxp.capabilities.contracts import (
+ CapabilityContext,
+ capability_install_hint,
+)
+from vidxp.capabilities.registry import (
+ capability_names,
+ collection_names,
+ dependency_checks,
+ get_capability,
+ index_capability_names,
+ validate_capability_names,
+)
+from vidxp.capabilities.schemas import (
ActorClusterSummary,
+ ActorDetection,
ActorRenderResult,
- actor_clusters,
- actor_detections,
- render_actor_result,
+ SearchResult,
)
from vidxp.core.contracts import (
- SUPPORTED_MODALITIES,
CancellationToken,
IndexConfig,
- SearchResult,
+ IndexSchemaError,
)
from vidxp.core.manifest import (
CHECKPOINT_DIRECTORY,
@@ -24,23 +36,13 @@
MANIFEST_FILE,
TIMINGS_FILE,
)
-from vidxp.core.models import (
- INDEXING_DEPENDENCIES,
- dependency_failures,
- get_alignment_model,
- get_clip_model,
- get_embedder,
- get_whisper_model,
-)
from vidxp.core.runner import (
ProgressCallback,
index_video,
indexing_in_progress,
local_config_from_status,
)
-from vidxp.core.search import search_dialogue, search_scene
from vidxp.core.storage import IndexStorage
-from vidxp.core.video import ffmpeg_binary
from vidxp.index_state import (
INDEX_STATUS_FILE,
INDEX_STATUS_SCHEMA,
@@ -90,17 +92,35 @@ def create_index(
self,
video_path: str | Path,
*,
- modalities: Iterable[str] = SUPPORTED_MODALITIES,
+ modalities: Iterable[str] | None = None,
frame_stride: int = 1,
+ capability_options: Mapping[
+ str,
+ Mapping[str, Any],
+ ] | None = None,
progress_callback: ProgressCallback | None = None,
cancellation: CancellationToken | None = None,
source_name: str | None = None,
) -> dict[str, Any]:
- selected = tuple(dict.fromkeys(str(item) for item in modalities))
+ selected = self._validate_modalities(
+ index_capability_names() if modalities is None else modalities
+ )
+ non_indexable = [
+ name
+ for name in selected
+ if get_capability(name).indexer is None
+ ]
+ if non_indexable:
+ raise ValueError(
+ "These capabilities do not support indexing: "
+ + ", ".join(non_indexable)
+ )
options: dict[str, Any] = {
"enabled_modalities": selected,
"frame_stride": frame_stride,
"storage_directory": self.index_directory,
+ "collection_names": collection_names(),
+ "capability_options": capability_options or {},
}
if self.device is not None:
options["device"] = self.device
@@ -123,55 +143,14 @@ def indexing_in_progress(self) -> bool:
def check_dependencies(
self,
- modalities: Iterable[str] = SUPPORTED_MODALITIES,
+ modalities: Iterable[str] | None = None,
) -> dict[str, Any]:
- selected = self._validate_modalities(modalities)
- failures = dict(
- dependency_failures(
- selected,
- needs_transcription="dialogue" in selected,
- )
+ selected = self._validate_modalities(
+ capability_names() if modalities is None else modalities
)
- dependencies = []
- for modality in selected:
- dependencies.extend(INDEXING_DEPENDENCIES[modality])
- if "dialogue" in selected:
- dependencies.extend(INDEXING_DEPENDENCIES["transcription"])
-
- checks = []
- seen = set()
- for dependency in dependencies:
- if dependency.label in seen:
- continue
- seen.add(dependency.label)
- error = failures.get(dependency.label)
- checks.append(
- {
- "name": dependency.label,
- "ok": error is None,
- "error": error,
- }
- )
- if "dialogue" in selected:
- try:
- resolved_ffmpeg = ffmpeg_binary()
- except Exception as exc:
- error = f"{type(exc).__name__}: {exc}"
- failures["FFmpeg"] = error
- checks.append(
- {"name": "FFmpeg", "ok": False, "error": error}
- )
- else:
- checks.append(
- {
- "name": "FFmpeg",
- "ok": True,
- "path": resolved_ffmpeg,
- "error": None,
- }
- )
+ checks = list(dependency_checks(selected))
return {
- "ok": not failures,
+ "ok": all(check["ok"] for check in checks),
"modalities": list(selected),
"checks": checks,
}
@@ -188,57 +167,29 @@ def prepare_models(
enabled_modalities=selected,
device=self.device or "cpu",
storage_directory=self.index_directory,
+ collection_names=collection_names(),
)
- failures = dependency_failures(
- selected,
- needs_transcription="dialogue" in selected,
- )
+ checks = dependency_checks(selected)
+ failures = [check for check in checks if not check["ok"]]
if failures:
details = "; ".join(
- f"{label}: {error}" for label, error in failures
+ f"{check['name']}: {check['error']}"
+ for check in failures
)
- raise RuntimeError(details)
-
- prepared = []
-
- def report(stage: str, message: str) -> None:
- if progress_callback is not None:
- progress_callback(
- {
- "state": "preparing",
- "stage": stage,
- "message": message,
- }
- )
-
- if "dialogue" in selected:
- report(
- "dialogue_model",
- f"Preparing dialogue model: {config.sentence_model}",
+ extras = ",".join(
+ get_capability(name).extra for name in selected
)
- get_embedder(config.sentence_model, config.device)
- prepared.append(config.sentence_model)
- report(
- "transcription_model",
- f"Preparing transcription model: WhisperX "
- f"{config.whisper_model}",
+ raise RuntimeError(
+ f"{details}. {capability_install_hint(extras)}"
)
- get_whisper_model(config.whisper_model, config.device)
- prepared.append(config.whisper_model)
- if language:
- report(
- "alignment_model",
- f"Preparing the {language} alignment model.",
+
+ prepared = []
+ for name in selected:
+ prepare = get_capability(name).prepare
+ if prepare is not None:
+ prepared.extend(
+ prepare(config, language, progress_callback)
)
- get_alignment_model(language, config.device)
- prepared.append(f"whisperx-alignment:{language}")
- if "scene" in selected:
- report(
- "scene_model",
- f"Preparing scene model: CLIP {config.clip_model}",
- )
- get_clip_model(config.clip_model, config.device)
- prepared.append(config.clip_model)
return {
"prepared": prepared,
"modalities": list(selected),
@@ -246,6 +197,35 @@ def report(stage: str, message: str) -> None:
"language": language,
}
+ def execute(
+ self,
+ capability: str,
+ operation: str,
+ payload: BaseModel | Mapping[str, Any],
+ ) -> BaseModel:
+ """Validate and execute a registered capability operation."""
+
+ definition = get_capability(capability)
+ try:
+ selected_operation = definition.operations[operation]
+ except KeyError as exc:
+ available = ", ".join(definition.operations) or "none"
+ raise ValueError(
+ f"Capability {capability!r} has no operation {operation!r}. "
+ f"Available operations: {available}."
+ ) from exc
+ config = None
+ if selected_operation.requires_index:
+ config, _ = self.active_config()
+ if capability not in config.enabled_modalities:
+ raise ValueError(
+ f"The {capability} capability is not present in this index."
+ )
+ return selected_operation.invoke(
+ CapabilityContext(config=config),
+ payload,
+ )
+
def search(
self,
modality: str,
@@ -253,35 +233,29 @@ def search(
*,
top_k: int = 10,
) -> SearchResult:
- config, _ = self.active_config()
- if modality not in config.enabled_modalities:
- raise ValueError(
- f"The {modality} modality is not present in this index."
- )
- find = {
- "dialogue": search_dialogue,
- "scene": search_scene,
- }.get(modality)
- if find is None:
- raise ValueError(
- "Semantic search supports dialogue and scene modalities."
- )
- return find(
- query,
- config=config,
- top_k=top_k,
- video_id=config.video_id,
+ return cast(
+ SearchResult,
+ self.execute(
+ modality,
+ "search",
+ {"query": query, "top_k": top_k},
+ ),
)
def actor_clusters(self) -> tuple[ActorClusterSummary, ...]:
- config, _ = self.active_config()
- self._require_actor(config)
- return actor_clusters(config)
+ result = self.execute("actor", "clusters", {})
+ return tuple(result.clusters)
- def actor_detections(self, cluster_id: str) -> list[dict[str, Any]]:
- config, _ = self.active_config()
- self._require_actor(config)
- return actor_detections(config, cluster_id)
+ def actor_detections(
+ self,
+ cluster_id: str,
+ ) -> list[ActorDetection]:
+ result = self.execute(
+ "actor",
+ "detections",
+ {"cluster_id": cluster_id},
+ )
+ return list(result.detections)
def render_actor(
self,
@@ -289,13 +263,17 @@ def render_actor(
input_path: str | Path,
output_path: str | Path,
) -> ActorRenderResult:
- config, _ = self.active_config()
- self._require_actor(config)
- return render_actor_result(
- config,
- cluster_id,
- input_path,
- output_path,
+ return cast(
+ ActorRenderResult,
+ self.execute(
+ "actor",
+ "render",
+ {
+ "cluster_id": cluster_id,
+ "input_path": input_path,
+ "output_path": output_path,
+ },
+ ),
)
def clear_index(self) -> bool:
@@ -303,6 +281,8 @@ def clear_index(self) -> bool:
return False
base_config = IndexConfig.local(
storage_directory=self.index_directory,
+ enabled_modalities=index_capability_names(),
+ collection_names=collection_names(),
)
if indexing_in_progress(base_config):
raise IndexingInProgressError(
@@ -341,15 +321,8 @@ def clear_index(self) -> bool:
pass
return True
- @staticmethod
- def _require_actor(config: IndexConfig) -> None:
- if "actor" not in config.enabled_modalities:
- raise ValueError("The actor modality is not present in this index.")
-
@staticmethod
def _validate_modalities(
modalities: Iterable[str],
) -> tuple[str, ...]:
- selected = tuple(dict.fromkeys(str(item) for item in modalities))
- IndexConfig(enabled_modalities=selected)
- return selected
+ return validate_capability_names(modalities)
diff --git a/src/vidxp/benchmarks/didemo.py b/src/vidxp/benchmarks/didemo.py
index d01f40a..bdf6752 100644
--- a/src/vidxp/benchmarks/didemo.py
+++ b/src/vidxp/benchmarks/didemo.py
@@ -16,10 +16,11 @@
run_logged_evaluator,
verify_artifact,
)
-from vidxp.core.contracts import IndexConfig, SearchHit, VideoSource
+from vidxp.capabilities.scene.operations import search_scene
+from vidxp.capabilities.schemas import SearchHit
+from vidxp.core.contracts import IndexConfig, VideoSource
from vidxp.core.manifest import write_json_atomic
from vidxp.core.runner import run_index
-from vidxp.core.search import search_scene
DIDEMO_REVISION = "b6a555c8134581305d0ed4716fbc192860e0b88c"
diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py
index f7e8c5c..27a89e8 100644
--- a/src/vidxp/benchmarks/hirest.py
+++ b/src/vidxp/benchmarks/hirest.py
@@ -18,10 +18,11 @@
run_logged_evaluator,
verify_artifact,
)
-from vidxp.core.contracts import IndexConfig, SearchHit, VideoSource
+from vidxp.capabilities.dialogue.operations import search_dialogue
+from vidxp.capabilities.schemas import SearchHit
+from vidxp.core.contracts import IndexConfig, VideoSource
from vidxp.core.manifest import sha256_file, write_json_atomic
from vidxp.core.runner import run_index
-from vidxp.core.search import search_dialogue
HIREST_REVISION = "deffc169b4e8d51c1589d5512ad05da61e81bcee"
diff --git a/src/vidxp/benchmarks/requirements.txt b/src/vidxp/benchmarks/requirements.txt
new file mode 100644
index 0000000..7b6efb9
--- /dev/null
+++ b/src/vidxp/benchmarks/requirements.txt
@@ -0,0 +1 @@
+srt>=3.5,<4
diff --git a/src/vidxp/capabilities/__init__.py b/src/vidxp/capabilities/__init__.py
new file mode 100644
index 0000000..381c275
--- /dev/null
+++ b/src/vidxp/capabilities/__init__.py
@@ -0,0 +1,15 @@
+"""Built-in VidXP capabilities and their explicit registry."""
+
+from vidxp.capabilities.registry import (
+ CAPABILITIES,
+ capability_names,
+ get_capability,
+ index_capability_names,
+)
+
+__all__ = [
+ "CAPABILITIES",
+ "capability_names",
+ "get_capability",
+ "index_capability_names",
+]
diff --git a/src/vidxp/capabilities/actor/__init__.py b/src/vidxp/capabilities/actor/__init__.py
new file mode 100644
index 0000000..e58f1ec
--- /dev/null
+++ b/src/vidxp/capabilities/actor/__init__.py
@@ -0,0 +1 @@
+"""Actor capability implementation."""
diff --git a/src/vidxp/cli_commands/actors.py b/src/vidxp/capabilities/actor/cli.py
similarity index 95%
rename from src/vidxp/cli_commands/actors.py
rename to src/vidxp/capabilities/actor/cli.py
index 25f461b..51767ed 100644
--- a/src/vidxp/cli_commands/actors.py
+++ b/src/vidxp/capabilities/actor/cli.py
@@ -103,7 +103,10 @@ def actors_inspect(
payload = {
"cluster_id": cluster_id,
"detection_count": len(detections),
- "detections": detections[:limit],
+ "detections": [
+ detection.model_dump(mode="json")
+ for detection in detections[:limit]
+ ],
"truncated": len(detections) > limit,
}
if effective_output_format(state, json_output) == OutputFormat.json:
@@ -115,9 +118,9 @@ def actors_inspect(
table.add_column("Detection")
for detection in detections[:limit]:
table.add_row(
- str(detection["frame_index"]),
- f"{float(detection['timestamp']):.3f}s",
- str(detection["detection_id"]),
+ str(detection.frame_index),
+ f"{detection.timestamp:.3f}s",
+ detection.detection_id,
)
Console().print(table)
if len(detections) > limit:
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
new file mode 100644
index 0000000..a7a63ce
--- /dev/null
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.capabilities.actor.operations import (
+ clusters_operation,
+ detections_operation,
+ render_operation,
+)
+from vidxp.capabilities.contracts import (
+ CapabilityDefinition,
+ OperationDefinition,
+ RuntimeDependency,
+)
+from vidxp.capabilities.schemas import (
+ ActorClustersInput,
+ ActorClustersOutput,
+ ActorDetectionsInput,
+ ActorDetectionsOutput,
+ ActorRenderInput,
+ ActorRenderResult,
+)
+from vidxp.capabilities.visual import index_capabilities
+from vidxp.core.contracts import IndexConfig, VideoSource
+
+
+DEPENDENCIES = (
+ RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
+ RuntimeDependency(
+ "face recognition",
+ "face-recognition",
+ "face_recognition",
+ ),
+ RuntimeDependency("dlib", "dlib", "dlib"),
+ RuntimeDependency(
+ "face recognition models",
+ "face-recognition-models",
+ "face_recognition_models",
+ ),
+ RuntimeDependency("NumPy", "numpy", "numpy"),
+ RuntimeDependency("OpenCV", "opencv-python", "cv2"),
+)
+
+
+def model_manifest(
+ config: IndexConfig,
+ _sources: tuple[VideoSource, ...],
+) -> Mapping[str, Any]:
+ return {
+ "actor": {
+ "library": "face_recognition",
+ "match_threshold": config.face_match_threshold,
+ "num_jitters": config.face_num_jitters,
+ "minimum_detections": config.actor_min_detections,
+ }
+ }
+
+
+def cli_app():
+ from vidxp.capabilities.actor.cli import app
+
+ return app
+
+
+DEFINITION = CapabilityDefinition(
+ name="actor",
+ description="Index, inspect, and render actor clusters.",
+ extra="actor",
+ collection_name="actor",
+ indexer=index_capabilities,
+ index_stage="visual_indexing",
+ dependencies=DEPENDENCIES,
+ model_manifest=model_manifest,
+ operations={
+ "clusters": OperationDefinition(
+ input_model=ActorClustersInput,
+ output_model=ActorClustersOutput,
+ handler=clusters_operation,
+ ),
+ "detections": OperationDefinition(
+ input_model=ActorDetectionsInput,
+ output_model=ActorDetectionsOutput,
+ handler=detections_operation,
+ ),
+ "render": OperationDefinition(
+ input_model=ActorRenderInput,
+ output_model=ActorRenderResult,
+ handler=render_operation,
+ ),
+ },
+ cli_name="actors",
+ cli_factory=cli_app,
+)
diff --git a/src/vidxp/core/indexing_actor.py b/src/vidxp/capabilities/actor/indexing.py
similarity index 96%
rename from src/vidxp/core/indexing_actor.py
rename to src/vidxp/capabilities/actor/indexing.py
index 7f4a9ce..ca11f01 100644
--- a/src/vidxp/core/indexing_actor.py
+++ b/src/vidxp/capabilities/actor/indexing.py
@@ -146,7 +146,11 @@ def finalize_actor_index(
if size < config.actor_min_detections
]
for cluster_id in rejected:
- storage.delete_actor_cluster(str(config.video_id), cluster_id)
+ storage.delete_records(
+ "actor",
+ video_id=str(config.video_id),
+ filters={"cluster_id": cluster_id},
+ )
retained = {
cluster_id: size
for cluster_id, size in state.cluster_sizes.items()
diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py
new file mode 100644
index 0000000..3e9547c
--- /dev/null
+++ b/src/vidxp/capabilities/actor/operations.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from vidxp.capabilities.actor.results import (
+ actor_clusters,
+ actor_detections,
+ render_actor_result,
+)
+from vidxp.capabilities.contracts import CapabilityContext
+from vidxp.capabilities.schemas import (
+ ActorClustersInput,
+ ActorClustersOutput,
+ ActorDetectionsInput,
+ ActorDetectionsOutput,
+ ActorRenderInput,
+ ActorRenderResult,
+)
+
+
+def clusters_operation(
+ context: CapabilityContext,
+ _request: ActorClustersInput,
+) -> ActorClustersOutput:
+ return ActorClustersOutput(
+ clusters=actor_clusters(context.require_config())
+ )
+
+
+def detections_operation(
+ context: CapabilityContext,
+ request: ActorDetectionsInput,
+) -> ActorDetectionsOutput:
+ config = context.require_config()
+ return ActorDetectionsOutput(
+ cluster_id=request.cluster_id,
+ detections=actor_detections(
+ config,
+ request.cluster_id,
+ ),
+ )
+
+
+def render_operation(
+ context: CapabilityContext,
+ request: ActorRenderInput,
+) -> ActorRenderResult:
+ config = context.require_config()
+ return render_actor_result(
+ config,
+ request.cluster_id,
+ request.input_path,
+ request.output_path,
+ )
diff --git a/src/vidxp/capabilities/actor/requirements.txt b/src/vidxp/capabilities/actor/requirements.txt
new file mode 100644
index 0000000..8cf9c97
--- /dev/null
+++ b/src/vidxp/capabilities/actor/requirements.txt
@@ -0,0 +1,4 @@
+chromadb
+face-recognition
+numpy>=2.1,<3
+opencv-python
diff --git a/src/vidxp/core/actor_results.py b/src/vidxp/capabilities/actor/results.py
similarity index 74%
rename from src/vidxp/core/actor_results.py
rename to src/vidxp/capabilities/actor/results.py
index c7b4d05..5ed3328 100644
--- a/src/vidxp/core/actor_results.py
+++ b/src/vidxp/capabilities/actor/results.py
@@ -1,8 +1,12 @@
from __future__ import annotations
-from dataclasses import dataclass
from pathlib import Path
+from vidxp.capabilities.schemas import (
+ ActorClusterSummary,
+ ActorDetection,
+ ActorRenderResult,
+)
from vidxp.core.contracts import IndexConfig
from vidxp.core.storage import IndexStorage
from vidxp.core.video import render_actor_video
@@ -12,30 +16,6 @@ class ActorClusterNotFoundError(LookupError):
"""Raised when an actor cluster has no retained detections."""
-@dataclass(frozen=True)
-class ActorRenderResult:
- output_path: Path
- detection_count: int
-
-
-@dataclass(frozen=True)
-class ActorClusterSummary:
- cluster_id: str
- video_id: str
- detection_count: int
- first_timestamp: float
- last_timestamp: float
-
- def to_dict(self) -> dict:
- return {
- "cluster_id": self.cluster_id,
- "video_id": self.video_id,
- "detection_count": self.detection_count,
- "first_timestamp": self.first_timestamp,
- "last_timestamp": self.last_timestamp,
- }
-
-
def actor_clusters(
config: IndexConfig,
*,
@@ -46,7 +26,8 @@ def actor_clusters(
owns_storage = storage is None
active_storage = storage or IndexStorage(config)
try:
- records = active_storage.actor_cluster_records(
+ records = active_storage.records(
+ "actor",
video_id=config.video_id,
)
finally:
@@ -77,32 +58,41 @@ def actor_detections(
cluster_id: str,
*,
storage: IndexStorage | None = None,
-) -> list[dict]:
+) -> list[ActorDetection]:
if config.video_id is None:
raise ValueError("IndexConfig.video_id is required for actor results.")
owns_storage = storage is None
active_storage = storage or IndexStorage(config)
try:
- records = active_storage.actor_detections(
+ records = active_storage.records(
+ "actor",
video_id=config.video_id,
- cluster_id=cluster_id,
+ filters={"cluster_id": cluster_id},
)
finally:
if owns_storage:
active_storage.close()
- return [
- {
- **record,
- "bbox": (
+ detections = [
+ ActorDetection(
+ **{
+ key: value
+ for key, value in record.items()
+ if not key.startswith("bbox_")
+ },
+ bbox=(
int(record["bbox_top"]),
int(record["bbox_right"]),
int(record["bbox_bottom"]),
int(record["bbox_left"]),
),
- }
+ )
for record in records
]
+ return sorted(
+ detections,
+ key=lambda item: (item.frame_index, item.detection_id),
+ )
def render_actor_result(
@@ -120,4 +110,7 @@ def render_actor_result(
)
destination = Path(output_path)
render_actor_video(input_path, destination, cluster_id, detections)
- return ActorRenderResult(destination, len(detections))
+ return ActorRenderResult(
+ output_path=destination,
+ detection_count=len(detections),
+ )
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
new file mode 100644
index 0000000..c0d913d
--- /dev/null
+++ b/src/vidxp/capabilities/contracts.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from importlib import import_module
+from types import MappingProxyType
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Callable,
+ Mapping,
+)
+
+from pydantic import BaseModel
+
+if TYPE_CHECKING:
+ from vidxp.core.contracts import IndexConfig, VideoSource
+ from vidxp.core.indexing_common import ProgressCallback
+
+
+class CapabilityInput(BaseModel):
+ """Base model for validated capability input."""
+
+ model_config = {"extra": "forbid"}
+
+
+class CapabilityOutput(BaseModel):
+ """Base model for validated capability output."""
+
+ model_config = {"extra": "forbid", "frozen": True}
+
+
+@dataclass(frozen=True)
+class RuntimeDependency:
+ """One import or executable required by a capability."""
+
+ label: str
+ distribution: str | None = None
+ module: str | None = None
+ check: Callable[[], str | None] | None = None
+
+ def inspect(self) -> dict[str, Any]:
+ try:
+ if self.module is not None:
+ import_module(self.module)
+ detail = self.check() if self.check is not None else None
+ except Exception as exc:
+ return {
+ "name": self.label,
+ "ok": False,
+ "error": f"{type(exc).__name__}: {exc}",
+ }
+ result: dict[str, Any] = {
+ "name": self.label,
+ "ok": True,
+ "error": None,
+ }
+ if detail is not None:
+ result["path"] = detail
+ return result
+
+
+@dataclass(frozen=True)
+class CapabilityContext:
+ """Runtime context shared by transport-neutral capability operations."""
+
+ config: IndexConfig | None
+
+ def require_config(self) -> IndexConfig:
+ if self.config is None:
+ raise RuntimeError("This operation requires an active index.")
+ return self.config
+
+
+OperationHandler = Callable[[CapabilityContext, BaseModel], BaseModel | Mapping]
+
+
+@dataclass(frozen=True)
+class OperationDefinition:
+ """Validated input, output, and implementation for one operation."""
+
+ input_model: type[BaseModel]
+ output_model: type[BaseModel]
+ handler: OperationHandler
+ requires_index: bool = True
+
+ def invoke(
+ self,
+ context: CapabilityContext,
+ payload: BaseModel | Mapping[str, Any],
+ ) -> BaseModel:
+ request = self.input_model.model_validate(payload)
+ result = self.handler(context, request)
+ return self.output_model.model_validate(result)
+
+
+@dataclass(frozen=True)
+class CapabilityIndexResult:
+ """Summary and timing data returned by an indexing handler."""
+
+ summary: Mapping[str, Any]
+ timings: Mapping[str, float] = field(default_factory=dict)
+
+
+IndexHandler = Callable[..., CapabilityIndexResult]
+PrepareHandler = Callable[
+ ["IndexConfig", str | None, "ProgressCallback | None"],
+ tuple[str, ...],
+]
+DependencySelector = Callable[
+ ["VideoSource"],
+ tuple[RuntimeDependency, ...],
+]
+ModelManifest = Callable[
+ ["IndexConfig", tuple["VideoSource", ...]],
+ Mapping[str, Any],
+]
+CLIFactory = Callable[[], Any]
+
+
+@dataclass(frozen=True)
+class CapabilityDefinition:
+ """Everything the application needs to run one named capability."""
+
+ name: str
+ description: str
+ extra: str
+ dependencies: tuple[RuntimeDependency, ...] = ()
+ collection_name: str | None = None
+ indexer: IndexHandler | None = None
+ index_stage: str | None = None
+ operations: Mapping[str, OperationDefinition] = field(default_factory=dict)
+ dependencies_for_source: DependencySelector | None = None
+ prepare: PrepareHandler | None = None
+ model_manifest: ModelManifest | None = None
+ cli_name: str | None = None
+ cli_factory: CLIFactory | None = None
+
+ def __post_init__(self) -> None:
+ for label, value in (
+ ("name", self.name),
+ ("description", self.description),
+ ("extra", self.extra),
+ ):
+ if not str(value).strip():
+ raise ValueError(f"Capability {label} must not be empty.")
+ indexing_fields = (
+ self.collection_name,
+ self.indexer,
+ self.index_stage,
+ )
+ if any(value is not None for value in indexing_fields) and not all(
+ value is not None for value in indexing_fields
+ ):
+ raise ValueError(
+ "Indexable capabilities must declare collection names, "
+ "an indexer, and an index stage together."
+ )
+ if (self.cli_name is None) != (self.cli_factory is None):
+ raise ValueError(
+ "cli_name and cli_factory must either both be set or both be unset."
+ )
+ if self.indexer is None and not self.operations:
+ raise ValueError(
+ "A capability must provide an indexer or at least one operation."
+ )
+ object.__setattr__(
+ self,
+ "operations",
+ MappingProxyType(dict(self.operations)),
+ )
+
+ def source_dependencies(
+ self,
+ source: VideoSource,
+ ) -> tuple[RuntimeDependency, ...]:
+ if self.dependencies_for_source is None:
+ return self.dependencies
+ return self.dependencies_for_source(source)
+
+
+def capability_install_hint(name: str) -> str:
+ return f'Install the capability with: pip install "vidxp[{name}]"'
diff --git a/src/vidxp/capabilities/dialogue/__init__.py b/src/vidxp/capabilities/dialogue/__init__.py
new file mode 100644
index 0000000..0573c3c
--- /dev/null
+++ b/src/vidxp/capabilities/dialogue/__init__.py
@@ -0,0 +1 @@
+"""Dialogue capability implementation."""
diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py
new file mode 100644
index 0000000..5df65f9
--- /dev/null
+++ b/src/vidxp/capabilities/dialogue/definition.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.capabilities.contracts import (
+ CapabilityDefinition,
+ OperationDefinition,
+ RuntimeDependency,
+)
+from vidxp.capabilities.dialogue.models import (
+ get_alignment_model,
+ get_embedder,
+ get_whisper_model,
+)
+from vidxp.capabilities.dialogue.operations import (
+ index_capability,
+ search_operation,
+)
+from vidxp.capabilities.schemas import SearchInput, SearchResult
+from vidxp.core.contracts import IndexConfig, VideoSource
+from vidxp.core.indexing_common import ProgressCallback
+from vidxp.core.video import ffmpeg_binary
+
+
+CHROMA = RuntimeDependency("ChromaDB", "chromadb", "chromadb")
+SENTENCE_TRANSFORMERS = RuntimeDependency(
+ "Sentence Transformers",
+ "sentence-transformers",
+ "sentence_transformers",
+)
+MOVIEPY = RuntimeDependency("MoviePy", "moviepy", "moviepy.editor")
+WHISPERX = RuntimeDependency("WhisperX", "whisperx", "whisperx")
+FFMPEG = RuntimeDependency("FFmpeg", check=ffmpeg_binary)
+
+DEPENDENCIES = (
+ CHROMA,
+ SENTENCE_TRANSFORMERS,
+ MOVIEPY,
+ WHISPERX,
+ FFMPEG,
+)
+
+
+def dependencies_for_source(
+ source: VideoSource,
+) -> tuple[RuntimeDependency, ...]:
+ if source.transcript is not None:
+ return CHROMA, SENTENCE_TRANSFORMERS
+ return DEPENDENCIES
+
+
+def prepare_models(
+ config: IndexConfig,
+ language: str | None,
+ progress: ProgressCallback | None,
+) -> tuple[str, ...]:
+ prepared = []
+
+ def report(stage: str, message: str) -> None:
+ if progress is not None:
+ progress(
+ {
+ "state": "preparing",
+ "stage": stage,
+ "message": message,
+ }
+ )
+
+ report(
+ "dialogue_model",
+ f"Preparing dialogue model: {config.sentence_model}",
+ )
+ get_embedder(config.sentence_model, config.device)
+ prepared.append(config.sentence_model)
+ report(
+ "transcription_model",
+ f"Preparing transcription model: WhisperX {config.whisper_model}",
+ )
+ get_whisper_model(config.whisper_model, config.device)
+ prepared.append(config.whisper_model)
+ if language:
+ report(
+ "alignment_model",
+ f"Preparing the {language} alignment model.",
+ )
+ get_alignment_model(language, config.device)
+ prepared.append(f"whisperx-alignment:{language}")
+ return tuple(prepared)
+
+
+def model_manifest(
+ config: IndexConfig,
+ sources: tuple[VideoSource, ...],
+) -> Mapping[str, Any]:
+ result: dict[str, Any] = {"dialogue": config.sentence_model}
+ if any(source.transcript is None for source in sources):
+ result["transcription"] = config.whisper_model
+ return result
+
+
+DEFINITION = CapabilityDefinition(
+ name="dialogue",
+ description="Index and search spoken dialogue.",
+ extra="dialogue",
+ collection_name="dialogue",
+ indexer=index_capability,
+ index_stage="dialogue_indexing",
+ dependencies=DEPENDENCIES,
+ dependencies_for_source=dependencies_for_source,
+ prepare=prepare_models,
+ model_manifest=model_manifest,
+ operations={
+ "search": OperationDefinition(
+ input_model=SearchInput,
+ output_model=SearchResult,
+ handler=search_operation,
+ )
+ },
+)
diff --git a/src/vidxp/core/indexing_dialogue.py b/src/vidxp/capabilities/dialogue/indexing.py
similarity index 99%
rename from src/vidxp/core/indexing_dialogue.py
rename to src/vidxp/capabilities/dialogue/indexing.py
index e1c44c4..ae99d25 100644
--- a/src/vidxp/core/indexing_dialogue.py
+++ b/src/vidxp/capabilities/dialogue/indexing.py
@@ -13,7 +13,7 @@
stable_source_id,
)
from vidxp.core.indexing_common import ProgressCallback, report_progress
-from vidxp.core.models import (
+from vidxp.capabilities.dialogue.models import (
get_alignment_model,
get_embedder,
get_whisper_model,
diff --git a/src/vidxp/capabilities/dialogue/models.py b/src/vidxp/capabilities/dialogue/models.py
new file mode 100644
index 0000000..449d50a
--- /dev/null
+++ b/src/vidxp/capabilities/dialogue/models.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from functools import lru_cache
+
+
+@lru_cache
+def get_embedder(model_name: str, device: str):
+ from sentence_transformers import SentenceTransformer
+
+ return SentenceTransformer(model_name, device=device)
+
+
+@lru_cache
+def get_whisper_model(model_name: str, device: str):
+ import whisperx
+
+ return whisperx.load_model(model_name, device, compute_type="float32")
+
+
+@lru_cache
+def get_alignment_model(language: str, device: str):
+ import whisperx
+
+ return whisperx.load_align_model(language_code=language, device=device)
+
+
+def clear_model_cache() -> None:
+ get_embedder.cache_clear()
+ get_whisper_model.cache_clear()
+ get_alignment_model.cache_clear()
diff --git a/src/vidxp/capabilities/dialogue/operations.py b/src/vidxp/capabilities/dialogue/operations.py
new file mode 100644
index 0000000..3551551
--- /dev/null
+++ b/src/vidxp/capabilities/dialogue/operations.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.capabilities.contracts import (
+ CapabilityContext,
+ CapabilityIndexResult,
+)
+from vidxp.capabilities.dialogue.indexing import index_dialogue
+from vidxp.capabilities.dialogue.models import get_embedder
+from vidxp.capabilities.schemas import SearchInput, SearchResult
+from vidxp.capabilities.search import search_embeddings
+from vidxp.core.contracts import (
+ CancellationToken,
+ IndexConfig,
+ VideoSource,
+)
+from vidxp.core.indexing_common import ProgressCallback
+from vidxp.core.storage import IndexStorage
+
+
+REQUIRED_METADATA = frozenset(
+ {
+ "dataset",
+ "split",
+ "run_id",
+ "video_id",
+ "source_id",
+ "start",
+ "end",
+ "text",
+ "phrase_id",
+ "modality",
+ }
+)
+
+
+def index_capability(
+ source: VideoSource,
+ *,
+ config: IndexConfig,
+ storage: IndexStorage,
+ cancellation: CancellationToken,
+ progress: ProgressCallback | None = None,
+ modalities: tuple[str, ...] = ("dialogue",),
+) -> CapabilityIndexResult:
+ if modalities != ("dialogue",):
+ raise ValueError("The dialogue indexer only accepts dialogue.")
+ return CapabilityIndexResult(
+ summary=index_dialogue(
+ source,
+ config=config,
+ storage=storage,
+ cancellation=cancellation,
+ progress=progress,
+ )
+ )
+
+
+def dialogue_embedding(query: str, config: IndexConfig) -> list[float]:
+ encoder = get_embedder(config.sentence_model, config.device)
+ encoded = encoder.encode(
+ [query],
+ convert_to_numpy=True,
+ normalize_embeddings=config.normalize_dialogue_embeddings,
+ )
+ return encoded[0].tolist()
+
+
+def search_dialogue(
+ query: str,
+ *,
+ config: IndexConfig,
+ top_k: int = 10,
+ video_id: str | None = None,
+ query_id: str | None = None,
+ filters: Mapping[str, Any] | None = None,
+ storage: IndexStorage | None = None,
+) -> SearchResult:
+ cleaned = query.strip()
+ if not cleaned:
+ raise ValueError("Search query must not be empty.")
+ if top_k <= 0:
+ raise ValueError("top_k must be greater than zero.")
+ return search_embeddings(
+ cleaned,
+ "dialogue",
+ dialogue_embedding(cleaned, config),
+ config=config,
+ required_metadata=REQUIRED_METADATA,
+ top_k=top_k,
+ video_id=video_id,
+ query_id=query_id,
+ filters=filters,
+ storage=storage,
+ )
+
+
+def search_operation(
+ context: CapabilityContext,
+ request: SearchInput,
+) -> SearchResult:
+ config = context.require_config()
+ return search_dialogue(
+ request.query,
+ config=config,
+ top_k=request.top_k,
+ video_id=config.video_id,
+ )
diff --git a/src/vidxp/capabilities/dialogue/requirements.txt b/src/vidxp/capabilities/dialogue/requirements.txt
new file mode 100644
index 0000000..91fe239
--- /dev/null
+++ b/src/vidxp/capabilities/dialogue/requirements.txt
@@ -0,0 +1,4 @@
+chromadb
+moviepy==1.0.3
+sentence-transformers>=3.4,<4
+whisperx>=3.8.6,<3.9
diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py
new file mode 100644
index 0000000..c228253
--- /dev/null
+++ b/src/vidxp/capabilities/registry.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from types import MappingProxyType
+from typing import Iterable
+
+from vidxp.capabilities.actor.definition import DEFINITION as ACTOR
+from vidxp.capabilities.contracts import (
+ CapabilityDefinition,
+ RuntimeDependency,
+ capability_install_hint,
+)
+from vidxp.capabilities.dialogue.definition import DEFINITION as DIALOGUE
+from vidxp.capabilities.scene.definition import DEFINITION as SCENE
+from vidxp.core.contracts import VideoSource
+
+
+_BUILT_INS = (DIALOGUE, SCENE, ACTOR)
+CAPABILITIES = MappingProxyType(
+ {capability.name: capability for capability in _BUILT_INS}
+)
+
+if len(CAPABILITIES) != len(_BUILT_INS):
+ raise RuntimeError("Capability names must be unique.")
+
+
+def capability_names() -> tuple[str, ...]:
+ return tuple(CAPABILITIES)
+
+
+def index_capability_names() -> tuple[str, ...]:
+ return tuple(
+ name
+ for name, capability in CAPABILITIES.items()
+ if capability.indexer is not None
+ )
+
+
+def get_capability(name: str) -> CapabilityDefinition:
+ try:
+ return CAPABILITIES[name]
+ except KeyError as exc:
+ available = ", ".join(capability_names())
+ raise ValueError(
+ f"Unknown capability {name!r}. Available capabilities: {available}."
+ ) from exc
+
+
+def validate_capability_names(names: Iterable[str]) -> tuple[str, ...]:
+ selected = tuple(dict.fromkeys(str(name).strip() for name in names))
+ if not selected:
+ raise ValueError("At least one capability is required.")
+ for name in selected:
+ get_capability(name)
+ return selected
+
+
+def collection_names() -> dict[str, str]:
+ return {
+ name: capability.collection_name
+ for name, capability in CAPABILITIES.items()
+ if capability.indexer is not None
+ }
+
+
+def dependencies_for(
+ names: Iterable[str],
+ *,
+ source: VideoSource | None = None,
+) -> tuple[RuntimeDependency, ...]:
+ dependencies = []
+ for name in validate_capability_names(names):
+ capability = get_capability(name)
+ dependencies.extend(
+ capability.dependencies
+ if source is None
+ else capability.source_dependencies(source)
+ )
+ unique = {}
+ for dependency in dependencies:
+ key = (
+ dependency.module,
+ dependency.distribution,
+ dependency.label,
+ )
+ unique.setdefault(key, dependency)
+ return tuple(unique.values())
+
+
+def dependency_checks(names: Iterable[str]) -> tuple[dict, ...]:
+ return tuple(
+ dependency.inspect()
+ for dependency in dependencies_for(names)
+ )
+
+
+def require_dependencies(
+ names: Iterable[str],
+ *,
+ source: VideoSource,
+) -> None:
+ selected = validate_capability_names(names)
+ failures = [
+ result
+ for dependency in dependencies_for(selected, source=source)
+ if not (result := dependency.inspect())["ok"]
+ ]
+ if failures:
+ details = "; ".join(
+ f"{failure['name']}: {failure['error']}"
+ for failure in failures
+ )
+ extras = ",".join(
+ get_capability(name).extra for name in selected
+ )
+ raise RuntimeError(
+ f"Capability dependencies are unavailable: {details}. "
+ + capability_install_hint(extras)
+ )
+
+
+def runtime_distributions() -> tuple[str, ...]:
+ distributions = {
+ dependency.distribution
+ for capability in CAPABILITIES.values()
+ for dependency in capability.dependencies
+ if dependency.distribution is not None
+ }
+ distributions.update({"filelock", "pydantic", "rich", "typer"})
+ return tuple(sorted(distributions, key=str.lower))
diff --git a/src/vidxp/capabilities/scene/__init__.py b/src/vidxp/capabilities/scene/__init__.py
new file mode 100644
index 0000000..6f5e85b
--- /dev/null
+++ b/src/vidxp/capabilities/scene/__init__.py
@@ -0,0 +1 @@
+"""Scene capability implementation."""
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
new file mode 100644
index 0000000..02edf71
--- /dev/null
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.capabilities.contracts import (
+ CapabilityDefinition,
+ OperationDefinition,
+ RuntimeDependency,
+)
+from vidxp.capabilities.scene.models import get_clip_model
+from vidxp.capabilities.scene.operations import search_operation
+from vidxp.capabilities.schemas import SearchInput, SearchResult
+from vidxp.capabilities.visual import index_capabilities
+from vidxp.core.contracts import IndexConfig, VideoSource
+from vidxp.core.indexing_common import ProgressCallback
+
+
+DEPENDENCIES = (
+ RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
+ RuntimeDependency("CLIP", "clip-anytorch", "clip"),
+ RuntimeDependency("NumPy", "numpy", "numpy"),
+ RuntimeDependency("OpenCV", "opencv-python", "cv2"),
+ RuntimeDependency("Pillow", "Pillow", "PIL.Image"),
+ RuntimeDependency("PyTorch", "torch", "torch"),
+)
+
+
+def prepare_models(
+ config: IndexConfig,
+ _language: str | None,
+ progress: ProgressCallback | None,
+) -> tuple[str, ...]:
+ if progress is not None:
+ progress(
+ {
+ "state": "preparing",
+ "stage": "scene_model",
+ "message": (
+ f"Preparing scene model: CLIP {config.clip_model}"
+ ),
+ }
+ )
+ get_clip_model(config.clip_model, config.device)
+ return (config.clip_model,)
+
+
+def model_manifest(
+ config: IndexConfig,
+ _sources: tuple[VideoSource, ...],
+) -> Mapping[str, Any]:
+ return {"scene": config.clip_model}
+
+
+DEFINITION = CapabilityDefinition(
+ name="scene",
+ description="Index and search visual scenes.",
+ extra="scene",
+ collection_name="scene",
+ indexer=index_capabilities,
+ index_stage="visual_indexing",
+ dependencies=DEPENDENCIES,
+ prepare=prepare_models,
+ model_manifest=model_manifest,
+ operations={
+ "search": OperationDefinition(
+ input_model=SearchInput,
+ output_model=SearchResult,
+ handler=search_operation,
+ )
+ },
+)
diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py
new file mode 100644
index 0000000..77a08f6
--- /dev/null
+++ b/src/vidxp/capabilities/scene/indexing.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from vidxp.core.contracts import (
+ CancellationToken,
+ IndexConfig,
+ StorageRecord,
+ batched,
+ stable_source_id,
+)
+from vidxp.core.storage import IndexStorage
+
+
+@dataclass
+class SceneIndexState:
+ model: Any
+ preprocess: Any
+ stored_frames: int = 0
+
+
+def encode_scene_batch(samples, model, preprocess, device):
+ import torch
+ from PIL import Image
+
+ images = torch.stack(
+ [preprocess(Image.fromarray(sample.frame)) for sample in samples]
+ ).to(device)
+ with torch.no_grad():
+ features = model.encode_image(images)
+ features /= features.norm(dim=-1, keepdim=True)
+ return features.cpu().numpy().tolist()
+
+
+def scene_records(
+ samples,
+ vectors,
+ info,
+ config: IndexConfig,
+) -> list[StorageRecord]:
+ records = []
+ for sample, vector in zip(samples, vectors):
+ end = min(
+ info.duration,
+ sample.timestamp + config.frame_stride / info.fps,
+ )
+ if end <= sample.timestamp:
+ end = sample.timestamp + 1 / info.fps
+ source_id = stable_source_id(
+ config.run_id,
+ str(config.video_id),
+ "scene",
+ f"f{sample.frame_index:012d}",
+ )
+ records.append(
+ StorageRecord(
+ source_id=source_id,
+ embedding=vector,
+ metadata={
+ **config.record_identity("scene", source_id),
+ "frame_index": sample.frame_index,
+ "timestamp": sample.timestamp,
+ "start": sample.timestamp,
+ "end": end,
+ "fps": info.fps,
+ "duration": info.duration,
+ },
+ )
+ )
+ return records
+
+
+def process_scene_samples(
+ samples,
+ *,
+ state: SceneIndexState,
+ info,
+ config: IndexConfig,
+ storage: IndexStorage,
+ cancellation: CancellationToken,
+) -> None:
+ for group in batched(samples, config.scene_batch_size):
+ cancellation.raise_if_cancelled()
+ vectors = encode_scene_batch(
+ group,
+ state.model,
+ state.preprocess,
+ config.device,
+ )
+ state.stored_frames += storage.upsert(
+ "scene",
+ scene_records(group, vectors, info, config),
+ batch_size=config.storage_batch_size,
+ cancellation=cancellation,
+ )
diff --git a/src/vidxp/capabilities/scene/models.py b/src/vidxp/capabilities/scene/models.py
new file mode 100644
index 0000000..380f9f1
--- /dev/null
+++ b/src/vidxp/capabilities/scene/models.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from functools import lru_cache
+
+
+@lru_cache
+def get_clip_model(model_name: str, device: str):
+ import clip
+
+ return clip.load(model_name, device=device)
+
+
+def clear_model_cache() -> None:
+ get_clip_model.cache_clear()
diff --git a/src/vidxp/capabilities/scene/operations.py b/src/vidxp/capabilities/scene/operations.py
new file mode 100644
index 0000000..a2c8c4c
--- /dev/null
+++ b/src/vidxp/capabilities/scene/operations.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.capabilities.contracts import CapabilityContext
+from vidxp.capabilities.scene.models import get_clip_model
+from vidxp.capabilities.schemas import SearchInput, SearchResult
+from vidxp.capabilities.search import search_embeddings
+from vidxp.core.contracts import IndexConfig
+from vidxp.core.storage import IndexStorage
+
+
+REQUIRED_METADATA = frozenset(
+ {
+ "dataset",
+ "split",
+ "run_id",
+ "video_id",
+ "source_id",
+ "start",
+ "end",
+ "frame_index",
+ "timestamp",
+ "fps",
+ "duration",
+ "modality",
+ }
+)
+
+
+def scene_embedding(query: str, config: IndexConfig) -> list[float]:
+ import clip
+ import torch
+
+ model, _ = get_clip_model(config.clip_model, config.device)
+ tokens = clip.tokenize([query]).to(config.device)
+ with torch.no_grad():
+ features = model.encode_text(tokens)
+ features /= features.norm(dim=-1, keepdim=True)
+ return features.cpu().numpy().tolist()[0]
+
+
+def search_scene(
+ query: str,
+ *,
+ config: IndexConfig,
+ top_k: int = 10,
+ video_id: str | None = None,
+ query_id: str | None = None,
+ filters: Mapping[str, Any] | None = None,
+ storage: IndexStorage | None = None,
+) -> SearchResult:
+ cleaned = query.strip()
+ if not cleaned:
+ raise ValueError("Search query must not be empty.")
+ if top_k <= 0:
+ raise ValueError("top_k must be greater than zero.")
+ return search_embeddings(
+ cleaned,
+ "scene",
+ scene_embedding(cleaned, config),
+ config=config,
+ required_metadata=REQUIRED_METADATA,
+ top_k=top_k,
+ video_id=video_id,
+ query_id=query_id,
+ filters=filters,
+ storage=storage,
+ )
+
+
+def search_operation(
+ context: CapabilityContext,
+ request: SearchInput,
+) -> SearchResult:
+ config = context.require_config()
+ return search_scene(
+ request.query,
+ config=config,
+ top_k=request.top_k,
+ video_id=config.video_id,
+ )
diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt
new file mode 100644
index 0000000..eb0ec14
--- /dev/null
+++ b/src/vidxp/capabilities/scene/requirements.txt
@@ -0,0 +1,6 @@
+chromadb
+clip-anytorch==2.6.0
+numpy>=2.1,<3
+opencv-python
+Pillow>=7.0.0
+torch
diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py
new file mode 100644
index 0000000..b5d7363
--- /dev/null
+++ b/src/vidxp/capabilities/schemas.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from pydantic import Field
+
+from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput
+from vidxp.core.contracts import INDEX_SCHEMA_VERSION
+
+
+class SearchInput(CapabilityInput):
+ query: str = Field(min_length=1)
+ top_k: int = Field(default=10, gt=0)
+
+
+class SearchHit(CapabilityOutput):
+ rank: int = Field(gt=0)
+ video_id: str = Field(min_length=1)
+ start: float = Field(ge=0)
+ end: float = Field(gt=0)
+ score: float
+ raw_distance: float
+ modality: str = Field(min_length=1)
+ source_id: str = Field(min_length=1)
+ metadata: dict[str, Any] = Field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ return self.model_dump(mode="json")
+
+
+class SearchResult(CapabilityOutput):
+ query_id: str = Field(min_length=1)
+ query: str = Field(min_length=1)
+ modality: str = Field(min_length=1)
+ hits: tuple[SearchHit, ...] = ()
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema_version": INDEX_SCHEMA_VERSION,
+ **self.model_dump(mode="json"),
+ }
+
+ def to_prediction(self) -> dict[str, list[dict[str, Any]]]:
+ return {
+ self.query_id: [
+ hit.model_dump(mode="json") for hit in self.hits
+ ]
+ }
+
+
+class ActorClustersInput(CapabilityInput):
+ pass
+
+
+class ActorClusterSummary(CapabilityOutput):
+ cluster_id: str = Field(min_length=1)
+ video_id: str = Field(min_length=1)
+ detection_count: int = Field(ge=0)
+ first_timestamp: float = Field(ge=0)
+ last_timestamp: float = Field(ge=0)
+
+ def to_dict(self) -> dict[str, Any]:
+ return self.model_dump(mode="json")
+
+
+class ActorClustersOutput(CapabilityOutput):
+ clusters: tuple[ActorClusterSummary, ...] = ()
+
+
+class ActorDetectionsInput(CapabilityInput):
+ cluster_id: str = Field(min_length=1)
+
+
+class ActorDetection(CapabilityOutput):
+ detection_id: str = Field(min_length=1)
+ cluster_id: str = Field(min_length=1)
+ frame_index: int = Field(ge=0)
+ timestamp: float = Field(ge=0)
+ bbox: tuple[int, int, int, int]
+ dataset: str
+ split: str
+ run_id: str
+ video_id: str
+ modality: str
+ source_id: str
+
+
+class ActorDetectionsOutput(CapabilityOutput):
+ cluster_id: str = Field(min_length=1)
+ detections: tuple[ActorDetection, ...] = ()
+
+
+class ActorRenderInput(CapabilityInput):
+ cluster_id: str = Field(min_length=1)
+ input_path: Path
+ output_path: Path
+
+
+class ActorRenderResult(CapabilityOutput):
+ output_path: Path
+ detection_count: int = Field(gt=0)
diff --git a/src/vidxp/core/search.py b/src/vidxp/capabilities/search.py
similarity index 68%
rename from src/vidxp/core/search.py
rename to src/vidxp/capabilities/search.py
index ab9053e..4b8a6ff 100644
--- a/src/vidxp/core/search.py
+++ b/src/vidxp/capabilities/search.py
@@ -6,46 +6,14 @@
from pathlib import Path
from typing import Any, Mapping
+from vidxp.capabilities.schemas import SearchHit, SearchResult
from vidxp.core.contracts import (
IndexConfig,
IndexSchemaError,
- SearchHit,
- SearchResult,
)
-from vidxp.core.models import get_clip_model, get_embedder
from vidxp.core.storage import IndexStorage
-REQUIRED_METADATA = {
- "dialogue": {
- "dataset",
- "split",
- "run_id",
- "video_id",
- "source_id",
- "start",
- "end",
- "text",
- "phrase_id",
- "modality",
- },
- "scene": {
- "dataset",
- "split",
- "run_id",
- "video_id",
- "source_id",
- "start",
- "end",
- "frame_index",
- "timestamp",
- "fps",
- "duration",
- "modality",
- },
-}
-
-
def distance_to_score(raw_distance: float) -> float:
"""Map distance to an ordering score without claiming probability.
@@ -77,33 +45,11 @@ def stable_query_id(
return f"{modality}:{digest}"
-def _dialogue_embedding(query: str, config: IndexConfig) -> list[float]:
- encoder = get_embedder(config.sentence_model, config.device)
- encoded = encoder.encode(
- [query],
- convert_to_numpy=True,
- normalize_embeddings=config.normalize_dialogue_embeddings,
- )
- return encoded[0].tolist()
-
-
-def _scene_embedding(query: str, config: IndexConfig) -> list[float]:
- import clip
- import torch
-
- model, _ = get_clip_model(config.clip_model, config.device)
- tokens = clip.tokenize([query]).to(config.device)
- with torch.no_grad():
- features = model.encode_text(tokens)
- features /= features.norm(dim=-1, keepdim=True)
- return features.cpu().numpy().tolist()[0]
-
-
def _to_hits(
modality: str,
rows: list[dict[str, Any]],
+ required_metadata: frozenset[str],
) -> tuple[SearchHit, ...]:
- required = REQUIRED_METADATA[modality]
ordered = sorted(
rows,
key=lambda row: (row["raw_distance"], row["source_id"]),
@@ -111,7 +57,7 @@ def _to_hits(
hits = []
for rank, row in enumerate(ordered, start=1):
metadata = row["metadata"]
- missing = sorted(required - metadata.keys())
+ missing = sorted(required_metadata - metadata.keys())
if missing:
raise IndexSchemaError(
"The saved index predates the benchmark-ready schema and must "
@@ -141,11 +87,13 @@ def _to_hits(
return tuple(hits)
-def search(
+def search_embeddings(
query: str,
modality: str,
+ embedding: list[float],
*,
config: IndexConfig,
+ required_metadata: frozenset[str],
top_k: int = 10,
video_id: str | None = None,
query_id: str | None = None,
@@ -161,13 +109,6 @@ def search(
raise ValueError(
f"The {modality} modality is not present in this index run."
)
- if modality == "dialogue":
- embedding = _dialogue_embedding(query, config)
- elif modality == "scene":
- embedding = _scene_embedding(query, config)
- else:
- raise ValueError("Semantic search supports dialogue and scene modalities.")
-
owns_storage = storage is None
store = storage or IndexStorage(config)
try:
@@ -185,18 +126,10 @@ def search(
query_id=query_id or stable_query_id(query, modality, config),
query=query,
modality=modality,
- hits=_to_hits(modality, rows),
+ hits=_to_hits(modality, rows, required_metadata),
)
-def search_dialogue(query: str, **options: Any) -> SearchResult:
- return search(query, "dialogue", **options)
-
-
-def search_scene(query: str, **options: Any) -> SearchResult:
- return search(query, "scene", **options)
-
-
def serialize_predictions(
results: list[SearchResult],
path: str | Path | None = None,
diff --git a/src/vidxp/core/indexing_visual.py b/src/vidxp/capabilities/visual.py
similarity index 72%
rename from src/vidxp/core/indexing_visual.py
rename to src/vidxp/capabilities/visual.py
index 6238151..02876dd 100644
--- a/src/vidxp/core/indexing_visual.py
+++ b/src/vidxp/capabilities/visual.py
@@ -1,24 +1,25 @@
from __future__ import annotations
-from dataclasses import dataclass
from time import perf_counter
-from typing import Any, Mapping, Sequence
+from typing import Any, Sequence
+from vidxp.capabilities.contracts import CapabilityIndexResult
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
- StorageRecord,
VideoSource,
- batched,
- stable_source_id,
)
-from vidxp.core.indexing_actor import (
+from vidxp.capabilities.actor.indexing import (
ActorIndexState,
finalize_actor_index,
process_actor_samples,
)
from vidxp.core.indexing_common import ProgressCallback, report_progress
-from vidxp.core.models import get_clip_model
+from vidxp.capabilities.scene.indexing import (
+ SceneIndexState,
+ process_scene_samples,
+)
+from vidxp.capabilities.scene.models import get_clip_model
from vidxp.core.storage import IndexStorage
from vidxp.core.video import (
FrameSample,
@@ -28,70 +29,6 @@
)
-@dataclass
-class SceneIndexState:
- model: Any
- preprocess: Any
- stored_frames: int = 0
-
-
-@dataclass(frozen=True)
-class VisualIndexResult:
- summary: Mapping[str, Any]
- timings: Mapping[str, float]
-
-
-def _encode_scene_batch(samples, model, preprocess, device):
- import torch
- from PIL import Image
-
- images = torch.stack(
- [preprocess(Image.fromarray(sample.frame)) for sample in samples]
- ).to(device)
- with torch.no_grad():
- features = model.encode_image(images)
- features /= features.norm(dim=-1, keepdim=True)
- return features.cpu().numpy().tolist()
-
-
-def _scene_records(
- samples,
- vectors,
- info,
- config: IndexConfig,
-) -> list[StorageRecord]:
- records = []
- for sample, vector in zip(samples, vectors):
- end = min(
- info.duration,
- sample.timestamp + config.frame_stride / info.fps,
- )
- if end <= sample.timestamp:
- end = sample.timestamp + 1 / info.fps
- source_id = stable_source_id(
- config.run_id,
- str(config.video_id),
- "scene",
- f"f{sample.frame_index:012d}",
- )
- records.append(
- StorageRecord(
- source_id=source_id,
- embedding=vector,
- metadata={
- **config.record_identity("scene", source_id),
- "frame_index": sample.frame_index,
- "timestamp": sample.timestamp,
- "start": sample.timestamp,
- "end": end,
- "fps": info.fps,
- "duration": info.duration,
- },
- )
- )
- return records
-
-
def _rgb_samples(samples) -> list[FrameSample]:
import cv2
@@ -105,31 +42,6 @@ def _rgb_samples(samples) -> list[FrameSample]:
]
-def _process_scene_samples(
- samples,
- *,
- state: SceneIndexState,
- info,
- config: IndexConfig,
- storage: IndexStorage,
- cancellation: CancellationToken,
-) -> None:
- for group in batched(samples, config.scene_batch_size):
- cancellation.raise_if_cancelled()
- vectors = _encode_scene_batch(
- group,
- state.model,
- state.preprocess,
- config.device,
- )
- state.stored_frames += storage.upsert(
- "scene",
- _scene_records(group, vectors, info, config),
- batch_size=config.storage_batch_size,
- cancellation=cancellation,
- )
-
-
def _consume_visual_stream(
source: VideoSource,
*,
@@ -170,7 +82,7 @@ def _consume_visual_stream(
if scene_state is not None:
scene_started = perf_counter()
- _process_scene_samples(
+ process_scene_samples(
rgb_samples,
state=scene_state,
info=info,
@@ -240,7 +152,7 @@ def index_visuals(
cancellation: CancellationToken,
progress: ProgressCallback | None = None,
modalities: Sequence[str] | None = None,
-) -> VisualIndexResult:
+) -> CapabilityIndexResult:
if config.video_id is None:
raise ValueError("IndexConfig.video_id is required for indexing.")
if source.path is None:
@@ -311,7 +223,7 @@ def index_visuals(
timings["actor"] += perf_counter() - actor_started
timings["visual_total"] = perf_counter() - started
- return VisualIndexResult(
+ return CapabilityIndexResult(
summary=_visual_summary(
scene_state=scene_state,
actor_state=actor_state,
@@ -322,3 +234,24 @@ def index_visuals(
),
timings=timings,
)
+
+
+def index_capabilities(
+ source: VideoSource,
+ *,
+ config: IndexConfig,
+ storage: IndexStorage,
+ cancellation: CancellationToken,
+ progress: ProgressCallback | None = None,
+ modalities: Sequence[str] | None = None,
+) -> CapabilityIndexResult:
+ """Registry-facing wrapper that keeps the shared indexer patchable."""
+
+ return index_visuals(
+ source,
+ config=config,
+ storage=storage,
+ cancellation=cancellation,
+ progress=progress,
+ modalities=modalities,
+ )
diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py
index dd7d05d..d7a7cfd 100644
--- a/src/vidxp/cli.py
+++ b/src/vidxp/cli.py
@@ -11,13 +11,13 @@
from vidxp import __version__
from vidxp.application import VidXPService
from vidxp.benchmarks.cli import app as benchmark_app
-from vidxp.cli_commands.actors import app as actors_app
+from vidxp.capabilities.actor.results import ActorClusterNotFoundError
+from vidxp.capabilities.registry import CAPABILITIES
from vidxp.cli_commands.index import app as index_app
from vidxp.cli_commands.repositories import app as repositories_app
from vidxp.cli_commands.runtime import doctor, prepare, ui
from vidxp.cli_commands.search import app as search_app
from vidxp.cli_support import CLIState, OutputFormat
-from vidxp.core.actor_results import ActorClusterNotFoundError
from vidxp.core.contracts import IndexSchemaError
from vidxp.index_state import (
IndexingInProgressError,
@@ -28,13 +28,18 @@
app = typer.Typer(
no_args_is_help=True,
- help="Index and search video by dialogue, scene, and actor.",
+ help="Index and search video with installable capabilities.",
)
app.add_typer(index_app, name="index")
app.add_typer(search_app, name="search")
-app.add_typer(actors_app, name="actors")
app.add_typer(repositories_app, name="repositories")
app.add_typer(benchmark_app, name="benchmark")
+for _capability in CAPABILITIES.values():
+ if _capability.cli_factory is not None:
+ app.add_typer(
+ _capability.cli_factory(),
+ name=_capability.cli_name,
+ )
app.command()(doctor)
app.command()(prepare)
app.command()(ui)
diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py
index 32743c4..67dd818 100644
--- a/src/vidxp/cli_commands/index.py
+++ b/src/vidxp/cli_commands/index.py
@@ -8,7 +8,6 @@
from vidxp.cli_support import (
CLIState,
IndexProgress,
- Modality,
OutputFormat,
effective_output_format,
emit_json,
@@ -63,7 +62,7 @@ def index_create(
),
],
modalities: Annotated[
- list[Modality] | None,
+ list[str] | None,
typer.Option(
"--modality",
"-m",
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
index 309fdb7..77c416f 100644
--- a/src/vidxp/cli_commands/runtime.py
+++ b/src/vidxp/cli_commands/runtime.py
@@ -1,11 +1,11 @@
from __future__ import annotations
import os
-from pathlib import Path
from typing import Annotated
import typer
+from vidxp.capabilities.registry import CAPABILITIES, capability_names
from vidxp.cli_support import (
OutputFormat,
effective_output_format,
@@ -14,6 +14,13 @@
state_from_context,
)
+ALL_CAPABILITIES = ",".join(capability_names())
+PREPARABLE_CAPABILITIES = ",".join(
+ name
+ for name, capability in CAPABILITIES.items()
+ if capability.prepare is not None
+)
+
def doctor(
ctx: typer.Context,
@@ -24,7 +31,7 @@ def doctor(
"-m",
help="Only validate dependencies for these modalities.",
),
- ] = "dialogue,scene,actor",
+ ] = ALL_CAPABILITIES,
json_output: Annotated[
bool,
typer.Option("--json", help="Emit machine-readable JSON."),
@@ -69,7 +76,7 @@ def prepare(
"-m",
help="Only prepare models for these modalities.",
),
- ] = "dialogue,scene",
+ ] = PREPARABLE_CAPABILITIES,
language: Annotated[
str | None,
typer.Option(
diff --git a/src/vidxp/cli_commands/search.py b/src/vidxp/cli_commands/search.py
index 18491fe..d2c9f90 100644
--- a/src/vidxp/cli_commands/search.py
+++ b/src/vidxp/cli_commands/search.py
@@ -1,16 +1,17 @@
from __future__ import annotations
-from typing import Annotated
+from typing import Annotated, Callable
import typer
+from vidxp.capabilities.registry import CAPABILITIES
+from vidxp.capabilities.schemas import SearchResult
from vidxp.cli_support import (
CLIState,
effective_output_format,
emit_search,
state_from_context,
)
-from vidxp.core.contracts import SearchResult
app = typer.Typer(no_args_is_help=True, help="Search the active index.")
@@ -18,13 +19,13 @@
def run_search(
state: CLIState,
- modality: str,
+ capability: str,
query: str,
*,
top_k: int,
json_output: bool,
) -> SearchResult:
- result = state.service.search(modality, query, top_k=top_k)
+ result = state.service.search(capability, query, top_k=top_k)
emit_search(
result,
output_format=effective_output_format(state, json_output),
@@ -32,49 +33,40 @@ def run_search(
return result
-@app.command("dialogue")
-def search_dialogue_command(
- ctx: typer.Context,
- query: Annotated[str, typer.Argument(help="Dialogue text to find.")],
- top_k: Annotated[
- int,
- typer.Option("--top-k", "-k", min=1, help="Maximum ranked hits."),
- ] = 10,
- json_output: Annotated[
- bool,
- typer.Option("--json", help="Emit machine-readable JSON."),
- ] = False,
-) -> None:
- """Find ranked intervals matching spoken dialogue."""
-
- run_search(
- state_from_context(ctx),
- "dialogue",
- query,
- top_k=top_k,
- json_output=json_output,
- )
+def _search_command(capability: str) -> Callable:
+ def command(
+ ctx: typer.Context,
+ query: Annotated[
+ str,
+ typer.Argument(help="Text query to find."),
+ ],
+ top_k: Annotated[
+ int,
+ typer.Option(
+ "--top-k",
+ "-k",
+ min=1,
+ help="Maximum ranked hits.",
+ ),
+ ] = 10,
+ json_output: Annotated[
+ bool,
+ typer.Option("--json", help="Emit machine-readable JSON."),
+ ] = False,
+ ) -> None:
+ run_search(
+ state_from_context(ctx),
+ capability,
+ query,
+ top_k=top_k,
+ json_output=json_output,
+ )
+ command.__name__ = f"search_{capability}"
+ command.__doc__ = CAPABILITIES[capability].description
+ return command
-@app.command("scene")
-def search_scene_command(
- ctx: typer.Context,
- query: Annotated[str, typer.Argument(help="Visual description to find.")],
- top_k: Annotated[
- int,
- typer.Option("--top-k", "-k", min=1, help="Maximum ranked hits."),
- ] = 10,
- json_output: Annotated[
- bool,
- typer.Option("--json", help="Emit machine-readable JSON."),
- ] = False,
-) -> None:
- """Find ranked intervals matching a visual description."""
- run_search(
- state_from_context(ctx),
- "scene",
- query,
- top_k=top_k,
- json_output=json_output,
- )
+for _name, _capability in CAPABILITIES.items():
+ if "search" in _capability.operations:
+ app.command(_name)(_search_command(_name))
diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py
index 6d3106c..6607586 100644
--- a/src/vidxp/cli_support.py
+++ b/src/vidxp/cli_support.py
@@ -17,11 +17,11 @@
from rich.table import Table
from vidxp.application import VidXPService
-from vidxp.core.contracts import (
- SUPPORTED_MODALITIES,
- IndexConfig,
- SearchResult,
+from vidxp.capabilities.registry import (
+ index_capability_names,
+ validate_capability_names,
)
+from vidxp.capabilities.schemas import SearchResult
from vidxp.repositories import RepositoryConfig, RepositoryRegistry
@@ -30,12 +30,6 @@ class OutputFormat(str, Enum):
json = "json"
-class Modality(str, Enum):
- dialogue = "dialogue"
- scene = "scene"
- actor = "actor"
-
-
@dataclass
class CLIState:
service: VidXPService
@@ -171,14 +165,14 @@ def update(self, event: dict[str, Any]) -> None:
def selected_modalities(
- values: Iterable[Modality] | None,
+ values: Iterable[str] | None,
) -> tuple[str, ...]:
if values is None:
- return SUPPORTED_MODALITIES
- selected = tuple(dict.fromkeys(value.value for value in values))
- if not selected:
- raise typer.BadParameter("At least one modality is required.")
- return selected
+ return index_capability_names()
+ try:
+ return validate_capability_names(values)
+ except ValueError as exc:
+ raise typer.BadParameter(str(exc)) from exc
def parse_modalities(value: str) -> tuple[str, ...]:
@@ -188,7 +182,6 @@ def parse_modalities(value: str) -> tuple[str, ...]:
if item.strip()
)
try:
- IndexConfig(enabled_modalities=selected)
+ return validate_capability_names(selected)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from exc
- return selected
diff --git a/src/vidxp/core/__init__.py b/src/vidxp/core/__init__.py
index 192cb11..37bb927 100644
--- a/src/vidxp/core/__init__.py
+++ b/src/vidxp/core/__init__.py
@@ -1,17 +1,13 @@
-"""Benchmark-ready indexing and retrieval engine."""
+"""Shared runtime primitives used by VidXP capabilities."""
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
- SearchHit,
- SearchResult,
VideoSource,
)
__all__ = [
"CancellationToken",
"IndexConfig",
- "SearchHit",
- "SearchResult",
"VideoSource",
]
diff --git a/src/vidxp/core/contracts.py b/src/vidxp/core/contracts.py
index fe8fdfa..804e964 100644
--- a/src/vidxp/core/contracts.py
+++ b/src/vidxp/core/contracts.py
@@ -11,9 +11,8 @@
from urllib.parse import quote
-INDEX_SCHEMA_VERSION = 2
+INDEX_SCHEMA_VERSION = 3
MANIFEST_SCHEMA_VERSION = 1
-SUPPORTED_MODALITIES = ("dialogue", "scene", "actor")
class IndexCancelledError(RuntimeError):
@@ -73,7 +72,11 @@ class IndexConfig:
split: str = "local"
run_id: str = "default"
video_id: str | None = None
- enabled_modalities: tuple[str, ...] = SUPPORTED_MODALITIES
+ enabled_modalities: tuple[str, ...] = (
+ "dialogue",
+ "scene",
+ "actor",
+ )
frame_stride: int = 1
dialogue_words_per_phrase: int = 5
scene_batch_size: int = 32
@@ -90,12 +93,13 @@ class IndexConfig:
sentence_model: str = "sentence-transformers/all-MiniLM-L6-v2"
whisper_model: str = "large-v2"
clip_model: str = "ViT-B/32"
+ capability_options: Mapping[str, Mapping[str, Any]] = field(
+ default_factory=dict
+ )
output_root: str | Path = "benchmark_runs"
storage_directory: str | Path | None = None
- collection_names: tuple[str, str, str] = (
- "dialogue",
- "scene",
- "actor",
+ collection_names: Mapping[str, str] = field(
+ default_factory=dict
)
def __post_init__(self) -> None:
@@ -112,12 +116,44 @@ def __post_init__(self) -> None:
_require_identifier("video_id", self.video_id)
modalities = tuple(dict.fromkeys(self.enabled_modalities))
- unknown = sorted(set(modalities) - set(SUPPORTED_MODALITIES))
- if unknown:
- raise ValueError(f"Unsupported indexing modalities: {', '.join(unknown)}")
if not modalities:
raise ValueError("At least one indexing modality must be enabled.")
object.__setattr__(self, "enabled_modalities", modalities)
+ collection_names = {
+ str(capability): str(name)
+ for capability, name in self.collection_names.items()
+ }
+ if not collection_names:
+ collection_names = {
+ capability: capability
+ for capability in modalities
+ }
+ missing_collections = sorted(
+ set(modalities) - set(collection_names)
+ )
+ if missing_collections:
+ raise ValueError(
+ "Missing collection names for capabilities: "
+ + ", ".join(missing_collections)
+ )
+ object.__setattr__(self, "collection_names", collection_names)
+ capability_options = {
+ str(capability): dict(options)
+ for capability, options in self.capability_options.items()
+ }
+ unknown_options = sorted(
+ set(capability_options) - set(modalities)
+ )
+ if unknown_options:
+ raise ValueError(
+ "Options were supplied for disabled capabilities: "
+ + ", ".join(unknown_options)
+ )
+ object.__setattr__(
+ self,
+ "capability_options",
+ capability_options,
+ )
for label in (
"frame_stride",
@@ -138,14 +174,12 @@ def __post_init__(self) -> None:
raise ValueError(
"vector_distance must be one of: l2, cosine, ip."
)
- if len(self.collection_names) != len(SUPPORTED_MODALITIES):
- raise ValueError("collection_names must define dialogue, scene, and actor.")
collection_pattern = re.compile(
r"^[A-Za-z0-9][A-Za-z0-9._-]{1,510}[A-Za-z0-9]$"
)
invalid_names = [
name
- for name in self.collection_names
+ for name in self.collection_names.values()
if not collection_pattern.fullmatch(str(name))
]
if invalid_names:
@@ -154,7 +188,9 @@ def __post_init__(self) -> None:
"with an alphanumeric character, and contain only "
"letters, numbers, periods, underscores, or hyphens."
)
- if len(set(self.collection_names)) != len(self.collection_names):
+ if len(set(self.collection_names.values())) != len(
+ self.collection_names
+ ):
raise ValueError("collection_names must be distinct.")
@classmethod
@@ -163,11 +199,6 @@ def local(cls, **changes: Any) -> "IndexConfig":
defaults = {
"storage_directory": "chroma_data",
- "collection_names": (
- "voiceEmbeddings",
- "sceneEmbeddings",
- "actorCollection",
- ),
}
defaults.update(changes)
return cls(**defaults)
@@ -198,8 +229,10 @@ def record_identity(
) -> dict[str, str]:
if self.video_id is None:
raise ValueError("IndexConfig.video_id is required for record metadata.")
- if modality not in SUPPORTED_MODALITIES:
- raise ValueError(f"Unsupported indexing modality: {modality}")
+ if modality not in self.enabled_modalities:
+ raise ValueError(
+ f"Capability {modality!r} is not enabled for this run."
+ )
return {
"dataset": self.dataset,
"split": self.split,
@@ -209,10 +242,17 @@ def record_identity(
"source_id": source_id,
}
+ def options_for(self, capability: str) -> dict[str, Any]:
+ if capability not in self.enabled_modalities:
+ raise ValueError(
+ f"Capability {capability!r} is not enabled for this run."
+ )
+ return dict(self.capability_options.get(capability, {}))
+
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
payload["enabled_modalities"] = list(self.enabled_modalities)
- payload["collection_names"] = list(self.collection_names)
+ payload["collection_names"] = dict(self.collection_names)
payload["run_directory"] = str(self.run_directory)
payload["index_directory"] = str(self.index_directory)
return payload
@@ -262,52 +302,6 @@ class StorageRecord:
document: str | None = None
-@dataclass(frozen=True)
-class SearchHit:
- rank: int
- video_id: str
- start: float
- end: float
- score: float
- raw_distance: float
- modality: str
- source_id: str
- metadata: Mapping[str, Any] = field(default_factory=dict)
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "rank": self.rank,
- "video_id": self.video_id,
- "start": self.start,
- "end": self.end,
- "score": self.score,
- "raw_distance": self.raw_distance,
- "modality": self.modality,
- "source_id": self.source_id,
- "metadata": dict(self.metadata),
- }
-
-
-@dataclass(frozen=True)
-class SearchResult:
- query_id: str
- query: str
- modality: str
- hits: tuple[SearchHit, ...]
-
- def to_dict(self) -> dict[str, Any]:
- return {
- "schema_version": INDEX_SCHEMA_VERSION,
- "query_id": self.query_id,
- "query": self.query,
- "modality": self.modality,
- "hits": [hit.to_dict() for hit in self.hits],
- }
-
- def to_prediction(self) -> dict[str, list[dict[str, Any]]]:
- return {self.query_id: [hit.to_dict() for hit in self.hits]}
-
-
class CancellationToken:
"""A small cooperative cancellation token checked between work batches."""
diff --git a/src/vidxp/core/indexing.py b/src/vidxp/core/indexing.py
deleted file mode 100644
index 5ecd836..0000000
--- a/src/vidxp/core/indexing.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""Public indexing entry points.
-
-Implementation details live in the modality-specific modules. These wrappers
-remain for callers that index one visual modality at a time.
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-from vidxp.core.contracts import VideoSource
-from vidxp.core.indexing_dialogue import (
- DialoguePhrase,
- build_dialogue_phrases,
- index_dialogue,
- transcribe_video,
-)
-from vidxp.core.indexing_visual import VisualIndexResult, index_visuals
-
-
-def index_scenes(source: VideoSource, **options: Any) -> dict[str, Any]:
- return dict(index_visuals(source, modalities=("scene",), **options).summary)
-
-
-def index_actors(source: VideoSource, **options: Any) -> dict[str, Any]:
- return dict(index_visuals(source, modalities=("actor",), **options).summary)
-
-
-__all__ = [
- "DialoguePhrase",
- "VisualIndexResult",
- "build_dialogue_phrases",
- "index_actors",
- "index_dialogue",
- "index_scenes",
- "index_visuals",
- "transcribe_video",
-]
diff --git a/src/vidxp/core/manifest.py b/src/vidxp/core/manifest.py
index 3afccd8..d2a35f0 100644
--- a/src/vidxp/core/manifest.py
+++ b/src/vidxp/core/manifest.py
@@ -11,13 +11,16 @@
from pathlib import Path
from typing import Any, Mapping
+from vidxp.capabilities.registry import (
+ get_capability,
+ runtime_distributions,
+)
from vidxp.core.contracts import (
INDEX_SCHEMA_VERSION,
MANIFEST_SCHEMA_VERSION,
IndexConfig,
VideoSource,
)
-from vidxp.core.models import runtime_distributions
MANIFEST_FILE = "manifest.json"
@@ -212,19 +215,11 @@ def _model_manifest(
],
) -> dict[str, Any]:
models: dict[str, Any] = {"device": self.config.device}
- if "dialogue" in self.config.enabled_modalities:
- models["dialogue"] = self.config.sentence_model
- if any(source.transcript is None for _, source, _, _ in sources):
- models["transcription"] = self.config.whisper_model
- if "scene" in self.config.enabled_modalities:
- models["scene"] = self.config.clip_model
- if "actor" in self.config.enabled_modalities:
- models["actor"] = {
- "library": "face_recognition",
- "match_threshold": self.config.face_match_threshold,
- "num_jitters": self.config.face_num_jitters,
- "minimum_detections": self.config.actor_min_detections,
- }
+ source_values = tuple(source for _, source, _, _ in sources)
+ for name in self.config.enabled_modalities:
+ manifest = get_capability(name).model_manifest
+ if manifest is not None:
+ models.update(manifest(self.config, source_values))
return models
def initialize(
diff --git a/src/vidxp/core/models.py b/src/vidxp/core/models.py
deleted file mode 100644
index b275f6e..0000000
--- a/src/vidxp/core/models.py
+++ /dev/null
@@ -1,150 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from functools import lru_cache
-from importlib import import_module
-
-
-@dataclass(frozen=True)
-class RuntimeDependency:
- label: str
- module: str
- distribution: str
-
-
-INDEXING_DEPENDENCIES = {
- "dialogue": (
- RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
- RuntimeDependency(
- "Sentence Transformers",
- "sentence_transformers",
- "sentence-transformers",
- ),
- ),
- "scene": (
- RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
- RuntimeDependency("CLIP", "clip", "clip-anytorch"),
- RuntimeDependency("NumPy", "numpy", "numpy"),
- RuntimeDependency("OpenCV", "cv2", "opencv-python"),
- RuntimeDependency("Pillow", "PIL.Image", "Pillow"),
- RuntimeDependency("PyTorch", "torch", "torch"),
- ),
- "actor": (
- RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
- RuntimeDependency(
- "face recognition",
- "face_recognition",
- "face-recognition",
- ),
- RuntimeDependency("NumPy", "numpy", "numpy"),
- RuntimeDependency("OpenCV", "cv2", "opencv-python"),
- ),
- "transcription": (
- RuntimeDependency("MoviePy", "moviepy.editor", "moviepy"),
- RuntimeDependency("WhisperX", "whisperx", "whisperx"),
- ),
-}
-
-PROVENANCE_ONLY_DISTRIBUTIONS = (
- "dlib",
- "face-recognition-models",
- "filelock",
-)
-
-
-@lru_cache
-def get_embedder(model_name: str, device: str):
- from sentence_transformers import SentenceTransformer
-
- return SentenceTransformer(model_name, device=device)
-
-
-@lru_cache
-def get_clip_model(model_name: str, device: str):
- import clip
-
- return clip.load(model_name, device=device)
-
-
-@lru_cache
-def get_whisper_model(model_name: str, device: str):
- import whisperx
-
- return whisperx.load_model(model_name, device, compute_type="float32")
-
-
-@lru_cache
-def get_alignment_model(language: str, device: str):
- import whisperx
-
- return whisperx.load_align_model(language_code=language, device=device)
-
-
-def selected_dependencies(
- modalities: tuple[str, ...],
- *,
- needs_transcription: bool,
-) -> tuple[RuntimeDependency, ...]:
- dependencies = [
- dependency
- for modality in modalities
- for dependency in INDEXING_DEPENDENCIES[modality]
- ]
- if needs_transcription:
- dependencies.extend(INDEXING_DEPENDENCIES["transcription"])
- return tuple(
- {
- dependency.module: dependency
- for dependency in dependencies
- }.values()
- )
-
-
-def runtime_distributions() -> tuple[str, ...]:
- distributions = {
- dependency.distribution
- for group in INDEXING_DEPENDENCIES.values()
- for dependency in group
- }
- distributions.update(PROVENANCE_ONLY_DISTRIBUTIONS)
- return tuple(sorted(distributions, key=str.lower))
-
-
-def clear_model_cache() -> None:
- get_embedder.cache_clear()
- get_clip_model.cache_clear()
- get_whisper_model.cache_clear()
- get_alignment_model.cache_clear()
-
-
-def dependency_failures(
- modalities: tuple[str, ...],
- *,
- needs_transcription: bool,
-) -> list[tuple[str, str]]:
- failures = []
- for dependency in selected_dependencies(
- modalities,
- needs_transcription=needs_transcription,
- ):
- try:
- import_module(dependency.module)
- except Exception as exc:
- failures.append(
- (dependency.label, f"{type(exc).__name__}: {exc}")
- )
- return failures
-
-
-def require_dependencies(
- modalities: tuple[str, ...],
- *,
- needs_transcription: bool,
-) -> None:
- failures = dependency_failures(
- modalities,
- needs_transcription=needs_transcription,
- )
- if failures:
- details = "; ".join(f"{label}: {error}" for label, error in failures)
- raise RuntimeError(f"Indexing dependencies are unavailable: {details}")
diff --git a/src/vidxp/core/runner.py b/src/vidxp/core/runner.py
index 552e30b..896652a 100644
--- a/src/vidxp/core/runner.py
+++ b/src/vidxp/core/runner.py
@@ -7,6 +7,10 @@
from filelock import FileLock, Timeout
+from vidxp.capabilities.registry import (
+ get_capability,
+ require_dependencies,
+)
from vidxp.core.contracts import (
INDEX_SCHEMA_VERSION,
CancellationToken,
@@ -15,15 +19,12 @@
IndexSchemaError,
VideoSource,
)
-from vidxp.core.indexing_dialogue import index_dialogue
-from vidxp.core.indexing_visual import index_visuals
from vidxp.core.manifest import (
ManifestStore,
combined_checksum,
source_checksum,
source_checksums,
)
-from vidxp.core.models import require_dependencies
from vidxp.core.storage import IndexStorage
from vidxp.index_state import (
IndexingInProgressError,
@@ -106,7 +107,7 @@ def local_config_from_status(
if "enabled_modalities" in stored:
stored["enabled_modalities"] = tuple(stored["enabled_modalities"])
if "collection_names" in stored:
- stored["collection_names"] = tuple(stored["collection_names"])
+ stored["collection_names"] = dict(stored["collection_names"])
return IndexConfig(**stored)
@@ -135,8 +136,8 @@ def _report(
callback(event)
-def _run_modality(
- modality: str,
+def _run_capability_group(
+ names: tuple[str, ...],
source: VideoSource,
config: IndexConfig,
storage: IndexStorage,
@@ -144,6 +145,21 @@ def _run_modality(
cancellation: CancellationToken,
progress_callback: ProgressCallback | None,
) -> dict[str, Any]:
+ definitions = tuple(get_capability(name) for name in names)
+ indexer = definitions[0].indexer
+ index_stage = definitions[0].index_stage
+ if indexer is None or index_stage is None:
+ raise ValueError(
+ f"Capability {names[0]!r} does not support indexing."
+ )
+ if any(definition.indexer is not indexer for definition in definitions):
+ raise RuntimeError("Grouped capabilities must share one indexer.")
+ if any(
+ definition.index_stage != index_stage
+ for definition in definitions
+ ):
+ raise RuntimeError("Grouped capabilities must share one index stage.")
+
started = perf_counter()
active_substage: str | None = None
substage_started = started
@@ -169,94 +185,83 @@ def stage_progress(event: dict[str, Any]) -> None:
)
try:
- if modality != "dialogue":
- raise ValueError(f"Unsupported non-visual modality: {modality}")
- stats = index_dialogue(
+ result = indexer(
source,
config=config,
storage=storage,
cancellation=cancellation,
progress=stage_progress,
+ modalities=names,
)
except BaseException:
- if active_substage is not None:
+ if active_substage is not None and active_substage != index_stage:
manifest.record_stage(
str(config.video_id),
active_substage,
perf_counter() - substage_started,
{"state": "incomplete"},
)
+ manifest.record_stage(
+ str(config.video_id),
+ index_stage,
+ perf_counter() - started,
+ {"state": "incomplete", "capabilities": list(names)},
+ )
raise
- if active_substage is not None:
+ if active_substage is not None and active_substage != index_stage:
manifest.record_stage(
str(config.video_id),
active_substage,
perf_counter() - substage_started,
{},
)
- manifest.record_stage(
- str(config.video_id),
- modality,
- perf_counter() - started,
- stats,
- )
- return stats
-
-
-def _run_visual_modalities(
- modalities: tuple[str, ...],
- source: VideoSource,
- config: IndexConfig,
- storage: IndexStorage,
- manifest: ManifestStore,
- cancellation: CancellationToken,
- progress_callback: ProgressCallback | None,
-) -> dict[str, Any]:
- started = perf_counter()
-
- def report(event: dict[str, Any]) -> None:
- _report(
- progress_callback,
- {**event, "video_id": config.video_id},
- )
-
- try:
- result = index_visuals(
- source,
- config=config,
- storage=storage,
- cancellation=cancellation,
- progress=report,
- modalities=modalities,
- )
- except BaseException:
+ for stage_name, duration in result.timings.items():
+ if stage_name.endswith("_total"):
+ continue
manifest.record_stage(
str(config.video_id),
- "visual_indexing",
- perf_counter() - started,
- {"state": "incomplete", "modalities": list(modalities)},
+ stage_name,
+ float(duration),
+ {},
)
- raise
-
- stats = dict(result.summary)
- timings = dict(result.timings)
- for stage_name in ("frame_stream", "scene", "actor"):
- if stage_name in timings and (
- stage_name == "frame_stream" or stage_name in modalities
- ):
- manifest.record_stage(
- str(config.video_id),
- stage_name,
- float(timings[stage_name]),
- {},
- )
manifest.record_stage(
str(config.video_id),
- "visual_indexing",
- float(timings.get("visual_total", perf_counter() - started)),
- stats,
+ index_stage,
+ float(
+ result.timings.get(
+ f"{index_stage.removesuffix('_indexing')}_total",
+ result.timings.get(
+ "visual_total",
+ perf_counter() - started,
+ ),
+ )
+ ),
+ result.summary,
)
- return stats
+ return dict(result.summary)
+
+
+def _index_groups(names: tuple[str, ...]) -> tuple[tuple[str, ...], ...]:
+ groups: list[list[str]] = []
+ handlers = []
+ for name in names:
+ handler = get_capability(name).indexer
+ if handler is None:
+ raise ValueError(
+ f"Capability {name!r} does not support indexing."
+ )
+ try:
+ group_index = next(
+ index
+ for index, existing in enumerate(handlers)
+ if existing is handler
+ )
+ except StopIteration:
+ handlers.append(handler)
+ groups.append([name])
+ else:
+ groups[group_index].append(name)
+ return tuple(tuple(group) for group in groups)
def _run_enabled_modalities(
@@ -269,36 +274,12 @@ def _run_enabled_modalities(
set_stage: Callable[[str], None],
) -> dict[str, Any]:
summary: dict[str, Any] = {}
- visual_modalities = tuple(
- modality
- for modality in config.enabled_modalities
- if modality in {"scene", "actor"}
- )
- visual_complete = False
- for modality in config.enabled_modalities:
+ for names in _index_groups(config.enabled_modalities):
cancellation.raise_if_cancelled()
- if modality in visual_modalities:
- if visual_complete:
- continue
- set_stage("visual_indexing")
- summary.update(
- _run_visual_modalities(
- visual_modalities,
- source,
- config,
- storage,
- manifest,
- cancellation,
- progress_callback,
- )
- )
- visual_complete = True
- continue
-
- set_stage(f"{modality}_indexing")
+ set_stage(get_capability(names[0]).index_stage)
summary.update(
- _run_modality(
- modality,
+ _run_capability_group(
+ names,
source,
config,
storage,
@@ -310,19 +291,6 @@ def _run_enabled_modalities(
return summary
-def _normalize_frame_summary(summary: dict[str, Any]) -> None:
- scene_frames = int(summary.get("scene_frames", 0))
- actor_frames = int(summary.get("actor_frames", 0))
- summary.setdefault("sampled_frames", max(scene_frames, actor_frames))
- summary.setdefault("processed_frames", int(summary["sampled_frames"]))
- summary.setdefault("frame_operations", scene_frames + actor_frames)
- summary.setdefault(
- "source_frames_advanced",
- int(summary.get("decoded_frames", 0))
- + int(summary.get("actor_decoded_frames", 0)),
- )
-
-
def _process_video(
video_id: str,
source: VideoSource,
@@ -351,10 +319,7 @@ def set_stage(value: str) -> None:
cancellation.raise_if_cancelled()
require_dependencies(
config.enabled_modalities,
- needs_transcription=(
- "dialogue" in config.enabled_modalities
- and source.transcript is None
- ),
+ source=source,
)
stage = "preparing_storage"
for modality in config.enabled_modalities:
@@ -371,7 +336,6 @@ def set_stage(value: str) -> None:
set_stage,
)
)
- _normalize_frame_summary(summary)
manifest.complete_video(
video_id,
checksum=checksum,
diff --git a/src/vidxp/core/storage.py b/src/vidxp/core/storage.py
index e176104..50ac3db 100644
--- a/src/vidxp/core/storage.py
+++ b/src/vidxp/core/storage.py
@@ -48,7 +48,7 @@ def __init__(self, config: IndexConfig):
self.path = config.index_directory
self.path.mkdir(parents=True, exist_ok=True)
self.client = _client_for_path(str(self.path.resolve()))
- self._names = dict(zip(("dialogue", "scene", "actor"), config.collection_names))
+ self._names = dict(config.collection_names)
self._collections: dict[str, Any] = {}
def close(self) -> None:
@@ -104,12 +104,18 @@ def delete_video(self, modality: str, video_id: str) -> None:
where=metadata_filter(self.config, video_id=video_id),
)
- def delete_actor_cluster(self, video_id: str, cluster_id: str) -> None:
- self.collection("actor").delete(
+ def delete_records(
+ self,
+ modality: str,
+ *,
+ video_id: str,
+ filters: Mapping[str, Any] | None = None,
+ ) -> None:
+ self.collection(modality).delete(
where=metadata_filter(
self.config,
video_id=video_id,
- extra={"cluster_id": cluster_id},
+ extra=filters,
),
)
@@ -179,55 +185,26 @@ def query(
for source_id, metadata, distance in zip(ids, metadatas, distances)
]
- def actor_detections(
+ def records(
self,
+ modality: str,
*,
- video_id: str,
- cluster_id: str,
+ video_id: str | None = None,
+ filters: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
- result = self.collection("actor").get(
+ result = self.collection(modality).get(
where=metadata_filter(
self.config,
video_id=video_id,
- extra={"cluster_id": cluster_id},
+ extra=filters,
),
include=["metadatas"],
)
- records = [
+ return [
dict(metadata)
for metadata in (result.get("metadatas") or [])
if metadata
]
- return sorted(
- records,
- key=lambda item: (int(item["frame_index"]), item["detection_id"]),
- )
-
- def actor_cluster_records(
- self,
- *,
- video_id: str,
- ) -> list[dict[str, Any]]:
- result = self.collection("actor").get(
- where=metadata_filter(
- self.config,
- video_id=video_id,
- ),
- include=["metadatas"],
- )
- records = [
- dict(metadata)
- for metadata in (result.get("metadatas") or [])
- if metadata and metadata.get("cluster_id") is not None
- ]
- return sorted(
- records,
- key=lambda item: (
- str(item["cluster_id"]),
- int(item["frame_index"]),
- str(item["detection_id"]),
- ),
- )
def size_bytes(self) -> int:
return directory_size(self.path)
diff --git a/src/vidxp/requirements/frontend.txt b/src/vidxp/requirements/frontend.txt
new file mode 100644
index 0000000..c8038a5
--- /dev/null
+++ b/src/vidxp/requirements/frontend.txt
@@ -0,0 +1 @@
+streamlit>=1.37
diff --git a/tests/test_actor_results.py b/tests/test_actor_results.py
index 36d7027..d916368 100644
--- a/tests/test_actor_results.py
+++ b/tests/test_actor_results.py
@@ -3,7 +3,7 @@
from pathlib import Path
from unittest.mock import Mock, patch
-from vidxp.core.actor_results import (
+from vidxp.capabilities.actor.results import (
ActorClusterNotFoundError,
actor_clusters,
actor_detections,
@@ -18,13 +18,22 @@ def setUp(self):
def test_actor_detection_metadata_is_converted_once(self):
storage = Mock()
- storage.actor_detections.return_value = [
+ storage.records.return_value = [
{
+ "detection_id": "d2",
+ "cluster_id": "3",
"frame_index": 2,
+ "timestamp": 0.2,
"bbox_top": 1,
"bbox_right": 4,
"bbox_bottom": 5,
"bbox_left": 0,
+ "dataset": "local",
+ "split": "local",
+ "run_id": "default",
+ "video_id": "video-1",
+ "modality": "actor",
+ "source_id": "actor:d2",
}
]
@@ -34,16 +43,17 @@ def test_actor_detection_metadata_is_converted_once(self):
storage=storage,
)
- self.assertEqual(detections[0]["bbox"], (1, 4, 5, 0))
- storage.actor_detections.assert_called_once_with(
+ self.assertEqual(detections[0].bbox, (1, 4, 5, 0))
+ storage.records.assert_called_once_with(
+ "actor",
video_id="video-1",
- cluster_id="3",
+ filters={"cluster_id": "3"},
)
storage.close.assert_not_called()
def test_actor_clusters_summarize_detection_ranges(self):
storage = Mock()
- storage.actor_cluster_records.return_value = [
+ storage.records.return_value = [
{"cluster_id": "1", "timestamp": 4.5},
{"cluster_id": "1", "timestamp": 1.5},
{"cluster_id": "2", "timestamp": 9.0},
@@ -61,7 +71,7 @@ def test_actor_clusters_summarize_detection_ranges(self):
def test_render_actor_result_rejects_an_empty_cluster(self):
storage = Mock()
- storage.actor_detections.return_value = []
+ storage.records.return_value = []
with self.assertRaises(ActorClusterNotFoundError):
render_actor_result(
@@ -74,19 +84,28 @@ def test_render_actor_result_rejects_an_empty_cluster(self):
def test_render_actor_result_returns_output_details(self):
storage = Mock()
- storage.actor_detections.return_value = [
+ storage.records.return_value = [
{
+ "detection_id": "d2",
+ "cluster_id": "3",
"frame_index": 2,
+ "timestamp": 0.2,
"bbox_top": 1,
"bbox_right": 4,
"bbox_bottom": 5,
"bbox_left": 0,
+ "dataset": "local",
+ "split": "local",
+ "run_id": "default",
+ "video_id": "video-1",
+ "modality": "actor",
+ "source_id": "actor:d2",
}
]
with TemporaryDirectory() as directory:
output = Path(directory) / "actor.mp4"
with patch(
- "vidxp.core.actor_results.render_actor_video"
+ "vidxp.capabilities.actor.results.render_actor_video"
) as renderer:
result = render_actor_result(
self.config,
diff --git a/tests/test_application.py b/tests/test_application.py
index 07d16eb..eaa8f11 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -4,10 +4,54 @@
from unittest.mock import Mock, patch
from vidxp.application import VidXPService
+from vidxp.capabilities.contracts import (
+ CapabilityDefinition,
+ OperationDefinition,
+)
+from vidxp.capabilities.schemas import SearchInput, SearchResult
from vidxp.core.contracts import IndexConfig
class ApplicationServiceTests(unittest.TestCase):
+ def test_execute_supports_validated_operation_without_an_index(self):
+ capability = CapabilityDefinition(
+ name="export",
+ description="Export results.",
+ extra="export",
+ operations={
+ "run": OperationDefinition(
+ input_model=SearchInput,
+ output_model=SearchResult,
+ handler=lambda context, request: {
+ "query_id": "export:1",
+ "query": request.query,
+ "modality": "export",
+ "hits": (),
+ },
+ requires_index=False,
+ )
+ },
+ )
+ service = VidXPService()
+ with (
+ patch(
+ "vidxp.application.get_capability",
+ return_value=capability,
+ ),
+ patch.object(
+ service,
+ "active_config",
+ side_effect=AssertionError("index should not be loaded"),
+ ),
+ ):
+ result = service.execute(
+ "export",
+ "run",
+ {"query": "result bundle"},
+ )
+
+ self.assertEqual(result.query, "result bundle")
+
def test_missing_index_has_a_stable_status_contract(self):
service = VidXPService("missing-index")
with patch(
@@ -48,30 +92,23 @@ def test_active_config_uses_selected_directory_and_device(self):
def test_search_is_a_thin_adapter_over_the_core(self):
service = VidXPService()
- config = IndexConfig.local(
- video_id="video-1",
- enabled_modalities=("scene",),
+ expected = SearchResult(
+ query_id="scene:1",
+ query="yellow taxi",
+ modality="scene",
)
- expected = Mock()
- with (
- patch.object(
- service,
- "active_config",
- return_value=(config, {}),
- ),
- patch(
- "vidxp.application.search_scene",
- return_value=expected,
- ) as search,
- ):
+ with patch.object(
+ service,
+ "execute",
+ return_value=expected,
+ ) as execute:
result = service.search("scene", "yellow taxi", top_k=7)
self.assertIs(result, expected)
- search.assert_called_once_with(
- "yellow taxi",
- config=config,
- top_k=7,
- video_id="video-1",
+ execute.assert_called_once_with(
+ "scene",
+ "search",
+ {"query": "yellow taxi", "top_k": 7},
)
def test_create_index_centralizes_storage_and_runtime_configuration(self):
@@ -95,14 +132,10 @@ def test_create_index_centralizes_storage_and_runtime_configuration(self):
def test_dependency_checks_return_a_transport_neutral_contract(self):
service = VidXPService()
- with (
- patch(
- "vidxp.application.dependency_failures",
- return_value=[("CLIP", "missing")],
- ),
- patch(
- "vidxp.application.ffmpeg_binary",
- return_value="ffmpeg",
+ with patch(
+ "vidxp.application.dependency_checks",
+ return_value=(
+ {"name": "CLIP", "ok": False, "error": "missing"},
),
):
result = service.check_dependencies(("scene",))
@@ -116,19 +149,35 @@ def test_dependency_checks_return_a_transport_neutral_contract(self):
def test_model_preparation_reports_progress_without_cli_dependencies(self):
service = VidXPService(device="cuda")
events = []
+ prepare = Mock(
+ side_effect=lambda config, _language, progress: (
+ progress(
+ {
+ "state": "preparing",
+ "stage": "scene_model",
+ "message": "Preparing scene model",
+ }
+ ),
+ (config.clip_model,),
+ )[1]
+ )
+ capability = Mock(prepare=prepare)
with (
patch(
- "vidxp.application.dependency_failures",
- return_value=[],
+ "vidxp.application.dependency_checks",
+ return_value=(),
+ ),
+ patch(
+ "vidxp.application.get_capability",
+ return_value=capability,
),
- patch("vidxp.application.get_clip_model") as load_clip,
):
result = service.prepare_models(
("scene",),
progress_callback=events.append,
)
- load_clip.assert_called_once_with("ViT-B/32", "cuda")
+ prepare.assert_called_once()
self.assertEqual(result["device"], "cuda")
self.assertEqual(events[0]["stage"], "scene_model")
diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py
index 03e0cb1..055e3cb 100644
--- a/tests/test_benchmarks.py
+++ b/tests/test_benchmarks.py
@@ -23,7 +23,7 @@
select_ground_truth,
validate_predictions as validate_hirest_predictions,
)
-from vidxp.core.contracts import SearchHit
+from vidxp.capabilities.schemas import SearchHit
def scene_hit(chunk, score):
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
new file mode 100644
index 0000000..3b7605d
--- /dev/null
+++ b/tests/test_capabilities.py
@@ -0,0 +1,121 @@
+import unittest
+
+from pydantic import BaseModel, ValidationError
+
+from vidxp.capabilities.contracts import (
+ CapabilityContext,
+ CapabilityDefinition,
+ CapabilityInput,
+ CapabilityOutput,
+ OperationDefinition,
+)
+from vidxp.capabilities.registry import (
+ CAPABILITIES,
+ capability_names,
+ collection_names,
+ index_capability_names,
+)
+from vidxp.core.contracts import IndexConfig
+from vidxp.core.runner import _index_groups
+
+
+class ExampleInput(CapabilityInput):
+ value: int
+
+
+class ExampleOutput(CapabilityOutput):
+ doubled: int
+
+
+class CapabilityTests(unittest.TestCase):
+ def test_registry_is_explicit_and_drives_index_collections(self):
+ self.assertEqual(
+ capability_names(),
+ ("dialogue", "scene", "actor"),
+ )
+ self.assertEqual(
+ index_capability_names(),
+ capability_names(),
+ )
+ self.assertEqual(
+ collection_names(),
+ {
+ "dialogue": "dialogue",
+ "scene": "scene",
+ "actor": "actor",
+ },
+ )
+
+ def test_registered_operations_use_pydantic_contracts(self):
+ for capability in CAPABILITIES.values():
+ for operation in capability.operations.values():
+ self.assertTrue(
+ issubclass(operation.input_model, BaseModel)
+ )
+ self.assertTrue(
+ issubclass(operation.output_model, BaseModel)
+ )
+
+ def test_operation_validates_both_input_and_output(self):
+ operation = OperationDefinition(
+ input_model=ExampleInput,
+ output_model=ExampleOutput,
+ handler=lambda _context, request: {
+ "doubled": request.value * 2
+ },
+ requires_index=False,
+ )
+
+ result = operation.invoke(
+ CapabilityContext(config=None),
+ {"value": 3},
+ )
+
+ self.assertEqual(result, ExampleOutput(doubled=6))
+ with self.assertRaises(ValidationError):
+ operation.invoke(
+ CapabilityContext(config=None),
+ {"value": 3, "unexpected": True},
+ )
+
+ def test_operation_only_capability_needs_no_dummy_indexer(self):
+ capability = CapabilityDefinition(
+ name="export",
+ description="Export results.",
+ extra="export",
+ operations={
+ "run": OperationDefinition(
+ input_model=ExampleInput,
+ output_model=ExampleOutput,
+ handler=lambda _context, request: {
+ "doubled": request.value * 2
+ },
+ requires_index=False,
+ )
+ },
+ )
+
+ self.assertIsNone(capability.indexer)
+ self.assertIsNone(capability.collection_name)
+
+ def test_shared_visual_handler_is_grouped_without_name_switches(self):
+ self.assertEqual(
+ _index_groups(("dialogue", "scene", "actor")),
+ (("dialogue",), ("scene", "actor")),
+ )
+
+ def test_capability_options_do_not_require_core_config_fields(self):
+ config = IndexConfig(
+ enabled_modalities=("ocr",),
+ collection_names={"ocr": "ocr"},
+ capability_options={"ocr": {"language": "en"}},
+ )
+
+ self.assertEqual(
+ config.options_for("ocr"),
+ {"language": "en"},
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1288ef7..1612739 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -11,8 +11,13 @@
from typer.testing import CliRunner
from vidxp import cli
-from vidxp.core.actor_results import ActorClusterSummary, ActorRenderResult
-from vidxp.core.contracts import SearchHit, SearchResult
+from vidxp.capabilities.schemas import (
+ ActorClusterSummary,
+ ActorDetection,
+ ActorRenderResult,
+ SearchHit,
+ SearchResult,
+)
from vidxp.index_state import IndexNotReadyError
@@ -291,18 +296,32 @@ def test_ui_receives_the_selected_service_configuration(self):
)
def test_actor_commands_expose_clusters_detections_and_rendering(self):
- cluster = ActorClusterSummary("3", "video-1", 4, 1.0, 8.0)
+ cluster = ActorClusterSummary(
+ cluster_id="3",
+ video_id="video-1",
+ detection_count=4,
+ first_timestamp=1.0,
+ last_timestamp=8.0,
+ )
self.service.actor_clusters.return_value = (cluster,)
self.service.actor_detections.return_value = [
- {
- "frame_index": 2,
- "timestamp": 1.5,
- "detection_id": "d2",
- }
+ ActorDetection(
+ detection_id="d2",
+ cluster_id="3",
+ frame_index=2,
+ timestamp=1.5,
+ bbox=(1, 2, 3, 0),
+ dataset="local",
+ split="local",
+ run_id="default",
+ video_id="video-1",
+ modality="actor",
+ source_id="actor:d2",
+ )
]
self.service.render_actor.return_value = ActorRenderResult(
- Path("actor.mp4"),
- 4,
+ output_path=Path("actor.mp4"),
+ detection_count=4,
)
listed = self.invoke(["actors", "list", "--json"])
diff --git a/tests/test_contracts.py b/tests/test_contracts.py
index 414d93a..d52b7af 100644
--- a/tests/test_contracts.py
+++ b/tests/test_contracts.py
@@ -6,11 +6,10 @@
CancellationToken,
IndexCancelledError,
IndexConfig,
- SearchHit,
- SearchResult,
VideoSource,
stable_source_id,
)
+from vidxp.capabilities.schemas import SearchHit, SearchResult
class ContractTests(unittest.TestCase):
@@ -74,8 +73,11 @@ def test_path_objects_are_normalized_for_manifest_serialization(self):
def test_invalid_config_is_rejected(self):
with self.assertRaisesRegex(ValueError, "At least one"):
IndexConfig(enabled_modalities=())
- with self.assertRaisesRegex(ValueError, "Unsupported"):
- IndexConfig(enabled_modalities=("ocr",))
+ with self.assertRaisesRegex(ValueError, "Missing collection names"):
+ IndexConfig(
+ enabled_modalities=("ocr",),
+ collection_names={"scene": "scene"},
+ )
with self.assertRaisesRegex(ValueError, "frame_stride"):
IndexConfig(frame_stride=0)
with self.assertRaisesRegex(ValueError, "cannot be"):
@@ -83,9 +85,21 @@ def test_invalid_config_is_rejected(self):
with self.assertRaisesRegex(ValueError, "reserved on Windows"):
IndexConfig(dataset="CON.txt").run_directory
with self.assertRaisesRegex(ValueError, "distinct"):
- IndexConfig(collection_names=("shared", "shared", "actor"))
+ IndexConfig(
+ collection_names={
+ "dialogue": "shared",
+ "scene": "shared",
+ "actor": "actor",
+ }
+ )
with self.assertRaisesRegex(ValueError, "3-512"):
- IndexConfig(collection_names=("a", "scene", "actor"))
+ IndexConfig(
+ collection_names={
+ "dialogue": "a",
+ "scene": "scene",
+ "actor": "actor",
+ }
+ )
with self.assertRaisesRegex(ValueError, "vector_distance"):
IndexConfig(vector_distance="unknown")
with self.assertRaisesRegex(ValueError, "SHA-256"):
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 8a9d7a7..4599a22 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -6,7 +6,7 @@
from streamlit.testing.v1 import AppTest
from vidxp import frontend
-from vidxp.core.contracts import SearchHit, SearchResult
+from vidxp.capabilities.schemas import SearchHit, SearchResult
from vidxp.index_state import IndexNotReadyError
@@ -50,7 +50,7 @@ def frontend_harness(video_path, actor_output_path):
from unittest.mock import Mock, patch
from vidxp import frontend
- from vidxp.core.contracts import SearchHit, SearchResult
+ from vidxp.capabilities.schemas import SearchHit, SearchResult
video_path = Path(video_path)
actor_output_path = Path(actor_output_path)
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index 5bd3913..7a444e1 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -5,15 +5,13 @@
import numpy as np
-from vidxp.core import indexing_visual as indexing_visual_module
-from vidxp.core.contracts import CancellationToken, IndexConfig, VideoSource
-from vidxp.core.indexing import (
+from vidxp.capabilities import visual as indexing_visual_module
+from vidxp.capabilities.dialogue.indexing import (
build_dialogue_phrases,
- index_actors,
index_dialogue,
- index_scenes,
- index_visuals,
)
+from vidxp.capabilities.visual import index_visuals
+from vidxp.core.contracts import CancellationToken, IndexConfig, VideoSource
from vidxp.core.video import FrameSample, VideoInfo
@@ -26,8 +24,10 @@ def upsert(self, modality, records, **options):
self.calls.append((modality, list(records), options))
return len(records)
- def delete_actor_cluster(self, video_id, cluster_id):
- self.deleted_actor_clusters.append((video_id, cluster_id))
+ def delete_records(self, modality, *, video_id, filters):
+ self.deleted_actor_clusters.append(
+ (modality, video_id, filters["cluster_id"])
+ )
class FakeEncoder:
@@ -117,11 +117,11 @@ def test_supplied_transcript_is_batched_without_video_or_whisper(self):
encoder = FakeEncoder()
with (
patch(
- "vidxp.core.indexing_dialogue.get_embedder",
+ "vidxp.capabilities.dialogue.indexing.get_embedder",
return_value=encoder,
),
patch(
- "vidxp.core.indexing_dialogue.transcribe_video",
+ "vidxp.capabilities.dialogue.indexing.transcribe_video",
side_effect=AssertionError("video/Whisper path was used"),
),
):
@@ -188,25 +188,26 @@ def test_scene_model_and_storage_writes_are_batched_with_full_metadata(self):
)
with (
- patch("vidxp.core.indexing_visual.probe_video", return_value=info),
+ patch("vidxp.capabilities.visual.probe_video", return_value=info),
patch(
- "vidxp.core.indexing_visual.iter_frame_batches",
+ "vidxp.capabilities.visual.iter_frame_batches",
return_value=iter(batches),
),
patch(
- "vidxp.core.indexing_visual.get_clip_model",
+ "vidxp.capabilities.visual.get_clip_model",
return_value=(
model,
lambda _: torch.ones((3, 2, 2), dtype=torch.float32),
),
),
):
- stats = index_scenes(
+ stats = index_visuals(
source,
config=config,
storage=storage,
cancellation=CancellationToken(),
- )
+ modalities=("scene",),
+ ).summary
self.assertEqual(model.batch_sizes, [2, 1])
self.assertEqual(stats["scene_frames"], 3)
@@ -257,19 +258,20 @@ def test_actor_only_records_have_stable_detection_metadata(self):
),
)
with (
- patch("vidxp.core.indexing_visual.probe_video", return_value=info),
+ patch("vidxp.capabilities.visual.probe_video", return_value=info),
patch(
- "vidxp.core.indexing_visual.iter_frame_batches",
+ "vidxp.capabilities.visual.iter_frame_batches",
return_value=iter(batches),
),
patch.dict(sys.modules, {"face_recognition": fake_faces}),
):
- stats = index_actors(
+ stats = index_visuals(
source,
config=config,
storage=storage,
cancellation=CancellationToken(),
- )
+ modalities=("actor",),
+ ).summary
self.assertEqual(stats["actor_frames"], 2)
self.assertEqual(stats["actor_detections"], 2)
@@ -349,26 +351,26 @@ def consume_actor(samples, *, state, **_):
with (
patch(
- "vidxp.core.indexing_visual.probe_video",
+ "vidxp.capabilities.visual.probe_video",
return_value=info,
) as probe,
patch(
- "vidxp.core.indexing_visual.iter_frame_batches",
+ "vidxp.capabilities.visual.iter_frame_batches",
frame_stream,
),
patch(
- "vidxp.core.indexing_visual.get_clip_model",
+ "vidxp.capabilities.visual.get_clip_model",
return_value=(
model,
lambda _: torch.ones((3, 2, 2), dtype=torch.float32),
),
),
patch(
- "vidxp.core.indexing_visual.process_actor_samples",
+ "vidxp.capabilities.visual.process_actor_samples",
side_effect=consume_actor,
) as actor_consumer,
patch(
- "vidxp.core.indexing_visual._rgb_samples",
+ "vidxp.capabilities.visual._rgb_samples",
wraps=indexing_visual_module._rgb_samples,
) as rgb_conversion,
):
diff --git a/tests/test_models.py b/tests/test_models.py
index 765c46e..18ab9d3 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -3,64 +3,72 @@
import unittest
from unittest.mock import Mock, patch
-from vidxp.core import models
+from vidxp.capabilities.dialogue import models as dialogue_models
+from vidxp.capabilities.registry import (
+ dependencies_for,
+ dependency_checks,
+ runtime_distributions,
+)
+from vidxp.core.contracts import VideoSource
class ModelTests(unittest.TestCase):
def tearDown(self):
- models.clear_model_cache()
+ dialogue_models.clear_model_cache()
def test_dialogue_model_is_reused_across_videos(self):
constructor = Mock(return_value=object())
fake_module = types.SimpleNamespace(SentenceTransformer=constructor)
with patch.dict(sys.modules, {"sentence_transformers": fake_module}):
- first = models.get_embedder("model-id", "cpu")
- second = models.get_embedder("model-id", "cpu")
+ first = dialogue_models.get_embedder("model-id", "cpu")
+ second = dialogue_models.get_embedder("model-id", "cpu")
self.assertIs(first, second)
constructor.assert_called_once_with("model-id", device="cpu")
- def test_scene_only_dependency_check_does_not_touch_other_modalities(self):
+ def test_scene_dependency_check_does_not_touch_other_capabilities(self):
imported = []
def record(module_name):
imported.append(module_name)
return object()
- with patch.object(models, "import_module", side_effect=record):
- failures = models.dependency_failures(
- ("scene",),
- needs_transcription=False,
- )
+ with patch(
+ "vidxp.capabilities.contracts.import_module",
+ side_effect=record,
+ ):
+ checks = dependency_checks(("scene",))
- self.assertEqual(failures, [])
+ self.assertTrue(all(check["ok"] for check in checks))
self.assertNotIn("whisperx", imported)
self.assertNotIn("face_recognition", imported)
self.assertNotIn("sentence_transformers", imported)
- def test_supplied_transcript_only_checks_the_dialogue_encoder(self):
- imported = []
- with patch.object(
- models,
- "import_module",
- side_effect=lambda name: imported.append(name) or object(),
- ):
- models.dependency_failures(
+ def test_supplied_transcript_only_requires_dialogue_search_dependencies(self):
+ source = VideoSource(
+ transcript=({"text": "hello", "start": 0, "end": 1},)
+ )
+
+ modules = {
+ dependency.module
+ for dependency in dependencies_for(
("dialogue",),
- needs_transcription=False,
+ source=source,
)
+ }
- self.assertIn("sentence_transformers", imported)
- self.assertNotIn("whisperx", imported)
- self.assertNotIn("moviepy.editor", imported)
- self.assertNotIn("cv2", imported)
+ self.assertIn("sentence_transformers", modules)
+ self.assertNotIn("whisperx", modules)
+ self.assertNotIn("moviepy.editor", modules)
+ self.assertNotIn("cv2", modules)
- def test_runtime_distributions_come_from_dependency_registry(self):
- distributions = models.runtime_distributions()
+ def test_runtime_distributions_come_from_capability_registry(self):
+ distributions = runtime_distributions()
self.assertIn("clip-anytorch", distributions)
self.assertIn("face-recognition", distributions)
self.assertIn("filelock", distributions)
+ self.assertIn("pydantic", distributions)
self.assertEqual(len(distributions), len(set(distributions)))
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
new file mode 100644
index 0000000..511f644
--- /dev/null
+++ b/tests/test_packaging.py
@@ -0,0 +1,62 @@
+import unittest
+from pathlib import Path
+
+from vidxp.capabilities.registry import CAPABILITIES
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class PackagingTests(unittest.TestCase):
+ def test_capability_extras_read_capability_owned_requirements(self):
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ all_block = pyproject.split("all = { file = [", 1)[1].split(
+ "] }",
+ 1,
+ )[0]
+
+ for capability in CAPABILITIES.values():
+ requirements = (
+ ROOT
+ / "src"
+ / "vidxp"
+ / "capabilities"
+ / capability.name
+ / "requirements.txt"
+ )
+ relative = requirements.relative_to(ROOT).as_posix()
+ self.assertTrue(requirements.is_file())
+ self.assertIn(
+ f'{capability.extra} = {{ file = ["{relative}"] }}',
+ pyproject,
+ )
+ self.assertIn(f'"{relative}"', all_block)
+
+ self.assertNotIn("benchmarks/requirements.txt", all_block)
+ self.assertNotIn("requirements/frontend.txt", all_block)
+
+ def test_base_dependencies_exclude_capability_runtimes(self):
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ base_dependencies = pyproject.split("dependencies = [", 1)[1].split(
+ "]",
+ 1,
+ )[0]
+
+ for distribution in (
+ "chromadb",
+ "face-recognition",
+ "moviepy",
+ "numpy",
+ "opencv-python",
+ "sentence-transformers",
+ "torch",
+ "whisperx",
+ "clip-anytorch",
+ "streamlit",
+ "srt",
+ ):
+ self.assertNotIn(distribution, base_dependencies)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_runner.py b/tests/test_runner.py
index d032730..4d1a47c 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -5,13 +5,12 @@
from unittest.mock import Mock, patch
from vidxp.core.contracts import (
- CancellationToken,
IndexCancelledError,
IndexConfig,
VideoSource,
)
from vidxp.core.manifest import COMPLETION_FILE
-from vidxp.core.indexing_visual import VisualIndexResult
+from vidxp.capabilities.contracts import CapabilityIndexResult
from vidxp.core.runner import (
_RunLock,
index_video,
@@ -32,7 +31,18 @@
def visual_result(summary, timings=None):
- return VisualIndexResult(summary=summary, timings=timings or {})
+ normalized = dict(summary)
+ scene_frames = int(normalized.get("scene_frames", 0))
+ actor_frames = int(normalized.get("actor_frames", 0))
+ sampled_frames = max(scene_frames, actor_frames)
+ normalized.setdefault("sampled_frames", sampled_frames)
+ normalized.setdefault("processed_frames", sampled_frames)
+ normalized.setdefault("frame_operations", scene_frames + actor_frames)
+ normalized.setdefault("source_frames_advanced", sampled_frames)
+ return CapabilityIndexResult(
+ summary=normalized,
+ timings=timings or {},
+ )
class FakeStorage:
@@ -92,7 +102,7 @@ def scene_indexer(source, *, config, **_):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
side_effect=scene_indexer,
),
patch(
@@ -147,7 +157,7 @@ def test_scene_and_actor_are_dispatched_as_one_visual_pipeline(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
visual,
),
patch(
@@ -195,7 +205,7 @@ def cancelling_indexer(source, *, config, **_):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
side_effect=cancelling_indexer,
),
patch(
@@ -216,7 +226,7 @@ def successful_indexer(source, *, config, **_):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
side_effect=successful_indexer,
),
patch(
@@ -247,7 +257,7 @@ def test_transcript_only_run_does_not_request_transcription_dependencies(self):
dependency_check,
),
patch(
- "vidxp.core.runner.index_dialogue",
+ "vidxp.capabilities.dialogue.operations.index_dialogue",
return_value={"dialogue_phrases": 1},
),
patch(
@@ -257,10 +267,10 @@ def test_transcript_only_run_does_not_request_transcription_dependencies(self):
):
run_index([source], config, storage=FakeStorage())
- dependency_check.assert_called_once_with(
- ("dialogue",),
- needs_transcription=False,
- )
+ dependency_check.assert_called_once_with(
+ ("dialogue",),
+ source=source,
+ )
def test_manifest_adds_transcription_model_when_run_later_needs_it(self):
with TemporaryDirectory() as directory:
@@ -277,7 +287,7 @@ def test_manifest_adds_transcription_model_when_run_later_needs_it(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_dialogue",
+ "vidxp.capabilities.dialogue.operations.index_dialogue",
return_value={"dialogue_phrases": 1},
),
patch(
@@ -314,7 +324,7 @@ def test_changed_input_is_not_silently_accepted_by_checkpoint(self):
common = (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
indexer,
),
patch(
@@ -357,7 +367,7 @@ def test_changed_supplied_transcript_invalidates_same_video_input(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_dialogue",
+ "vidxp.capabilities.dialogue.operations.index_dialogue",
return_value={"dialogue_phrases": 1},
),
patch(
@@ -385,7 +395,7 @@ def test_reset_clears_every_collection_not_only_enabled_modalities(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_dialogue",
+ "vidxp.capabilities.dialogue.operations.index_dialogue",
return_value={"dialogue_phrases": 1},
),
patch(
@@ -419,7 +429,7 @@ def indexer(*_, **__):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
side_effect=indexer,
),
patch(
@@ -453,7 +463,7 @@ def test_resume_rejects_execution_environment_drift(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
return_value=visual_result({"scene_frames": 1}),
),
patch(
@@ -495,7 +505,7 @@ def test_generated_run_files_do_not_invalidate_execution_fingerprint(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
indexer,
),
patch(
@@ -572,7 +582,7 @@ def test_manifest_and_timing_files_are_valid_json(self):
with (
patch("vidxp.core.runner.require_dependencies"),
patch(
- "vidxp.core.runner.index_visuals",
+ "vidxp.capabilities.visual.index_visuals",
return_value=visual_result({"scene_frames": 1}),
),
patch(
diff --git a/tests/test_search.py b/tests/test_search.py
index 1a16c44..3b10003 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -4,13 +4,14 @@
from tempfile import TemporaryDirectory
from unittest.mock import patch
-from vidxp.core.contracts import IndexConfig, IndexSchemaError, SearchResult
-from vidxp.core.search import (
+from vidxp.capabilities.dialogue.operations import search_dialogue
+from vidxp.capabilities.schemas import SearchResult
+from vidxp.capabilities.search import (
distance_to_score,
- search_dialogue,
serialize_predictions,
stable_query_id,
)
+from vidxp.core.contracts import IndexConfig, IndexSchemaError
class FakeStorage:
@@ -60,7 +61,7 @@ def test_top_k_filter_order_distance_and_score_are_preserved(self):
]
)
with patch(
- "vidxp.core.search._dialogue_embedding",
+ "vidxp.capabilities.dialogue.operations.dialogue_embedding",
return_value=[0.5, 0.25],
):
result = search_dialogue(
@@ -126,7 +127,7 @@ def test_old_metadata_requires_an_explicit_reindex(self):
)
with (
patch(
- "vidxp.core.search._dialogue_embedding",
+ "vidxp.capabilities.dialogue.operations.dialogue_embedding",
return_value=[0.5],
),
self.assertRaisesRegex(IndexSchemaError, "must be rebuilt"),
diff --git a/tests/test_storage.py b/tests/test_storage.py
index 02fdc05..b52d322 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -142,39 +142,34 @@ def test_query_requests_distances_and_applies_run_and_video_filter(self):
self.assertIn({"video_id": "video-1"}, clauses)
self.assertIn({"run_id": "run-1"}, clauses)
- def test_actor_detections_are_chronologically_ordered(self):
+ def test_records_apply_capability_filters(self):
collection = FakeCollection()
storage = fake_storage(self.config, collection)
- detections = storage.actor_detections(
+ records = storage.records(
+ "actor",
video_id="video-1",
- cluster_id="1",
+ filters={"cluster_id": "1"},
)
- self.assertEqual(
- [item["detection_id"] for item in detections],
- ["d1", "d3"],
- )
-
- def test_actor_cluster_records_are_scoped_and_ordered(self):
- collection = FakeCollection()
- storage = fake_storage(self.config, collection)
-
- records = storage.actor_cluster_records(video_id="video-1")
-
self.assertEqual(
[item["detection_id"] for item in records],
- ["d1", "d3"],
+ ["d3", "d1"],
)
clauses = collection.get_options["where"]["$and"]
self.assertIn({"run_id": "run-1"}, clauses)
self.assertIn({"video_id": "video-1"}, clauses)
+ self.assertIn({"cluster_id": "1"}, clauses)
- def test_actor_cluster_cleanup_remains_scoped_to_video_and_run(self):
+ def test_record_cleanup_remains_scoped_to_capability_and_run(self):
collection = FakeCollection()
storage = fake_storage(self.config, collection)
- storage.delete_actor_cluster("video-1", "3")
+ storage.delete_records(
+ "actor",
+ video_id="video-1",
+ filters={"cluster_id": "3"},
+ )
clauses = collection.deletes[0]["where"]["$and"]
self.assertIn({"run_id": "run-1"}, clauses)
From baec97b0ae1ab96c4c2bc67014fa472a3b26dc0f Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 22:15:35 +0500
Subject: [PATCH 12/44] build: clean stale artifacts before packaging
---
utils/build_package.sh | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/utils/build_package.sh b/utils/build_package.sh
index 0fa100d..368b730 100644
--- a/utils/build_package.sh
+++ b/utils/build_package.sh
@@ -4,6 +4,16 @@ set -euo pipefail
BASE_URL="https://github.com/grayhatdevelopers/vidxp/blob/main"
README="README.md"
README_BAK="$README.bak"
+BUILD_DIR="build"
+DIST_DIR="dist"
+
+restore_readme() {
+ if [[ -f "$README_BAK" ]]; then
+ mv "$README_BAK" "$README"
+ fi
+}
+
+trap restore_readme EXIT
echo "📝 Backing up original README..."
cp "$README" "$README_BAK"
@@ -11,10 +21,13 @@ cp "$README" "$README_BAK"
echo "🔧 Processing README.md for PyPI rendering..."
python utils/fix_readme_links.py "$BASE_URL" "$README" --inplace
+echo "🧹 Removing stale package artifacts..."
+rm -rf -- "$BUILD_DIR" "$DIST_DIR"
+
echo "📦 Building package..."
python -m build
echo "♻️ Restoring original README..."
-mv "$README_BAK" "$README"
+restore_readme
echo "✅ Build finished. Original README restored."
From fa9f8d21126db46cc78ee45f9d670cba7efccce4 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 22:15:40 +0500
Subject: [PATCH 13/44] docs: explain selective capability contributions
---
INSTALLATION_GUIDE.md | 33 ++++++++----
README.md | 12 +++--
docs/CONTRIBUTING.md | 20 ++++---
docs/adding-a-capability.md | 87 ++++++++++++++++++++++++++++++
docs/benchmarking/core_contract.md | 2 +-
5 files changed, 130 insertions(+), 24 deletions(-)
create mode 100644 docs/adding-a-capability.md
diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md
index 53f6566..470c386 100644
--- a/INSTALLATION_GUIDE.md
+++ b/INSTALLATION_GUIDE.md
@@ -7,8 +7,8 @@ installation, model preparation, and common first-run issues.
## Prerequisites
- Python 3.10 through 3.13
-- FFmpeg available on `PATH`
-- CMake and a C/C++ build toolchain for `dlib`
+- FFmpeg available on `PATH` when installing `dialogue`
+- CMake and a C/C++ build toolchain when installing `actor`
The official `dlib` package is distributed through PyPI as source. Installing
VidXP therefore compiles it locally unless a compatible build is already cached
@@ -43,19 +43,29 @@ python -m pip install --upgrade pip
## Install from PyPI
-Install the command-line package:
+Install the lightweight command and application layer:
```bash
python -m pip install vidxp
```
-Install the command-line package and Streamlit interface:
+Add only the indexing capabilities you need:
```bash
-python -m pip install "vidxp[frontend]"
+python -m pip install "vidxp[dialogue]"
+python -m pip install "vidxp[scene,actor]"
+python -m pip install "vidxp[all]"
```
-`frontend` is an optional dependency group. The package name remains `vidxp`.
+`all` contains all runtime capabilities. It does not include the `frontend` or
+`benchmarks` extras:
+
+```bash
+python -m pip install "vidxp[all,frontend]"
+```
+
+The `benchmarks` extra contains evaluation tooling only. Combine it with the
+capability being evaluated, for example `vidxp[scene,benchmarks]`.
## Install from source
@@ -65,16 +75,16 @@ From the repository root:
python -m pip install .
```
-Include the Streamlit interface:
+Include all capabilities and the Streamlit interface:
```bash
-python -m pip install ".[frontend]"
+python -m pip install ".[all,frontend]"
```
Use an editable installation while developing:
```bash
-python -m pip install -e ".[frontend,benchmarks]"
+python -m pip install -e ".[all,frontend,benchmarks]"
```
## Verify the installation
@@ -85,14 +95,15 @@ Display the installed package version:
vidxp --version
```
-Check FFmpeg and the Python dependencies needed by all indexing capabilities:
+Check the Python and system dependencies needed by all indexing capabilities:
```bash
vidxp doctor
```
`vidxp doctor` imports the selected dependencies but does not download model
-weights. Restrict the check when diagnosing one capability:
+weights. A base-only install therefore reports which capability extras are
+missing. Restrict the check when diagnosing one capability:
```bash
vidxp doctor --modalities scene
diff --git a/README.md b/README.md
index 0f3db36..494d5c2 100644
--- a/README.md
+++ b/README.md
@@ -50,18 +50,21 @@ venv\Scripts\activate
source venv/bin/activate
```
-Install the command-line package:
+Install the command line and all indexing capabilities:
```bash
-python -m pip install vidxp
+python -m pip install "vidxp[all]"
```
Include the browser interface:
```bash
-python -m pip install "vidxp[frontend]"
+python -m pip install "vidxp[all,frontend]"
```
+Install only the capabilities you need with extras such as
+`vidxp[dialogue]`, `vidxp[scene]`, or `vidxp[scene,actor]`.
+
Confirm the installed package and its runtime dependencies:
```bash
@@ -133,7 +136,7 @@ transcripts, resumable per-video checkpoints, and metadata-rich top-k results.
```python
from vidxp.core import IndexConfig, VideoSource
from vidxp.core.runner import run_index
-from vidxp.core.search import search_scene
+from vidxp.capabilities.scene.operations import search_scene
config = IndexConfig(
dataset="my-library",
@@ -207,6 +210,7 @@ caches normally live outside this directory and outside the virtual environment.
- [Installation and troubleshooting](INSTALLATION_GUIDE.md)
- [Benchmarking status and results](docs/benchmarking/README.md)
- [Contribution guidelines](docs/CONTRIBUTING.md)
+- [Adding a capability](docs/adding-a-capability.md)
- [Changelog](CHANGELOG.md)
- [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues)
- [MIT license](LICENSE)
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 75b34bd..0d2658c 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -8,9 +8,10 @@ Thanks for contributing to VidXP (Video eXPlain).
|-------------|------|
| `src/vidxp/cli.py` | Typer commands and installed `vidxp` entry point |
| `src/vidxp/application.py` | Reusable application boundary for CLI and future adapters |
+| `src/vidxp/capabilities/` | Capability registry, schemas, operations, dependencies, and optional CLI modules |
| `src/vidxp/repositories.py` | Persistent named local-index configuration |
| `src/vidxp/frontend.py` | Streamlit interface launched by `vidxp ui` |
-| `src/vidxp/core/` | Indexing, retrieval, storage, models, run state, and shared contracts |
+| `src/vidxp/core/` | Capability-neutral storage, media, run state, and execution contracts |
| `src/vidxp/benchmarks/` | Benchmark-specific loaders, prediction adapters, and evaluator calls |
| `pyproject.toml` | Package metadata and Python dependencies |
| `docs/` | Installation-linked guidance, benchmark research, and contribution notes |
@@ -19,7 +20,7 @@ Thanks for contributing to VidXP (Video eXPlain).
| Model caches | Managed by WhisperX, SentenceTransformer, and CLIP outside the repository |
The full local CLI/UI index uses up to three collections:
-`voiceEmbeddings`, `sceneEmbeddings`, and `actorCollection`. Runs containing
+`dialogue`, `scene`, and `actor`. Runs containing
selected capabilities create only the collections they need.
## Setup
@@ -31,7 +32,7 @@ package metadata directly.
python -m venv venv
# Windows: venv\Scripts\activate
# macOS/Linux: source venv/bin/activate
-python -m pip install -e ".[frontend,benchmarks]"
+python -m pip install -e ".[all,frontend,benchmarks]"
```
Verify the environment:
@@ -50,15 +51,17 @@ record the exact identifier in benchmark results.
## Where to put work
-- Indexing, retrieval, storage, metadata, and face clustering:
+- Capability-specific indexing, retrieval, models, schemas, and dependencies:
+ the matching folder under `src/vidxp/capabilities/`.
+- Shared storage, media handling, run state, and execution mechanics:
`src/vidxp/core/`.
- Transport-neutral application operations: `src/vidxp/application.py`.
- Command-line behavior: `src/vidxp/cli.py`.
- Upload and search UX: `src/vidxp/frontend.py`; keep product logic in the
shared application and core modules.
- Official benchmark formats and evaluator calls: `src/vidxp/benchmarks/`.
-- New dependencies: `pyproject.toml`, with the reason stated in the pull
- request.
+- Capability dependencies: that capability's `requirements.txt`; wire a new
+ install extra into `pyproject.toml`.
- Product direction: the roadmap in the main [README](../README.md).
Prefer small, focused pull requests. If you change how embeddings or metadata
@@ -90,5 +93,6 @@ python -m unittest discover -s tests
## Questions
-Open an issue before large refactors or new modalities so scope stays aligned
-with the roadmap.
+Follow [Adding a capability](adding-a-capability.md) for the complete extension
+contract. Open an issue before large cross-capability refactors so scope stays
+aligned with the roadmap.
diff --git a/docs/adding-a-capability.md b/docs/adding-a-capability.md
new file mode 100644
index 0000000..2690208
--- /dev/null
+++ b/docs/adding-a-capability.md
@@ -0,0 +1,87 @@
+# Adding a capability
+
+A capability is a self-contained VidXP feature with its own operations,
+schemas, runtime dependencies, and optional indexing or CLI integration.
+Chroma collections are storage details; do not call capability packages
+“collections.”
+
+## Required shape
+
+Create `src/vidxp/capabilities//` with only the files the feature needs:
+
+```text
+/
+├── __init__.py
+├── definition.py
+├── operations.py
+├── schemas.py # omit when shared schemas are sufficient
+├── requirements.txt
+├── indexing.py # only for an indexable capability
+└── cli.py # only for specialized commands
+```
+
+`definition.py` exports one `CapabilityDefinition` named `DEFINITION`.
+Register it explicitly in `src/vidxp/capabilities/registry.py`. The registry is
+the only central file that should change for ordinary runtime registration.
+
+## Contracts
+
+Every public operation declares:
+
+- a Pydantic input model;
+- a Pydantic output model;
+- a transport-neutral handler;
+- whether it requires an active index.
+
+Handlers receive `CapabilityContext`. Use `context.require_config()` only for
+operations that declare `requires_index=True`. Do not import Typer, Streamlit,
+FastAPI, or an MCP implementation into operation modules.
+
+An indexable capability also declares its collection names, indexing handler,
+and index stage. The generic runner groups capabilities that reference the same
+indexing handler, allowing related capabilities to share work without adding a
+name switch to the runner. Capability-specific settings belong in
+`IndexConfig.capability_options` and are read with
+`config.options_for("")`; do not add feature-specific fields to the
+runner.
+
+Operation-only capabilities leave the indexing fields unset. They do not need
+dummy collections or index handlers.
+
+## Dependencies and installation
+
+Put direct runtime requirements in the capability's `requirements.txt`.
+Declare matching `RuntimeDependency` checks in `definition.py`, then expose the
+requirements file as an optional dependency in `pyproject.toml`:
+
+```toml
+[tool.setuptools.dynamic.optional-dependencies]
+example = { file = ["src/vidxp/capabilities/example/requirements.txt"] }
+```
+
+Add runtime capabilities to the `all` file list. Do not add benchmark,
+frontend, or development dependencies to `all`. Imports of optional libraries
+must stay inside the functions that use them so `import vidxp` and
+`vidxp --help` work in a base-only installation.
+
+## CLI integration
+
+Generic commands obtain capability names and operations from the registry.
+Only add `cli.py` when the capability needs specialized human interaction that
+does not fit a generic command. Expose it through `cli_name` and `cli_factory`
+on the definition; keep business logic in operations.
+
+## Tests
+
+Add focused tests under `tests/`. At minimum, cover:
+
+1. Pydantic input and output validation.
+2. Operation dispatch through `VidXPService.execute`.
+3. Dependency selection without importing unrelated capabilities.
+4. Index grouping and collection behavior when indexing is supported.
+5. CLI behavior when specialized commands are provided.
+6. Successful package metadata generation and a base-only import smoke test.
+
+Adding a normal capability must not require edits to
+`src/vidxp/application.py`, `src/vidxp/core/runner.py`, or
+`src/vidxp/core/storage.py`.
diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md
index cde8151..a9b13cd 100644
--- a/docs/benchmarking/core_contract.md
+++ b/docs/benchmarking/core_contract.md
@@ -126,7 +126,7 @@ an official ID cannot collide with the separator.
## Retrieval API
```python
-from vidxp.core.search import search_scene
+from vidxp.capabilities.scene.operations import search_scene
result = search_scene(
"a person cuts bread",
From e752fc3ec47947326388d7abb585c0bcdf4aadfe Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 22:54:49 +0500
Subject: [PATCH 14/44] refactor(config): move settings into capabilities
---
docs/adding-a-capability.md | 6 ++-
docs/benchmarking/core_contract.md | 2 +-
src/vidxp/application.py | 8 +++-
src/vidxp/benchmarks/hirest.py | 3 +-
src/vidxp/capabilities/actor/config.py | 17 +++++++++
src/vidxp/capabilities/actor/definition.py | 9 +++--
src/vidxp/capabilities/actor/indexing.py | 13 ++++---
src/vidxp/capabilities/contracts.py | 7 ++++
src/vidxp/capabilities/dialogue/config.py | 22 +++++++++++
src/vidxp/capabilities/dialogue/definition.py | 20 ++++++----
src/vidxp/capabilities/dialogue/indexing.py | 34 ++++++++++-------
src/vidxp/capabilities/dialogue/operations.py | 6 ++-
src/vidxp/capabilities/registry.py | 37 +++++++++++++++---
src/vidxp/capabilities/scene/config.py | 15 ++++++++
src/vidxp/capabilities/scene/definition.py | 11 ++++--
src/vidxp/capabilities/scene/indexing.py | 4 +-
src/vidxp/capabilities/scene/operations.py | 4 +-
src/vidxp/capabilities/visual.py | 11 ++++--
src/vidxp/cli_commands/index.py | 14 +++++++
src/vidxp/cli_support.py | 19 ++++++++++
src/vidxp/core/contracts.py | 31 +++------------
tests/test_application.py | 2 +-
tests/test_capabilities.py | 38 +++++++++++++++++++
tests/test_cli.py | 8 ++++
tests/test_contracts.py | 18 ++++++---
tests/test_indexing.py | 18 ++++++---
tests/test_manifest.py | 1 +
tests/test_runner.py | 3 +-
28 files changed, 289 insertions(+), 92 deletions(-)
create mode 100644 src/vidxp/capabilities/actor/config.py
create mode 100644 src/vidxp/capabilities/dialogue/config.py
create mode 100644 src/vidxp/capabilities/scene/config.py
diff --git a/docs/adding-a-capability.md b/docs/adding-a-capability.md
index 2690208..8f8679a 100644
--- a/docs/adding-a-capability.md
+++ b/docs/adding-a-capability.md
@@ -13,6 +13,7 @@ Create `src/vidxp/capabilities//` with only the files the feature needs:
/
├── __init__.py
├── definition.py
+├── config.py # Pydantic settings owned by the capability
├── operations.py
├── schemas.py # omit when shared schemas are sufficient
├── requirements.txt
@@ -42,8 +43,9 @@ and index stage. The generic runner groups capabilities that reference the same
indexing handler, allowing related capabilities to share work without adding a
name switch to the runner. Capability-specific settings belong in
`IndexConfig.capability_options` and are read with
-`config.options_for("")`; do not add feature-specific fields to the
-runner.
+`config.options_for("")`. Validate them through the capability's
+Pydantic settings model; do not add feature-specific fields to `IndexConfig`
+or the runner.
Operation-only capabilities leave the indexing fields unset. They do not need
dummy collections or index handlers.
diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md
index a9b13cd..c179ed6 100644
--- a/docs/benchmarking/core_contract.md
+++ b/docs/benchmarking/core_contract.md
@@ -49,7 +49,7 @@ config = IndexConfig(
run_id="clip-stride-5",
enabled_modalities=("scene",),
frame_stride=5,
- scene_batch_size=32,
+ capability_options={"scene": {"batch_size": 32}},
storage_batch_size=256,
)
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index e6dd0d3..fca32dd 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -16,6 +16,7 @@
dependency_checks,
get_capability,
index_capability_names,
+ validate_capability_options,
validate_capability_names,
)
from vidxp.capabilities.schemas import (
@@ -119,8 +120,11 @@ def create_index(
"enabled_modalities": selected,
"frame_stride": frame_stride,
"storage_directory": self.index_directory,
- "collection_names": collection_names(),
- "capability_options": capability_options or {},
+ "collection_names": collection_names(selected),
+ "capability_options": validate_capability_options(
+ selected,
+ capability_options,
+ ),
}
if self.device is not None:
options["device"] = self.device
diff --git a/src/vidxp/benchmarks/hirest.py b/src/vidxp/benchmarks/hirest.py
index 27a89e8..ce499a3 100644
--- a/src/vidxp/benchmarks/hirest.py
+++ b/src/vidxp/benchmarks/hirest.py
@@ -18,6 +18,7 @@
run_logged_evaluator,
verify_artifact,
)
+from vidxp.capabilities.dialogue.config import dialogue_config
from vidxp.capabilities.dialogue.operations import search_dialogue
from vidxp.capabilities.schemas import SearchHit
from vidxp.core.contracts import IndexConfig, VideoSource
@@ -479,7 +480,7 @@ def run_hirest(
"prediction_format_validated": True,
"input_mode": "released_timestamped_asr",
"dialogue_words_per_phrase": (
- config.dialogue_words_per_phrase
+ dialogue_config(config).words_per_phrase
),
"segment_word_timestamps": (
"linear_interpolation_within_srt_cue"
diff --git a/src/vidxp/capabilities/actor/config.py b/src/vidxp/capabilities/actor/config.py
new file mode 100644
index 0000000..8756fd0
--- /dev/null
+++ b/src/vidxp/capabilities/actor/config.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from pydantic import Field
+
+from vidxp.capabilities.contracts import CapabilityConfig
+from vidxp.core.contracts import IndexConfig
+
+
+class ActorConfig(CapabilityConfig):
+ batch_size: int = Field(default=16, gt=0)
+ match_threshold: float = Field(default=0.55, gt=0, lt=1)
+ num_jitters: int = Field(default=2, gt=0)
+ minimum_detections: int = Field(default=4, gt=0)
+
+
+def actor_config(config: IndexConfig) -> ActorConfig:
+ return ActorConfig.model_validate(config.options_for("actor"))
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
index a7a63ce..979768c 100644
--- a/src/vidxp/capabilities/actor/definition.py
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -2,6 +2,7 @@
from typing import Any, Mapping
+from vidxp.capabilities.actor.config import ActorConfig, actor_config
from vidxp.capabilities.actor.operations import (
clusters_operation,
detections_operation,
@@ -46,12 +47,13 @@ def model_manifest(
config: IndexConfig,
_sources: tuple[VideoSource, ...],
) -> Mapping[str, Any]:
+ settings = actor_config(config)
return {
"actor": {
"library": "face_recognition",
- "match_threshold": config.face_match_threshold,
- "num_jitters": config.face_num_jitters,
- "minimum_detections": config.actor_min_detections,
+ "match_threshold": settings.match_threshold,
+ "num_jitters": settings.num_jitters,
+ "minimum_detections": settings.minimum_detections,
}
}
@@ -66,6 +68,7 @@ def cli_app():
name="actor",
description="Index, inspect, and render actor clusters.",
extra="actor",
+ config_model=ActorConfig,
collection_name="actor",
indexer=index_capabilities,
index_stage="visual_indexing",
diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py
index ca11f01..39ad22b 100644
--- a/src/vidxp/capabilities/actor/indexing.py
+++ b/src/vidxp/capabilities/actor/indexing.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass, field
from typing import Any
+from vidxp.capabilities.actor.config import actor_config
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
@@ -79,7 +80,8 @@ def process_actor_samples(
import face_recognition
import numpy as np
- for group in batched(samples, config.actor_batch_size):
+ settings = actor_config(config)
+ for group in batched(samples, settings.batch_size):
cancellation.raise_if_cancelled()
detections = []
for sample in group:
@@ -88,7 +90,7 @@ def process_actor_samples(
encodings = face_recognition.face_encodings(
sample.frame,
locations,
- num_jitters=config.face_num_jitters,
+ num_jitters=settings.num_jitters,
)
for ordinal, (encoding, location) in enumerate(
zip(encodings, locations)
@@ -97,7 +99,7 @@ def process_actor_samples(
face_recognition,
state.known_encodings,
encoding,
- config.face_match_threshold,
+ settings.match_threshold,
)
if match is None:
cluster_id = str(len(state.known_ids) + 1)
@@ -140,10 +142,11 @@ def finalize_actor_index(
config: IndexConfig,
storage: IndexStorage,
) -> tuple[int, int]:
+ settings = actor_config(config)
rejected = [
cluster_id
for cluster_id, size in state.cluster_sizes.items()
- if size < config.actor_min_detections
+ if size < settings.minimum_detections
]
for cluster_id in rejected:
storage.delete_records(
@@ -154,6 +157,6 @@ def finalize_actor_index(
retained = {
cluster_id: size
for cluster_id, size in state.cluster_sizes.items()
- if size >= config.actor_min_detections
+ if size >= settings.minimum_detections
}
return sum(retained.values()), len(retained)
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
index c0d913d..edf9646 100644
--- a/src/vidxp/capabilities/contracts.py
+++ b/src/vidxp/capabilities/contracts.py
@@ -29,6 +29,12 @@ class CapabilityOutput(BaseModel):
model_config = {"extra": "forbid", "frozen": True}
+class CapabilityConfig(BaseModel):
+ """Base model for settings owned and validated by one capability."""
+
+ model_config = {"extra": "forbid", "frozen": True}
+
+
@dataclass(frozen=True)
class RuntimeDependency:
"""One import or executable required by a capability."""
@@ -124,6 +130,7 @@ class CapabilityDefinition:
name: str
description: str
extra: str
+ config_model: type[CapabilityConfig] = CapabilityConfig
dependencies: tuple[RuntimeDependency, ...] = ()
collection_name: str | None = None
indexer: IndexHandler | None = None
diff --git a/src/vidxp/capabilities/dialogue/config.py b/src/vidxp/capabilities/dialogue/config.py
new file mode 100644
index 0000000..988da56
--- /dev/null
+++ b/src/vidxp/capabilities/dialogue/config.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from pydantic import Field
+
+from vidxp.capabilities.contracts import CapabilityConfig
+from vidxp.core.contracts import IndexConfig
+
+
+class DialogueConfig(CapabilityConfig):
+ words_per_phrase: int = Field(default=5, gt=0)
+ embedding_batch_size: int = Field(default=128, gt=0)
+ transcription_batch_size: int = Field(default=16, gt=0)
+ normalize_embeddings: bool = True
+ sentence_model: str = Field(
+ default="sentence-transformers/all-MiniLM-L6-v2",
+ min_length=1,
+ )
+ whisper_model: str = Field(default="large-v2", min_length=1)
+
+
+def dialogue_config(config: IndexConfig) -> DialogueConfig:
+ return DialogueConfig.model_validate(config.options_for("dialogue"))
diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py
index 5df65f9..c4744d8 100644
--- a/src/vidxp/capabilities/dialogue/definition.py
+++ b/src/vidxp/capabilities/dialogue/definition.py
@@ -7,6 +7,7 @@
OperationDefinition,
RuntimeDependency,
)
+from vidxp.capabilities.dialogue.config import DialogueConfig, dialogue_config
from vidxp.capabilities.dialogue.models import (
get_alignment_model,
get_embedder,
@@ -54,6 +55,7 @@ def prepare_models(
language: str | None,
progress: ProgressCallback | None,
) -> tuple[str, ...]:
+ settings = dialogue_config(config)
prepared = []
def report(stage: str, message: str) -> None:
@@ -68,16 +70,16 @@ def report(stage: str, message: str) -> None:
report(
"dialogue_model",
- f"Preparing dialogue model: {config.sentence_model}",
+ f"Preparing dialogue model: {settings.sentence_model}",
)
- get_embedder(config.sentence_model, config.device)
- prepared.append(config.sentence_model)
+ get_embedder(settings.sentence_model, config.device)
+ prepared.append(settings.sentence_model)
report(
"transcription_model",
- f"Preparing transcription model: WhisperX {config.whisper_model}",
+ f"Preparing transcription model: WhisperX {settings.whisper_model}",
)
- get_whisper_model(config.whisper_model, config.device)
- prepared.append(config.whisper_model)
+ get_whisper_model(settings.whisper_model, config.device)
+ prepared.append(settings.whisper_model)
if language:
report(
"alignment_model",
@@ -92,9 +94,10 @@ def model_manifest(
config: IndexConfig,
sources: tuple[VideoSource, ...],
) -> Mapping[str, Any]:
- result: dict[str, Any] = {"dialogue": config.sentence_model}
+ settings = dialogue_config(config)
+ result: dict[str, Any] = {"dialogue": settings.sentence_model}
if any(source.transcript is None for source in sources):
- result["transcription"] = config.whisper_model
+ result["transcription"] = settings.whisper_model
return result
@@ -102,6 +105,7 @@ def model_manifest(
name="dialogue",
description="Index and search spoken dialogue.",
extra="dialogue",
+ config_model=DialogueConfig,
collection_name="dialogue",
indexer=index_capability,
index_stage="dialogue_indexing",
diff --git a/src/vidxp/capabilities/dialogue/indexing.py b/src/vidxp/capabilities/dialogue/indexing.py
index ae99d25..4e5201c 100644
--- a/src/vidxp/capabilities/dialogue/indexing.py
+++ b/src/vidxp/capabilities/dialogue/indexing.py
@@ -5,6 +5,12 @@
from pathlib import Path
from typing import Any, Mapping, Sequence
+from vidxp.capabilities.dialogue.config import dialogue_config
+from vidxp.capabilities.dialogue.models import (
+ get_alignment_model,
+ get_embedder,
+ get_whisper_model,
+)
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
@@ -13,11 +19,6 @@
stable_source_id,
)
from vidxp.core.indexing_common import ProgressCallback, report_progress
-from vidxp.capabilities.dialogue.models import (
- get_alignment_model,
- get_embedder,
- get_whisper_model,
-)
from vidxp.core.storage import IndexStorage
from vidxp.core.video import extract_audio
@@ -123,6 +124,7 @@ def transcribe_video(
) -> tuple[list[Mapping[str, Any]], str]:
import whisperx
+ settings = dialogue_config(config)
cancellation.raise_if_cancelled()
audio_name = hashlib.sha256(
str(config.video_id).encode("utf-8")
@@ -139,9 +141,12 @@ def transcribe_video(
report_progress(
progress,
"preparing_transcription_model",
- f"Preparing transcription model: WhisperX {config.whisper_model}.",
+ f"Preparing transcription model: WhisperX {settings.whisper_model}.",
+ )
+ whisper_model = get_whisper_model(
+ settings.whisper_model,
+ config.device,
)
- whisper_model = get_whisper_model(config.whisper_model, config.device)
audio = whisperx.load_audio(str(audio_path))
report_progress(
progress,
@@ -150,7 +155,7 @@ def transcribe_video(
)
transcription = whisper_model.transcribe(
audio,
- batch_size=config.transcription_batch_size,
+ batch_size=settings.transcription_batch_size,
)
language = str(transcription["language"])
@@ -222,6 +227,7 @@ def index_dialogue(
) -> dict[str, Any]:
if config.video_id is None:
raise ValueError("IndexConfig.video_id is required for indexing.")
+ settings = dialogue_config(config)
language = None
if source.transcript is not None:
@@ -241,7 +247,7 @@ def index_dialogue(
phrases = build_dialogue_phrases(
segments,
- words_per_phrase=config.dialogue_words_per_phrase,
+ words_per_phrase=settings.words_per_phrase,
)
if not phrases:
return {"dialogue_phrases": 0, "language": language}
@@ -249,11 +255,11 @@ def index_dialogue(
report_progress(
progress,
"preparing_dialogue_model",
- f"Preparing dialogue model: {config.sentence_model}.",
+ f"Preparing dialogue model: {settings.sentence_model}.",
0,
len(phrases),
)
- encoder = get_embedder(config.sentence_model, config.device)
+ encoder = get_embedder(settings.sentence_model, config.device)
report_progress(
progress,
"dialogue_indexing",
@@ -262,14 +268,14 @@ def index_dialogue(
len(phrases),
)
stored = 0
- for offset in range(0, len(phrases), config.dialogue_batch_size):
+ for offset in range(0, len(phrases), settings.embedding_batch_size):
cancellation.raise_if_cancelled()
- group = phrases[offset:offset + config.dialogue_batch_size]
+ group = phrases[offset:offset + settings.embedding_batch_size]
vectors = encoder.encode(
[phrase.text for phrase in group],
batch_size=len(group),
convert_to_numpy=True,
- normalize_embeddings=config.normalize_dialogue_embeddings,
+ normalize_embeddings=settings.normalize_embeddings,
)
stored += storage.upsert(
"dialogue",
diff --git a/src/vidxp/capabilities/dialogue/operations.py b/src/vidxp/capabilities/dialogue/operations.py
index 3551551..9fd98e3 100644
--- a/src/vidxp/capabilities/dialogue/operations.py
+++ b/src/vidxp/capabilities/dialogue/operations.py
@@ -6,6 +6,7 @@
CapabilityContext,
CapabilityIndexResult,
)
+from vidxp.capabilities.dialogue.config import dialogue_config
from vidxp.capabilities.dialogue.indexing import index_dialogue
from vidxp.capabilities.dialogue.models import get_embedder
from vidxp.capabilities.schemas import SearchInput, SearchResult
@@ -58,11 +59,12 @@ def index_capability(
def dialogue_embedding(query: str, config: IndexConfig) -> list[float]:
- encoder = get_embedder(config.sentence_model, config.device)
+ settings = dialogue_config(config)
+ encoder = get_embedder(settings.sentence_model, config.device)
encoded = encoder.encode(
[query],
convert_to_numpy=True,
- normalize_embeddings=config.normalize_dialogue_embeddings,
+ normalize_embeddings=settings.normalize_embeddings,
)
return encoded[0].tolist()
diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py
index c228253..cce3c83 100644
--- a/src/vidxp/capabilities/registry.py
+++ b/src/vidxp/capabilities/registry.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from types import MappingProxyType
-from typing import Iterable
+from typing import Any, Iterable, Mapping
from vidxp.capabilities.actor.definition import DEFINITION as ACTOR
from vidxp.capabilities.contracts import (
@@ -54,11 +54,38 @@ def validate_capability_names(names: Iterable[str]) -> tuple[str, ...]:
return selected
-def collection_names() -> dict[str, str]:
+def collection_names(
+ names: Iterable[str] | None = None,
+) -> dict[str, str]:
+ selected = (
+ index_capability_names()
+ if names is None
+ else validate_capability_names(names)
+ )
return {
- name: capability.collection_name
- for name, capability in CAPABILITIES.items()
- if capability.indexer is not None
+ name: get_capability(name).collection_name
+ for name in selected
+ if get_capability(name).indexer is not None
+ }
+
+
+def validate_capability_options(
+ names: Iterable[str],
+ options: Mapping[str, Mapping[str, Any]] | None,
+) -> dict[str, dict[str, Any]]:
+ selected = validate_capability_names(names)
+ supplied = dict(options or {})
+ unknown = sorted(set(supplied) - set(selected))
+ if unknown:
+ raise ValueError(
+ "Options were supplied for disabled capabilities: "
+ + ", ".join(unknown)
+ )
+ return {
+ name: get_capability(name)
+ .config_model.model_validate(supplied.get(name, {}))
+ .model_dump(mode="python")
+ for name in selected
}
diff --git a/src/vidxp/capabilities/scene/config.py b/src/vidxp/capabilities/scene/config.py
new file mode 100644
index 0000000..87b2e34
--- /dev/null
+++ b/src/vidxp/capabilities/scene/config.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from pydantic import Field
+
+from vidxp.capabilities.contracts import CapabilityConfig
+from vidxp.core.contracts import IndexConfig
+
+
+class SceneConfig(CapabilityConfig):
+ batch_size: int = Field(default=32, gt=0)
+ model: str = Field(default="ViT-B/32", min_length=1)
+
+
+def scene_config(config: IndexConfig) -> SceneConfig:
+ return SceneConfig.model_validate(config.options_for("scene"))
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
index 02edf71..d1b8fe6 100644
--- a/src/vidxp/capabilities/scene/definition.py
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -7,6 +7,7 @@
OperationDefinition,
RuntimeDependency,
)
+from vidxp.capabilities.scene.config import SceneConfig, scene_config
from vidxp.capabilities.scene.models import get_clip_model
from vidxp.capabilities.scene.operations import search_operation
from vidxp.capabilities.schemas import SearchInput, SearchResult
@@ -30,31 +31,33 @@ def prepare_models(
_language: str | None,
progress: ProgressCallback | None,
) -> tuple[str, ...]:
+ settings = scene_config(config)
if progress is not None:
progress(
{
"state": "preparing",
"stage": "scene_model",
"message": (
- f"Preparing scene model: CLIP {config.clip_model}"
+ f"Preparing scene model: CLIP {settings.model}"
),
}
)
- get_clip_model(config.clip_model, config.device)
- return (config.clip_model,)
+ get_clip_model(settings.model, config.device)
+ return (settings.model,)
def model_manifest(
config: IndexConfig,
_sources: tuple[VideoSource, ...],
) -> Mapping[str, Any]:
- return {"scene": config.clip_model}
+ return {"scene": scene_config(config).model}
DEFINITION = CapabilityDefinition(
name="scene",
description="Index and search visual scenes.",
extra="scene",
+ config_model=SceneConfig,
collection_name="scene",
indexer=index_capabilities,
index_stage="visual_indexing",
diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py
index 77a08f6..59bc7ff 100644
--- a/src/vidxp/capabilities/scene/indexing.py
+++ b/src/vidxp/capabilities/scene/indexing.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from typing import Any
+from vidxp.capabilities.scene.config import scene_config
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
@@ -80,7 +81,8 @@ def process_scene_samples(
storage: IndexStorage,
cancellation: CancellationToken,
) -> None:
- for group in batched(samples, config.scene_batch_size):
+ settings = scene_config(config)
+ for group in batched(samples, settings.batch_size):
cancellation.raise_if_cancelled()
vectors = encode_scene_batch(
group,
diff --git a/src/vidxp/capabilities/scene/operations.py b/src/vidxp/capabilities/scene/operations.py
index a2c8c4c..be04be7 100644
--- a/src/vidxp/capabilities/scene/operations.py
+++ b/src/vidxp/capabilities/scene/operations.py
@@ -3,6 +3,7 @@
from typing import Any, Mapping
from vidxp.capabilities.contracts import CapabilityContext
+from vidxp.capabilities.scene.config import scene_config
from vidxp.capabilities.scene.models import get_clip_model
from vidxp.capabilities.schemas import SearchInput, SearchResult
from vidxp.capabilities.search import search_embeddings
@@ -32,7 +33,8 @@ def scene_embedding(query: str, config: IndexConfig) -> list[float]:
import clip
import torch
- model, _ = get_clip_model(config.clip_model, config.device)
+ settings = scene_config(config)
+ model, _ = get_clip_model(settings.model, config.device)
tokens = clip.tokenize([query]).to(config.device)
with torch.no_grad():
features = model.encode_text(tokens)
diff --git a/src/vidxp/capabilities/visual.py b/src/vidxp/capabilities/visual.py
index 02876dd..7b99620 100644
--- a/src/vidxp/capabilities/visual.py
+++ b/src/vidxp/capabilities/visual.py
@@ -3,6 +3,7 @@
from time import perf_counter
from typing import Any, Sequence
+from vidxp.capabilities.actor.config import actor_config
from vidxp.capabilities.contracts import CapabilityIndexResult
from vidxp.core.contracts import (
CancellationToken,
@@ -19,6 +20,7 @@
SceneIndexState,
process_scene_samples,
)
+from vidxp.capabilities.scene.config import scene_config
from vidxp.capabilities.scene.models import get_clip_model
from vidxp.core.storage import IndexStorage
from vidxp.core.video import (
@@ -58,8 +60,8 @@ def _consume_visual_stream(
) -> FrameStreamStats:
stream_stats = FrameStreamStats()
decode_batch_size = max(
- config.scene_batch_size if "scene" in selected else 0,
- config.actor_batch_size if "actor" in selected else 0,
+ scene_config(config).batch_size if "scene" in selected else 0,
+ actor_config(config).batch_size if "actor" in selected else 0,
)
stream = iter(
iter_frame_batches(
@@ -176,14 +178,15 @@ def index_visuals(
timings = {"frame_stream": 0.0, "scene": 0.0, "actor": 0.0}
if "scene" in selected:
+ settings = scene_config(config)
scene_started = perf_counter()
report_progress(
progress,
"preparing_scene_model",
- f"Preparing scene model: CLIP {config.clip_model}.",
+ f"Preparing scene model: CLIP {settings.model}.",
)
scene_model, scene_preprocess = get_clip_model(
- config.clip_model,
+ settings.model,
config.device,
)
scene_state = SceneIndexState(scene_model, scene_preprocess)
diff --git a/src/vidxp/cli_commands/index.py b/src/vidxp/cli_commands/index.py
index 67dd818..f552757 100644
--- a/src/vidxp/cli_commands/index.py
+++ b/src/vidxp/cli_commands/index.py
@@ -12,6 +12,7 @@
effective_output_format,
emit_json,
emit_status,
+ parse_capability_options,
selected_modalities,
state_from_context,
)
@@ -26,6 +27,7 @@ def create_index(
*,
modalities: Iterable[str],
frame_stride: int,
+ capability_options: dict[str, dict],
) -> dict:
show_progress = (
not state.quiet and state.output_format == OutputFormat.rich
@@ -35,6 +37,7 @@ def create_index(
path,
modalities=modalities,
frame_stride=frame_stride,
+ capability_options=capability_options,
progress_callback=progress.update,
)
if state.output_format == OutputFormat.json:
@@ -77,6 +80,16 @@ def index_create(
help="Materialize every Nth frame for visual modalities.",
),
] = 1,
+ capability_options: Annotated[
+ list[str] | None,
+ typer.Option(
+ "--option",
+ help=(
+ "Capability setting as CAPABILITY.KEY=VALUE; "
+ "repeat for multiple settings."
+ ),
+ ),
+ ] = None,
) -> None:
"""Create or replace a local index for one video."""
@@ -86,6 +99,7 @@ def index_create(
path,
modalities=selected_modalities(modalities),
frame_stride=frame_stride,
+ capability_options=parse_capability_options(capability_options),
)
diff --git a/src/vidxp/cli_support.py b/src/vidxp/cli_support.py
index 6607586..67947f9 100644
--- a/src/vidxp/cli_support.py
+++ b/src/vidxp/cli_support.py
@@ -185,3 +185,22 @@ def parse_modalities(value: str) -> tuple[str, ...]:
return validate_capability_names(selected)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from exc
+
+
+def parse_capability_options(
+ values: Iterable[str] | None,
+) -> dict[str, dict[str, Any]]:
+ options: dict[str, dict[str, Any]] = {}
+ for value in values or ():
+ path, separator, raw = value.partition("=")
+ capability, dot, key = path.partition(".")
+ if not separator or not dot or not capability or not key:
+ raise typer.BadParameter(
+ "Capability options must use CAPABILITY.KEY=VALUE."
+ )
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ parsed = raw
+ options.setdefault(capability, {})[key] = parsed
+ return options
diff --git a/src/vidxp/core/contracts.py b/src/vidxp/core/contracts.py
index 804e964..cece2ce 100644
--- a/src/vidxp/core/contracts.py
+++ b/src/vidxp/core/contracts.py
@@ -72,27 +72,11 @@ class IndexConfig:
split: str = "local"
run_id: str = "default"
video_id: str | None = None
- enabled_modalities: tuple[str, ...] = (
- "dialogue",
- "scene",
- "actor",
- )
+ enabled_modalities: tuple[str, ...] = ()
frame_stride: int = 1
- dialogue_words_per_phrase: int = 5
- scene_batch_size: int = 32
- dialogue_batch_size: int = 128
- transcription_batch_size: int = 16
- actor_batch_size: int = 16
storage_batch_size: int = 256
- normalize_dialogue_embeddings: bool = True
vector_distance: str = "l2"
- face_match_threshold: float = 0.55
- face_num_jitters: int = 2
- actor_min_detections: int = 4
device: str = "cpu"
- sentence_model: str = "sentence-transformers/all-MiniLM-L6-v2"
- whisper_model: str = "large-v2"
- clip_model: str = "ViT-B/32"
capability_options: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
@@ -157,19 +141,10 @@ def __post_init__(self) -> None:
for label in (
"frame_stride",
- "dialogue_words_per_phrase",
- "scene_batch_size",
- "dialogue_batch_size",
- "transcription_batch_size",
- "actor_batch_size",
"storage_batch_size",
- "face_num_jitters",
- "actor_min_detections",
):
if getattr(self, label) <= 0:
raise ValueError(f"{label} must be greater than zero.")
- if not 0 < self.face_match_threshold < 1:
- raise ValueError("face_match_threshold must be between zero and one.")
if self.vector_distance not in {"l2", "cosine", "ip"}:
raise ValueError(
"vector_distance must be one of: l2, cosine, ip."
@@ -200,6 +175,10 @@ def local(cls, **changes: Any) -> "IndexConfig":
defaults = {
"storage_directory": "chroma_data",
}
+ if "enabled_modalities" not in changes:
+ from vidxp.capabilities.registry import index_capability_names
+
+ defaults["enabled_modalities"] = index_capability_names()
defaults.update(changes)
return cls(**defaults)
diff --git a/tests/test_application.py b/tests/test_application.py
index eaa8f11..4b07c03 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -158,7 +158,7 @@ def test_model_preparation_reports_progress_without_cli_dependencies(self):
"message": "Preparing scene model",
}
),
- (config.clip_model,),
+ ("ViT-B/32",),
)[1]
)
capability = Mock(prepare=prepare)
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index 3b7605d..ef99165 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -9,12 +9,16 @@
CapabilityOutput,
OperationDefinition,
)
+from vidxp.capabilities.actor.config import ActorConfig
+from vidxp.capabilities.dialogue.config import DialogueConfig
from vidxp.capabilities.registry import (
CAPABILITIES,
capability_names,
collection_names,
index_capability_names,
+ validate_capability_options,
)
+from vidxp.capabilities.scene.config import SceneConfig
from vidxp.core.contracts import IndexConfig
from vidxp.core.runner import _index_groups
@@ -45,6 +49,7 @@ def test_registry_is_explicit_and_drives_index_collections(self):
"actor": "actor",
},
)
+ self.assertEqual(collection_names(("scene",)), {"scene": "scene"})
def test_registered_operations_use_pydantic_contracts(self):
for capability in CAPABILITIES.values():
@@ -56,6 +61,39 @@ def test_registered_operations_use_pydantic_contracts(self):
issubclass(operation.output_model, BaseModel)
)
+ def test_built_in_settings_are_capability_owned_and_validated(self):
+ self.assertIs(CAPABILITIES["dialogue"].config_model, DialogueConfig)
+ self.assertIs(CAPABILITIES["scene"].config_model, SceneConfig)
+ self.assertIs(CAPABILITIES["actor"].config_model, ActorConfig)
+
+ options = validate_capability_options(
+ ("scene",),
+ {"scene": {"batch_size": 4, "model": "test-model"}},
+ )
+
+ self.assertEqual(
+ options["scene"],
+ {"batch_size": 4, "model": "test-model"},
+ )
+ with self.assertRaises(ValidationError):
+ validate_capability_options(
+ ("actor",),
+ {"actor": {"match_threshold": 2}},
+ )
+
+ def test_core_config_has_no_built_in_capability_fields(self):
+ fields = IndexConfig.__dataclass_fields__
+ for name in (
+ "sentence_model",
+ "whisper_model",
+ "clip_model",
+ "dialogue_batch_size",
+ "scene_batch_size",
+ "actor_batch_size",
+ "face_match_threshold",
+ ):
+ self.assertNotIn(name, fields)
+
def test_operation_validates_both_input_and_output(self):
operation = OperationDefinition(
input_model=ExampleInput,
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1612739..dae6ce6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -211,6 +211,10 @@ def test_index_create_uses_repeated_typed_modalities(self):
"scene",
"--frame-stride",
"5",
+ "--option",
+ "scene.batch_size=4",
+ "--option",
+ "scene.model=test-model",
]
)
@@ -220,6 +224,10 @@ def test_index_create_uses_repeated_typed_modalities(self):
call = self.service.create_index.call_args
self.assertEqual(call.kwargs["modalities"], ("scene",))
self.assertEqual(call.kwargs["frame_stride"], 5)
+ self.assertEqual(
+ call.kwargs["capability_options"],
+ {"scene": {"batch_size": 4, "model": "test-model"}},
+ )
def test_index_status_reports_missing_index_as_json(self):
self.service.index_status.return_value = {
diff --git a/tests/test_contracts.py b/tests/test_contracts.py
index d52b7af..33fa14e 100644
--- a/tests/test_contracts.py
+++ b/tests/test_contracts.py
@@ -61,7 +61,7 @@ def test_config_is_validated_and_run_paths_are_isolated(self):
self.assertEqual(first.fingerprint(), relocated.fingerprint())
def test_path_objects_are_normalized_for_manifest_serialization(self):
- config = IndexConfig(
+ config = IndexConfig.local(
output_root=Path("benchmark-output"),
storage_directory=Path("benchmark-index"),
)
@@ -79,13 +79,20 @@ def test_invalid_config_is_rejected(self):
collection_names={"scene": "scene"},
)
with self.assertRaisesRegex(ValueError, "frame_stride"):
- IndexConfig(frame_stride=0)
+ IndexConfig.local(frame_stride=0)
with self.assertRaisesRegex(ValueError, "cannot be"):
- IndexConfig(dataset="..").run_directory
+ IndexConfig(
+ dataset="..",
+ enabled_modalities=("scene",),
+ ).run_directory
with self.assertRaisesRegex(ValueError, "reserved on Windows"):
- IndexConfig(dataset="CON.txt").run_directory
+ IndexConfig(
+ dataset="CON.txt",
+ enabled_modalities=("scene",),
+ ).run_directory
with self.assertRaisesRegex(ValueError, "distinct"):
IndexConfig(
+ enabled_modalities=("dialogue", "scene", "actor"),
collection_names={
"dialogue": "shared",
"scene": "shared",
@@ -94,6 +101,7 @@ def test_invalid_config_is_rejected(self):
)
with self.assertRaisesRegex(ValueError, "3-512"):
IndexConfig(
+ enabled_modalities=("dialogue", "scene", "actor"),
collection_names={
"dialogue": "a",
"scene": "scene",
@@ -101,7 +109,7 @@ def test_invalid_config_is_rejected(self):
}
)
with self.assertRaisesRegex(ValueError, "vector_distance"):
- IndexConfig(vector_distance="unknown")
+ IndexConfig.local(vector_distance="unknown")
with self.assertRaisesRegex(ValueError, "SHA-256"):
VideoSource(path="video.mp4", checksum="not-a-checksum")
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index 7a444e1..c74dc3c 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -103,7 +103,9 @@ def test_supplied_transcript_is_batched_without_video_or_whisper(self):
run_id="asr",
video_id="video-1",
enabled_modalities=("dialogue",),
- dialogue_batch_size=2,
+ capability_options={
+ "dialogue": {"embedding_batch_size": 2},
+ },
)
source = VideoSource(
video_id="video-1",
@@ -166,7 +168,7 @@ def test_scene_model_and_storage_writes_are_batched_with_full_metadata(self):
video_id="video-1",
enabled_modalities=("scene",),
frame_stride=2,
- scene_batch_size=2,
+ capability_options={"scene": {"batch_size": 2}},
)
source = VideoSource(video_id="video-1", path="unused.mp4")
storage = CapturingStorage()
@@ -231,7 +233,7 @@ def test_actor_only_records_have_stable_detection_metadata(self):
run_id="actors",
video_id="video-1",
enabled_modalities=("actor",),
- actor_min_detections=2,
+ capability_options={"actor": {"minimum_detections": 2}},
storage_batch_size=2,
)
source = VideoSource(video_id="video-1", path="unused.mp4")
@@ -315,9 +317,13 @@ def test_scene_and_actor_share_one_probe_and_frame_stream(self):
video_id="video-1",
enabled_modalities=("scene", "actor"),
frame_stride=2,
- scene_batch_size=2,
- actor_batch_size=1,
- actor_min_detections=1,
+ capability_options={
+ "scene": {"batch_size": 2},
+ "actor": {
+ "batch_size": 1,
+ "minimum_detections": 1,
+ },
+ },
)
source = VideoSource(video_id="video-1", path="unused.mp4")
storage = CapturingStorage()
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 2829a6c..6db7d5f 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -53,6 +53,7 @@ def test_checkpoint_filenames_do_not_embed_dataset_video_ids(self):
split="test",
run_id="run-1",
output_root=directory,
+ enabled_modalities=("scene",),
)
store = ManifestStore(config)
video_id = "folder/name:video"
diff --git a/tests/test_runner.py b/tests/test_runner.py
index 4d1a47c..001be5a 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -11,6 +11,7 @@
)
from vidxp.core.manifest import COMPLETION_FILE
from vidxp.capabilities.contracts import CapabilityIndexResult
+from vidxp.capabilities.dialogue.config import dialogue_config
from vidxp.core.runner import (
_RunLock,
index_video,
@@ -309,7 +310,7 @@ def test_manifest_adds_transcription_model_when_run_later_needs_it(self):
self.assertNotIn("transcription", first["models"])
self.assertEqual(
second["models"]["transcription"],
- config.whisper_model,
+ dialogue_config(config).whisper_model,
)
def test_changed_input_is_not_silently_accepted_by_checkpoint(self):
From 39134281bafc8ac8a6f6455239368b7581c1f302 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 22:56:41 +0500
Subject: [PATCH 15/44] refactor(runtime): generalize capability preparation
---
INSTALLATION_GUIDE.md | 2 +-
src/vidxp/application.py | 36 ++++++++++++-------
src/vidxp/capabilities/contracts.py | 10 +++++-
src/vidxp/capabilities/dialogue/config.py | 1 +
src/vidxp/capabilities/dialogue/definition.py | 20 ++++++-----
src/vidxp/capabilities/registry.py | 8 +++++
src/vidxp/capabilities/scene/definition.py | 8 ++---
src/vidxp/cli_commands/runtime.py | 15 ++++----
tests/test_application.py | 14 ++++++--
tests/test_capabilities.py | 5 +++
tests/test_cli.py | 18 +++++++---
11 files changed, 97 insertions(+), 40 deletions(-)
diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md
index 470c386..8bf22d4 100644
--- a/INSTALLATION_GUIDE.md
+++ b/INSTALLATION_GUIDE.md
@@ -135,7 +135,7 @@ WhisperX selects its alignment model after detecting the video's language. Cache
a known language explicitly when required:
```bash
-vidxp prepare --language en
+vidxp prepare --option dialogue.alignment_language=en
```
SentenceTransformer and WhisperX use the Hugging Face cache; CLIP uses its own
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index fca32dd..00ae01f 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -8,6 +8,7 @@
from vidxp.capabilities.contracts import (
CapabilityContext,
+ PreparationContext,
capability_install_hint,
)
from vidxp.capabilities.registry import (
@@ -16,6 +17,7 @@
dependency_checks,
get_capability,
index_capability_names,
+ preparable_capability_names,
validate_capability_options,
validate_capability_names,
)
@@ -161,18 +163,20 @@ def check_dependencies(
def prepare_models(
self,
- modalities: Iterable[str] = ("dialogue", "scene"),
+ modalities: Iterable[str] | None = None,
*,
- language: str | None = None,
+ capability_options: Mapping[
+ str,
+ Mapping[str, Any],
+ ] | None = None,
progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
- selected = self._validate_modalities(modalities)
- config = IndexConfig.local(
- enabled_modalities=selected,
- device=self.device or "cpu",
- storage_directory=self.index_directory,
- collection_names=collection_names(),
+ selected = self._validate_modalities(
+ preparable_capability_names()
+ if modalities is None
+ else modalities
)
+ options = validate_capability_options(selected, capability_options)
checks = dependency_checks(selected)
failures = [check for check in checks if not check["ok"]]
if failures:
@@ -189,16 +193,24 @@ def prepare_models(
prepared = []
for name in selected:
- prepare = get_capability(name).prepare
+ capability = get_capability(name)
+ prepare = capability.prepare
if prepare is not None:
prepared.extend(
- prepare(config, language, progress_callback)
+ prepare(
+ PreparationContext(
+ device=self.device or "cpu",
+ settings=capability.config_model.model_validate(
+ options[name]
+ ),
+ ),
+ progress_callback,
+ )
)
return {
"prepared": prepared,
"modalities": list(selected),
- "device": config.device,
- "language": language,
+ "device": self.device or "cpu",
}
def execute(
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
index edf9646..a06c8e6 100644
--- a/src/vidxp/capabilities/contracts.py
+++ b/src/vidxp/capabilities/contracts.py
@@ -77,6 +77,14 @@ def require_config(self) -> IndexConfig:
return self.config
+@dataclass(frozen=True)
+class PreparationContext:
+ """Runtime values supplied to one capability's preparation hook."""
+
+ device: str
+ settings: CapabilityConfig
+
+
OperationHandler = Callable[[CapabilityContext, BaseModel], BaseModel | Mapping]
@@ -109,7 +117,7 @@ class CapabilityIndexResult:
IndexHandler = Callable[..., CapabilityIndexResult]
PrepareHandler = Callable[
- ["IndexConfig", str | None, "ProgressCallback | None"],
+ [PreparationContext, "ProgressCallback | None"],
tuple[str, ...],
]
DependencySelector = Callable[
diff --git a/src/vidxp/capabilities/dialogue/config.py b/src/vidxp/capabilities/dialogue/config.py
index 988da56..3999801 100644
--- a/src/vidxp/capabilities/dialogue/config.py
+++ b/src/vidxp/capabilities/dialogue/config.py
@@ -16,6 +16,7 @@ class DialogueConfig(CapabilityConfig):
min_length=1,
)
whisper_model: str = Field(default="large-v2", min_length=1)
+ alignment_language: str | None = Field(default=None, min_length=1)
def dialogue_config(config: IndexConfig) -> DialogueConfig:
diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py
index c4744d8..d4b3855 100644
--- a/src/vidxp/capabilities/dialogue/definition.py
+++ b/src/vidxp/capabilities/dialogue/definition.py
@@ -5,6 +5,7 @@
from vidxp.capabilities.contracts import (
CapabilityDefinition,
OperationDefinition,
+ PreparationContext,
RuntimeDependency,
)
from vidxp.capabilities.dialogue.config import DialogueConfig, dialogue_config
@@ -51,11 +52,10 @@ def dependencies_for_source(
def prepare_models(
- config: IndexConfig,
- language: str | None,
+ context: PreparationContext,
progress: ProgressCallback | None,
) -> tuple[str, ...]:
- settings = dialogue_config(config)
+ settings = DialogueConfig.model_validate(context.settings)
prepared = []
def report(stage: str, message: str) -> None:
@@ -72,21 +72,23 @@ def report(stage: str, message: str) -> None:
"dialogue_model",
f"Preparing dialogue model: {settings.sentence_model}",
)
- get_embedder(settings.sentence_model, config.device)
+ get_embedder(settings.sentence_model, context.device)
prepared.append(settings.sentence_model)
report(
"transcription_model",
f"Preparing transcription model: WhisperX {settings.whisper_model}",
)
- get_whisper_model(settings.whisper_model, config.device)
+ get_whisper_model(settings.whisper_model, context.device)
prepared.append(settings.whisper_model)
- if language:
+ if settings.alignment_language:
report(
"alignment_model",
- f"Preparing the {language} alignment model.",
+ f"Preparing the {settings.alignment_language} alignment model.",
+ )
+ get_alignment_model(settings.alignment_language, context.device)
+ prepared.append(
+ f"whisperx-alignment:{settings.alignment_language}"
)
- get_alignment_model(language, config.device)
- prepared.append(f"whisperx-alignment:{language}")
return tuple(prepared)
diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py
index cce3c83..1f6b025 100644
--- a/src/vidxp/capabilities/registry.py
+++ b/src/vidxp/capabilities/registry.py
@@ -35,6 +35,14 @@ def index_capability_names() -> tuple[str, ...]:
)
+def preparable_capability_names() -> tuple[str, ...]:
+ return tuple(
+ name
+ for name, capability in CAPABILITIES.items()
+ if capability.prepare is not None
+ )
+
+
def get_capability(name: str) -> CapabilityDefinition:
try:
return CAPABILITIES[name]
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
index d1b8fe6..993254b 100644
--- a/src/vidxp/capabilities/scene/definition.py
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -5,6 +5,7 @@
from vidxp.capabilities.contracts import (
CapabilityDefinition,
OperationDefinition,
+ PreparationContext,
RuntimeDependency,
)
from vidxp.capabilities.scene.config import SceneConfig, scene_config
@@ -27,11 +28,10 @@
def prepare_models(
- config: IndexConfig,
- _language: str | None,
+ context: PreparationContext,
progress: ProgressCallback | None,
) -> tuple[str, ...]:
- settings = scene_config(config)
+ settings = SceneConfig.model_validate(context.settings)
if progress is not None:
progress(
{
@@ -42,7 +42,7 @@ def prepare_models(
),
}
)
- get_clip_model(settings.model, config.device)
+ get_clip_model(settings.model, context.device)
return (settings.model,)
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
index 77c416f..94a97e6 100644
--- a/src/vidxp/cli_commands/runtime.py
+++ b/src/vidxp/cli_commands/runtime.py
@@ -10,6 +10,7 @@
OutputFormat,
effective_output_format,
emit_json,
+ parse_capability_options,
parse_modalities,
state_from_context,
)
@@ -77,12 +78,14 @@ def prepare(
help="Only prepare models for these modalities.",
),
] = PREPARABLE_CAPABILITIES,
- language: Annotated[
- str | None,
+ capability_options: Annotated[
+ list[str] | None,
typer.Option(
- "--language",
- "-l",
- help="Also cache the WhisperX alignment model for this language.",
+ "--option",
+ help=(
+ "Capability setting as CAPABILITY.KEY=VALUE; "
+ "repeat for multiple settings."
+ ),
),
] = None,
json_output: Annotated[
@@ -96,7 +99,7 @@ def prepare(
state = state_from_context(ctx)
result = state.service.prepare_models(
selected,
- language=language,
+ capability_options=parse_capability_options(capability_options),
progress_callback=(
None
if state.quiet
diff --git a/tests/test_application.py b/tests/test_application.py
index 4b07c03..f6ea034 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -7,7 +7,9 @@
from vidxp.capabilities.contracts import (
CapabilityDefinition,
OperationDefinition,
+ PreparationContext,
)
+from vidxp.capabilities.scene.config import SceneConfig
from vidxp.capabilities.schemas import SearchInput, SearchResult
from vidxp.core.contracts import IndexConfig
@@ -150,7 +152,7 @@ def test_model_preparation_reports_progress_without_cli_dependencies(self):
service = VidXPService(device="cuda")
events = []
prepare = Mock(
- side_effect=lambda config, _language, progress: (
+ side_effect=lambda context, progress: (
progress(
{
"state": "preparing",
@@ -158,10 +160,13 @@ def test_model_preparation_reports_progress_without_cli_dependencies(self):
"message": "Preparing scene model",
}
),
- ("ViT-B/32",),
+ (SceneConfig.model_validate(context.settings).model,),
)[1]
)
- capability = Mock(prepare=prepare)
+ capability = Mock(
+ prepare=prepare,
+ config_model=SceneConfig,
+ )
with (
patch(
"vidxp.application.dependency_checks",
@@ -178,6 +183,9 @@ def test_model_preparation_reports_progress_without_cli_dependencies(self):
)
prepare.assert_called_once()
+ context = prepare.call_args.args[0]
+ self.assertIsInstance(context, PreparationContext)
+ self.assertEqual(context.device, "cuda")
self.assertEqual(result["device"], "cuda")
self.assertEqual(events[0]["stage"], "scene_model")
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index ef99165..06bf784 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -16,6 +16,7 @@
capability_names,
collection_names,
index_capability_names,
+ preparable_capability_names,
validate_capability_options,
)
from vidxp.capabilities.scene.config import SceneConfig
@@ -41,6 +42,10 @@ def test_registry_is_explicit_and_drives_index_collections(self):
index_capability_names(),
capability_names(),
)
+ self.assertEqual(
+ preparable_capability_names(),
+ ("dialogue", "scene"),
+ )
self.assertEqual(
collection_names(),
{
diff --git a/tests/test_cli.py b/tests/test_cli.py
index dae6ce6..4dca03b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -259,15 +259,19 @@ def test_doctor_and_prepare_use_the_reusable_service(self):
"prepared": ["ViT-B/32"],
"modalities": ["scene"],
"device": "cpu",
- "language": None,
}
checked = self.invoke(
["doctor", "--modalities", "scene", "--json"]
)
- prepared = self.invoke(
- ["prepare", "--modalities", "scene", "--json"]
- )
+ prepared = self.invoke([
+ "prepare",
+ "--modalities",
+ "scene",
+ "--option",
+ "scene.model=test-model",
+ "--json",
+ ])
self.assertTrue(json.loads(checked.stdout)["ok"])
self.assertEqual(
@@ -276,6 +280,12 @@ def test_doctor_and_prepare_use_the_reusable_service(self):
)
self.service.check_dependencies.assert_called_once_with(("scene",))
self.service.prepare_models.assert_called_once()
+ self.assertEqual(
+ self.service.prepare_models.call_args.kwargs[
+ "capability_options"
+ ],
+ {"scene": {"model": "test-model"}},
+ )
def test_ui_receives_the_selected_service_configuration(self):
self.service.index_directory = Path("selected-index")
From cf170729c8c35ca943a9f0e1493b9be149f3386d Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:01:22 +0500
Subject: [PATCH 16/44] refactor(indexing): make visual processors
capability-owned
---
docs/adding-a-capability.md | 4 +-
src/vidxp/capabilities/actor/definition.py | 2 +
src/vidxp/capabilities/actor/indexing.py | 55 +++++
src/vidxp/capabilities/contracts.py | 1 +
src/vidxp/capabilities/scene/definition.py | 2 +
src/vidxp/capabilities/scene/indexing.py | 52 +++++
src/vidxp/capabilities/visual.py | 246 +++++++++++----------
tests/test_capabilities.py | 3 +
tests/test_indexing.py | 61 ++++-
9 files changed, 306 insertions(+), 120 deletions(-)
diff --git a/docs/adding-a-capability.md b/docs/adding-a-capability.md
index 8f8679a..4209402 100644
--- a/docs/adding-a-capability.md
+++ b/docs/adding-a-capability.md
@@ -41,7 +41,9 @@ FastAPI, or an MCP implementation into operation modules.
An indexable capability also declares its collection names, indexing handler,
and index stage. The generic runner groups capabilities that reference the same
indexing handler, allowing related capabilities to share work without adding a
-name switch to the runner. Capability-specific settings belong in
+name switch to the runner. A capability joining a shared decoder also supplies
+its own prepare/process/finalize processor through its definition; the shared
+handler must not import individual capabilities. Capability-specific settings belong in
`IndexConfig.capability_options` and are read with
`config.options_for("")`. Validate them through the capability's
Pydantic settings model; do not add feature-specific fields to `IndexConfig`
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
index 979768c..b2156b6 100644
--- a/src/vidxp/capabilities/actor/definition.py
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -3,6 +3,7 @@
from typing import Any, Mapping
from vidxp.capabilities.actor.config import ActorConfig, actor_config
+from vidxp.capabilities.actor.indexing import VISUAL_PROCESSOR
from vidxp.capabilities.actor.operations import (
clusters_operation,
detections_operation,
@@ -71,6 +72,7 @@ def cli_app():
config_model=ActorConfig,
collection_name="actor",
indexer=index_capabilities,
+ index_processor=VISUAL_PROCESSOR,
index_stage="visual_indexing",
dependencies=DEPENDENCIES,
model_manifest=model_manifest,
diff --git a/src/vidxp/capabilities/actor/indexing.py b/src/vidxp/capabilities/actor/indexing.py
index 39ad22b..7328d10 100644
--- a/src/vidxp/capabilities/actor/indexing.py
+++ b/src/vidxp/capabilities/actor/indexing.py
@@ -11,6 +11,7 @@
batched,
stable_source_id,
)
+from vidxp.core.indexing_common import ProgressCallback
from vidxp.core.storage import IndexStorage
@@ -160,3 +161,57 @@ def finalize_actor_index(
if size >= settings.minimum_detections
}
return sum(retained.values()), len(retained)
+
+
+class ActorVisualProcessor:
+ def batch_size(self, config: IndexConfig) -> int:
+ return actor_config(config).batch_size
+
+ def prepare(
+ self,
+ config: IndexConfig,
+ progress: ProgressCallback | None,
+ ) -> ActorIndexState:
+ return ActorIndexState()
+
+ def process(
+ self,
+ samples,
+ *,
+ state: ActorIndexState,
+ info,
+ config: IndexConfig,
+ storage: IndexStorage,
+ cancellation: CancellationToken,
+ ) -> None:
+ process_actor_samples(
+ samples,
+ state=state,
+ config=config,
+ storage=storage,
+ cancellation=cancellation,
+ )
+
+ def finalize(
+ self,
+ state: ActorIndexState,
+ *,
+ config: IndexConfig,
+ storage: IndexStorage,
+ ) -> tuple[dict[str, Any], int]:
+ detections, clusters = finalize_actor_index(
+ state,
+ config=config,
+ storage=storage,
+ )
+ return (
+ {
+ "actor_frames": state.processed_frames,
+ "actor_detections": detections,
+ "actor_clusters": clusters,
+ },
+ state.processed_frames,
+ )
+
+
+VISUAL_PROCESSOR = ActorVisualProcessor()
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
index a06c8e6..2f78ed3 100644
--- a/src/vidxp/capabilities/contracts.py
+++ b/src/vidxp/capabilities/contracts.py
@@ -142,6 +142,7 @@ class CapabilityDefinition:
dependencies: tuple[RuntimeDependency, ...] = ()
collection_name: str | None = None
indexer: IndexHandler | None = None
+ index_processor: Any | None = None
index_stage: str | None = None
operations: Mapping[str, OperationDefinition] = field(default_factory=dict)
dependencies_for_source: DependencySelector | None = None
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
index 993254b..135d932 100644
--- a/src/vidxp/capabilities/scene/definition.py
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -9,6 +9,7 @@
RuntimeDependency,
)
from vidxp.capabilities.scene.config import SceneConfig, scene_config
+from vidxp.capabilities.scene.indexing import VISUAL_PROCESSOR
from vidxp.capabilities.scene.models import get_clip_model
from vidxp.capabilities.scene.operations import search_operation
from vidxp.capabilities.schemas import SearchInput, SearchResult
@@ -60,6 +61,7 @@ def model_manifest(
config_model=SceneConfig,
collection_name="scene",
indexer=index_capabilities,
+ index_processor=VISUAL_PROCESSOR,
index_stage="visual_indexing",
dependencies=DEPENDENCIES,
prepare=prepare_models,
diff --git a/src/vidxp/capabilities/scene/indexing.py b/src/vidxp/capabilities/scene/indexing.py
index 59bc7ff..33cb1a0 100644
--- a/src/vidxp/capabilities/scene/indexing.py
+++ b/src/vidxp/capabilities/scene/indexing.py
@@ -4,6 +4,7 @@
from typing import Any
from vidxp.capabilities.scene.config import scene_config
+from vidxp.capabilities.scene.models import get_clip_model
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
@@ -11,6 +12,7 @@
batched,
stable_source_id,
)
+from vidxp.core.indexing_common import ProgressCallback, report_progress
from vidxp.core.storage import IndexStorage
@@ -96,3 +98,53 @@ def process_scene_samples(
batch_size=config.storage_batch_size,
cancellation=cancellation,
)
+
+
+class SceneVisualProcessor:
+ def batch_size(self, config: IndexConfig) -> int:
+ return scene_config(config).batch_size
+
+ def prepare(
+ self,
+ config: IndexConfig,
+ progress: ProgressCallback | None,
+ ) -> SceneIndexState:
+ settings = scene_config(config)
+ report_progress(
+ progress,
+ "preparing_scene_model",
+ f"Preparing scene model: CLIP {settings.model}.",
+ )
+ model, preprocess = get_clip_model(settings.model, config.device)
+ return SceneIndexState(model, preprocess)
+
+ def process(
+ self,
+ samples,
+ *,
+ state: SceneIndexState,
+ info,
+ config: IndexConfig,
+ storage: IndexStorage,
+ cancellation: CancellationToken,
+ ) -> None:
+ process_scene_samples(
+ samples,
+ state=state,
+ info=info,
+ config=config,
+ storage=storage,
+ cancellation=cancellation,
+ )
+
+ def finalize(
+ self,
+ state: SceneIndexState,
+ *,
+ config: IndexConfig,
+ storage: IndexStorage,
+ ) -> tuple[dict[str, Any], int]:
+ return {"scene_frames": state.stored_frames}, state.stored_frames
+
+
+VISUAL_PROCESSOR = SceneVisualProcessor()
diff --git a/src/vidxp/capabilities/visual.py b/src/vidxp/capabilities/visual.py
index 7b99620..b7ef539 100644
--- a/src/vidxp/capabilities/visual.py
+++ b/src/vidxp/capabilities/visual.py
@@ -1,27 +1,16 @@
from __future__ import annotations
+from dataclasses import dataclass
from time import perf_counter
-from typing import Any, Sequence
+from typing import Any, Protocol, Sequence
-from vidxp.capabilities.actor.config import actor_config
from vidxp.capabilities.contracts import CapabilityIndexResult
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
VideoSource,
)
-from vidxp.capabilities.actor.indexing import (
- ActorIndexState,
- finalize_actor_index,
- process_actor_samples,
-)
from vidxp.core.indexing_common import ProgressCallback, report_progress
-from vidxp.capabilities.scene.indexing import (
- SceneIndexState,
- process_scene_samples,
-)
-from vidxp.capabilities.scene.config import scene_config
-from vidxp.capabilities.scene.models import get_clip_model
from vidxp.core.storage import IndexStorage
from vidxp.core.video import (
FrameSample,
@@ -31,6 +20,42 @@
)
+class VisualProcessor(Protocol):
+ def batch_size(self, config: IndexConfig) -> int: ...
+
+ def prepare(
+ self,
+ config: IndexConfig,
+ progress: ProgressCallback | None,
+ ) -> Any: ...
+
+ def process(
+ self,
+ samples: Sequence[FrameSample],
+ *,
+ state: Any,
+ info: Any,
+ config: IndexConfig,
+ storage: IndexStorage,
+ cancellation: CancellationToken,
+ ) -> None: ...
+
+ def finalize(
+ self,
+ state: Any,
+ *,
+ config: IndexConfig,
+ storage: IndexStorage,
+ ) -> tuple[dict[str, Any], int]: ...
+
+
+@dataclass
+class _Participant:
+ name: str
+ processor: VisualProcessor
+ state: Any
+
+
def _rgb_samples(samples) -> list[FrameSample]:
import cv2
@@ -44,14 +69,35 @@ def _rgb_samples(samples) -> list[FrameSample]:
]
+def _participants(
+ names: Sequence[str],
+ *,
+ config: IndexConfig,
+ progress: ProgressCallback | None,
+ timings: dict[str, float],
+) -> list[_Participant]:
+ from vidxp.capabilities.registry import get_capability
+
+ participants = []
+ for name in names:
+ processor = get_capability(name).index_processor
+ if processor is None:
+ raise ValueError(
+ f"Capability {name!r} does not provide a visual processor."
+ )
+ started = perf_counter()
+ state = processor.prepare(config, progress)
+ timings[name] = perf_counter() - started
+ participants.append(_Participant(name, processor, state))
+ return participants
+
+
def _consume_visual_stream(
source: VideoSource,
*,
- selected: tuple[str, ...],
+ participants: Sequence[_Participant],
expected: int,
- info,
- scene_state: SceneIndexState | None,
- actor_state: ActorIndexState | None,
+ info: Any,
config: IndexConfig,
storage: IndexStorage,
cancellation: CancellationToken,
@@ -59,15 +105,14 @@ def _consume_visual_stream(
timings: dict[str, float],
) -> FrameStreamStats:
stream_stats = FrameStreamStats()
- decode_batch_size = max(
- scene_config(config).batch_size if "scene" in selected else 0,
- actor_config(config).batch_size if "actor" in selected else 0,
- )
stream = iter(
iter_frame_batches(
source.path,
frame_stride=config.frame_stride,
- batch_size=decode_batch_size,
+ batch_size=max(
+ participant.processor.batch_size(config)
+ for participant in participants
+ ),
cancellation=cancellation,
stats=stream_stats,
)
@@ -82,28 +127,19 @@ def _consume_visual_stream(
rgb_samples = _rgb_samples(samples)
timings["frame_stream"] += perf_counter() - stream_started
- if scene_state is not None:
- scene_started = perf_counter()
- process_scene_samples(
+ for participant in participants:
+ processor_started = perf_counter()
+ participant.processor.process(
rgb_samples,
- state=scene_state,
+ state=participant.state,
info=info,
config=config,
storage=storage,
cancellation=cancellation,
)
- timings["scene"] += perf_counter() - scene_started
-
- if actor_state is not None:
- actor_started = perf_counter()
- process_actor_samples(
- rgb_samples,
- state=actor_state,
- config=config,
- storage=storage,
- cancellation=cancellation,
+ timings[participant.name] += (
+ perf_counter() - processor_started
)
- timings["actor"] += perf_counter() - actor_started
report_progress(
progress,
@@ -115,35 +151,32 @@ def _consume_visual_stream(
return stream_stats
-def _visual_summary(
+def _finalize(
+ participants: Sequence[_Participant],
*,
- scene_state: SceneIndexState | None,
- actor_state: ActorIndexState | None,
- stream_stats: FrameStreamStats,
- actor_detections: int,
- actor_clusters: int,
- info,
-) -> dict[str, Any]:
- scene_frames = scene_state.stored_frames if scene_state is not None else 0
- actor_frames = (
- actor_state.processed_frames if actor_state is not None else 0
- )
- sampled_frames = (
- stream_stats.frames_materialized
- or max(scene_frames, actor_frames)
- )
- return {
- "source_frames_advanced": stream_stats.frames_advanced,
- "sampled_frames": sampled_frames,
- "processed_frames": sampled_frames,
- "frame_operations": scene_frames + actor_frames,
- "scene_frames": scene_frames,
- "actor_frames": actor_frames,
- "actor_detections": actor_detections,
- "actor_clusters": actor_clusters,
- "duration": info.duration,
- "fps": info.fps,
- }
+ config: IndexConfig,
+ storage: IndexStorage,
+ timings: dict[str, float],
+) -> tuple[dict[str, Any], int]:
+ summary: dict[str, Any] = {}
+ frame_operations = 0
+ for participant in participants:
+ started = perf_counter()
+ result, operations = participant.processor.finalize(
+ participant.state,
+ config=config,
+ storage=storage,
+ )
+ timings[participant.name] += perf_counter() - started
+ duplicate = set(summary).intersection(result)
+ if duplicate:
+ raise ValueError(
+ "Visual capability summaries contain duplicate keys: "
+ + ", ".join(sorted(duplicate))
+ )
+ summary.update(result)
+ frame_operations += operations
+ return summary, frame_operations
def index_visuals(
@@ -158,40 +191,28 @@ def index_visuals(
if config.video_id is None:
raise ValueError("IndexConfig.video_id is required for indexing.")
if source.path is None:
- raise ValueError("Scene and actor indexing require a video path.")
+ raise ValueError("Visual indexing requires a video path.")
selected = tuple(
- modality
- for modality in (
- config.enabled_modalities if modalities is None else modalities
- )
- if modality in {"scene", "actor"}
+ config.enabled_modalities if modalities is None else modalities
)
if not selected:
- raise ValueError("At least one visual modality must be selected.")
+ raise ValueError("At least one visual capability must be selected.")
started = perf_counter()
info = probe_video(source.path)
- expected = (info.frame_count + config.frame_stride - 1) // config.frame_stride
- scene_state = None
- actor_state = ActorIndexState() if "actor" in selected else None
- timings = {"frame_stream": 0.0, "scene": 0.0, "actor": 0.0}
-
- if "scene" in selected:
- settings = scene_config(config)
- scene_started = perf_counter()
- report_progress(
- progress,
- "preparing_scene_model",
- f"Preparing scene model: CLIP {settings.model}.",
- )
- scene_model, scene_preprocess = get_clip_model(
- settings.model,
- config.device,
- )
- scene_state = SceneIndexState(scene_model, scene_preprocess)
- timings["scene"] += perf_counter() - scene_started
-
+ expected = (
+ info.frame_count + config.frame_stride - 1
+ ) // config.frame_stride
+ timings = {
+ "frame_stream": 0.0,
+ }
+ participants = _participants(
+ selected,
+ config=config,
+ progress=progress,
+ timings=timings,
+ )
report_progress(
progress,
"visual_indexing",
@@ -203,38 +224,33 @@ def index_visuals(
)
stream_stats = _consume_visual_stream(
source,
- selected=selected,
+ participants=participants,
expected=expected,
info=info,
- scene_state=scene_state,
- actor_state=actor_state,
config=config,
storage=storage,
cancellation=cancellation,
progress=progress,
timings=timings,
)
-
- actor_detections = actor_clusters = 0
- if actor_state is not None:
- actor_started = perf_counter()
- actor_detections, actor_clusters = finalize_actor_index(
- actor_state,
- config=config,
- storage=storage,
- )
- timings["actor"] += perf_counter() - actor_started
+ capability_summary, frame_operations = _finalize(
+ participants,
+ config=config,
+ storage=storage,
+ timings=timings,
+ )
+ sampled_frames = stream_stats.frames_materialized
timings["visual_total"] = perf_counter() - started
-
return CapabilityIndexResult(
- summary=_visual_summary(
- scene_state=scene_state,
- actor_state=actor_state,
- stream_stats=stream_stats,
- actor_detections=actor_detections,
- actor_clusters=actor_clusters,
- info=info,
- ),
+ summary={
+ "source_frames_advanced": stream_stats.frames_advanced,
+ "sampled_frames": sampled_frames,
+ "processed_frames": sampled_frames,
+ "frame_operations": frame_operations,
+ "duration": info.duration,
+ "fps": info.fps,
+ **capability_summary,
+ },
timings=timings,
)
@@ -248,8 +264,6 @@ def index_capabilities(
progress: ProgressCallback | None = None,
modalities: Sequence[str] | None = None,
) -> CapabilityIndexResult:
- """Registry-facing wrapper that keeps the shared indexer patchable."""
-
return index_visuals(
source,
config=config,
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index 06bf784..7351890 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -146,6 +146,9 @@ def test_shared_visual_handler_is_grouped_without_name_switches(self):
_index_groups(("dialogue", "scene", "actor")),
(("dialogue",), ("scene", "actor")),
)
+ self.assertIsNotNone(CAPABILITIES["scene"].index_processor)
+ self.assertIsNotNone(CAPABILITIES["actor"].index_processor)
+ self.assertIsNone(CAPABILITIES["dialogue"].index_processor)
def test_capability_options_do_not_require_core_config_fields(self):
config = IndexConfig(
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index c74dc3c..b8ebb7d 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -51,6 +51,61 @@ def encode_image(self, images):
class IndexingTests(unittest.TestCase):
+ def test_shared_visual_stream_accepts_a_registered_processor(self):
+ frame = np.zeros((2, 2, 3), dtype=np.uint8)
+ info = VideoInfo(
+ fps=10.0,
+ frame_count=1,
+ duration=0.1,
+ width=2,
+ height=2,
+ )
+ processor = Mock()
+ processor.batch_size.return_value = 1
+ processor.prepare.return_value = object()
+ processor.finalize.return_value = ({"ocr_frames": 1}, 1)
+
+ def stream(*_, **options):
+ options["stats"].frames_advanced = 1
+ options["stats"].frames_materialized = 1
+ return iter([[FrameSample(0, 0.0, frame)]])
+
+ config = IndexConfig(
+ video_id="video-1",
+ enabled_modalities=("ocr",),
+ collection_names={"ocr": "ocr"},
+ )
+ with (
+ patch(
+ "vidxp.capabilities.registry.get_capability",
+ return_value=Mock(index_processor=processor),
+ ),
+ patch(
+ "vidxp.capabilities.visual.probe_video",
+ return_value=info,
+ ),
+ patch(
+ "vidxp.capabilities.visual.iter_frame_batches",
+ side_effect=stream,
+ ),
+ patch(
+ "vidxp.capabilities.visual._rgb_samples",
+ side_effect=lambda samples: samples,
+ ),
+ ):
+ result = index_visuals(
+ VideoSource(path="unused.mp4"),
+ config=config,
+ storage=CapturingStorage(),
+ cancellation=CancellationToken(),
+ )
+
+ processor.prepare.assert_called_once()
+ processor.process.assert_called_once()
+ processor.finalize.assert_called_once()
+ self.assertEqual(result.summary["ocr_frames"], 1)
+ self.assertEqual(result.summary["frame_operations"], 1)
+
def test_timestamped_words_and_segments_keep_real_intervals(self):
phrases = build_dialogue_phrases(
[
@@ -196,7 +251,7 @@ def test_scene_model_and_storage_writes_are_batched_with_full_metadata(self):
return_value=iter(batches),
),
patch(
- "vidxp.capabilities.visual.get_clip_model",
+ "vidxp.capabilities.scene.indexing.get_clip_model",
return_value=(
model,
lambda _: torch.ones((3, 2, 2), dtype=torch.float32),
@@ -365,14 +420,14 @@ def consume_actor(samples, *, state, **_):
frame_stream,
),
patch(
- "vidxp.capabilities.visual.get_clip_model",
+ "vidxp.capabilities.scene.indexing.get_clip_model",
return_value=(
model,
lambda _: torch.ones((3, 2, 2), dtype=torch.float32),
),
),
patch(
- "vidxp.capabilities.visual.process_actor_samples",
+ "vidxp.capabilities.actor.indexing.process_actor_samples",
side_effect=consume_actor,
) as actor_consumer,
patch(
From 18b2d3aa6e7517e719c1da9d56d218638fa00ba4 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:02:25 +0500
Subject: [PATCH 17/44] refactor(actor): own capability schemas
---
src/vidxp/application.py | 4 +-
src/vidxp/capabilities/actor/definition.py | 2 +-
src/vidxp/capabilities/actor/operations.py | 2 +-
src/vidxp/capabilities/actor/results.py | 2 +-
src/vidxp/capabilities/actor/schemas.py | 60 ++++++++++++++++++++++
src/vidxp/capabilities/schemas.py | 54 -------------------
tests/test_cli.py | 5 +-
7 files changed, 67 insertions(+), 62 deletions(-)
create mode 100644 src/vidxp/capabilities/actor/schemas.py
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index 00ae01f..8e44911 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -21,12 +21,12 @@
validate_capability_options,
validate_capability_names,
)
-from vidxp.capabilities.schemas import (
+from vidxp.capabilities.actor.schemas import (
ActorClusterSummary,
ActorDetection,
ActorRenderResult,
- SearchResult,
)
+from vidxp.capabilities.schemas import SearchResult
from vidxp.core.contracts import (
CancellationToken,
IndexConfig,
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
index b2156b6..4a94a5d 100644
--- a/src/vidxp/capabilities/actor/definition.py
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -14,7 +14,7 @@
OperationDefinition,
RuntimeDependency,
)
-from vidxp.capabilities.schemas import (
+from vidxp.capabilities.actor.schemas import (
ActorClustersInput,
ActorClustersOutput,
ActorDetectionsInput,
diff --git a/src/vidxp/capabilities/actor/operations.py b/src/vidxp/capabilities/actor/operations.py
index 3e9547c..88b7276 100644
--- a/src/vidxp/capabilities/actor/operations.py
+++ b/src/vidxp/capabilities/actor/operations.py
@@ -6,7 +6,7 @@
render_actor_result,
)
from vidxp.capabilities.contracts import CapabilityContext
-from vidxp.capabilities.schemas import (
+from vidxp.capabilities.actor.schemas import (
ActorClustersInput,
ActorClustersOutput,
ActorDetectionsInput,
diff --git a/src/vidxp/capabilities/actor/results.py b/src/vidxp/capabilities/actor/results.py
index 5ed3328..1ab4386 100644
--- a/src/vidxp/capabilities/actor/results.py
+++ b/src/vidxp/capabilities/actor/results.py
@@ -2,7 +2,7 @@
from pathlib import Path
-from vidxp.capabilities.schemas import (
+from vidxp.capabilities.actor.schemas import (
ActorClusterSummary,
ActorDetection,
ActorRenderResult,
diff --git a/src/vidxp/capabilities/actor/schemas.py b/src/vidxp/capabilities/actor/schemas.py
new file mode 100644
index 0000000..26fcea8
--- /dev/null
+++ b/src/vidxp/capabilities/actor/schemas.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from pydantic import Field
+
+from vidxp.capabilities.contracts import CapabilityInput, CapabilityOutput
+
+
+class ActorClustersInput(CapabilityInput):
+ pass
+
+
+class ActorClusterSummary(CapabilityOutput):
+ cluster_id: str = Field(min_length=1)
+ video_id: str = Field(min_length=1)
+ detection_count: int = Field(ge=0)
+ first_timestamp: float = Field(ge=0)
+ last_timestamp: float = Field(ge=0)
+
+ def to_dict(self) -> dict:
+ return self.model_dump(mode="json")
+
+
+class ActorClustersOutput(CapabilityOutput):
+ clusters: tuple[ActorClusterSummary, ...] = ()
+
+
+class ActorDetectionsInput(CapabilityInput):
+ cluster_id: str = Field(min_length=1)
+
+
+class ActorDetection(CapabilityOutput):
+ detection_id: str = Field(min_length=1)
+ cluster_id: str = Field(min_length=1)
+ frame_index: int = Field(ge=0)
+ timestamp: float = Field(ge=0)
+ bbox: tuple[int, int, int, int]
+ dataset: str
+ split: str
+ run_id: str
+ video_id: str
+ modality: str
+ source_id: str
+
+
+class ActorDetectionsOutput(CapabilityOutput):
+ cluster_id: str = Field(min_length=1)
+ detections: tuple[ActorDetection, ...] = ()
+
+
+class ActorRenderInput(CapabilityInput):
+ cluster_id: str = Field(min_length=1)
+ input_path: Path
+ output_path: Path
+
+
+class ActorRenderResult(CapabilityOutput):
+ output_path: Path
+ detection_count: int = Field(gt=0)
diff --git a/src/vidxp/capabilities/schemas.py b/src/vidxp/capabilities/schemas.py
index b5d7363..a70b948 100644
--- a/src/vidxp/capabilities/schemas.py
+++ b/src/vidxp/capabilities/schemas.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-from pathlib import Path
from typing import Any
from pydantic import Field
@@ -47,56 +46,3 @@ def to_prediction(self) -> dict[str, list[dict[str, Any]]]:
hit.model_dump(mode="json") for hit in self.hits
]
}
-
-
-class ActorClustersInput(CapabilityInput):
- pass
-
-
-class ActorClusterSummary(CapabilityOutput):
- cluster_id: str = Field(min_length=1)
- video_id: str = Field(min_length=1)
- detection_count: int = Field(ge=0)
- first_timestamp: float = Field(ge=0)
- last_timestamp: float = Field(ge=0)
-
- def to_dict(self) -> dict[str, Any]:
- return self.model_dump(mode="json")
-
-
-class ActorClustersOutput(CapabilityOutput):
- clusters: tuple[ActorClusterSummary, ...] = ()
-
-
-class ActorDetectionsInput(CapabilityInput):
- cluster_id: str = Field(min_length=1)
-
-
-class ActorDetection(CapabilityOutput):
- detection_id: str = Field(min_length=1)
- cluster_id: str = Field(min_length=1)
- frame_index: int = Field(ge=0)
- timestamp: float = Field(ge=0)
- bbox: tuple[int, int, int, int]
- dataset: str
- split: str
- run_id: str
- video_id: str
- modality: str
- source_id: str
-
-
-class ActorDetectionsOutput(CapabilityOutput):
- cluster_id: str = Field(min_length=1)
- detections: tuple[ActorDetection, ...] = ()
-
-
-class ActorRenderInput(CapabilityInput):
- cluster_id: str = Field(min_length=1)
- input_path: Path
- output_path: Path
-
-
-class ActorRenderResult(CapabilityOutput):
- output_path: Path
- detection_count: int = Field(gt=0)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4dca03b..9e996e7 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -11,13 +11,12 @@
from typer.testing import CliRunner
from vidxp import cli
-from vidxp.capabilities.schemas import (
+from vidxp.capabilities.actor.schemas import (
ActorClusterSummary,
ActorDetection,
ActorRenderResult,
- SearchHit,
- SearchResult,
)
+from vidxp.capabilities.schemas import SearchHit, SearchResult
from vidxp.index_state import IndexNotReadyError
From fb3dd012b0f2747c105d77314933b5cd219e2a6e Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:05:05 +0500
Subject: [PATCH 18/44] refactor(capabilities): validate contracts with
pydantic
---
docs/adding-a-capability.md | 3 +-
src/vidxp/capabilities/actor/definition.py | 36 +++--
src/vidxp/capabilities/contracts.py | 125 ++++++++++--------
src/vidxp/capabilities/dialogue/definition.py | 26 +++-
src/vidxp/capabilities/scene/definition.py | 36 ++++-
tests/test_capabilities.py | 20 +++
6 files changed, 170 insertions(+), 76 deletions(-)
diff --git a/docs/adding-a-capability.md b/docs/adding-a-capability.md
index 4209402..5990fe8 100644
--- a/docs/adding-a-capability.md
+++ b/docs/adding-a-capability.md
@@ -21,7 +21,8 @@ Create `src/vidxp/capabilities//` with only the files the feature needs:
└── cli.py # only for specialized commands
```
-`definition.py` exports one `CapabilityDefinition` named `DEFINITION`.
+`definition.py` exports one frozen, Pydantic-validated
+`CapabilityDefinition` named `DEFINITION`.
Register it explicitly in `src/vidxp/capabilities/registry.py`. The registry is
the only central file that should change for ordinary runtime registration.
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
index 4a94a5d..b6f60f1 100644
--- a/src/vidxp/capabilities/actor/definition.py
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -27,20 +27,36 @@
DEPENDENCIES = (
- RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
RuntimeDependency(
- "face recognition",
- "face-recognition",
- "face_recognition",
+ label="ChromaDB",
+ distribution="chromadb",
+ module="chromadb",
),
- RuntimeDependency("dlib", "dlib", "dlib"),
RuntimeDependency(
- "face recognition models",
- "face-recognition-models",
- "face_recognition_models",
+ label="face recognition",
+ distribution="face-recognition",
+ module="face_recognition",
+ ),
+ RuntimeDependency(
+ label="dlib",
+ distribution="dlib",
+ module="dlib",
+ ),
+ RuntimeDependency(
+ label="face recognition models",
+ distribution="face-recognition-models",
+ module="face_recognition_models",
+ ),
+ RuntimeDependency(
+ label="NumPy",
+ distribution="numpy",
+ module="numpy",
+ ),
+ RuntimeDependency(
+ label="OpenCV",
+ distribution="opencv-python",
+ module="cv2",
),
- RuntimeDependency("NumPy", "numpy", "numpy"),
- RuntimeDependency("OpenCV", "opencv-python", "cv2"),
)
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
index 2f78ed3..44abf58 100644
--- a/src/vidxp/capabilities/contracts.py
+++ b/src/vidxp/capabilities/contracts.py
@@ -1,45 +1,48 @@
from __future__ import annotations
-from dataclasses import dataclass, field
from importlib import import_module
from types import MappingProxyType
-from typing import (
- TYPE_CHECKING,
- Any,
- Callable,
- Mapping,
+from typing import Any, Callable, Mapping
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+ NonNegativeFloat,
+ field_validator,
+ model_validator,
)
-from pydantic import BaseModel
+from vidxp.core.contracts import IndexConfig, VideoSource
+from vidxp.core.indexing_common import ProgressCallback
-if TYPE_CHECKING:
- from vidxp.core.contracts import IndexConfig, VideoSource
- from vidxp.core.indexing_common import ProgressCallback
+
+class _ContractModel(BaseModel):
+ model_config = ConfigDict(
+ arbitrary_types_allowed=True,
+ extra="forbid",
+ frozen=True,
+ )
class CapabilityInput(BaseModel):
"""Base model for validated capability input."""
- model_config = {"extra": "forbid"}
+ model_config = ConfigDict(extra="forbid")
-class CapabilityOutput(BaseModel):
+class CapabilityOutput(_ContractModel):
"""Base model for validated capability output."""
- model_config = {"extra": "forbid", "frozen": True}
-
-class CapabilityConfig(BaseModel):
+class CapabilityConfig(_ContractModel):
"""Base model for settings owned and validated by one capability."""
- model_config = {"extra": "forbid", "frozen": True}
-
-@dataclass(frozen=True)
-class RuntimeDependency:
+class RuntimeDependency(_ContractModel):
"""One import or executable required by a capability."""
- label: str
+ label: str = Field(min_length=1)
distribution: str | None = None
module: str | None = None
check: Callable[[], str | None] | None = None
@@ -65,8 +68,7 @@ def inspect(self) -> dict[str, Any]:
return result
-@dataclass(frozen=True)
-class CapabilityContext:
+class CapabilityContext(_ContractModel):
"""Runtime context shared by transport-neutral capability operations."""
config: IndexConfig | None
@@ -77,19 +79,17 @@ def require_config(self) -> IndexConfig:
return self.config
-@dataclass(frozen=True)
-class PreparationContext:
+class PreparationContext(_ContractModel):
"""Runtime values supplied to one capability's preparation hook."""
- device: str
+ device: str = Field(min_length=1)
settings: CapabilityConfig
OperationHandler = Callable[[CapabilityContext, BaseModel], BaseModel | Mapping]
-@dataclass(frozen=True)
-class OperationDefinition:
+class OperationDefinition(_ContractModel):
"""Validated input, output, and implementation for one operation."""
input_model: type[BaseModel]
@@ -97,6 +97,16 @@ class OperationDefinition:
handler: OperationHandler
requires_index: bool = True
+ @field_validator("input_model", "output_model")
+ @classmethod
+ def _require_model(
+ cls,
+ value: type[BaseModel],
+ ) -> type[BaseModel]:
+ if not isinstance(value, type) or not issubclass(value, BaseModel):
+ raise ValueError("Operation schemas must be Pydantic models.")
+ return value
+
def invoke(
self,
context: CapabilityContext,
@@ -107,58 +117,73 @@ def invoke(
return self.output_model.model_validate(result)
-@dataclass(frozen=True)
-class CapabilityIndexResult:
+class CapabilityIndexResult(_ContractModel):
"""Summary and timing data returned by an indexing handler."""
- summary: Mapping[str, Any]
- timings: Mapping[str, float] = field(default_factory=dict)
+ summary: dict[str, Any]
+ timings: dict[str, NonNegativeFloat] = Field(default_factory=dict)
IndexHandler = Callable[..., CapabilityIndexResult]
PrepareHandler = Callable[
- [PreparationContext, "ProgressCallback | None"],
+ [PreparationContext, ProgressCallback | None],
tuple[str, ...],
]
DependencySelector = Callable[
- ["VideoSource"],
+ [VideoSource],
tuple[RuntimeDependency, ...],
]
ModelManifest = Callable[
- ["IndexConfig", tuple["VideoSource", ...]],
+ [IndexConfig, tuple[VideoSource, ...]],
Mapping[str, Any],
]
CLIFactory = Callable[[], Any]
-@dataclass(frozen=True)
-class CapabilityDefinition:
+class CapabilityDefinition(_ContractModel):
"""Everything the application needs to run one named capability."""
- name: str
- description: str
- extra: str
+ name: str = Field(min_length=1)
+ description: str = Field(min_length=1)
+ extra: str = Field(min_length=1)
config_model: type[CapabilityConfig] = CapabilityConfig
dependencies: tuple[RuntimeDependency, ...] = ()
collection_name: str | None = None
indexer: IndexHandler | None = None
index_processor: Any | None = None
index_stage: str | None = None
- operations: Mapping[str, OperationDefinition] = field(default_factory=dict)
+ operations: Mapping[str, OperationDefinition] = Field(default_factory=dict)
dependencies_for_source: DependencySelector | None = None
prepare: PrepareHandler | None = None
model_manifest: ModelManifest | None = None
cli_name: str | None = None
cli_factory: CLIFactory | None = None
- def __post_init__(self) -> None:
- for label, value in (
- ("name", self.name),
- ("description", self.description),
- ("extra", self.extra),
+ @field_validator("config_model")
+ @classmethod
+ def _require_config_model(
+ cls,
+ value: type[CapabilityConfig],
+ ) -> type[CapabilityConfig]:
+ if (
+ not isinstance(value, type)
+ or not issubclass(value, CapabilityConfig)
):
- if not str(value).strip():
- raise ValueError(f"Capability {label} must not be empty.")
+ raise ValueError(
+ "Capability config_model must extend CapabilityConfig."
+ )
+ return value
+
+ @field_validator("operations")
+ @classmethod
+ def _freeze_operations(
+ cls,
+ value: Mapping[str, OperationDefinition],
+ ) -> Mapping[str, OperationDefinition]:
+ return MappingProxyType(dict(value))
+
+ @model_validator(mode="after")
+ def _require_complete_integrations(self) -> CapabilityDefinition:
indexing_fields = (
self.collection_name,
self.indexer,
@@ -179,11 +204,7 @@ def __post_init__(self) -> None:
raise ValueError(
"A capability must provide an indexer or at least one operation."
)
- object.__setattr__(
- self,
- "operations",
- MappingProxyType(dict(self.operations)),
- )
+ return self
def source_dependencies(
self,
diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py
index d4b3855..34c2941 100644
--- a/src/vidxp/capabilities/dialogue/definition.py
+++ b/src/vidxp/capabilities/dialogue/definition.py
@@ -24,15 +24,27 @@
from vidxp.core.video import ffmpeg_binary
-CHROMA = RuntimeDependency("ChromaDB", "chromadb", "chromadb")
+CHROMA = RuntimeDependency(
+ label="ChromaDB",
+ distribution="chromadb",
+ module="chromadb",
+)
SENTENCE_TRANSFORMERS = RuntimeDependency(
- "Sentence Transformers",
- "sentence-transformers",
- "sentence_transformers",
+ label="Sentence Transformers",
+ distribution="sentence-transformers",
+ module="sentence_transformers",
+)
+MOVIEPY = RuntimeDependency(
+ label="MoviePy",
+ distribution="moviepy",
+ module="moviepy.editor",
+)
+WHISPERX = RuntimeDependency(
+ label="WhisperX",
+ distribution="whisperx",
+ module="whisperx",
)
-MOVIEPY = RuntimeDependency("MoviePy", "moviepy", "moviepy.editor")
-WHISPERX = RuntimeDependency("WhisperX", "whisperx", "whisperx")
-FFMPEG = RuntimeDependency("FFmpeg", check=ffmpeg_binary)
+FFMPEG = RuntimeDependency(label="FFmpeg", check=ffmpeg_binary)
DEPENDENCIES = (
CHROMA,
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
index 135d932..d277cd8 100644
--- a/src/vidxp/capabilities/scene/definition.py
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -19,12 +19,36 @@
DEPENDENCIES = (
- RuntimeDependency("ChromaDB", "chromadb", "chromadb"),
- RuntimeDependency("CLIP", "clip-anytorch", "clip"),
- RuntimeDependency("NumPy", "numpy", "numpy"),
- RuntimeDependency("OpenCV", "opencv-python", "cv2"),
- RuntimeDependency("Pillow", "Pillow", "PIL.Image"),
- RuntimeDependency("PyTorch", "torch", "torch"),
+ RuntimeDependency(
+ label="ChromaDB",
+ distribution="chromadb",
+ module="chromadb",
+ ),
+ RuntimeDependency(
+ label="CLIP",
+ distribution="clip-anytorch",
+ module="clip",
+ ),
+ RuntimeDependency(
+ label="NumPy",
+ distribution="numpy",
+ module="numpy",
+ ),
+ RuntimeDependency(
+ label="OpenCV",
+ distribution="opencv-python",
+ module="cv2",
+ ),
+ RuntimeDependency(
+ label="Pillow",
+ distribution="Pillow",
+ module="PIL.Image",
+ ),
+ RuntimeDependency(
+ label="PyTorch",
+ distribution="torch",
+ module="torch",
+ ),
)
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index 7351890..07847ec 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -5,6 +5,7 @@
from vidxp.capabilities.contracts import (
CapabilityContext,
CapabilityDefinition,
+ CapabilityIndexResult,
CapabilityInput,
CapabilityOutput,
OperationDefinition,
@@ -58,7 +59,9 @@ def test_registry_is_explicit_and_drives_index_collections(self):
def test_registered_operations_use_pydantic_contracts(self):
for capability in CAPABILITIES.values():
+ self.assertIsInstance(capability, BaseModel)
for operation in capability.operations.values():
+ self.assertIsInstance(operation, BaseModel)
self.assertTrue(
issubclass(operation.input_model, BaseModel)
)
@@ -66,6 +69,23 @@ def test_registered_operations_use_pydantic_contracts(self):
issubclass(operation.output_model, BaseModel)
)
+ def test_capability_contracts_are_frozen_and_schema_validated(self):
+ with self.assertRaises(ValidationError):
+ CAPABILITIES["scene"].name = "changed"
+ with self.assertRaises(ValidationError):
+ CapabilityDefinition(
+ name="broken",
+ description="Incomplete index integration.",
+ extra="broken",
+ collection_name="broken",
+ indexer=lambda **_: None,
+ )
+ with self.assertRaises(ValidationError):
+ CapabilityIndexResult(
+ summary={},
+ timings={"index": -1},
+ )
+
def test_built_in_settings_are_capability_owned_and_validated(self):
self.assertIs(CAPABILITIES["dialogue"].config_model, DialogueConfig)
self.assertIs(CAPABILITIES["scene"].config_model, SceneConfig)
From a208b9beadbccc4c08aefa97d30194d4d0804832 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:08:54 +0500
Subject: [PATCH 19/44] refactor(deps): use capability requirement manifests
---
docs/adding-a-capability.md | 7 +-
pyproject.toml | 1 +
src/vidxp/capabilities/actor/definition.py | 37 --------
src/vidxp/capabilities/contracts.py | 43 ++++++----
src/vidxp/capabilities/dialogue/definition.py | 55 +++++-------
src/vidxp/capabilities/registry.py | 76 ++++++++++------
src/vidxp/capabilities/scene/definition.py | 37 --------
src/vidxp/dependencies.py | 84 ++++++++++++++++++
tests/test_models.py | 86 ++++++++++++++-----
9 files changed, 251 insertions(+), 175 deletions(-)
create mode 100644 src/vidxp/dependencies.py
diff --git a/docs/adding-a-capability.md b/docs/adding-a-capability.md
index 5990fe8..d12ebf6 100644
--- a/docs/adding-a-capability.md
+++ b/docs/adding-a-capability.md
@@ -56,8 +56,11 @@ dummy collections or index handlers.
## Dependencies and installation
Put direct runtime requirements in the capability's `requirements.txt`.
-Declare matching `RuntimeDependency` checks in `definition.py`, then expose the
-requirements file as an optional dependency in `pyproject.toml`:
+This file is the only declaration of Python package dependencies: setuptools
+uses it to build the extra, and runtime dependency checks read the same packaged
+file. Declare a `RuntimeCheck` only for a non-Python environment prerequisite
+such as an executable. Expose the requirements file as an optional dependency
+in `pyproject.toml`:
```toml
[tool.setuptools.dynamic.optional-dependencies]
diff --git a/pyproject.toml b/pyproject.toml
index 5576e01..9098020 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ classifiers = [
dynamic = ["optional-dependencies"]
dependencies = [
"filelock>=3.13",
+ "packaging>=24",
"pydantic>=2.8,<3",
"rich",
"typer>=0.27,<1",
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
index b6f60f1..690005d 100644
--- a/src/vidxp/capabilities/actor/definition.py
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -12,7 +12,6 @@
from vidxp.capabilities.contracts import (
CapabilityDefinition,
OperationDefinition,
- RuntimeDependency,
)
from vidxp.capabilities.actor.schemas import (
ActorClustersInput,
@@ -25,41 +24,6 @@
from vidxp.capabilities.visual import index_capabilities
from vidxp.core.contracts import IndexConfig, VideoSource
-
-DEPENDENCIES = (
- RuntimeDependency(
- label="ChromaDB",
- distribution="chromadb",
- module="chromadb",
- ),
- RuntimeDependency(
- label="face recognition",
- distribution="face-recognition",
- module="face_recognition",
- ),
- RuntimeDependency(
- label="dlib",
- distribution="dlib",
- module="dlib",
- ),
- RuntimeDependency(
- label="face recognition models",
- distribution="face-recognition-models",
- module="face_recognition_models",
- ),
- RuntimeDependency(
- label="NumPy",
- distribution="numpy",
- module="numpy",
- ),
- RuntimeDependency(
- label="OpenCV",
- distribution="opencv-python",
- module="cv2",
- ),
-)
-
-
def model_manifest(
config: IndexConfig,
_sources: tuple[VideoSource, ...],
@@ -90,7 +54,6 @@ def cli_app():
indexer=index_capabilities,
index_processor=VISUAL_PROCESSOR,
index_stage="visual_indexing",
- dependencies=DEPENDENCIES,
model_manifest=model_manifest,
operations={
"clusters": OperationDefinition(
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
index 44abf58..d022cde 100644
--- a/src/vidxp/capabilities/contracts.py
+++ b/src/vidxp/capabilities/contracts.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-from importlib import import_module
from types import MappingProxyType
from typing import Any, Callable, Mapping
@@ -12,6 +11,7 @@
field_validator,
model_validator,
)
+from packaging.requirements import Requirement
from vidxp.core.contracts import IndexConfig, VideoSource
from vidxp.core.indexing_common import ProgressCallback
@@ -39,19 +39,16 @@ class CapabilityConfig(_ContractModel):
"""Base model for settings owned and validated by one capability."""
-class RuntimeDependency(_ContractModel):
- """One import or executable required by a capability."""
+class RuntimeCheck(_ContractModel):
+ """One non-package environment requirement."""
label: str = Field(min_length=1)
- distribution: str | None = None
- module: str | None = None
- check: Callable[[], str | None] | None = None
+ check: Callable[[], str | None]
+ applies_to: Callable[[VideoSource], bool] | None = None
def inspect(self) -> dict[str, Any]:
try:
- if self.module is not None:
- import_module(self.module)
- detail = self.check() if self.check is not None else None
+ detail = self.check()
except Exception as exc:
return {
"name": self.label,
@@ -67,6 +64,13 @@ def inspect(self) -> dict[str, Any]:
result["path"] = detail
return result
+ def applies(self, source: VideoSource | None) -> bool:
+ return (
+ source is None
+ or self.applies_to is None
+ or self.applies_to(source)
+ )
+
class CapabilityContext(_ContractModel):
"""Runtime context shared by transport-neutral capability operations."""
@@ -129,9 +133,9 @@ class CapabilityIndexResult(_ContractModel):
[PreparationContext, ProgressCallback | None],
tuple[str, ...],
]
-DependencySelector = Callable[
- [VideoSource],
- tuple[RuntimeDependency, ...],
+RequirementFilter = Callable[
+ [VideoSource, tuple[Requirement, ...]],
+ tuple[Requirement, ...],
]
ModelManifest = Callable[
[IndexConfig, tuple[VideoSource, ...]],
@@ -147,13 +151,13 @@ class CapabilityDefinition(_ContractModel):
description: str = Field(min_length=1)
extra: str = Field(min_length=1)
config_model: type[CapabilityConfig] = CapabilityConfig
- dependencies: tuple[RuntimeDependency, ...] = ()
+ runtime_checks: tuple[RuntimeCheck, ...] = ()
collection_name: str | None = None
indexer: IndexHandler | None = None
index_processor: Any | None = None
index_stage: str | None = None
operations: Mapping[str, OperationDefinition] = Field(default_factory=dict)
- dependencies_for_source: DependencySelector | None = None
+ requirement_filter: RequirementFilter | None = None
prepare: PrepareHandler | None = None
model_manifest: ModelManifest | None = None
cli_name: str | None = None
@@ -206,13 +210,14 @@ def _require_complete_integrations(self) -> CapabilityDefinition:
)
return self
- def source_dependencies(
+ def source_requirements(
self,
source: VideoSource,
- ) -> tuple[RuntimeDependency, ...]:
- if self.dependencies_for_source is None:
- return self.dependencies
- return self.dependencies_for_source(source)
+ requirements: tuple[Requirement, ...],
+ ) -> tuple[Requirement, ...]:
+ if self.requirement_filter is None:
+ return requirements
+ return self.requirement_filter(source, requirements)
def capability_install_hint(name: str) -> str:
diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py
index 34c2941..f7682e5 100644
--- a/src/vidxp/capabilities/dialogue/definition.py
+++ b/src/vidxp/capabilities/dialogue/definition.py
@@ -2,11 +2,14 @@
from typing import Any, Mapping
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+
from vidxp.capabilities.contracts import (
CapabilityDefinition,
OperationDefinition,
PreparationContext,
- RuntimeDependency,
+ RuntimeCheck,
)
from vidxp.capabilities.dialogue.config import DialogueConfig, dialogue_config
from vidxp.capabilities.dialogue.models import (
@@ -24,43 +27,25 @@
from vidxp.core.video import ffmpeg_binary
-CHROMA = RuntimeDependency(
- label="ChromaDB",
- distribution="chromadb",
- module="chromadb",
-)
-SENTENCE_TRANSFORMERS = RuntimeDependency(
- label="Sentence Transformers",
- distribution="sentence-transformers",
- module="sentence_transformers",
-)
-MOVIEPY = RuntimeDependency(
- label="MoviePy",
- distribution="moviepy",
- module="moviepy.editor",
-)
-WHISPERX = RuntimeDependency(
- label="WhisperX",
- distribution="whisperx",
- module="whisperx",
-)
-FFMPEG = RuntimeDependency(label="FFmpeg", check=ffmpeg_binary)
-
-DEPENDENCIES = (
- CHROMA,
- SENTENCE_TRANSFORMERS,
- MOVIEPY,
- WHISPERX,
- FFMPEG,
+FFMPEG = RuntimeCheck(
+ label="FFmpeg",
+ check=ffmpeg_binary,
+ applies_to=lambda source: source.transcript is None,
)
-def dependencies_for_source(
+def filter_requirements_for_source(
source: VideoSource,
-) -> tuple[RuntimeDependency, ...]:
+ requirements: tuple[Requirement, ...],
+) -> tuple[Requirement, ...]:
if source.transcript is not None:
- return CHROMA, SENTENCE_TRANSFORMERS
- return DEPENDENCIES
+ needed = {"chromadb", "sentence-transformers"}
+ return tuple(
+ requirement
+ for requirement in requirements
+ if canonicalize_name(requirement.name) in needed
+ )
+ return requirements
def prepare_models(
@@ -123,8 +108,8 @@ def model_manifest(
collection_name="dialogue",
indexer=index_capability,
index_stage="dialogue_indexing",
- dependencies=DEPENDENCIES,
- dependencies_for_source=dependencies_for_source,
+ runtime_checks=(FFMPEG,),
+ requirement_filter=filter_requirements_for_source,
prepare=prepare_models,
model_manifest=model_manifest,
operations={
diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py
index 1f6b025..60ad1ac 100644
--- a/src/vidxp/capabilities/registry.py
+++ b/src/vidxp/capabilities/registry.py
@@ -3,15 +3,24 @@
from types import MappingProxyType
from typing import Any, Iterable, Mapping
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+
from vidxp.capabilities.actor.definition import DEFINITION as ACTOR
from vidxp.capabilities.contracts import (
CapabilityDefinition,
- RuntimeDependency,
+ RuntimeCheck,
capability_install_hint,
)
from vidxp.capabilities.dialogue.definition import DEFINITION as DIALOGUE
from vidxp.capabilities.scene.definition import DEFINITION as SCENE
from vidxp.core.contracts import VideoSource
+from vidxp.dependencies import (
+ active_requirements,
+ inspect_requirement,
+ installed_base_requirements,
+ packaged_requirements,
+)
_BUILT_INS = (DIALOGUE, SCENE, ACTOR)
@@ -97,34 +106,47 @@ def validate_capability_options(
}
-def dependencies_for(
+def requirements_for(
names: Iterable[str],
*,
source: VideoSource | None = None,
-) -> tuple[RuntimeDependency, ...]:
- dependencies = []
+) -> tuple[Requirement, ...]:
+ requirements = []
for name in validate_capability_names(names):
capability = get_capability(name)
- dependencies.extend(
- capability.dependencies
- if source is None
- else capability.source_dependencies(source)
+ selected = active_requirements(
+ packaged_requirements(f"vidxp.capabilities.{name}")
)
- unique = {}
- for dependency in dependencies:
- key = (
- dependency.module,
- dependency.distribution,
- dependency.label,
+ requirements.extend(
+ selected
+ if source is None
+ else capability.source_requirements(source, selected)
)
- unique.setdefault(key, dependency)
+ unique = {str(requirement): requirement for requirement in requirements}
return tuple(unique.values())
+def runtime_checks_for(
+ names: Iterable[str],
+ *,
+ source: VideoSource | None = None,
+) -> tuple[RuntimeCheck, ...]:
+ checks = (
+ check
+ for name in validate_capability_names(names)
+ for check in get_capability(name).runtime_checks
+ if check.applies(source)
+ )
+ return tuple({check.label: check for check in checks}.values())
+
+
def dependency_checks(names: Iterable[str]) -> tuple[dict, ...]:
return tuple(
- dependency.inspect()
- for dependency in dependencies_for(names)
+ inspect_requirement(requirement)
+ for requirement in requirements_for(names)
+ ) + tuple(
+ check.inspect()
+ for check in runtime_checks_for(names)
)
@@ -136,9 +158,14 @@ def require_dependencies(
selected = validate_capability_names(names)
failures = [
result
- for dependency in dependencies_for(selected, source=source)
- if not (result := dependency.inspect())["ok"]
+ for requirement in requirements_for(selected, source=source)
+ if not (result := inspect_requirement(requirement))["ok"]
]
+ failures.extend(
+ result
+ for check in runtime_checks_for(selected, source=source)
+ if not (result := check.inspect())["ok"]
+ )
if failures:
details = "; ".join(
f"{failure['name']}: {failure['error']}"
@@ -155,10 +182,11 @@ def require_dependencies(
def runtime_distributions() -> tuple[str, ...]:
distributions = {
- dependency.distribution
- for capability in CAPABILITIES.values()
- for dependency in capability.dependencies
- if dependency.distribution is not None
+ canonicalize_name(requirement.name)
+ for requirement in installed_base_requirements()
}
- distributions.update({"filelock", "pydantic", "rich", "typer"})
+ distributions.update(
+ canonicalize_name(requirement.name)
+ for requirement in requirements_for(capability_names())
+ )
return tuple(sorted(distributions, key=str.lower))
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
index d277cd8..6deaf99 100644
--- a/src/vidxp/capabilities/scene/definition.py
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -6,7 +6,6 @@
CapabilityDefinition,
OperationDefinition,
PreparationContext,
- RuntimeDependency,
)
from vidxp.capabilities.scene.config import SceneConfig, scene_config
from vidxp.capabilities.scene.indexing import VISUAL_PROCESSOR
@@ -17,41 +16,6 @@
from vidxp.core.contracts import IndexConfig, VideoSource
from vidxp.core.indexing_common import ProgressCallback
-
-DEPENDENCIES = (
- RuntimeDependency(
- label="ChromaDB",
- distribution="chromadb",
- module="chromadb",
- ),
- RuntimeDependency(
- label="CLIP",
- distribution="clip-anytorch",
- module="clip",
- ),
- RuntimeDependency(
- label="NumPy",
- distribution="numpy",
- module="numpy",
- ),
- RuntimeDependency(
- label="OpenCV",
- distribution="opencv-python",
- module="cv2",
- ),
- RuntimeDependency(
- label="Pillow",
- distribution="Pillow",
- module="PIL.Image",
- ),
- RuntimeDependency(
- label="PyTorch",
- distribution="torch",
- module="torch",
- ),
-)
-
-
def prepare_models(
context: PreparationContext,
progress: ProgressCallback | None,
@@ -87,7 +51,6 @@ def model_manifest(
indexer=index_capabilities,
index_processor=VISUAL_PROCESSOR,
index_stage="visual_indexing",
- dependencies=DEPENDENCIES,
prepare=prepare_models,
model_manifest=model_manifest,
operations={
diff --git a/src/vidxp/dependencies.py b/src/vidxp/dependencies.py
new file mode 100644
index 0000000..1da6943
--- /dev/null
+++ b/src/vidxp/dependencies.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+from functools import lru_cache
+from importlib.metadata import (
+ PackageNotFoundError,
+ requires as distribution_requirements,
+ version,
+)
+from importlib.resources import files
+from typing import Iterable
+
+from packaging.requirements import Requirement
+
+
+@lru_cache(maxsize=None)
+def packaged_requirements(package: str) -> tuple[Requirement, ...]:
+ content = files(package).joinpath("requirements.txt").read_text(
+ encoding="utf-8"
+ )
+ return tuple(
+ Requirement(line)
+ for raw_line in content.splitlines()
+ if (line := raw_line.strip()) and not line.startswith("#")
+ )
+
+
+def active_requirements(
+ requirements: Iterable[Requirement],
+ *,
+ extra: str = "",
+) -> tuple[Requirement, ...]:
+ environment = {"extra": extra}
+ return tuple(
+ requirement
+ for requirement in requirements
+ if requirement.marker is None
+ or requirement.marker.evaluate(environment)
+ )
+
+
+def inspect_requirement(requirement: Requirement) -> dict:
+ try:
+ installed = version(requirement.name)
+ except PackageNotFoundError:
+ return {
+ "name": requirement.name,
+ "requirement": str(requirement),
+ "installed_version": None,
+ "ok": False,
+ "error": "distribution is not installed",
+ }
+ if requirement.specifier and not requirement.specifier.contains(
+ installed,
+ prereleases=True,
+ ):
+ return {
+ "name": requirement.name,
+ "requirement": str(requirement),
+ "installed_version": installed,
+ "ok": False,
+ "error": (
+ f"installed version {installed} does not satisfy "
+ f"{requirement.specifier}"
+ ),
+ }
+ return {
+ "name": requirement.name,
+ "requirement": str(requirement),
+ "installed_version": installed,
+ "ok": True,
+ "error": None,
+ }
+
+
+def installed_base_requirements(
+ distribution: str = "vidxp",
+) -> tuple[Requirement, ...]:
+ try:
+ declared = distribution_requirements(distribution) or ()
+ except PackageNotFoundError:
+ return ()
+ return active_requirements(
+ (Requirement(value) for value in declared),
+ )
diff --git a/tests/test_models.py b/tests/test_models.py
index 18ab9d3..824aeac 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,15 +1,20 @@
import sys
import types
import unittest
+from importlib.metadata import PackageNotFoundError
from unittest.mock import Mock, patch
+from vidxp.capabilities.contracts import CapabilityDefinition
from vidxp.capabilities.dialogue import models as dialogue_models
from vidxp.capabilities.registry import (
- dependencies_for,
dependency_checks,
+ requirements_for,
+ runtime_checks_for,
runtime_distributions,
)
from vidxp.core.contracts import VideoSource
+from vidxp.dependencies import inspect_requirement
+from packaging.requirements import Requirement
class ModelTests(unittest.TestCase):
@@ -27,50 +32,89 @@ def test_dialogue_model_is_reused_across_videos(self):
constructor.assert_called_once_with("model-id", device="cpu")
def test_scene_dependency_check_does_not_touch_other_capabilities(self):
- imported = []
-
- def record(module_name):
- imported.append(module_name)
- return object()
+ inspected = []
+
+ versions = {
+ "chromadb": "1.0",
+ "clip-anytorch": "2.6.0",
+ "numpy": "2.1",
+ "opencv-python": "4.10",
+ "Pillow": "10.0",
+ "torch": "2.5",
+ }
with patch(
- "vidxp.capabilities.contracts.import_module",
- side_effect=record,
+ "vidxp.dependencies.version",
+ side_effect=lambda name: (
+ inspected.append(name),
+ versions[name],
+ )[1],
):
checks = dependency_checks(("scene",))
self.assertTrue(all(check["ok"] for check in checks))
- self.assertNotIn("whisperx", imported)
- self.assertNotIn("face_recognition", imported)
- self.assertNotIn("sentence_transformers", imported)
+ self.assertNotIn("whisperx", inspected)
+ self.assertNotIn("face-recognition", inspected)
+ self.assertNotIn("sentence-transformers", inspected)
def test_supplied_transcript_only_requires_dialogue_search_dependencies(self):
source = VideoSource(
transcript=({"text": "hello", "start": 0, "end": 1},)
)
- modules = {
- dependency.module
- for dependency in dependencies_for(
+ distributions = {
+ requirement.name
+ for requirement in requirements_for(
("dialogue",),
source=source,
)
}
- self.assertIn("sentence_transformers", modules)
- self.assertNotIn("whisperx", modules)
- self.assertNotIn("moviepy.editor", modules)
- self.assertNotIn("cv2", modules)
+ self.assertIn("sentence-transformers", distributions)
+ self.assertNotIn("whisperx", distributions)
+ self.assertNotIn("moviepy", distributions)
+ self.assertNotIn("opencv-python", distributions)
def test_runtime_distributions_come_from_capability_registry(self):
- distributions = runtime_distributions()
+ with patch(
+ "vidxp.capabilities.registry.installed_base_requirements",
+ return_value=(),
+ ):
+ distributions = runtime_distributions()
self.assertIn("clip-anytorch", distributions)
self.assertIn("face-recognition", distributions)
- self.assertIn("filelock", distributions)
- self.assertIn("pydantic", distributions)
self.assertEqual(len(distributions), len(set(distributions)))
+ def test_requirement_files_are_the_only_python_dependency_contract(self):
+ self.assertNotIn("dependencies", CapabilityDefinition.model_fields)
+ checks = runtime_checks_for(
+ ("dialogue",),
+ source=VideoSource(
+ transcript=({"text": "hello", "start": 0, "end": 1},)
+ ),
+ )
+ self.assertEqual(checks, ())
+ self.assertEqual(
+ [check.label for check in runtime_checks_for(
+ ("dialogue",),
+ source=VideoSource(path="video.mp4"),
+ )],
+ ["FFmpeg"],
+ )
+
+ def test_requirement_check_uses_distribution_metadata_and_specifier(self):
+ requirement = Requirement("example>=2,<3")
+ with patch("vidxp.dependencies.version", return_value="2.5"):
+ self.assertTrue(inspect_requirement(requirement)["ok"])
+ with patch("vidxp.dependencies.version", return_value="1.0"):
+ self.assertFalse(inspect_requirement(requirement)["ok"])
+ with patch(
+ "vidxp.dependencies.version",
+ side_effect=PackageNotFoundError("example"),
+ ):
+ self.assertFalse(inspect_requirement(requirement)["ok"])
+
if __name__ == "__main__":
unittest.main()
From 29472a8d87288c8e2896ce825b83963d04b70e14 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:10:23 +0500
Subject: [PATCH 20/44] refactor(benchmarks): gate optional cli commands
---
docs/benchmarking/adapter_validation.md | 4 ++--
src/vidxp/cli.py | 15 +++++++++++++--
src/vidxp/dependencies.py | 9 +++++++++
tests/test_benchmark_cli.py | 4 ++++
4 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/docs/benchmarking/adapter_validation.md b/docs/benchmarking/adapter_validation.md
index 3f0f80e..d314e2b 100644
--- a/docs/benchmarking/adapter_validation.md
+++ b/docs/benchmarking/adapter_validation.md
@@ -184,10 +184,10 @@ official-media run.
## Commands
-Install the optional adapter parser:
+Install the optional adapters with the capabilities they evaluate:
```powershell
-python -m pip install -e ".[benchmarks]"
+python -m pip install -e ".[scene,dialogue,benchmarks]"
```
Run a declared DiDeMo smoke subset by zero-based official annotation indices:
diff --git a/src/vidxp/cli.py b/src/vidxp/cli.py
index d7a7cfd..7135302 100644
--- a/src/vidxp/cli.py
+++ b/src/vidxp/cli.py
@@ -10,7 +10,6 @@
from vidxp import __version__
from vidxp.application import VidXPService
-from vidxp.benchmarks.cli import app as benchmark_app
from vidxp.capabilities.actor.results import ActorClusterNotFoundError
from vidxp.capabilities.registry import CAPABILITIES
from vidxp.cli_commands.index import app as index_app
@@ -19,6 +18,7 @@
from vidxp.cli_commands.search import app as search_app
from vidxp.cli_support import CLIState, OutputFormat
from vidxp.core.contracts import IndexSchemaError
+from vidxp.dependencies import requirements_available
from vidxp.index_state import (
IndexingInProgressError,
IndexNotReadyError,
@@ -33,7 +33,18 @@
app.add_typer(index_app, name="index")
app.add_typer(search_app, name="search")
app.add_typer(repositories_app, name="repositories")
-app.add_typer(benchmark_app, name="benchmark")
+
+
+def _load_benchmark_app():
+ if not requirements_available("vidxp.benchmarks"):
+ return None
+ from vidxp.benchmarks.cli import app as benchmark_app
+
+ return benchmark_app
+
+
+if _benchmark_app := _load_benchmark_app():
+ app.add_typer(_benchmark_app, name="benchmark")
for _capability in CAPABILITIES.values():
if _capability.cli_factory is not None:
app.add_typer(
diff --git a/src/vidxp/dependencies.py b/src/vidxp/dependencies.py
index 1da6943..c27ea69 100644
--- a/src/vidxp/dependencies.py
+++ b/src/vidxp/dependencies.py
@@ -72,6 +72,15 @@ def inspect_requirement(requirement: Requirement) -> dict:
}
+def requirements_available(package: str) -> bool:
+ return all(
+ inspect_requirement(requirement)["ok"]
+ for requirement in active_requirements(
+ packaged_requirements(package)
+ )
+ )
+
+
def installed_base_requirements(
distribution: str = "vidxp",
) -> tuple[Requirement, ...]:
diff --git a/tests/test_benchmark_cli.py b/tests/test_benchmark_cli.py
index 3e2552a..21abef4 100644
--- a/tests/test_benchmark_cli.py
+++ b/tests/test_benchmark_cli.py
@@ -10,6 +10,10 @@
class BenchmarkCliTests(unittest.TestCase):
+ def test_benchmark_app_is_not_loaded_without_its_extra(self):
+ with patch.object(cli, "requirements_available", return_value=False):
+ self.assertIsNone(cli._load_benchmark_app())
+
def test_benchmark_uses_global_device_and_json_output(self):
runner = CliRunner()
service = Mock()
From ee803b3f2c9e2bda159c6763705f015a74a38e5c Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:15:20 +0500
Subject: [PATCH 21/44] ci: validate pull requests before publishing
---
.github/workflows/ci.yml | 74 ++++++++++++++++++++++
.github/workflows/release-to-pypi.yml | 6 ++
.github/workflows/release-to-test-pypi.yml | 6 ++
3 files changed, 86 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..56e7a26
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,74 @@
+name: CI
+
+on:
+ pull_request:
+ workflow_call:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.11"
+ cache: pip
+ - name: Install test surface
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install ".[scene,frontend,benchmarks]" ruff
+ - name: Lint
+ run: ruff check src tests
+ - name: Test
+ run: python -m unittest discover -s tests -q
+ env:
+ PYTHONPATH: src
+
+ package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.11"
+ cache: pip
+ - name: Build wheel and source distribution
+ run: |
+ python -m pip install --upgrade pip build
+ python -m build
+ - name: Verify minimal wheel
+ shell: bash
+ run: |
+ python -m venv .wheel-smoke
+ .wheel-smoke/bin/python -m pip install --upgrade pip
+ .wheel-smoke/bin/python -m pip install dist/*.whl
+ .wheel-smoke/bin/python -m pip check
+ .wheel-smoke/bin/python - <<'PY'
+ from importlib.util import find_spec
+ from subprocess import check_output
+
+ import vidxp
+ from vidxp.capabilities.registry import capability_names
+
+ assert capability_names() == ("dialogue", "scene", "actor")
+ help_text = check_output(
+ [".wheel-smoke/bin/vidxp", "--help"],
+ text=True,
+ )
+ assert "benchmark" not in help_text
+ for module in (
+ "chromadb",
+ "clip",
+ "cv2",
+ "face_recognition",
+ "sentence_transformers",
+ "srt",
+ "streamlit",
+ "torch",
+ "whisperx",
+ ):
+ assert find_spec(module) is None, module
+ PY
diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml
index c4ef7da..19e5540 100644
--- a/.github/workflows/release-to-pypi.yml
+++ b/.github/workflows/release-to-pypi.yml
@@ -20,7 +20,13 @@ permissions:
pull-requests: write
jobs:
+ ci:
+ uses: ./.github/workflows/ci.yml
+ permissions:
+ contents: read
+
release:
+ needs: ci
runs-on: ubuntu-latest
environment:
name: pypi
diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml
index fdd4f5a..a82a417 100644
--- a/.github/workflows/release-to-test-pypi.yml
+++ b/.github/workflows/release-to-test-pypi.yml
@@ -19,7 +19,13 @@ permissions:
id-token: write
jobs:
+ ci:
+ uses: ./.github/workflows/ci.yml
+ permissions:
+ contents: read
+
prerelease:
+ needs: ci
runs-on: ubuntu-latest
environment: testpypi
From c36b410bad7760a184be7b9866f0dbf3992dec9f Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:16:44 +0500
Subject: [PATCH 22/44] test(schema): keep fixtures aligned with index version
---
docs/benchmarking/core_contract.md | 6 +++---
tests/test_frontend.py | 6 ++++--
tests/test_runner.py | 13 +++++++++++++
3 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/docs/benchmarking/core_contract.md b/docs/benchmarking/core_contract.md
index c179ed6..e7581e8 100644
--- a/docs/benchmarking/core_contract.md
+++ b/docs/benchmarking/core_contract.md
@@ -33,9 +33,9 @@ does not change merely because the same run is relocated to another output or
storage directory. Checkpoint filenames are hashes of video IDs, so official IDs
cannot accidentally become platform-specific paths.
-The existing CLI and Streamlit interface use `chroma_data/` as their single local
-run for compatibility. Indexes created before schema version 2 must be rebuilt;
-VidXP does not invent missing end timestamps, video IDs, or source IDs.
+The CLI and Streamlit interface use `chroma_data/` as their local run. Indexes
+created with an older schema must be rebuilt; VidXP does not invent missing end
+timestamps, video IDs, or source IDs.
## Indexing API
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 4599a22..1df869b 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -7,6 +7,7 @@
from vidxp import frontend
from vidxp.capabilities.schemas import SearchHit, SearchResult
+from vidxp.core.contracts import INDEX_SCHEMA_VERSION
from vidxp.index_state import IndexNotReadyError
@@ -14,7 +15,7 @@
"state": "ready",
"message": "Video indexing completed successfully.",
"summary": {
- "index_schema_version": 2,
+ "index_schema_version": INDEX_SCHEMA_VERSION,
"dataset": "local",
"split": "local",
"run_id": "default",
@@ -51,6 +52,7 @@ def frontend_harness(video_path, actor_output_path):
from vidxp import frontend
from vidxp.capabilities.schemas import SearchHit, SearchResult
+ from vidxp.core.contracts import INDEX_SCHEMA_VERSION
video_path = Path(video_path)
actor_output_path = Path(actor_output_path)
@@ -59,7 +61,7 @@ def frontend_harness(video_path, actor_output_path):
"state": "ready",
"message": "Video indexing completed successfully.",
"summary": {
- "index_schema_version": 2,
+ "index_schema_version": INDEX_SCHEMA_VERSION,
"dataset": "local",
"split": "local",
"run_id": "default",
diff --git a/tests/test_runner.py b/tests/test_runner.py
index 001be5a..00db29a 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -5,8 +5,10 @@
from unittest.mock import Mock, patch
from vidxp.core.contracts import (
+ INDEX_SCHEMA_VERSION,
IndexCancelledError,
IndexConfig,
+ IndexSchemaError,
VideoSource,
)
from vidxp.core.manifest import COMPLETION_FILE
@@ -16,6 +18,7 @@
_RunLock,
index_video,
indexing_in_progress,
+ local_config_from_status,
run_index,
)
from vidxp.index_state import IndexingInProgressError
@@ -74,6 +77,16 @@ def _config(self, root, modalities=("scene",)):
output_root=root,
)
+ def test_local_config_rejects_an_older_index_schema(self):
+ status = {
+ "summary": {
+ "index_schema_version": INDEX_SCHEMA_VERSION - 1,
+ }
+ }
+
+ with self.assertRaisesRegex(IndexSchemaError, "Re-index"):
+ local_config_from_status(status)
+
def test_two_videos_complete_one_isolated_resumable_run(self):
with TemporaryDirectory() as directory:
root = Path(directory)
From 896628df6c3330a78bb2fc93d9994ef3f35aa475 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:29:53 +0500
Subject: [PATCH 23/44] refactor(deps): centralize storage requirements
---
pyproject.toml | 17 ++++++++--
src/vidxp/capabilities/actor/requirements.txt | 1 -
.../capabilities/dialogue/requirements.txt | 1 -
src/vidxp/capabilities/registry.py | 31 ++++++++++++++-----
src/vidxp/capabilities/scene/requirements.txt | 1 -
src/vidxp/dependencies.py | 7 +++--
src/vidxp/requirements/storage.txt | 1 +
tests/test_packaging.py | 20 +++++++++---
8 files changed, 60 insertions(+), 19 deletions(-)
create mode 100644 src/vidxp/requirements/storage.txt
diff --git a/pyproject.toml b/pyproject.toml
index 9098020..50be4a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,10 +65,21 @@ vidxp = [
]
[tool.setuptools.dynamic.optional-dependencies]
-dialogue = { file = ["src/vidxp/capabilities/dialogue/requirements.txt"] }
-scene = { file = ["src/vidxp/capabilities/scene/requirements.txt"] }
-actor = { file = ["src/vidxp/capabilities/actor/requirements.txt"] }
+storage = { file = ["src/vidxp/requirements/storage.txt"] }
+dialogue = { file = [
+ "src/vidxp/requirements/storage.txt",
+ "src/vidxp/capabilities/dialogue/requirements.txt",
+] }
+scene = { file = [
+ "src/vidxp/requirements/storage.txt",
+ "src/vidxp/capabilities/scene/requirements.txt",
+] }
+actor = { file = [
+ "src/vidxp/requirements/storage.txt",
+ "src/vidxp/capabilities/actor/requirements.txt",
+] }
all = { file = [
+ "src/vidxp/requirements/storage.txt",
"src/vidxp/capabilities/dialogue/requirements.txt",
"src/vidxp/capabilities/scene/requirements.txt",
"src/vidxp/capabilities/actor/requirements.txt",
diff --git a/src/vidxp/capabilities/actor/requirements.txt b/src/vidxp/capabilities/actor/requirements.txt
index 8cf9c97..383f8ac 100644
--- a/src/vidxp/capabilities/actor/requirements.txt
+++ b/src/vidxp/capabilities/actor/requirements.txt
@@ -1,4 +1,3 @@
-chromadb
face-recognition
numpy>=2.1,<3
opencv-python
diff --git a/src/vidxp/capabilities/dialogue/requirements.txt b/src/vidxp/capabilities/dialogue/requirements.txt
index 91fe239..c97c911 100644
--- a/src/vidxp/capabilities/dialogue/requirements.txt
+++ b/src/vidxp/capabilities/dialogue/requirements.txt
@@ -1,4 +1,3 @@
-chromadb
moviepy==1.0.3
sentence-transformers>=3.4,<4
whisperx>=3.8.6,<3.9
diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py
index 60ad1ac..2a7a633 100644
--- a/src/vidxp/capabilities/registry.py
+++ b/src/vidxp/capabilities/registry.py
@@ -111,16 +111,33 @@ def requirements_for(
*,
source: VideoSource | None = None,
) -> tuple[Requirement, ...]:
- requirements = []
- for name in validate_capability_names(names):
- capability = get_capability(name)
- selected = active_requirements(
- packaged_requirements(f"vidxp.capabilities.{name}")
+ selected = tuple(
+ get_capability(name)
+ for name in validate_capability_names(names)
+ )
+ requirements = list(
+ active_requirements(
+ packaged_requirements(
+ "vidxp",
+ "requirements/storage.txt",
+ )
+ )
+ if any(capability.indexer is not None for capability in selected)
+ else ()
+ )
+ for capability in selected:
+ capability_requirements = active_requirements(
+ packaged_requirements(
+ f"vidxp.capabilities.{capability.name}"
+ )
)
requirements.extend(
- selected
+ capability_requirements
if source is None
- else capability.source_requirements(source, selected)
+ else capability.source_requirements(
+ source,
+ capability_requirements,
+ )
)
unique = {str(requirement): requirement for requirement in requirements}
return tuple(unique.values())
diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt
index eb0ec14..5c84d0d 100644
--- a/src/vidxp/capabilities/scene/requirements.txt
+++ b/src/vidxp/capabilities/scene/requirements.txt
@@ -1,4 +1,3 @@
-chromadb
clip-anytorch==2.6.0
numpy>=2.1,<3
opencv-python
diff --git a/src/vidxp/dependencies.py b/src/vidxp/dependencies.py
index c27ea69..cdc8826 100644
--- a/src/vidxp/dependencies.py
+++ b/src/vidxp/dependencies.py
@@ -13,8 +13,11 @@
@lru_cache(maxsize=None)
-def packaged_requirements(package: str) -> tuple[Requirement, ...]:
- content = files(package).joinpath("requirements.txt").read_text(
+def packaged_requirements(
+ package: str,
+ resource: str = "requirements.txt",
+) -> tuple[Requirement, ...]:
+ content = files(package).joinpath(resource).read_text(
encoding="utf-8"
)
return tuple(
diff --git a/src/vidxp/requirements/storage.txt b/src/vidxp/requirements/storage.txt
new file mode 100644
index 0000000..99812b1
--- /dev/null
+++ b/src/vidxp/requirements/storage.txt
@@ -0,0 +1 @@
+chromadb
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
index 511f644..5338ddc 100644
--- a/tests/test_packaging.py
+++ b/tests/test_packaging.py
@@ -10,12 +10,17 @@
class PackagingTests(unittest.TestCase):
def test_capability_extras_read_capability_owned_requirements(self):
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ storage = "src/vidxp/requirements/storage.txt"
all_block = pyproject.split("all = { file = [", 1)[1].split(
"] }",
1,
)[0]
for capability in CAPABILITIES.values():
+ extra_block = pyproject.split(
+ f"{capability.extra} = {{ file = [",
+ 1,
+ )[1].split("] }", 1)[0]
requirements = (
ROOT
/ "src"
@@ -26,12 +31,19 @@ def test_capability_extras_read_capability_owned_requirements(self):
)
relative = requirements.relative_to(ROOT).as_posix()
self.assertTrue(requirements.is_file())
- self.assertIn(
- f'{capability.extra} = {{ file = ["{relative}"] }}',
- pyproject,
- )
+ self.assertIn(f'"{storage}"', extra_block)
+ self.assertIn(f'"{relative}"', extra_block)
self.assertIn(f'"{relative}"', all_block)
+ self.assertIn(f'storage = {{ file = ["{storage}"] }}', pyproject)
+ self.assertEqual(
+ sum(
+ line.strip() == "chromadb"
+ for path in (ROOT / "src" / "vidxp").rglob("*.txt")
+ for line in path.read_text(encoding="utf-8").splitlines()
+ ),
+ 1,
+ )
self.assertNotIn("benchmarks/requirements.txt", all_block)
self.assertNotIn("requirements/frontend.txt", all_block)
From efbbce20cb2a4c814c9dffd619beee3e0bbd2e38 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:32:06 +0500
Subject: [PATCH 24/44] ci(security): harden public repository workflows
---
.github/dependabot.yml | 13 +++++++++++++
.github/workflows/ci.yml | 8 ++++----
.github/workflows/release-to-pypi.yml | 6 +++---
.github/workflows/release-to-test-pypi.yml | 6 +++---
.github/workflows/security.yml | 16 ++++++++++++++++
5 files changed, 39 insertions(+), 10 deletions(-)
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/security.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..b0f5089
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 56e7a26..d77c82f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,8 +11,8 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-python@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
cache: pip
@@ -30,8 +30,8 @@ jobs:
package:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-python@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
cache: pip
diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml
index 19e5540..51e9d08 100644
--- a/.github/workflows/release-to-pypi.yml
+++ b/.github/workflows/release-to-pypi.yml
@@ -32,12 +32,12 @@ jobs:
name: pypi
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v7
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
@@ -56,6 +56,6 @@ jobs:
- name: Publish to PyPI
if: steps.release.outputs.released == 'true'
- uses: pypa/gh-action-pypi-publish@release/v1
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
packages-dir: dist/
diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml
index a82a417..7745594 100644
--- a/.github/workflows/release-to-test-pypi.yml
+++ b/.github/workflows/release-to-test-pypi.yml
@@ -30,12 +30,12 @@ jobs:
environment: testpypi
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v7
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
@@ -54,7 +54,7 @@ jobs:
- name: Publish to TestPyPI
if: steps.release.outputs.released == 'true'
- uses: pypa/gh-action-pypi-publish@release/v1
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
repository-url: https://test.pypi.org/legacy/
packages-dir: dist/
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..fe22784
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,16 @@
+name: Security
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ dependency-review:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+ with:
+ fail-on-severity: high
From e927322ed9eba8e5aa4451d23bf4b9ba6515b6b1 Mon Sep 17 00:00:00 2001
From: Talha
Date: Mon, 27 Jul 2026 23:39:03 +0500
Subject: [PATCH 25/44] ci: stabilize lint rule selection
---
pyproject.toml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index 50be4a0..d0302d9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -87,6 +87,12 @@ all = { file = [
frontend = { file = ["src/vidxp/requirements/frontend.txt"] }
benchmarks = { file = ["src/vidxp/benchmarks/requirements.txt"] }
+[tool.ruff]
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["E4", "E7", "E9", "F"]
+
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
tag_format = "v{version}"
From ca2e1122db6bafb3db38e7b50770eaf514d81c9f Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 00:04:11 +0500
Subject: [PATCH 26/44] ci: streamline workflow execution
---
.github/workflows/ci.yml | 36 ++++++++++++----------
.github/workflows/release-to-pypi.yml | 10 ++++--
.github/workflows/release-to-test-pypi.yml | 10 ++++--
.github/workflows/security.yml | 14 +++++++--
4 files changed, 46 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d77c82f..c401ad7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,43 +2,47 @@ name: CI
on:
pull_request:
+ paths:
+ - ".github/workflows/ci.yml"
+ - "src/**"
+ - "tests/**"
+ - "pyproject.toml"
+ - "MANIFEST.in"
+ - "README.md"
+ - "LICENSE"
+ - "utils/build_package.sh"
+ - "utils/build-requirements.txt"
+ - "utils/fix_readme_links.py"
workflow_call:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
permissions:
contents: read
jobs:
- test:
+ validate:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
with:
python-version: "3.11"
cache: pip
- name: Install test surface
run: |
python -m pip install --upgrade pip
- python -m pip install ".[scene,frontend,benchmarks]" ruff
+ python -m pip install ".[scene,frontend,benchmarks]" build ruff
- name: Lint
run: ruff check src tests
- name: Test
run: python -m unittest discover -s tests -q
env:
PYTHONPATH: src
-
- package:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- cache: pip
- name: Build wheel and source distribution
- run: |
- python -m pip install --upgrade pip build
- python -m build
+ run: python -m build
- name: Verify minimal wheel
shell: bash
run: |
diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml
index 51e9d08..f9874c8 100644
--- a/.github/workflows/release-to-pypi.yml
+++ b/.github/workflows/release-to-pypi.yml
@@ -19,6 +19,10 @@ permissions:
id-token: write
pull-requests: write
+concurrency:
+ group: pypi-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
ci:
uses: ./.github/workflows/ci.yml
@@ -32,12 +36,12 @@ jobs:
name: pypi
steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -56,6 +60,6 @@ jobs:
- name: Publish to PyPI
if: steps.release.outputs.released == 'true'
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
+ uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml
index 7745594..cdc8a0a 100644
--- a/.github/workflows/release-to-test-pypi.yml
+++ b/.github/workflows/release-to-test-pypi.yml
@@ -18,6 +18,10 @@ permissions:
contents: write
id-token: write
+concurrency:
+ group: testpypi-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
ci:
uses: ./.github/workflows/ci.yml
@@ -30,12 +34,12 @@ jobs:
environment: testpypi
steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -54,7 +58,7 @@ jobs:
- name: Publish to TestPyPI
if: steps.release.outputs.released == 'true'
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
+ uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
packages-dir: dist/
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index fe22784..268772c 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -2,6 +2,16 @@ name: Security
on:
pull_request:
+ paths:
+ - ".github/dependabot.yml"
+ - ".github/workflows/**"
+ - "pyproject.toml"
+ - "src/**/*.txt"
+ - "utils/build-requirements.txt"
+
+concurrency:
+ group: security-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
permissions:
contents: read
@@ -10,7 +20,7 @@ jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+ - uses: actions/checkout@v7
+ - uses: actions/dependency-review-action@v5
with:
fail-on-severity: high
From 6c6bb35dcf42d61524fee9ad219a453fc1accc89 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 00:04:14 +0500
Subject: [PATCH 27/44] ci(security): control CodeQL scanning
---
.github/workflows/codeql.yml | 37 ++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
create mode 100644 .github/workflows/codeql.yml
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..8eb1ef8
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,37 @@
+name: CodeQL
+
+on:
+ pull_request:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/codeql.yml"
+ - "src/**/*.py"
+ - "utils/**/*.py"
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/codeql.yml"
+ - "src/**/*.py"
+ - "utils/**/*.py"
+ schedule:
+ - cron: "17 4 * * 6"
+
+concurrency:
+ group: codeql-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ security-events: write
+
+jobs:
+ analyze:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: github/codeql-action/init@v4
+ with:
+ languages: python
+ - uses: github/codeql-action/analyze@v4
From bd49f2ada505e4b5f5afd8a10ce9e9355ae3f0b2 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 00:04:18 +0500
Subject: [PATCH 28/44] chore(deps): reduce update noise
---
.github/dependabot.yml | 32 +++++++++++++++++++++++++++-----
1 file changed, 27 insertions(+), 5 deletions(-)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index b0f5089..1c55fbc 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,13 +1,35 @@
version: 2
updates:
- package-ecosystem: pip
- directory: /
+ directories:
+ - "/"
+ - "/src/vidxp/requirements"
+ - "/src/vidxp/capabilities/dialogue"
+ - "/src/vidxp/capabilities/scene"
+ - "/src/vidxp/capabilities/actor"
+ - "/src/vidxp/benchmarks"
+ - "/utils"
schedule:
- interval: weekly
- open-pull-requests-limit: 5
+ interval: cron
+ cronjob: "0 9 1,15 * *"
+ timezone: UTC
+ groups:
+ compatible-updates:
+ patterns:
+ - "*"
+ update-types:
+ - minor
+ - patch
+ open-pull-requests-limit: 2
- package-ecosystem: github-actions
directory: /
schedule:
- interval: weekly
- open-pull-requests-limit: 5
+ interval: cron
+ cronjob: "0 9 1,15 * *"
+ timezone: UTC
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+ open-pull-requests-limit: 2
From 6464a51fd8d571a81e0752c38809a4902f8ce1f4 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 01:18:44 +0500
Subject: [PATCH 29/44] build(release): adopt fragment-based changelogs
---
CHANGELOG.md | 2 +-
changes/11.feature.md | 1 +
changes/README.md | 28 ++++++++++++++++++++
pyproject.toml | 50 +++++++++++++++++++++++++++++-------
utils/build-requirements.txt | 6 ++++-
utils/build_package.sh | 25 +++++++++++++++++-
6 files changed, 100 insertions(+), 12 deletions(-)
create mode 100644 changes/11.feature.md
create mode 100644 changes/README.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 672a93b..0162af7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# CHANGELOG
-
+
## v0.1.0 (2026-07-27)
diff --git a/changes/11.feature.md b/changes/11.feature.md
new file mode 100644
index 0000000..0b418ac
--- /dev/null
+++ b/changes/11.feature.md
@@ -0,0 +1 @@
+Add a structured command-line interface and reusable application service with named repository configuration.
diff --git a/changes/README.md b/changes/README.md
new file mode 100644
index 0000000..1cc4a5b
--- /dev/null
+++ b/changes/README.md
@@ -0,0 +1,28 @@
+# Changelog fragments
+
+Every pull request with a user-visible change must add one short fragment here.
+Use the pull request number and the most specific type:
+
+```text
+changes/..md
+```
+
+Supported types are `breaking`, `feature`, `bugfix`, `deprecation`, `docs`, and
+`security`. Write one sentence for users in the imperative voice and do not add
+a heading or the version number.
+
+Example:
+
+```text
+changes/123.feature.md
+```
+
+```markdown
+Add named repositories for selecting shared index locations and devices.
+```
+
+Purely internal changes may omit a fragment only when a maintainer applies the
+`skip-changelog` label and the pull request explains why.
+
+Towncrier collects and removes fragments when a stable release is created.
+Do not edit `CHANGELOG.md` directly.
diff --git a/pyproject.toml b/pyproject.toml
index bf73b9c..768ff84 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,6 +80,8 @@ vidxp = []
version_toml = ["pyproject.toml:project.version"]
tag_format = "v{version}"
build_command = "bash ./utils/build_package.sh"
+build_command_env = ["BUILD_CHANGELOG"]
+assets = ["CHANGELOG.md"]
commit_parser = "conventional"
major_on_zero = true
allow_zero_version = true
@@ -91,15 +93,45 @@ patch_tags = ["fix", "perf"]
parse_squash_commits = true
ignore_merge_commits = true
-[tool.semantic_release.changelog.default_templates]
-changelog_file = "./CHANGELOG.md"
-output_format = "md"
-
-[tool.semantic_release.branches.release]
-match = "release"
-prerelease = false
-
[tool.semantic_release.branches.main]
match = "main"
-prerelease = true
+prerelease = false
prerelease_token = "b"
+
+[tool.towncrier]
+directory = "changes"
+filename = "CHANGELOG.md"
+start_string = ""
+title_format = "## v{version} ({project_date})"
+issue_format = "[#{issue}](https://github.com/grayhatdevelopers/vidxp/pull/{issue})"
+wrap = false
+
+[[tool.towncrier.type]]
+directory = "breaking"
+name = "Breaking Changes"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "feature"
+name = "Features"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "bugfix"
+name = "Bug Fixes"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "deprecation"
+name = "Deprecations"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "docs"
+name = "Documentation"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "security"
+name = "Security"
+showcontent = true
diff --git a/utils/build-requirements.txt b/utils/build-requirements.txt
index c1f5f71..212f08e 100644
--- a/utils/build-requirements.txt
+++ b/utils/build-requirements.txt
@@ -1 +1,5 @@
-beautifulsoup4
+beautifulsoup4~=4.15.0
+build~=1.5.0
+python-semantic-release~=10.6.0
+ruff~=0.15.0
+towncrier~=25.8.0
diff --git a/utils/build_package.sh b/utils/build_package.sh
index 0fa100d..c145dde 100644
--- a/utils/build_package.sh
+++ b/utils/build_package.sh
@@ -4,6 +4,26 @@ set -euo pipefail
BASE_URL="https://github.com/grayhatdevelopers/vidxp/blob/main"
README="README.md"
README_BAK="$README.bak"
+BUILD_DIR="build"
+DIST_DIR="dist"
+
+restore_readme() {
+ if [[ -f "$README_BAK" ]]; then
+ mv "$README_BAK" "$README"
+ fi
+}
+
+trap restore_readme EXIT
+
+if [[ "${BUILD_CHANGELOG:-0}" == "1" ]]; then
+ if [[ -z "${NEW_VERSION:-}" ]]; then
+ echo "NEW_VERSION is required when BUILD_CHANGELOG=1" >&2
+ exit 1
+ fi
+
+ echo "📰 Building changelog for v${NEW_VERSION}..."
+ towncrier build --yes --version "$NEW_VERSION"
+fi
echo "📝 Backing up original README..."
cp "$README" "$README_BAK"
@@ -11,10 +31,13 @@ cp "$README" "$README_BAK"
echo "🔧 Processing README.md for PyPI rendering..."
python utils/fix_readme_links.py "$BASE_URL" "$README" --inplace
+echo "🧹 Removing stale package artifacts..."
+rm -rf -- "$BUILD_DIR" "$DIST_DIR"
+
echo "📦 Building package..."
python -m build
echo "♻️ Restoring original README..."
-mv "$README_BAK" "$README"
+restore_readme
echo "✅ Build finished. Original README restored."
From b4ef6232652d13ecd705e1768057f4f4a61988eb Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 01:22:04 +0500
Subject: [PATCH 30/44] ci(release): publish prereleases and stable builds from
main
---
.github/pull_request_template.md | 13 ++++
.github/workflows/ci.yml | 75 ++++++++++++++++++++++
.github/workflows/release-to-pypi.yml | 34 +++++-----
.github/workflows/release-to-test-pypi.yml | 20 ++++--
4 files changed, 117 insertions(+), 25 deletions(-)
create mode 100644 .github/pull_request_template.md
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..b1d884b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,13 @@
+## Summary
+
+- Describe the user-facing outcome.
+
+## Validation
+
+- List the commands or checks run.
+
+## Changelog
+
+- [ ] Added `changes/..md`
+- [ ] This change is internal-only; explain why a maintainer should apply the
+ `skip-changelog` label:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1f684aa
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,75 @@
+name: CI
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - labeled
+ - unlabeled
+ workflow_call:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-python@v7
+ with:
+ python-version: "3.11"
+ cache: pip
+
+ - name: Install repository tooling
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -r utils/build-requirements.txt
+
+ - name: Require a changelog fragment
+ if: >-
+ github.event_name == 'pull_request' &&
+ !contains(github.event.pull_request.labels.*.name, 'skip-changelog')
+ run: towncrier check --compare-with "${{ github.event.pull_request.base.sha }}"
+
+ - name: Determine validation scope
+ id: scope
+ shell: bash
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ if [[ "${{ github.event_name }}" != "pull_request" ]]; then
+ echo "run_suite=true" >> "$GITHUB_OUTPUT"
+ elif git diff --name-only "$BASE_SHA" HEAD |
+ grep -Eq '^(\.github/workflows/ci\.yml|src/|tests/|utils/|Dockerfile$|LICENSE$|MANIFEST\.in$|README\.md$|pyproject\.toml$)'; then
+ echo "run_suite=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "run_suite=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Install test surface
+ if: steps.scope.outputs.run_suite == 'true'
+ run: python -m pip install ".[frontend]"
+
+ - name: Lint
+ if: steps.scope.outputs.run_suite == 'true'
+ run: ruff check src tests
+
+ - name: Test
+ if: steps.scope.outputs.run_suite == 'true'
+ run: python -m unittest discover -s tests -q
+ env:
+ PYTHONPATH: src
+
+ - name: Build wheel and source distribution
+ if: steps.scope.outputs.run_suite == 'true'
+ run: python -m build
diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml
index c4ef7da..13542dd 100644
--- a/.github/workflows/release-to-pypi.yml
+++ b/.github/workflows/release-to-pypi.yml
@@ -1,26 +1,24 @@
-name: Release (release → PyPI)
+name: Release (main → PyPI)
on:
- push:
- branches:
- - release
- paths:
- - "src/**"
- - "pyproject.toml"
- - "README.md"
- - "LICENSE"
- - "MANIFEST.in"
- - "utils/build_package.sh"
- - "utils/build-requirements.txt"
- - "utils/fix_readme_links.py"
+ workflow_dispatch:
permissions:
contents: write
id-token: write
- pull-requests: write
+
+concurrency:
+ group: pypi-main
+ cancel-in-progress: false
jobs:
+ ci:
+ uses: ./.github/workflows/ci.yml
+ permissions:
+ contents: read
+
release:
+ needs: ci
runs-on: ubuntu-latest
environment:
name: pypi
@@ -29,6 +27,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
+ ref: main
- name: Set up Python
uses: actions/setup-python@v7
@@ -38,15 +37,14 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install build "python-semantic-release~=10.6.0"
- pip install -r utils/build-requirements.txt
+ python -m pip install -r utils/build-requirements.txt
- name: Create stable release
id: release
env:
+ BUILD_CHANGELOG: "1"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- semantic-release version --commit --tag --push
+ run: semantic-release version --no-changelog
- name: Publish to PyPI
if: steps.release.outputs.released == 'true'
diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml
index fdd4f5a..1af8437 100644
--- a/.github/workflows/release-to-test-pypi.yml
+++ b/.github/workflows/release-to-test-pypi.yml
@@ -8,18 +8,26 @@ on:
- "src/**"
- "pyproject.toml"
- "README.md"
+ - "CHANGELOG.md"
+ - "changes/**"
- "LICENSE"
- "MANIFEST.in"
- - "utils/build_package.sh"
- - "utils/build-requirements.txt"
- - "utils/fix_readme_links.py"
+ - "utils/**"
+ - ".github/workflows/ci.yml"
+ - ".github/workflows/release-to-test-pypi.yml"
permissions:
contents: write
id-token: write
jobs:
+ ci:
+ uses: ./.github/workflows/ci.yml
+ permissions:
+ contents: read
+
prerelease:
+ needs: ci
runs-on: ubuntu-latest
environment: testpypi
@@ -36,15 +44,13 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install build "python-semantic-release~=10.6.0"
- pip install -r utils/build-requirements.txt
+ python -m pip install -r utils/build-requirements.txt
- name: Determine and tag prerelease version
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- semantic-release version --commit --tag --push
+ run: semantic-release version --as-prerelease --prerelease-token b --no-changelog
- name: Publish to TestPyPI
if: steps.release.outputs.released == 'true'
From 11bbc93e671f3d1ba62eb48e30742736aa1c775b Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 01:22:17 +0500
Subject: [PATCH 31/44] docs: explain changelog and release contributions
---
docs/CONTRIBUTING.md | 26 ++++++++++++++++++++++++++
docs/releasing.md | 31 +++++++++++++++++++++++++++++++
2 files changed, 57 insertions(+)
create mode 100644 docs/releasing.md
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 75b34bd..9a9168b 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -88,7 +88,33 @@ python -m unittest discover -s tests
- Note any new env vars, model downloads, or breaking index format changes.
- Link a related issue when there is one.
+### Changelog fragments
+
+Add one `changes/..md` file for every user-visible pull
+request. Use one of these types:
+
+- `breaking` for incompatible behavior or API changes.
+- `feature` for new behavior.
+- `bugfix` for corrected behavior.
+- `deprecation` for behavior scheduled for removal.
+- `docs` for user-facing documentation improvements.
+- `security` for security fixes or hardening users should know about.
+
+The fragment should be one sentence written for users. Do not include a heading,
+version number, commit message, or implementation details. See
+[`changes/README.md`](../changes/README.md) for an example.
+
+Internal-only maintenance does not need an empty fragment. Explain the reason in
+the pull request and ask a maintainer to apply the `skip-changelog` label. CI
+requires either a fragment or that label.
+
+Towncrier collects the pending fragments into `CHANGELOG.md` and removes them
+when a stable release is made. Do not edit the changelog or package version in a
+feature pull request.
+
## Questions
Open an issue before large refactors or new modalities so scope stays aligned
with the roadmap.
+
+Maintainers should follow the [release process](releasing.md).
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..1f194a7
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,31 @@
+# Release process
+
+VidXP publishes prerelease and stable packages from `main`. The old `release`
+branch is retained only as historical ancestry for `v0.1.0`; it is not an active
+publication branch.
+
+## Prereleases
+
+A release-relevant merge to `main` runs CI, lets Python Semantic Release
+calculate the next version, creates a `b` prerelease tag and GitHub prerelease,
+builds the distributions, and publishes them to TestPyPI. Commits that do not
+require a semantic version bump do not publish a package.
+
+Pending Towncrier fragments remain in `changes/` during prereleases.
+
+## Stable releases
+
+1. Confirm that `main` is green and its TestPyPI prerelease is usable.
+2. Confirm that every user-visible merged pull request has an accurate fragment.
+3. Run the **Release (main → PyPI)** workflow.
+4. Approve the protected `pypi` environment deployment.
+5. Confirm the new tag, GitHub release, PyPI package, and emptied pending
+ fragment set.
+
+The workflow always checks out `main`. Python Semantic Release is the only
+version authority. During its build step, Towncrier receives that calculated
+version, updates `CHANGELOG.md`, and removes the released fragments before the
+release commit and tag are created.
+
+Release and CI tools are declared once in `utils/build-requirements.txt`. Do not
+duplicate their versions in workflow files.
From 62500a52a1a928cbb874c5eaf66c98c6c5bc7a21 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 01:26:46 +0500
Subject: [PATCH 32/44] docs(changelog): describe capability architecture
---
changes/12.feature.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 changes/12.feature.md
diff --git a/changes/12.feature.md b/changes/12.feature.md
new file mode 100644
index 0000000..5f76b83
--- /dev/null
+++ b/changes/12.feature.md
@@ -0,0 +1 @@
+Add registry-driven dialogue, scene, and actor capabilities with selective installation extras and an explicit benchmark add-on.
From 655f2ecc259254b4d3b1c11f9a66bf1e1bfb8050 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 01:34:07 +0500
Subject: [PATCH 33/44] fix(ci): resolve cli access lint failures
---
src/vidxp/application.py | 1 +
src/vidxp/cli_commands/runtime.py | 1 -
tests/test_runner.py | 1 -
3 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index fc00cd6..7418866 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -15,6 +15,7 @@
SUPPORTED_MODALITIES,
CancellationToken,
IndexConfig,
+ IndexSchemaError,
SearchResult,
)
from vidxp.core.manifest import (
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
index 309fdb7..2a24de4 100644
--- a/src/vidxp/cli_commands/runtime.py
+++ b/src/vidxp/cli_commands/runtime.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import os
-from pathlib import Path
from typing import Annotated
import typer
diff --git a/tests/test_runner.py b/tests/test_runner.py
index d032730..e04a5b4 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -5,7 +5,6 @@
from unittest.mock import Mock, patch
from vidxp.core.contracts import (
- CancellationToken,
IndexCancelledError,
IndexConfig,
VideoSource,
From 8c6b417aa62c4ed5cbcf97b2725dd19c57bc720a Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 01:43:56 +0500
Subject: [PATCH 34/44] ci: install benchmark test dependencies
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1f684aa..f6c2598 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,7 +58,7 @@ jobs:
- name: Install test surface
if: steps.scope.outputs.run_suite == 'true'
- run: python -m pip install ".[frontend]"
+ run: python -m pip install ".[frontend,benchmarks]"
- name: Lint
if: steps.scope.outputs.run_suite == 'true'
From 13e9e3c323c1cc0437939217b4202fb7791c183a Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 08:20:55 +0500
Subject: [PATCH 35/44] fix(application): translate missing optional
dependencies
---
src/vidxp/application.py | 26 +++++++++++++-----
tests/test_application.py | 57 +++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 6 deletions(-)
diff --git a/src/vidxp/application.py b/src/vidxp/application.py
index 8e44911..57223d0 100644
--- a/src/vidxp/application.py
+++ b/src/vidxp/application.py
@@ -237,10 +237,17 @@ def execute(
raise ValueError(
f"The {capability} capability is not present in this index."
)
- return selected_operation.invoke(
- CapabilityContext(config=config),
- payload,
- )
+ try:
+ return selected_operation.invoke(
+ CapabilityContext(config=config),
+ payload,
+ )
+ except ModuleNotFoundError as exc:
+ dependency = exc.name or "optional dependency"
+ raise RuntimeError(
+ f"{dependency} is unavailable. "
+ + capability_install_hint(definition.extra)
+ ) from exc
def search(
self,
@@ -316,8 +323,15 @@ def clear_index(self) -> bool:
config = base_config
else:
config = base_config
- with IndexStorage(config) as storage:
- storage.clear()
+ try:
+ with IndexStorage(config) as storage:
+ storage.clear()
+ except ModuleNotFoundError as exc:
+ dependency = exc.name or "optional storage dependency"
+ raise RuntimeError(
+ f"{dependency} is unavailable. "
+ + capability_install_hint("storage")
+ ) from exc
for name in (
INDEX_STATUS_FILE,
diff --git a/tests/test_application.py b/tests/test_application.py
index f6ea034..5ea2650 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -113,6 +113,37 @@ def test_search_is_a_thin_adapter_over_the_core(self):
{"query": "yellow taxi", "top_k": 7},
)
+ def test_execute_translates_missing_optional_dependency(self):
+ def missing_dependency(_context, _request):
+ raise ModuleNotFoundError("No module named 'clip'", name="clip")
+
+ operation = OperationDefinition(
+ input_model=SearchInput,
+ output_model=SearchResult,
+ handler=missing_dependency,
+ requires_index=False,
+ )
+ capability = CapabilityDefinition(
+ name="scene",
+ description="Search visual scenes.",
+ extra="scene",
+ operations={"search": operation},
+ )
+ service = VidXPService()
+ with patch(
+ "vidxp.application.get_capability",
+ return_value=capability,
+ ):
+ with self.assertRaisesRegex(
+ RuntimeError,
+ r'clip is unavailable.*pip install "vidxp\[scene\]"',
+ ):
+ service.execute(
+ "scene",
+ "search",
+ {"query": "yellow taxi"},
+ )
+
def test_create_index_centralizes_storage_and_runtime_configuration(self):
service = VidXPService("selected-index", device="cuda")
with patch(
@@ -238,6 +269,32 @@ def test_clear_removes_only_known_run_state_after_clearing_collections(self):
)
self.assertFalse(checkpoint_directory.exists())
+ def test_clear_translates_missing_storage_dependency(self):
+ with TemporaryDirectory() as directory:
+ service = VidXPService(directory)
+ with (
+ patch(
+ "vidxp.application.indexing_in_progress",
+ return_value=False,
+ ),
+ patch(
+ "vidxp.application.read_index_status",
+ return_value=None,
+ ),
+ patch(
+ "vidxp.application.IndexStorage",
+ side_effect=ModuleNotFoundError(
+ "No module named 'chromadb'",
+ name="chromadb",
+ ),
+ ),
+ ):
+ with self.assertRaisesRegex(
+ RuntimeError,
+ r'chromadb is unavailable.*pip install "vidxp\[storage\]"',
+ ):
+ service.clear_index()
+
if __name__ == "__main__":
unittest.main()
From f5cc750e53dd4ebdbaeb2f270412274f0423ea9e Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 08:21:02 +0500
Subject: [PATCH 36/44] fix(frontend): honor installed indexing capabilities
---
src/vidxp/frontend.py | 36 +++++++++++++++++++++++++++++++++---
src/vidxp/index_worker.py | 5 +++++
tests/test_frontend.py | 35 +++++++++++++++++++++++++++++++++++
tests/test_index_worker.py | 10 ++++++++++
4 files changed, 83 insertions(+), 3 deletions(-)
diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py
index 0ab5f2d..84a26e2 100644
--- a/src/vidxp/frontend.py
+++ b/src/vidxp/frontend.py
@@ -6,6 +6,7 @@
import streamlit as st
from vidxp.application import VidXPService
+from vidxp.capabilities.registry import index_capability_names
from vidxp.index_state import IndexNotReadyError
from vidxp.index_worker import (
cancel_indexing,
@@ -122,7 +123,15 @@ def _request_cancellation():
)
-def _run_indexing(uploaded_video, status):
+def _available_index_modalities() -> tuple[str, ...]:
+ return tuple(
+ name
+ for name in index_capability_names()
+ if SERVICE.check_dependencies((name,))["ok"]
+ )
+
+
+def _run_indexing(uploaded_video, status, modalities):
try:
if uploaded_video is not None:
SAVED_VIDEO_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -134,7 +143,12 @@ def _run_indexing(uploaded_video, status):
if status
else SAVED_VIDEO_PATH.name
)
- start_indexing(str(SAVED_VIDEO_PATH), source_name, SERVICE)
+ start_indexing(
+ str(SAVED_VIDEO_PATH),
+ source_name,
+ SERVICE,
+ modalities=modalities,
+ )
except Exception as exc:
st.session_state[INDEX_ERROR_KEY] = f"{type(exc).__name__}: {exc}"
else:
@@ -287,6 +301,7 @@ def run():
requested = st.session_state.get(INDEX_REQUESTED_KEY, False)
busy = active or requested
status = SERVICE.index_status()
+ installed_modalities = _available_index_modalities()
video_column, workflow_column = st.columns(
[0.95, 1.05],
gap="large",
@@ -298,10 +313,25 @@ def run():
with workflow_column:
st.subheader("Build index")
+ selected_modalities = tuple(
+ st.multiselect(
+ "Capabilities",
+ installed_modalities,
+ default=installed_modalities,
+ disabled=busy,
+ help="Install another capability extra to make it available here.",
+ )
+ )
+ if not installed_modalities:
+ st.warning(
+ "No indexing capabilities are installed. "
+ 'Install one, for example: pip install "vidxp[scene]"'
+ )
st.button(
"Index video",
type="primary",
disabled=busy
+ or not selected_modalities
or (uploaded_video is None and not SAVED_VIDEO_PATH.is_file()),
help=(
"Indexing is already running."
@@ -371,7 +401,7 @@ def poll_index_status():
_render_search_result(st.session_state.get(SEARCH_RESULT_KEY))
if requested:
- _run_indexing(uploaded_video, status)
+ _run_indexing(uploaded_video, status, selected_modalities)
def main(arguments: Sequence[str] = ()):
diff --git a/src/vidxp/index_worker.py b/src/vidxp/index_worker.py
index eb60361..d96a9f4 100644
--- a/src/vidxp/index_worker.py
+++ b/src/vidxp/index_worker.py
@@ -26,9 +26,11 @@ def _run_indexing(
cancel_event,
index_directory: str,
device: str | None,
+ modalities: tuple[str, ...],
) -> None:
VidXPService(index_directory, device=device).create_index(
path,
+ modalities=modalities,
source_name=source_name,
cancellation=CancellationToken(cancel_event),
)
@@ -45,6 +47,8 @@ def start_indexing(
path: str,
source_name: str,
service: VidXPService | None = None,
+ *,
+ modalities: tuple[str, ...],
) -> None:
global _cancel_event, _process
@@ -63,6 +67,7 @@ def start_indexing(
_cancel_event,
str(active_service.index_directory),
active_service.device,
+ modalities,
),
name="vidxp-indexer",
daemon=True,
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 1df869b..d0afd30 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -57,6 +57,7 @@ def frontend_harness(video_path, actor_output_path):
video_path = Path(video_path)
actor_output_path = Path(actor_output_path)
service = Mock()
+ service.check_dependencies.return_value = {"ok": True}
service.index_status.return_value = {
"state": "ready",
"message": "Video indexing completed successfully.",
@@ -175,6 +176,40 @@ def test_starting_a_new_index_clears_the_previous_result(self):
self.assertNotIn(frontend.SEARCH_RESULT_KEY, state)
self.assertNotIn(frontend.INDEX_ERROR_KEY, state)
+ def test_available_index_modalities_excludes_missing_extras(self):
+ def dependency_status(modalities):
+ return {"ok": modalities == ("scene",)}
+
+ self.service.check_dependencies.side_effect = dependency_status
+ with patch.object(frontend, "SERVICE", self.service):
+ available = frontend._available_index_modalities()
+
+ self.assertEqual(available, ("scene",))
+
+ def test_indexing_passes_selected_modalities_to_the_worker(self):
+ uploaded_video = Mock()
+ uploaded_video.name = "source.mp4"
+ uploaded_video.getvalue.return_value = b"video"
+ with TemporaryDirectory() as directory:
+ saved_video = Path(directory) / "source-video.mp4"
+ with (
+ patch.object(frontend, "SAVED_VIDEO_PATH", saved_video),
+ patch.object(frontend, "start_indexing") as start,
+ patch.object(frontend.st, "rerun"),
+ ):
+ frontend._run_indexing(
+ uploaded_video,
+ {},
+ ("scene",),
+ )
+
+ start.assert_called_once_with(
+ str(saved_video),
+ "source.mp4",
+ frontend.SERVICE,
+ modalities=("scene",),
+ )
+
def test_cancellation_request_uses_the_worker_token(self):
state = {}
with (
diff --git a/tests/test_index_worker.py b/tests/test_index_worker.py
index 01c8545..aeb1a84 100644
--- a/tests/test_index_worker.py
+++ b/tests/test_index_worker.py
@@ -63,6 +63,7 @@ def test_worker_starts_indexing_in_a_separate_process(self):
"video.mp4",
"source.mp4",
self.service,
+ modalities=("scene",),
)
self.assertTrue(context.process.is_alive())
@@ -76,6 +77,7 @@ def test_worker_starts_indexing_in_a_separate_process(self):
context.event,
"selected-index",
"cuda",
+ ("scene",),
),
)
self.assertIs(context.process.options["target"], index_worker._run_indexing)
@@ -93,6 +95,7 @@ def test_worker_process_reconstructs_the_selected_service(self):
FakeEvent(),
"selected-index",
"cuda",
+ ("scene",),
)
service_type.assert_called_once_with("selected-index", device="cuda")
@@ -101,6 +104,10 @@ def test_worker_process_reconstructs_the_selected_service(self):
service.create_index.call_args.kwargs["source_name"],
"source.mp4",
)
+ self.assertEqual(
+ service.create_index.call_args.kwargs["modalities"],
+ ("scene",),
+ )
def test_worker_rejects_a_second_indexing_run(self):
context = FakeContext()
@@ -110,12 +117,14 @@ def test_worker_rejects_a_second_indexing_run(self):
"video.mp4",
"source.mp4",
self.service,
+ modalities=("scene",),
)
with self.assertRaises(IndexingInProgressError):
index_worker.start_indexing(
"video.mp4",
"source.mp4",
self.service,
+ modalities=("scene",),
)
def test_existing_service_run_remains_visible(self):
@@ -130,6 +139,7 @@ def test_cancellation_requests_are_cooperative(self):
"video.mp4",
"source.mp4",
self.service,
+ modalities=("scene",),
)
self.assertTrue(index_worker.cancel_indexing())
From 3bcca0893f36935c9ba2c9791653baf5b521aa01 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 08:21:07 +0500
Subject: [PATCH 37/44] fix(release): bind stable publishing to main
---
.github/workflows/release-to-pypi.yml | 3 ++-
docs/releasing.md | 13 +++++++------
2 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml
index 13542dd..b5085fd 100644
--- a/.github/workflows/release-to-pypi.yml
+++ b/.github/workflows/release-to-pypi.yml
@@ -13,11 +13,13 @@ concurrency:
jobs:
ci:
+ if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/ci.yml
permissions:
contents: read
release:
+ if: github.ref == 'refs/heads/main'
needs: ci
runs-on: ubuntu-latest
environment:
@@ -27,7 +29,6 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- ref: main
- name: Set up Python
uses: actions/setup-python@v7
diff --git a/docs/releasing.md b/docs/releasing.md
index 1f194a7..ad876fe 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -17,15 +17,16 @@ Pending Towncrier fragments remain in `changes/` during prereleases.
1. Confirm that `main` is green and its TestPyPI prerelease is usable.
2. Confirm that every user-visible merged pull request has an accurate fragment.
-3. Run the **Release (main → PyPI)** workflow.
-4. Approve the protected `pypi` environment deployment.
+3. Run the **Release (main → PyPI)** workflow from `main`.
+4. Approve the `pypi` environment deployment when reviewer protection is enabled.
5. Confirm the new tag, GitHub release, PyPI package, and emptied pending
fragment set.
-The workflow always checks out `main`. Python Semantic Release is the only
-version authority. During its build step, Towncrier receives that calculated
-version, updates `CHANGELOG.md`, and removes the released fragments before the
-release commit and tag are created.
+The workflow only runs its validation and release jobs for a `main` dispatch.
+Both jobs use the same immutable dispatch revision. Python Semantic Release is
+the only version authority. During its build step, Towncrier receives that
+calculated version, updates `CHANGELOG.md`, and removes the released fragments
+before the release commit and tag are created.
Release and CI tools are declared once in `utils/build-requirements.txt`. Do not
duplicate their versions in workflow files.
From a2b9f57b320c0236e9f505a4e56e8a2b7acade43 Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 09:38:33 +0500
Subject: [PATCH 38/44] docs(readme): simplify overview and installation
---
README.md | 71 +++++++++++++++++--------------------------------------
1 file changed, 22 insertions(+), 49 deletions(-)
diff --git a/README.md b/README.md
index df18a7f..d37db09 100644
--- a/README.md
+++ b/README.md
@@ -10,36 +10,24 @@
Search video by what was said, what appeared on screen, and recurring faces.
-
-
- VidXP is a local-first video indexing and search engine distributed as a Python
- package.
-
You can use it:
-
-
-
- - From the command line
- - Through its browser interface
- - As a desktop app
- - As an API
- - As an indexing and retrieval layer inside another application
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
-
## Why VidXP
Finding one moment in a video should not require scrubbing through the entire
@@ -70,34 +58,20 @@ VidXP supports Python 3.10 through 3.13 and requires FFmpeg. See the
[installation guide](INSTALLATION_GUIDE.md) for the `dlib` compiler
requirements, source installation, model preparation, and troubleshooting.
-Create and activate a virtual environment:
-
-```bash
-python -m venv venv
-```
-
-```bash
-# Windows
-venv\Scripts\activate
-
-# macOS/Linux
-source venv/bin/activate
-```
-
-Install the command line and all indexing capabilities:
-
-```bash
-python -m pip install "vidxp[all]"
-```
-
-Include the browser interface:
+Install the command line and browser interface with
+[pipx](https://packaging.python.org/en/latest/guides/installing-stand-alone-command-line-tools/).
+The command is available on your `PATH` while VidXP and its dependencies remain
+isolated:
```bash
-python -m pip install "vidxp[all,frontend]"
+pipx install "vidxp[all,frontend]"
```
-Install only the capabilities you need with extras such as
-`vidxp[dialogue]`, `vidxp[scene]`, or `vidxp[scene,actor]`.
+Install only the capabilities you need with a smaller selection such as
+`pipx install "vidxp[scene,frontend]"` or
+`pipx install "vidxp[dialogue,scene]"`. To import VidXP from another Python
+project, install it into that project's environment instead; the
+[installation guide](INSTALLATION_GUIDE.md) covers that path.
Confirm the installed package and its runtime dependencies:
@@ -250,7 +224,6 @@ caches normally live outside this directory and outside the virtual environment.
- [Installation and troubleshooting](INSTALLATION_GUIDE.md)
- [Benchmarking status and results](docs/benchmarking/README.md)
-- [Contribution guidelines](docs/CONTRIBUTING.md)
- [Adding a capability](docs/adding-a-capability.md)
- [Changelog](CHANGELOG.md)
- [Issue tracker](https://github.com/grayhatdevelopers/vidxp/issues)
From b457d9c3be118d036368a96ffd387f918ad10b2d Mon Sep 17 00:00:00 2001
From: Talha
Date: Tue, 28 Jul 2026 09:53:50 +0500
Subject: [PATCH 39/44] docs(readme): restore capability summary
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index d37db09..3827d97 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,10 @@
Search video by what was said, what appeared on screen, and recurring faces.
+
+ Dialogue search · Scene search · Actor grouping · CLI · Browser UI · Python API
+
+
From 6e6bb28f6fec68d9ef3c171959722493112699ec Mon Sep 17 00:00:00 2001
From: "Saad A. Bazaz"
Date: Tue, 28 Jul 2026 10:45:11 +0500
Subject: [PATCH 40/44] chore: clean readme further
---
README.md | 38 ++++++++++--------------------
docs/images/video-screenshot.jpeg | Bin 0 -> 300671 bytes
2 files changed, 13 insertions(+), 25 deletions(-)
create mode 100644 docs/images/video-screenshot.jpeg
diff --git a/README.md b/README.md
index 9ae660e..4d5f76c 100644
--- a/README.md
+++ b/README.md
@@ -53,14 +53,19 @@
Finding one moment in a video should not require scrubbing through the entire
timeline. VidXP builds a searchable index from three kinds of evidence:
-- **Dialogue:** semantic search over timestamped WhisperX transcripts.
-- **Scenes:** text-to-frame search using CLIP.
-- **Actors:** groups similar detected faces and exports a highlighted video for
- a selected cluster.
+- **Dialogue:** semantic search over timestamped transcripts.
+- **Scenes:** text-to-frame search.
+- **Actors:** groups similar detected faces and exports a highlighted video for a selected cluster.
+
+After the required model weights are available, video processing and search run completely locally, for your privacy and security.
+
+Some ideas on how to use VidXP:
+- Use it as way to find your favorite relatives in a huge folder of wedding videos (been there, done that)
+- Use it in your application, allow users to search videos (an idea: use it alongside a video-editing application)
+- Use it as an "understanding" layer so your LLM / agent can understand videos
+
+[](https://www.linkedin.com/feed/update/urn:li:activity:7343569473720725505/)
-After the required model weights are available, video processing and search run
-locally. VidXP also saves index state so an incomplete run is not mistaken for a
-searchable result.
## Current capabilities
@@ -197,24 +202,9 @@ documents configuration, stored metadata, result fields, and run layout.
---
-
-## Current scope
-
-- The standard CLI and browser interface manage one local searchable video
- index at a time. The Python layer supports isolated multi-video runs.
-- Actor clusters represent visually similar detected faces; VidXP does not
- automatically know or assign a person's name.
-- Model weights are cached separately from the Python package and require
- additional disk space.
-- CPU is the current default. Visual indexing processes every frame unless
- `--frame-stride` is configured.
-- Search quality depends on the selected models, video domain, speech quality,
- and frame sampling.
-
## Roadmap
-VidXP is an evolving beta. The roadmap extends the current engine rather than
-replacing its working search paths.
+VidXP is an evolving beta. We'd love to hear your feedback and where you'd like to see the project go.
| Area | Current foundation | Direction |
|---|---|---|
@@ -226,8 +216,6 @@ replacing its working search paths.
| Product experience | CLI and browser indexing/search | Clearer progress, result navigation, recovery, and long-running job controls |
| Evaluation | DiDeMo and HiREST baselines | Combined and component benchmarks, beginning with a LongVALE pilot |
-Roadmap items describe intended direction, not a release guarantee.
-
## Models and local data
| Capability | Model |
diff --git a/docs/images/video-screenshot.jpeg b/docs/images/video-screenshot.jpeg
new file mode 100644
index 0000000000000000000000000000000000000000..53322b9cddbd6cbc836ce0629f80b9868695b09e
GIT binary patch
literal 300671
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