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
+ + +
+ + + discord + +

- + download -

- -

- - discord + + download

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

- - download + + PyPI version + + + Supported Python versions + + + CI status + + + MIT license -

- -

- discord + Discord

- ## 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 +

+

PyPI version 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 + +[![Video Screenshot](./docs/images/video-screenshot.jpeg)](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 zcmb@tcUV(P*EhUFlP(=8dgvwe76<|&kU)UYdlLwu7fC^|9SfpV4WWl35PDHU4~U9V zLX%zvl@4}n2lX7^jpsg|`+2VKkMF&%cVO@N?KLxN%}i$2UNf`ykNF?Z0X}Oa3JE|U z5a1U02mE*eWFq3jf&l=H2A}`{umda*aex&>5b!dANc_S`h!Vj3lgpu(ANaBNcG^bR-iW07OL-a88zHQXZaOQipZ{Hc%!9zzIkOVDP6; zIyl(=tFi+FT@{&#bdw*I)J$YgYcLr5VwHWcg$%V0RWiq zf^^w4gwqFj8pJ|4u!11oKENS=8xQ8wCCrpOF8;0Re%(cm@Q7{K9{? z1uO{)wg|(Yjtofn`SAbp78OYZ`|D>=1n=CTI4dVGJ_7dHdPI!PFB~0cXa5g93~b#$ zIFxAR`VYq7&B4C>yDUD^`Cz#Gp?thv5O%2jlW*S-=87 z0Jh+t6yN}81Nz{lb3h|tk z0N}&?&v^+EVgHvFeFgyZw7^8Oe`%N}06-T90Di{5G`U*k@)z$fGj{>Kz1N|kgrV4 zOx#RDOh=g%m^7FSm`*a;Fu5@KGKDb3GM!~gWXfR5V=7~+XKG`5$TZ9}!}Of#CDSKn zfSHR~gjt$djoEWlb`k9T5O@vL6O`pw*&66#h?HpSgTM^qGwg+qzY-?lf8gQaQQ$G<@!}!yWboARJmOj5`GZ%G zSDhEd8^rrNFNL>_carzbVUEM{hff~%JAC$V&f(_6PY%E0*Sl` z`@}EEuf=cAAH|=>U(3(n-xfF|pdf${2ogvXC>MAnuqMbXC@W|#h!MOhNEPf8+z?_D zQWQc7g$tz$H3*Ffy%QD?))jUWju)l~-xGc=!Yra7f)a@oxhX;unHBjedQ|kJC{{FC zv{7_Y^ve;6Bc?~NM^cX5IWlwPtC*A+LM%dzB-SprEY2bh6?YUrBVH`tC;m!8NaDBz zMj}OmCb1~VBng#tk~}9_E;%Ck>FBYemPbz?%|H6+=&NHQ$4rlfAImz{b8P1~!QV`N z3;QkWxBI{CN(oDyl!}zflX@)mPFhmhN}4ELDm^CsO-511RpyG!9hqfWPFVxlVA(9$ zUfFkYzsaHH&db%wEy{Dq8_0*s=gJSre^F3Wa96ml(5|qpctjDUcvi7a@u?Dz(g~$l zr81>yWj194<#8wzR1X>or9hvkFsZ;)!c>Y>CRABf;i}Q9WvX*(+-fJ);?(NY zUZ{(x+p1qtZ&!b#A*bP`LDCq|_)AkyGg6bPxuhkaWvz8Zi>|e&4b{eIQ?#dbcyy3D zzw319yw_FH#p)L8&g%*4q4loo_3Hfz(}&?;jj$bkIemZqLj5@dAp-}4G=o7nE8GnJ zJG>jdZwNCa7}5;i9#=aaalH2UwvnO{)`)7fW-MbIU|eea+(gR6-{iK*^Apl10#1~j zST&V34Kl4X-8!j!GW=xy$v0-2W;nA}voGd`<`>K#STI{yTBKNvAp{Vvhyuh4QU)1< ztV6!D)U!Ni`M`?R%EpRhHHSKi!k}tU@2vH$FIYde;kI$MDX>|!h1%k5>1ZajHJXfm zYA0_OZP#uO*rV*p_R9{64zUhhj%XcZ0`*pJ)b0>Szkrp zcwdH}xL>5-y;Fxz1)OU3XZ3gYul4^PU>{Hx@F@@#SQz*YV~)wiybLl8$_m=S8e=oD zTfxS`nZes3#v$a8ozN4ZIiat@%)<)8-iKR-mxOV_*GnbhAJkK_-BVR86R{r;b(1IC?8Rbr)WMOjQm!hDe$zs#u#*(8YsU=@; zhuxkpwJdEbQz*+RXDg2@-=w-x2PzCIYAVGmQ!D?himrNI?Nt4^M!%+}RZla6ut zIDNusqG$5N%F`2 z^7PAZuaaK#zb<^E^rq#l>D!Ta9`82Z|%0IpU{0l*%-ia{(70Kj1|b^xDU zOaUexonIH2auEKu+Yb<5JpfD~HO;kcpNk$&JD~h`_}iujDgV^_f5%{JK<)?Of5bmt zKwg9r2&WA-G@|k90YNc=SanQHltz5OX$>uPO%1>(-Y`DubQG2lAQc}K8I3oLHnvV_+%{eABktq zoKZicqaK3`(a?g!;ToFS8rs@wU=B5WLNp;DUM(6g`!|z+_*j5TDO}iTLRd_+)B)ds zz!)OISVrbR(68`!J+a{0^p~Xn(YAw>pN;#)8!T#cKs9s-i^oQKT7*S`>q$_CiI%Re zrjf?~XVSkS|Es9SzeTn6VE?1&e&O9ax6iP7pcQN}Px&NK`pMpjj2W|ha zzWBSNe^SAoHsLhV`1PxqaQ;{V%mHSmgK%(X1_Nl<9RzkZHdZzcc1}(Xb`B0s?!(-i zT)bQy96bCyyodSt`1v@w1q2271VPMqzyxwol9`1aEXc>j!39$PFT;-q06#nHZBBq0 z!VfUo)*jd@xLEHQw znV*$SKu}uCQRrmAA(F6+wv%&gW+}UfsEcdhWpdLISsh(7HymC}P7gN0fpEuUKb(9g zZ*GAMx^lIQmi0oRym{)qC6?GbtwaE2NP@!u6aJ|JDD!6(z*G}{uxj8lGYf=?m5G@h z+?{|h@e42uO0#G=vI+@npJbB}(QyhO$;MuO_`=x*gDY*Ccz4Jw@V%V5#g9dRn;9(5 z&&&^;0M<%oPO)<`Rm_Wb>S^xrcGR0)YCYvBEI`-BLElS9yv51%SC-_H;=idzl)Ws~ zWoI~I$5$slGEM9rm2tn-*uH?zb|Pyllo^}Hh6Y7?5}+kRTZ$g))k%HdM;h}IXX@z! z@8DkbH-k1bc(Y-6oSBz@5ZARI1 z^9LSvq8Gjl+}_AsMTIlkr_+j{!^UG`t`QM@6{UsbaB66C@pZ1OrOF&nKT8!?88_!` z+z{Q0?y-phm4O*xCj&+2Cpxwt#0ttTI{bO^v88H!I^4cb(oQ zzdt?Ou_D=@p0ra%^46_!DmPuID5C|Bs?jGXx=o_Gx~UuEV`#al?L@|nVHHvf^_fwA zTUf}U%WYw<+}&lHwmohRxuot}-AXf*eWP?Tsy#aDsIfJigzE90Y)%tb9{U4_5k7XU zQ{z}A#xqAv)Vy&)=1%pJ`f)F%<;v9wf(E%LwU45W_s+$2Dx#Ln$?=VyG6FPoih)P@MM5QffUBKt%>lQb zKeqk_bNxCSQSuqgUX4EDcyBvA{jI5(@74V5c>zVG9=jAqJ`SpXk9`RibC!o;$#psB2>rx|8MuE4|0$R1$(;MMsjGU(@1i#iRiyY8zQEUZF~X6D&wQmgo*2TKlLwOZE17z7EQ0@KAblSlqA6~&z3*~@n&m^y)V&bKaXnvOl?Or^w5ioM*C)xZio z3TYNSGG=MDShpS5CrsXUPIo=3iM7Pf-E2UILn&s{2ST9|HSO4W{TfXgm&B>PqU(b~h7k2o`?r}@idkZ}kYl7kO-|cAd zp!yt7uhWd;w~8ZS(6?s=sUs~hXN9%28BQIYUc3s^Z$d;C8?_BOpXO})8I*_H?r5QF zbE#1R;%I6TIXdeD*4C4OA{bhwSHGh(t=FfMMCIkWMwV`2WCSBqU9cJmv_ zTsXw`G~cB19llkgk|iy8PP?Vp>d`A*oaQ$k$(!{H$%E?V5gOHF3IpRS3iG29kF$$K z@xh#na-_+?6>C(;U~G&+(EFt429IKouO!7Sb;O+IT}nWlz8<;Q;cFR#oaO+4W2-lf zN1xy8JLUM$skA;_CClZ2-94RfEiGpw{DbkVf?b0K_X&z792=Vo*JInH!-^lB=M z`ZWWZP1#k~!j+Q>11qLp@2r1E7hI&fSu>d<95ya}$1mf~~1a$&+* z=G4AW1*9upc=e`Uk8iL_1v$qP8kl`7M3Uyx7nCnjXVZr*Y;Z|8pvT|ANUZMZ8!pxkxU`vrKvkgEZG82U_ zElc$HPl_rCdL^*TxI4pAdQRajDsrc7DqX)^Krit;b0+kmM6WRIoMn1YvN6;*^{~F@ zunwVbO%f8{YhR{g-5M?QRViBw(XitM`yd-%w%qB^o_0PpZ)a$IlFxBF1<@elPHHl` zW`bR%xZBH1tJF%hmL;eix6_WqGC~`DNM3e}Ncq7nXYq^Wc4s{i>(tj}sR7*uW?}&n zF`QuuY~F3}t>8hEYW@ow{WEC-^rXJ&_nX#vbN8yyy6ZG7oiK28&KX~AU3n%5F}gcY z-toeYy1uuL9({J~D%|ChBDuR4N_k0QbO-KYZOPQP6h4}3xma4*JRX37g`$ar%$B_jV>CrI6Xl|RtbN} z?=nb1*ruyjx;?FpFC1MIMr@wsJtp(pDZw%FAY+3kY8@=#++q7h%Su- zLQM1a`I6Z@({SBo^IM7ULe$#9l!XeP&W{qh^%WbJn{A)c`WKrn`9s{NIeV_TAy5P! z!L%esn`Z*|Zw@VorHn4q`4uw7<(9bDjQZPZba~X#stb*oIsrW+l{75YRaSRF*JxiN za9=ERRMBc!tNCbtKsS2p>5A2A%-IaIb@jIlivgmM*Sa?<(w8&LI_cMebd?AxHd8__dKPF)y{t8^>t^v=Gyn-jRWB@!CX%D z`s3w2#%EB4cZ`WqUa3!uU?nB8J;%x!xw$-jTckHTUz7dLU7`7fOzwoFpC0p|-i(G@ zuT^Rv?{~#Aj>aN_hGJ_!8U_wsn0db=piEsy?URXgad%gS5_;+@>Fipx^B}2UmcHZLO!zCNVc=?+1MGDRk=G!NG5FShEaUJrYl;c zSf_Vq6{Pj7`raLW!>rLE>}g+$OMQD*qKVgs&T9_6Dfg68!O}ps9&BiJ`+gTz^J=6K z?Zu_KoFs~oc;5T!FlSQr!3iL+OjDYRZA%wd&cImbVp8;@vfW@co_bedopEiu!6P|C zK6~zp$A$Gt6+!)@DkVr*xk*}7f$Nqr8$&1}Z?R1O0&?-27x@JDpI3I)DY^?z7-|OjlQ)4zlU z2+;5D2+%oSe^;kV)(BJfG;6L~oXF`Qw?(KY+t3)t}mpPMfIxC|HGBjzT7Fs_OzSwbH{))GZz(ZK`(^NFM6}5q<)J|9g zRnHOQ;CF2GNNdcLw^S^ivvp77J)>qgIDsfHcEv>zMKiAFeCrAets377 zFLR0*+=vjpIgM|RAlp)zd?-9YgnI8ftwqh&WXetWh^D;S=0zL1Y>J zOf8Hzh#o~4f!z%peLm}zxL=Snv5kSw8(KvD=2+kEFF?Oc-xey7Qr_g7GP>d_EquPI zWu~k%Yq>FkziT(T7s>TdR&7c&hKo6}6`>yVt@4ks-_pmcQx-+`f~%rlM99tBQ*&dx#^BB+@z0#mJ-6`mPQ#X{hYo-=Tni5ej^UL=_ zD(s9+*Kg&c+im*V_Qkinra&G0h)fbksVA0VB~7l$%#uR2*J~a%xr6}TUccY>xw#FPaen#(t%$7QWU-Jm|ifo^h0sv9p zgGs|-4>U)J-`WP1G8mMw0t<{uw3IFYxh;F`dKZltGUMnV^FR*%a_` zuL5hdPtlo*uR1sFGb+tUphemB@ORTI(X}Ms&%tvoaD}_8CA0e8+C;;Qv7ImbcxqNj zfgvV4W9r0V`Py&Bg?P82O83a4vAgq)j+?}z#nZa4hVF@WbU*psX4-=yPtwZ|tXsDfHxnyB;U$F^{_m2)Nzx=hVxPG`>MBVYVR~wlM?}qz? zQQhVB2#1&anIf9+;%?M-CuEDcVjsKVe7+vV6xq6)V9l2W3|F@@GZ7urLP)3GBaA9? zqmn2xt}puta`i^}whxYheCJ$bLH}u77s&ZwLrkhrFCHo2&>sy;(X)|X(AL|8*z)ag z)x}Jol)hnwsDz_y=^MHU{LXDLu=xm#X2p*0~tODgK}#wAnv6E9TfqJ!ePP^F#Pn2&Zm-xErdJ4V0=3 zS5C^a%ZbP_huy}Sdm@La^L3t37mr4C2`c zd3PvWGrx>xRXvm@lizB)6qiJUMOjMjr?+S78zaZ;X?-mh#lA^;StyrsUh)tD{Jx83Ylz84pIZq;&2j`2=+7CnPiExjOHoteG_HuJ5C$rT+ zELMIz#o|asK}_k;9ezZaTGXCF8=`@vl_X|FQOrp;A}GerBFZx2>d=`U1{*m=CthU> zz!4*n;tz+RZCP`&Nv@L`$Es9|q&$!s^ZHYhrHSmw;Wu{o~rj)bIBuxZ{>asa6}NSfZOSJs)Ya+EAB#y zzvqkW%KoU0^{{Di7Tx(yrP|Sef=VpF_m#EZc~@vq#Wd)>(@J2--dkwq&d8{kQlM$4 zo){xYcHc4c4ngdm;aaB7^k}WC_tt10>F%;?eA;N8%c>4+S*;KGYSN(ieyk6vk0}4N z|GnLp>JVY%RIK$Y38@uk!gVQ6s!7sN(Trbg55jE5$HowJ=Z?V5eCK67pQDnI=G)=IDx#`c zS-3M9;Vq-s6l>#0IgW*yXeu$A`&s^^bYbTCcYaTCDF!W|O&fUCF)-6ZKiW-O3l7@ z*>3y(MV(5)OK*fIgaDCjpV*Q4BIuk>IiD#0G~1KzYOlt=2Xv`z4y{uKdNFT0mu^# zP7x6|Tx_l5QtxC63o?m!lrbvJE2o}JZ;lsqyJ>;eR^B}Q;>kM2U12Evtf0M$(OJnN zR=MsQglwu8Xw5PWm#PYK`QnuXhaUt_(n;rAF-2aO>6C6a=?Se}bbXJ*;zJl+UNwOf zO8;mu5!!;-+Y#ya<8^;jHe?be`MNHXJ$()(n(}BH*VcYyL4IT_n?O2bJZBNzO^On? zRFte;-=puMiQbZXcm}vJNicA&G`4aI#J8iuj@57Z%DjiWpCrnBQ^q`Tqiaq5;bxmk zcs#W`=II(ZL4?nF%&0c!xZ8x%C27L&iFo|3-RB)~_3M&FGJ&+OWMAm}o0x1_t9Ejc zkFWl8N@Z%z5FL>m;VcA+H+n9wQ2h}oyTxWi-oG&S(FJB0?RpMCqb_Y*C+NA|skM!QwWXKYWz1!hu?vUkxfpzS(FFRCXgM#yjKMq#&I((EMkM8c?Uf>7;J zqaMWI7S2!1Fid2pTe+?Is^sg|dxrMLa0!0;MlV;>gtvWuV*+%=XwaX|rdDFQMH+4F zU6#Q_q~d?KOTu(hES|0u8oJR_8_XIa-wbZy&)(hc80$*I7l)|3zARE2IwO#|9-@ev z!uBlf+RgQae$pWu+_|Y8?d{Izm7iv}2ldrnjj0&1X}%)wEjb_`X6buh2d-~D6!k$e z9ZH4k*wm&I6vJnypdGt&T44=JFL|w+v}&72R&1vb+HDPCSoI5ReYF0m;9T3NWG9A| zuTh@7kr-V!E$8%TmiM`)S((0RTDV(UJNibCc)uU^@wpb;21SMZ2U8g~NaT*1O#ijn zQ#wAi@Ao(wo-_XP=t;;+yaX!LM@+-F!KnfIOi@^$dIBl3N z0d>1<&bzF!yQ9R3n{GTuKKW1FbZka*hSRpenUz+rdsGW+Qd&=rsSdgw-f(M2IA%}} zFu!=R%k{qIHlt^~f~V|g;BAXrK8}@F33X&28lGUu{{`|m*h4mTm-*m7cRDI3s$L=2 zX#>$$`PGI*do=}j*;5H?lGs;R78Gy$0n`*`YuRKDxP-{R4_?16Z%Y`$ZMrbLf;Wn* zhkws{znCq}Nm|Ylw6$yWXip?~H4b@%JCGe6a&u*$?etWX*lXyC5h~)*?m4NQ3>IM zJwsI0ab1(7${HE6Ypyx<3%<6fjERaZofDcilBqxh_eL(}ZTir$ZoW2Yu}cYtr9qO_ zF7@>#PLq098}-Kef@mp?u4$x5H>-|~tdr;P;zQ~#=bA6rEu9{cnmaCbAL*v|MCs;V zn3hH_1XMX6E3`E>$!0J+7(ir>J%u4cn#k5^wuc_CDB@b#CX zj(!%d6_&?Bp!4)yd|hZUd#$fz1l7+#t1j<0*FX-F+XbFvDjE~|;z&WW|Lwm#RG)7T zwVW;JduF!*qRku;<>E9WfQ%IMWAA#@TN|8r4)8H*>(!WWK z-g$riiu$SEgoJdN=@*t1Y?zYjDUH&C=*JNvKIvDyH^ZTh)xN51-W6s1(BT>yY*{zW zW-{8QPfoWOUCy1YFom-XQzaN0_!RVKdSGd@O(>bjNwuX=o7*>*;u-$C^|hIlL42zn zRj)o!LcZ3m;~HzeE@IfO4M7@`c?oN}CRybwHL3GKds=tx$WmOaIp1!!dVU4goqfzM_cvanmqz(b zoXrw~{kM2kFVSkhUw)kJc|vm;x_dU!qXnuElM@m3Li^euMTr%u{iS|8-U$gT^DRN% z1-ln-d;dPo;fxIRwO!tBBFe^Xc_|0&7?sOUJ)Cqm4y(t@!Kb@oX|5fKv%Y^0>+` zx96K^mu~~?F@UR>m1L1AsBoDiyC4owfYXM|bWAe>qeIx`0mgaQ_RDA`)xtw>m=oA` z&~F~bQW6q)9J?Vtck5lUx^F~3pz0?|R7e)vE_RgJ-&(9w))A;Xe}mwBZk(lRxSEji z7AG9ve8I}8;WLwDv0*+!vWup+M2NXl7?}R)!=*_*_#W1#Ev$_4u#$AIxPLJW{z+lE z014_^>eYfb7#I8}ZAW+cb+@JH=@s9R8WP->(KKlhVw5)fCq>Mqf-_YBwbeOQH8Wgs z;VJ%6cESZbCh{$%h`k#9Vg14r#HTcRob`*6ilyG&v#g(soE+;=ZyMA7yjlLW(HI@w zo$xiv(DTy(=A$ua(_WnU(A+yzpQCv$wkrb`=00vPU#_g@ep3h3veof>$1RkVW!9(E z7WLnccLzFlJ9!TIrke>eoL*;p5<{%|HEPv_Rc`;~Q|+uG@_nr4*x8&^7@3N1O3T$= zE0A;PLObTFKXr{^UA-ngycHJ(PjaZpzl)SjtX$qnUbxu7e>f zi^@bgw$X)U1xjkp7NE`JWo;MJvqo#z)6@61bSsPZ}L=eNKTU_T61-y zDAAKMjCJMi75_qazD$3e|EpyLd*Ere_cxIa~@03nl3}y}IelcG>B*LCEn; z>f(Eu^?Qm(z^&7wr^Jxs-h6nkcRn<9L#kMGfxvwB^fmPYtF+qL>-qikK>bWLFK5t0 z?we%P*i(;b~k{Ud@YmogC}btwX9sfkL!*;3<|~DtxA|?E7GaPpU^-0u|R- z2b)}vWkje~Nw{1Qp>2!Y4@OYJC*W9#WYH%qI}3w1&9t5Dgrx`e1t|74yrlYLj>C!` zlWOaQMPM!`wPRI=9TNnFn$SW}nQG)`H{@`aUYmcY-149~a=t*l!cG6CS$McbAbyxc z$5y_dNdDvDaZjXQK+YX@D@C#^`nnM%~6G9Xws)W}C!x>0p9{iG3k54+b z3@0y9Cu>olmgNw`lNy5=)L!N=>Eu|a#dJ1>4b<4V#@8n|f}+us!8T>HVY# z?T&5i`Q}-LHgHJD#b+~KKC0Z_U74%>05{Uf@Z5R5^D%F_aZPedk-Hf*SO-MhdF?5( zygYYrD*fTce7gAHW2-tr*4&o^AQ`NxD5!SBU`+Q;P;rsTcb5D<{ZrViff3O+ass z;5jFqFkky)SlhNDb?Vp+@ADJTU@qm55iTT}WqrbSPO_jMfpKnVO=ZmbHruRkrPu1@uui`RM0oJDpfYmF8)HwUErFGK8Z2CVyb_OXPqTRQ@ZCB`!EyeHfNZ;4=K@H zc+=?VP1Npxq#CK+|5#jE9S39b&~gex+1wTWzs)mW25Kp3Zn+~}b-oEc#bRZ%+RVV44BBUPp!iy92^;l0y-4m`CV=q+WNAZETH;$*niY5MH zeAO`jRCt%>^`3Z7wa3rorF~a3<=UvCse|LUVXEZmm`}Lx_>T37R=BZuVKZ_;b zchIsl=|zRwj-L!MKC~2la+`j%->yl8^|EqdQ7oeR<+ck2fz(u;Qj?vbh&jq{5-@P0 z8v6wn++Ut=O+w7&gNAOHhgf)*8p*uJw-%P@)D_4@E7ennbM37E#72!Z=MEW;ket4X zs${2R6(oT+t4@KMUSe}*RacQMd6$4=P#hG`Vw2s6dnb?{&J$MF?Fmy;5#&jlHjhcf@SEDWPi?{OD>1+{+4p_VeK~7$7lWZ4RKaPu9<6@v_~u1gRAyd z#e-T<-Fi?&Qv%U(=>iYcyrfV=)l7krg4s_~I&!D`66uhd*c&y~uoz5;5ee5sDCsWyFSIC3jZ?zZEW9h>8!C4#-;!u92x0IyqyWP(5Gv zx>SoTYx*{x>iNmS9r4T}mf^^0Kk@ z+X~mCl$Al8)Vpq8>^Q4&jOy;XilcfN+Yw{Sw$V<`5Buo|GE%Spln(1&SDrkP2|GSt zkUd=qOMQ&`f-WE4bb{s8XpXj5A?=H~5JRwZ-sHr+?3OSKRGUxPQ>A^0DkGYw>9Y`3 zTFr7;L27HRGb=fP>5r}HW%%p(kGr~2m?L?Yr2zHd-chxA>dc&J;wPAfaAv%r%pI0_ zM3)yTK~~Rauu`smc$twXM=x(Fz}7{#jb$3l-d(?|M|GS2Dzupm`g8CI_zvGfsf6(@ z2qxxuitONdI=+a0jFQ<<4q@{tQbdiPda2h)T@$@Q!kyLJ?$GW`I~KGzc1E$iY9iC% z(_8$Wo%`@ihPJ_*?)Cj)($D}Jy7^2szXH{S-m0F&^v7gbmW0;gp@0 zkq}P9t{yWq9g}L9dzftpXYi)^L*O5gkBJifp3u9`%*iFfS+hJ#>dMcMkaNBav=C<^)OEug;QWqaJ;1VR~5~y*xgf_4(dWGQ=JR~WA=Mtb~ zQ~4ZY3INFmzMV`oW_WiaG7b0b5IXT;DSYVw&;`u@(e zJf0~ta%g@m$g@JZILOtyo_5s}Hkyl`xSx^KDj9i}au0?s&!7b_RlM*eTF^#y*Y21~ z##9&E9`!T7RafAPoRMifPfxG2EkY&e2$mXX zH5#@~t&$ORdN(ntl{G7N&aCu_?KAUA<=VkJO0`8nNcYf-NemyF>;BPLYsGPDP~RuD zvD@w9qm5V*HPz?cKJ^Z-WsAhlv?Z|HPR zzKu^1ch!$r?WwgBQsg!DYW5XBf5{x6?3Rp*|FPn7 zwgfds$CKXXe!3F3?Tpc%)&;kn0D#r-viqj6Vz#@pFW=&zP~j|((8@=>F6VeHp38@C z3p2kWh5K2qmNYlpR16NuZTW&mt|<52U6zd(%H5yjX)OaaPwYrMClp2_{S;ySSH07n zM>Ya0LX(ODYqYIsXwB(N)f|rYa5Cy~msmw!?x?cR zEeb1Hzdr06`1 zm9mM*4cAnkN_=Az==LWZ>WivPm%&$nM^lsPU&~a=LM9|}E&{s=dxUi9@oO3ai7`4w zE_H~WExpy2Q`(Vnp(Wt1Lm5L@86Gr0f#vrto47CrcSo$g8$+iHGQ^rl^ zjG4+esr5-N$goOl!QhQ$rUW(7OE^!X1gX)6l9N2ww1|mfE%kVYqwGLwxy8F=>wfUd z2V8LP1QGFA?rJNPo`D_+Ongz(?ZJGc_taROFRnpa(eS-?67`S)f6FDf9lTO zdhF(xk6(4{BpU;1x!Tdhog$@PBK|!MZWy;Go0#6jBP;6|yof|Z6?$3LIF+q-%ReDGj{OnCq0^`}44pC3 zOj#6oq&GIIpO}VkC|Bryq-$O4Yv#J9{@n5ga@)vETpZbU_DEFJtNr}o#k%-CInbGl z=dG#4$|Z0EBIYdQ!oL_;`3OVj>=4z>QSA@!Z95l_zBP@q?f$4Awrmr>3#$p&CsDl4 zJj1~5DR(DbP2geM6X#jil|46pYvm(TGbyrG@u}c4a*B5w5DT8kb;!#m6L#xq-Teh) zP7fyYJA*<)l+WfV-yeY~CWy7$x@L9v)^-N(WrTN{RJvPHDd!i`YC{gI7R-l`J;4Ly zGihHULARa{da=cOZ_uac88)3&vLrOfF4%TEn@gb$-B@vLx_4A!rLydC)pDLyVdD#=!{HRzsB%{HQfbe~ za)bLPN?Yx-8j5Z`^;*>tZ1hKa-VIih_r;U2q0HKs>S9yMsB@5JYOiZjjIKL8Eb z#azi1Z|C{c)C;>gz)S0BI5Z@oTs+$1|YD_3?w&+mLw6IUh-E4h_uQe|gbPr1M(@Kuym zVSlY@(GtpFe1ryW`7cab%#Rky;hRID@cASX-c>|d@p6j8t~?_*#glOd+-|ln(V-^n zeQ`clB1;X|FTp+CGruq#+vNKu{$UqdEE=v|w;qYF{CpmK0X&@xq#`hiIew~5lpL;% z1dEM*f+75y($WVNNA_cHTs?`xdOjQ4f0BZ;SM<1jI`=@21GQpbPOSU6@p%d?Y1U8} zs>@h2=FO}eg@Qh4{Z8BALJ>0s1=pDz&sw(Jsh2dOW5V%Y!r1^QC?MiBDGqc zTZ~Sh?Ak1J>>2#hFgaw;iu8!l@u<&>&K`PW!+l$2xPx2$xn@_EWY;x>-*0*0>Fdgc zlqb$^RLYx3tP`@S_|$31Z%JY9pVIZTbJa^7ec#sig?%>YlzksFujjNe*sod)&L={$ zmcL6Evnf}NUc|<&PN$O73r^jC(Zp$F(_#T+E|gIjT!i&D2~8R*_8qCq4!VnLrOA!f zBHk_S!VRqo^)}V5Nr%+#pnOUC(uBMCPD6~SkCuXjA~NMCE!Kd^gtO)_4-y_%It{FDEJ)-D%GaG3p}}WZD9y!A0OZb zPXt#ZTC8&t$}=1%b<=Ltk_gX8Vla*3v^AFrlYV#Wytak0=BTh<=bcP3#!yw5lb@|* zVM-kOe7OXpTWytIN(J|KvqVX;q6(x$_gr(wriyjJ(5jm{gf*Nmnn!}wZn8u@*eVhG zML#fb5%V;(*zw8)oTTe$`$B?>6>4hLHk3S)Q2_MY_@@ay&QkvA+h$Rf|XTsLt;cct!T{X zVWnN08Pz=Vi4N=@_(F)H>)%6;N)I2y1e5x7f-^KDZ)Dpx+Cm>At(MFBrhC1Ro_ZpB zX}PDjVtvv6lJ05W#8btUzZ_#U8QDAgyt1xYPV#68#)!IV(kX3jq`IfoDKjqIVPX^` zg|@U|M7q$Q)@VwYSmP$6{rHRS;A{GAwKg@lD3J=Ae4N4T$MvyHS*1mdT&c}TwY`T; zW1V~n4{tQ11s`CY5`wEz3csa4N~VL}Ts(LQ-ATc%tE%X9mo!+&|Dow!z?ts<|MBil zcPZUTlF^BfIpwsOq#|=1F>|Jz%`tPF=$vwxvYDAVZ^q0a$IPj_%Hr3u4~>qc|D)c$LW=OxQcOEWTyA}4CZg~SGw65eN+%S53Vis-Rs8x zoj^RW9d01X=ed^3WzOF6<^;0GfRrz~dg&iVDF8J-AK@N6=TTq=%A30-q&wW5x( z^wmt|bu|5lgO+r=^uIGyvdRpzq%na_(<9%o1q((Ka9_WRpOe+xC3+Mf46Mlwei@SQ z=QV#;$gkx1VP#N7r{a&jh%g>mOMufBLwlH(FUsjUB}f9IS~rSU;c`_PU% zvK)&4_W{oY!@6Ix5G#kNvZ z)N53y$~Uf#MEbh-sVUAjT5Na7!gv2IZPU57aa#oN{o63^rcn)f| zXVk{C+?(b4)lVM37H)g1Pm6x3tSCFZJ6ss?(!f1X-fELl%pw-xcw?A%Lg9NAU%QH^U^580q{crK=tD~S zZL7J}_=PTh8Q!0P^nwf<`50#z#&`QrPOxp4AeXkaq87X$1ijFZSY)Jx=!G(g+96w^ z7_y0T;R>of@?Xyr)?Ex^?BrrRjI z5`dVm?6OeoXi*QfvO)09CPWjjlVo~mZ}s2bXj8}SRVJoixh&tyEuKABIc6KUd)@tS z71D{W(nJx`^;>(g>Ju@t^=|n(8bU*o#s2ZSu2-W6^3E?QkUHXev5*@o1}xa17brW2 zcJz%!=b0iELSPwA@}lpc!r;cc?&58Xuj*Xbkmc7fdag%Ikp(-mu7>-uhV>;-U zMnpSjMa^1-HzjIRy9r&b>3sVphHW(8D-kT4*2`5x{hQ#ke?8ol$VS}Sh}S$Vmngf26?8|p)8cxa)M^n}?Ah2U3lsIO1#~@su{RX&ZR?@S%DzbG%!?Pgb~-&eQ9>veJk@>eQz&!-)u$v&|OFc}3N_!}bM+YDENbAVz3J zPDB#HCQdEG9I)aAzE@YM;yjH%V$80+Fu@S3+m>yh^Odw6PA&(v&cD!k2uED_*m2X2 zArjUzpT<#^3+q^LvnsBDr@Mi*I4fZ-CU1+ERIxk5L@?(*Z5-x0ZB(?>(6Fgi{W`5z z@WX2$?U^}u6Cf@Y5G0ATmSLyVjF}xu{U~isikKXB>gf2<>K|D-^R`e2lt$N~NK3nv zBA48~KbH}|TIpIz-4^U*7X@NCR=#8=>;I@ehxliU(|4@JRodk(`A+qT+vy8lj;4H_ zDI*W!@P!Ojsn*Yu5)HWh6UIn&MoAS-F+Cur5|rJhG9a{Wu*x!Y5{C5Ct4Nk0iu8@G z!!-yw1=l7cQ(!EBpkw;@#aEA0e?X-@8&NKAtag;i`i6IFS`RH%zb&fyGr$Xi`(aP6 ziN{GpNBaMFGE*G^=5CJQ@J#h05)99jDlpR*h2BVgB?>$ERUn%EsOA-9|HGS23Cr1} z^Q@@$=9Ao{8%>rpbK(?yG1qqLhznF5DWNvP6+qlB6R;va3Zr`qs(j8jFzFIfxu?}SXdwzKQd$A9jB38Si z=8WgrndNE~d0yHK@fn{HXi`OuN0x4GL6%G6+0U-1^kKr^CqwhYtakpreeUSGnBTj% ziy?&4=lbh6d&*pGIy4=_EWbzmwu-rD@16_0*Xeezz{%a1F_0ABSH9F*#b3Iuv#~Sv zMCQLe&$C-b65CE- zm)}s8c6%=H zM8`~7YxNt~pCwbVg>yyiZc~fPsa7!0gk-YrNUzP;h|Y^n^%!e?4jDEfWv(hmhbhd` z<9KQgGS3yr{qMK!k#Wf#thv9leR6EM-w&1jdXiz9o`D4nB=l~gx2u)q1o>WgjVTA} zKOh=bWtNdeBUhc0>ITcUU(WOdy|blrFF6NU#fuU%lv90|A!su>d0vj+r5}D~u3u4Q zaSrO6L!ulLAfZ?=>g8tpp z&1MU9MDu0kE$T8C2saH1UWnRS@RiKy59v#R@YCYYCq^#zo5(3gF*6QwDYL#G1?W26 zD*5N9uKxR6|F8f$?D^zD|D$8RU}KpL&R^94@h*Z|slW9+g(7t;5Kx)8jYjXqTet3W zGI(Tjy?8vka7gNB=a-gO+2VbdhpJ%S6PEg$Fd-AA(0Kh)+t)*C-oy5^LlzX#$HLx7 zOfW16io!%KX)sK=ji$Uarrq^7RAkESk_*omp7BPrBNR$pr;%rlhQoZ2vZk$)Cny)f zyr=EVKW7}e&pTP`B2nenchImNac8JxNBosBeuMz3@t<=zL6)eBRDA5VfBTgxapEa6 zFGVs*E<4!`RPpq3ybTNjSXY>Z>@u&5C4q{7JNhN^nVtU|D+{Z&)W;+>03kw>Mv@lO zr@e@F{<&BgU?2#+GmEOr$1sySGpr)(0GZgKQ&WoaW#jBN`LeugT11L%|9x+iXX{-r z8`6_Y=~KrdZK5x(^<*t4VaT0|rq#l`-AoG;rR?P(+?|%cFD*;+p4>N4Sx*kXvidQ(Sb!R57f zA1?25voCQ5RA8B_S!GO^3PUE#M?WItuabJT1>UM#)o zK(K4YXS|&szwbzz!$n99Ry1*XQ^Ryd?K8FHK=kiDIbRd{6vZkp`_CAB8i=6=_J8mON)K_u#CFC8qnH-J~WHJ~gAJQ*bJ;;OkOsZ?XA` zL!Lo`)PH-9;a9zDn-XCrGc_wa)|H!j_7=CbD!)%)exyFRHS(p@DJnGS<Wk5@K+I;iWt1?Cv9ZQoAZeLNmO9aS@OovO86z>hSYc(FYOUtSrq= zb1Owl%s6qK+t#zH+?N4v$brn=Y^u}$;4Zg4eV7p0hVy0SrGl? z1$K?$sLr$jyU`-&cl{~TT1Uj5?&K$HGu;vfa6{e`>gf?NI*<#^bms_0f0j|IIh;7T z*`?1gusn3MEZZ@lLjPa+mzQgaYHdvP4kZd; zGuRo64$%iK^4q@Nsyue};oaGhBf2JML?2h1c^yoG&U^Ahdqn28bZq_i-c|_xc3}ge znNW3N@!!r^xT=#Gz{>9I1!oxD4%oPM`nNmuj{O)9q2{eFu_5qrJ#@=+_;-RcV{h=l z072eXe)~>016XZyBGW?Xdg$Qck%hpBkoxVk0D+r{GNX60!YKNzw=?U2%6IQ&GX%NL zm{skNw42Pxrqs?IuML$2wb~E^87dJM9W=H-BzSE+B)vRe#AltTtxssX)GeGvwLAyk1558`|5z* zY2$U>jd(UUP)VlR{Stu#8%FWgWf>9u-9r^wFqD^#I5F~vQ}ZUwo5sKZKtG@5O_2bv z;e+PzRK$#?{deNUuAy--@~dHy)m;qxo}0CSpWa|Pi40Ecjnx9LC8juWR87o4@a#Mz zZZ77ES$$QpKE~vw8nyNvy8sRQTpQkE9G2Rfktd@<&FSG0js)0EUy{XEbD_8G4^4*E zX<#moBTtD|`FtF{a{XO|e7@(O8<*zuZZV<;RFr}qb|3Q(zmc)zwjekWdhHe`BC|`= zO|@l{C>P}usZ+mnWwk2HcK`tT15dr)|42i4FfFb+DeZ{D`XgUbx6Y9=osW|mG<1D{ z3zBA+SXY?3Dm$Bs^5@8;rrMV`_dE{I)BKd(I(Gr)C~YTxzkqPh&v}K|$Td$CBSyCs zV=7X2{R7Mz?eq+sbbTmG5mzC<0Ut*+4913U^~>+-o_L$)iZz}j#(9^^r!yfTPZ`j8i-P9ehtqd- z@hkhoZr=@daA4U&1J)ccKk0k`gfP#L{=&+#mcwpK`I76a*g9cxg@MS{#POi9wY)U5 z0b$JiXci)I=A|zhT%1-?6~kZXp#zAKk!>TnBC;H&#u6je`r;5)TXL=#XRSl!9>~`N z&T!_6U1BgO;foow!{_GE_bIC?j>5MUyRfQiG@vqvIn}==$@aS-Gq5M;0ttIkvKW;b zbd^%Cp2IUcj~who8e6GmC8$%_y=tXi%p&!IdV#-)*{|>LQes-`7yEYt5T!2+Sjp3DS?r8#Vz|30mvvrzs=^Y z@PO-fCfW?ODQ}8ufm3ArD+o%?1Hxv9%v`6UpRWTMjoLWDj_9FKR*C~QQ9DO^f{RPL zGWDp@FtF!~9;+{)SNFd?8qTkZY&`7>c`GkXEQTWQHW8)1-|fAp7uZ7Nscq5n3l!p~ z5u1OCE!r!|gF7KoyCz2QRVQeaH>~OS*Y`;S5A43QSCWQyAis0YOg$+XgSOnLn>?5Q zeVQL{P}~yNTRBkOHm0|e&NP~{#s7XjwHR+f9n7nl+l*I;`ESqBn$(gXp9h!bmfcl$ zuwK8E^lSQYd2I>yeV^fZWYNYMJmoHi{vZGqFKAO0>RswPkA&hQmvb5hEjM5ug+ zEB)1!2zIrxygtaYq0`=7sp_8t=sxor=Q1230T!;5G@Ev1=shi{mqr+F(FCUNq1Z1o$0Qvzau&sB!W$cln6PNN~?l%^rl+ z#$V0dbU)a=|{j^5;=6KQwkfW#lM*_b?#mVl;}bKT9WU?EMn`SN*bbiQ$)$ zQ->@O9|u%gprdg=KnP~Z!ojYEK*xVsGTV^kHZ3#3l%`Hb0f#(*D4a~p(VnyC$d^pT za~~t`dlp(NMS2{1BL`VZg)~S~OhN-C4{6VR%nf~DGO)gdP3D>x^=#;o-BO)=cjDC% z8h48@`8Ca$rb-8EDP^4fCI=q_TN+ojKSqyZ^K_~4TiUlD@Hr53;>BNG13#+qAV~c; z&)r2DW!7@6x|Awbno||6y8%Iv z6H-)4t}f)Nm3|tJ(tc)bJ2#+$L0jtro))+bJ7qwr^U{#GQg=tG(}tRz$(+2d*6+8ZMm2e!DkSUJ=?TfH#Rh)f^6FkbpwjO`QUJ#-S&PT(V2QdXzY=w zwtFA{1$bp4#_e=K3{WX@wl;uaWaFU^K8Uj-%E;KKazyDZ1bNX3k>AgKt<-FqQjH_u z*|$gT-e2?|*L@ChsF#!umpu|UBf~Z0cEo}AWveNvEHu47u(=faijHAC=O6WPPyk=x zZsY14v`;a~xFnc@GAg?_(0k!AJS0EjNlEi)#!{?qbIRCr8+EPVkJQ|_b>l|QR40H8 zmwE&AW$E~f9x#<~jRAYBKm~@|e|v68|H(JcT0sQKBS}b5#qP?!2Yd+p-k$<<*4#mM zeMm5HVP7!TraA}0#9MYy;}3M@d=D-i(0!4h+?wM0Tr67D9(CN4(#1uZrZ?mI>B0Cq z*D>YbkEOBF`gQsyo6`BSI{eAK4V{_&$e9*60g#O%fvRs~3cktu0pSuwsNPEnyJx!z z0W7bcJaA@5e{&<<(NnMGse(WaWTw5kh3@xq{Wj-?z&L z{jgCWCejNIO&}uQgB**_xPoznA{SlW7b!Y~2bmS^BNMd4E$` zu=(f7x5Wx=Rr4odISSwIlXwqoA{uiy*JA(M1MfTM|Eafk{-4vIIDFSAgn(w2tzqEpXn^4cTN{%&ed!_$5E+C~%%gp^y0~BDr z1jQ?m_c82g&}_QTliF@wAE|CtiWYV!2ZGS5fGyAV9dKUg`SM2j39MaEd?i_bHbW#e z?)Y}3n&zymObQ_cyn3e|@aytvc90#CxG&JX*xuT1e&mw^dgf#V!}zeKqk`)VgT_E$ zg%8kdjGA40bUb1#sJ}@FX*bl&vZ>UHo|8CcruM9xBV}$n1yAc^Rlm0F)`7?=oq8XPnR@T^YNs*nU6vE*lw#p|E>H(dUz`lmozVi6)YQ$vj_yk(sasaRz+ zx$_#h>Em(`{;Zrw!kvWI4>>Z8(yu%9DVGv55E5u_i-LcSbWPTyoR`HRG&42VB%1_( zy3`{;`EQ8d(>?c4chiNVF`yCJ)6N`b?psj}Uk7HG$6+HKKi!p;$=rbl)!oWh?|K$A zCpw%AjXluS|7ju65W>5JYef53Y6cG!TGn9{v4lFcyzs0S(yrMcKlhIngRY4o3(+^1{jFacfs7@=v^`2!oAqbHWYmP+w+6FQ=Wr*U7g zX%0tQKLj@gWWCD(AfNEMs-T*jh{UNCM57xt@p@4YKCO^Ti=0t+&zm&GwxI7f}H~T*%!cUvVEsZpbEgVEw=)>EZ1DLagqvsgHP3clxdj z(+9{pN;{>dZLO%YU(oC!A(vo9IF)Am6&v1X)i5JK7Ax{Me1;-B4_48_`pTAHBD#vq z5k*hh`CpvBq^fmK4IhSnT%RK{c@zWbz3?9gD8}sIuT*Vrex;J7 zi=`;9k8C*>{+yc$h=p;qZk_ZgIE>PDcxqDzZQTIKau4^F*!R3nhWI=sd!%y?Lt89F z=bPKS$B@=NqmYh7ib7hPPD++`23e9(Zb(de5Jp z<_sS~oe)?N>IRMHFVwwhl05NDs{&siK7XUUNHeNJ0=H6(_Ca))8xsWx_IPoBeKWnF zPGZHC@$ud(zzek)(W0^yY1Oo#FwWLo{$BOoOD1+Jlx{xbJkvdc*nlSD2JmI$^VX-P z3S(Zf^yS{D9|1QQJ%QAM{dy>g^-1>w_d691ZjELH>l5BC`V!SX(l+>`V%3o{4#=|h zjMq7F6-9T$1@BQVzP)6&zQaVtI7BaVLq=ND`plM$WNlcBd8Vcy70PIq*_J^~R2-du zj>3kEAF$*{R)51~%A|e3&U0p5@x{Q^lpXCl{<)e;36&yvc{#Fa;Wbv7l`T(lQeVu- zY6oALwXB^2)VrG7?wUF{FZEko^|u@X)U3uZ4-!a3OXWZNeC<|3Ke>unm>b7^!H(1p z&~AJc?q;^BX%E12zY$~3h6Mx1d|<66H~nO?!T|@+Ji8VkNy3Ar1fvmIElL}DCV9H^ zypllT?f7VD5OnyiQfm#r<3pJ3hN#KOBOS#hzFB@G1?_$S@u{w~iff~!=iYk3RcN^7 zLYH!5zX{fCdI%VCFUDXx?40AWa3DiEjQkrS+5M?jz|g z5cHLH=z0IC>xTHgTl`hUd7XN#T>Yf9Acb$zSNlnXGi&zNhi;3Itgf1cYgFM$2EM2d ztV)QN-IRNf>M@HmMqW?@4|6N`Vm-QT?qNGWrP&_6OAA6n-1o#Wfz6N`chpmtwaBhZ zS__-^9lW5ISmsTy!6>BAexMU9}_XP8$&jI{Bh|z=rrbl50Vd7Y8U+|6o4eG753{T z=bxP3ywK!;rHH0l^b}pF<5SJeJ*t(IwqHhup$21m%?YL@sOfOqu^dj4PJW|duMC?m7V0(Z+WYk{jeecwQ;jPks4G|FuQ4LOy{&2Ee|Nj`OIK14o_BXc&rIt} z?1PHU#`~KaJ8v!8)ISeR1`&yETv*7pl3n+_9j5-(X(;o%W$=%PEthlmtPs3h2a?=( z1IkYQ?dT`v-_?(`F8Eb0Z+BDtckk!z5Q_P&fpeR!i5Ui7r44VG#tpsMq_5Y^mRgzp zLhI4B*gwho>NU8YofyC8_A!-|T*)JI;@gTAFAptbRHv*>WNTG7_zaM`D@7x&>I&v^ zLB5{}v?lEOyX;}_NRlNL03Lz$_2t*biYBZwQh;Iz;+CBP1o!*?sk-sd3%f{2!FgeTHpux()Ry#ZsEHT#?7fD*Ks_Q(g+6#2= z;G17*7%19S*|ZMqBwL8$H=Q2LPHrc{VmL9zUo*$L>weeO$`tDm=Loq|6(soZjgnwD z6U8GaP9tohcFSX_ev#=u+(5}OdxKXUAAD};`5tMZesOKQ|K)Q~(Zt*8h|ax3YD~g> zL?&xu5aWZEE`37?QJM=tuJlG-J@L~*;FA?9U!bWH#O$E1gjWTwJ$+#<&7Tr$I@4`I z0pFpxq9kjJxnzDwMnT?}HjJ^`z{#HQnC~xCe5}=#C^C%K2vwkp% z*=gIB)Kf(aK+Uy0(VkyGmW<8IVl47XD*hJd|2q@=@c%y^#n1n9aO3YM*eCCr-+h|8 z^DXpp44@yi&1@`r2=msY-usxLn7Ao=X~x@MOQka*2`|B`xU-1=_S~R$ z+p$E?BK~x+=}T%Bf0YGC zK+!n#7We}Hrjm&b_BcJ9us4;i|DI2@ibwFYE2Xxy5Ki`+AUmUS$@!41R7FOXca~9Z zTnFX9Jw87AS@j|AbR`prBpAvqA6LAlLZBD)@ws~DeMz9O8`LCGnOhe)J*JZdU6axd z@rfd#)s&MMw+@p2rO|4&L3uSd1fn!_wnxrCvN;W-Nln_N(6%i@+ofwiAp*V)0nB;-0@*G7Eq<{#pRq=xJ27YFoq?N<2grLKV5eW zdHC-BL(L{~y_8!=JBU4u@a->*1xdO6n?Sm&{Pf}T=K@P-5ctq!BG02qtcsGfuGtQr z`StKA>tsf_YswZ9(v&|*4ZC)JvUTLXbm%x*L*$SB&9RZw!i*R0vT-Q`{3+jj0PXI= z9kajkVbvo-qa@|`v8Yn4Wle1fE3;;uB$;C3yKJNkKDory6?92=Y~vkAzKR@iGoMwJaK!63kLaKo>anG-fV-vJ6H z?vd44V!g|);?$xp98*a@D=4K27c{o*#^s%w+5{cA2!v1%l!k-6{5b}G2u;C&)FHu? znB&p8`hGJx8_aEGRu!a3Us8@p-%(TapE=J>!f-S@(6oS%GB&x`HEc}z_P7f&oT1Z2 z`OG_utGxE0Zd!s@z$=!^VSioPiOwr8K{ZmsDQ%l__u|jynHzi9ioJxuZHMUra8w9C z7V~cS7_kEDC->ZWlZEkPo=wfh2rn_`^?O@tGTKquD|(PO60IeNZMc%YChYuMrN$xgrRItf zIkni=`V^2Qw!jaA|-3Hr;&~$vKxPLfD(b++s zie!_i1jpK8In9t^TmM;w59E7V4UjVu`J^R9r@Le7;Lqm>iRT$K~IE%nMU%1JII z>twm>NE-g&YU7Nexc?)N_>iZK`V=v*G?DBz0_|Hi1x~j=LR?^X6(bJ>cX#yXX}ezn z?ighErY*3$pKeF$oQ%|8H;-U40mDEmd)!LS$Wxce%BIlPkaPH|@~ZoajM3f?rGOqQ zP$W^GZWNNegA!NjyT~dird$hu@DY7z%B_Pay0`@jO+Ypm$9~?{_lqn;*>lEIs(a+`|HCU&8Cb=>irAM(&UCaLY2sUP^Ue!=jD=*%Q!x| zvM3Gx2no#(zOo3hM`B%5B8i>puUo6#6ce;E;@%f|k9FQEk`_&3ecsf#XT%_WQi`r2!F+&yQm8I|v5RjYUb|%)TJgDT z>$kaKb*}z9iS+4kVq{Y2(Vnd_=-l~ zyZvkv-M3o0Kv-l3EK(+7ApqJ+jdZH>(buG30ZW@sT^hra7}d&Y&1A=ksvkuJbqS)m zU1_n@d)0W8kBhN?D&Z>*&&wBg;;ldT2e@d2hQsXWZ(P=|IeniN2JP5eGB^IzEII@nN+hYf*>zC_OagCa`bAhvEl9jZ5M0s&w4G2Oggrk!^Ra;hY9C zG^MT50~K#Na+@zxjtYj*Qa7@E=kVi2(N$d*bVq|{tO->7eugheEvOfsQk|KqSm2EQ z@Es(zD(9^9X>!p#jZvk#&~-vf=i{EnlYld3eeZpmRKE5eSBbXk_HO3K{Q>u^aQ`0R zEjmc(6J*gLW|4HT ztqpeX&{B2GO~LZ1UK?3*8w}-Hzy7cNV0WLb(~AuK9%omw6hyj@rIse;#w|?~oy%OJ z9Y@3ByDkESrJnh!=!81aqBitCup7s^LdUWOK>4~9sV#Kj>fN+3_iyepV%Qq=W!S0Z zj~s<$;~hXYM)9W`Gc9s0>pcCgI1?38#%LA{fpLIUfO(9sbIFj_96P&Owm;9HSZ|PD zGanwAa2pdm?mZSw?wwQvHgX8ISQR9EwFS~EB8XSG+3Oq&|Eza10=p+KJvD{KJyAn4 zsjx^ljdzhG!0K1Bq~T_~sp~Tsk*V|49-FJb)7vmJYN!;UPu^6G>J-!JBUX&e3`tL! z&V}Txby~T#)C#ZR*fQbVG0(4;oDF6A6tK&25pznH%H+5tJXfVSH^DRwaYj2BTx<}8 zR+X^CX#pyZNRaPfcw3A&i>@OoIU^ULLdS`Hi0@Y#{Sf~V@>Xo?Mw9N##)JLs9u(aa zX_xqDMN6wKzBaHH6<=A&J>RdE3I^E=g@y03AV6-}AB=rlytF?3Luet*qxB)tW_$l~ zOmlumBD8B1qEe6%LU<#4SY5U@A@LJh6lM2Xo2v>+R_LM}FyKn0_)x6Hw$#lAqQ{j@ z`-gMyuhG|xEwb#+&)K8z4`8a*WmF?byADAYEY3=YZ;5jgUMLU^|-E>hFg|vr8R3;m}1&MzWpDJEd$kzO328Qu4y0Kdb9lG zUNqsf@Vi}8iU299IWwBp+}s;2(L>0y)y6pM=)b5!NFa$x#1L7StJ@mKY*t0A{msQv z#y08hO?L3SwInd|eqm+1uAq_bLBO*cH|;xfxGG#BjWJK=Tv|%CD1EM~O7q>|o6@Vu z7&MgQ*hj8Fbv-jq^}8Zw9~loX1?zHH)$%bzZrwBOvo=^Q?Tji0hXOV1zE5>Fazw>* z(oVf8c93tFbLl7*1X04umj+kt5TPQIV&jr3*(G*9oq$)qpV`GnAFEk8}#U=O#1Dt1$6w+cSpW&1t0>OLb@9zYmv>wl=+3 zQhyqD)$+!N_d;ojne#NgT;L$@E;Dh%21fjOeDnUzu9t2b@5e>c3 zZb1c9m3|ZDiuf}@+eGTC(m%WyinY`ZISi7jwc<Z{yVKzSWW6jcSF+R#R zrRpNj{rS?){mPRoWeRH+H*_}zO%I^67IXi{MB5H3r z#wXVib@BCWMU~gSYs3412nknUYooLbm28e+{<*qMSCtfe-Q47Yg9;YM2haC6`Iv~a zs%zkDLAB`_mz`C{A#K_zpA@+HK?C%4pDeg$P}$`q0p}zdAiw<7oY<6sYv~?dN}rlB zClT8ldho^G*ppmqfmit!+JBVWogU5JppY!KO|F@hoAWQJOUFAH)Dl(^>0N-Mq zDpfrNJj9sSr0hs}=h*dda*+*kGMuoO3vl2Go*{r!k&2TfZ4D&A> zUFa)WPE{_M?fFcy8Soqu)8CGCt7@m8Sckb&i%(V*tJDM~7kABOgj5HEgVyKbDm)uY zrd7l+X~QzErI7U!USL|AL0RemdZfROD#7($TNsLJ=R6&7bkt`>LRPPhmIWww<-!xq zymNvfU#(vgtnSIsq^q=?>e9bS1$4*b1|o755R$l?PgJ454Q)&RlAbRBQ#bAaXyd=r zn|pN=o*zGqvH|%Hp~C02xkYwRQJX*_MPNOT6$isGQl(5dy-zNFd*Y z=7T)_re{j+O7Q++xkzeofm}pznQvOPhL1irBN^F#SkQ>~U1|a_PEJ&++)7|px08|g zdfAuv*{;Rfc&(6z&w<7KC_3&auRwK4wB>XJWJ5BmWl%sN!do=M>z3N6a><~LUTIB- z%EK3(T-DWzyl_RmXq-!WV{>kqo(pzesM}kbwwNP%C3DPpDmRVeKWkE@AHgk7X%baO z&y+@XvRrn{p73wOyb8dkkDSYdh)_>Cwa)@%@uq_y+w}py06f$r*#~r z{I*Y(^4?!izs1NF=LB@SQy+ENTJ$iUL@_DxQpkfC6nEQn34kVL>{-VS33BCRTxv^G zrWA%8PRlseeDLeyLQq6k;QRRn+@Gi7J!{t(NH?`}GhOfxIa3Y{&556MXrP55FMzlyyCZ;Y# zx$jmAUT82QOVy{?;IXM?nJYQ7&=VHWY%(cLlS^C-ggl2FMr;Dg3?M78 z)^F$Ck+5ryM*#hyi!vGQq8=Rsn-{El_eE3G71lv;LWdg;-Qo5yRmF0Q*X~aB=y0cc z#>{&DvbL%BpAiLYH+0%e>?0X_Uv9QNBxt6p#!muAv6hI|)@3#v1F!5$YI>9>77bUK zQCs|5Ec&``a^qjE`{EA%6#XUx55X0%b)Ez%SgFQs-dqY-1ldjswZCZI5q_?AldYg0 zOVWF(pD97~C~~2Pym9w-EIM=B_KZAF%C6UpNAm@?_lfX>W!8u1VNQ0S?)rS|?o0YL zDsdN>jQatr5Y#H+kABa9$u-*-)I7$k7r@YM>t?mC1xUH5r{1Hml&@XAD5=&jNo$A8 zb;(c*z_5JP=x;y+B9YYehuU5LTp#;V_KdrS|I8z`;`58W$jyC97KAp@aV4{@gQKk3 z9t|1z0o}^07P+qF184jumgHa}Z|N49+4wKX$IRFAFe-j>DZ69^O4p1xOPhjNEt%B9 zSEs>03cY%ykq`JK6vR5Y?##{R-n0yAQa}`yIl^$dCDaes^Grse&XHrsVK#iA9RswR zoaHv6%Zrc89qWaaFSSC=WriJeJDl3ok`|B%ujsR6-6@=_uw0oa8*0u}nezixUN!sd zsbE{dj3u@Y_ zUQJI#bAZOLcK|&E9bqqepQ4b!|5`ui9rT3^d z3HL?wgW+34w-o{?qZ{HEV11fD{tYthEU-9xbx$sRS%j@ob>O^ELV;Y%Q!4bRUWy+~ zq10U(k}^-}+5=R!3%d_h6zY`~`wpuAym55z%xz>�&YtAe35Sfb_dw?i-2dY0rp3 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

dvPgZF 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

T@rHBH;_eTr{K7D$`VWo{fzRw*m*q)&D2fk~ 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 z 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

?<^45yeSvg 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

I%Z%FY~Eg>&wMFriOSGl51T(lo+#- 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

06M_ZxTLkk@q~CG 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 literal 0 HcmV?d00001 From 783a32b1557099796e21a81536c5abe9cf57d25c Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 28 Jul 2026 12:48:18 +0500 Subject: [PATCH 41/44] build(container): add stable runtime image --- .dockerignore | 12 +++ .env.example | 3 + Dockerfile | 82 ++++++++++++++++--- INSTALLATION_GUIDE.md | 28 +++++++ README.md | 12 +++ changes/12.feature.md | 2 +- compose.yaml | 13 +++ src/vidxp/capabilities/scene/requirements.txt | 1 + tests/test_models.py | 1 + 9 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..db60049 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.pytest_cache +.ruff_cache +.venv* +build +chroma_data +dist +tmp +**/__pycache__ +*.py[cod] +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..00d0253 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VIDXP_IMAGE=ghcr.io/grayhatdevelopers/vidxp:latest +VIDXP_PORT=8501 +VIDXP_DEVICE=cpu diff --git a/Dockerfile b/Dockerfile index 4c1a474..e74c2f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM python:3.10-slim +# syntax=docker/dockerfile:1 + +FROM python:3.11-slim-bookworm AS builder WORKDIR /app @@ -7,27 +9,85 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ git \ pkg-config \ - ffmpeg \ - libglib2.0-0 \ - libgl1 \ - libsm6 \ libopenblas-dev \ liblapack-dev \ libjpeg62-turbo-dev \ libpng-dev \ - libxext6 \ - libxrender1 \ && rm -rf /var/lib/apt/lists/* -ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 +RUN python -m venv /opt/vidxp + +ENV PATH="/opt/vidxp/bin:${PATH}" \ + PIP_DISABLE_PIP_VERSION_CHECK=1 COPY pyproject.toml README.md LICENSE MANIFEST.in ./ +COPY src/vidxp/requirements ./src/vidxp/requirements +COPY src/vidxp/capabilities/dialogue/requirements.txt ./src/vidxp/capabilities/dialogue/requirements.txt +COPY src/vidxp/capabilities/scene/requirements.txt ./src/vidxp/capabilities/scene/requirements.txt +COPY src/vidxp/capabilities/actor/requirements.txt ./src/vidxp/capabilities/actor/requirements.txt + +ARG PYTORCH_INDEX_URL="https://download.pytorch.org/whl/cpu" + +RUN --mount=type=cache,target=/root/.cache/pip \ + python -m pip install --upgrade pip setuptools wheel \ + && python -m pip install \ + --extra-index-url "${PYTORCH_INDEX_URL}" \ + -r src/vidxp/requirements/storage.txt \ + -r src/vidxp/requirements/frontend.txt \ + -r src/vidxp/capabilities/dialogue/requirements.txt \ + -r src/vidxp/capabilities/scene/requirements.txt \ + -r src/vidxp/capabilities/actor/requirements.txt + COPY src ./src -RUN python -m pip install --upgrade pip setuptools wheel \ - && pip install ".[all,frontend]" +RUN --mount=type=cache,target=/root/.cache/pip \ + python -m pip install . \ + && python -m pip check + +FROM python:3.11-slim-bookworm AS runtime + +LABEL org.opencontainers.image.title="VidXP" \ + org.opencontainers.image.description="Local-first video indexing and search" \ + org.opencontainers.image.source="https://github.com/grayhatdevelopers/vidxp" \ + org.opencontainers.image.licenses="MIT" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libglib2.0-0 \ + libgl1 \ + libgomp1 \ + libjpeg62-turbo \ + liblapack3 \ + libopenblas0 \ + libpng16-16 \ + libsm6 \ + libxext6 \ + libxrender1 \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system vidxp \ + && useradd --system --gid vidxp --home-dir /var/lib/vidxp vidxp \ + && mkdir -p /var/lib/vidxp \ + && chown vidxp:vidxp /var/lib/vidxp + +COPY --from=builder /opt/vidxp /opt/vidxp + +ENV PATH="/opt/vidxp/bin:${PATH}" \ + HOME="/var/lib/vidxp" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + STREAMLIT_SERVER_HEADLESS=true \ + VIDXP_CONFIG_FILE="/var/lib/vidxp/config/repositories.json" \ + VIDXP_INDEX_DIR="/var/lib/vidxp/index" + +USER vidxp +WORKDIR /var/lib/vidxp + +VOLUME ["/var/lib/vidxp"] EXPOSE 8501 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"] + CMD ["vidxp", "ui", "--host", "0.0.0.0", "--port", "8501"] diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 8bf22d4..fc5c747 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -87,6 +87,34 @@ Use an editable installation while developing: python -m pip install -e ".[all,frontend,benchmarks]" ``` +## Run the container + +Stable container images include every runtime capability and the browser +interface, but not downloaded model weights. Docker Compose keeps indexes, +repository configuration, and model caches in the `vidxp-data` volume: + +```bash +docker compose pull +docker compose run --rm vidxp vidxp prepare +docker compose up +``` + +The interface is available at `http://localhost:8501`. Set `VIDXP_PORT` in a +local `.env` file to publish a different host port. Set `VIDXP_IMAGE` to select +an immutable release instead of `latest`: + +```dotenv +VIDXP_IMAGE=ghcr.io/grayhatdevelopers/vidxp:0.2.0 +VIDXP_PORT=8502 +VIDXP_DEVICE=cpu +``` + +Run the image without Compose when persistent storage is not required: + +```bash +docker run --rm -p 8501:8501 ghcr.io/grayhatdevelopers/vidxp:latest +``` + ## Verify the installation Display the installed package version: diff --git a/README.md b/README.md index 3827d97..0c39e07 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,18 @@ 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. +## Container + +Stable releases are available from GitHub Container Registry. Start the local +interface with persistent index and model storage by running: + +```bash +docker compose up +``` + +See the [installation guide](INSTALLATION_GUIDE.md#run-the-container) for model +preparation, configuration, and direct `docker run` usage. + ## Use VidXP as a Python package The programmatic API supports isolated multi-video runs, supplied timestamped diff --git a/changes/12.feature.md b/changes/12.feature.md index 5f76b83..9023f87 100644 --- a/changes/12.feature.md +++ b/changes/12.feature.md @@ -1 +1 @@ -Add registry-driven dialogue, scene, and actor capabilities with selective installation extras and an explicit benchmark add-on. +Add registry-driven capabilities with selective installation extras, an explicit benchmark add-on, and stable container images. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..8085ce5 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,13 @@ +services: + vidxp: + image: ${VIDXP_IMAGE:-ghcr.io/grayhatdevelopers/vidxp:latest} + ports: + - "${VIDXP_PORT:-8501}:8501" + environment: + VIDXP_DEVICE: ${VIDXP_DEVICE:-cpu} + volumes: + - vidxp-data:/var/lib/vidxp + init: true + +volumes: + vidxp-data: diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt index 5c84d0d..6c01cd3 100644 --- a/src/vidxp/capabilities/scene/requirements.txt +++ b/src/vidxp/capabilities/scene/requirements.txt @@ -2,4 +2,5 @@ clip-anytorch==2.6.0 numpy>=2.1,<3 opencv-python Pillow>=7.0.0 +setuptools<81 torch diff --git a/tests/test_models.py b/tests/test_models.py index 824aeac..50c144a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -40,6 +40,7 @@ def test_scene_dependency_check_does_not_touch_other_capabilities(self): "numpy": "2.1", "opencv-python": "4.10", "Pillow": "10.0", + "setuptools": "80.10.2", "torch": "2.5", } From 2c9cdaf4a0f226b2d7d4ba8adb13f7fdfe9b391d Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 28 Jul 2026 12:48:41 +0500 Subject: [PATCH 42/44] ci(release): publish stable container images --- .github/workflows/ci.yml | 63 +++++++++++++++++++++++-- .github/workflows/release-to-pypi.yml | 67 +++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9c5765..b1c186f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,12 @@ on: - labeled - unlabeled workflow_call: + inputs: + container: + description: Build and smoke-test the stable container surface. + required: false + type: boolean + default: false concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -46,15 +52,27 @@ jobs: shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} + CONTAINER_REQUESTED: ${{ inputs.container }} run: | + run_container=false 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" + run_suite=true + if [[ "$CONTAINER_REQUESTED" == "true" ]]; then + run_container=true + fi else - echo "run_suite=false" >> "$GITHUB_OUTPUT" + changed_files="$(git diff --name-only "$BASE_SHA" HEAD)" + if grep -Eq '^(\.github/workflows/ci\.yml|src/|tests/|utils/|Dockerfile$|LICENSE$|MANIFEST\.in$|README\.md$|pyproject\.toml$)' <<< "$changed_files"; then + run_suite=true + else + run_suite=false + fi + if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then + run_container=true + fi fi + echo "run_suite=$run_suite" >> "$GITHUB_OUTPUT" + echo "run_container=$run_container" >> "$GITHUB_OUTPUT" - name: Install test surface if: steps.scope.outputs.run_suite == 'true' @@ -108,3 +126,38 @@ jobs: ): assert find_spec(module) is None, module PY + + - name: Validate Compose configuration + if: steps.scope.outputs.run_container == 'true' + run: docker compose config --quiet + + - name: Build container + if: steps.scope.outputs.run_container == 'true' + run: docker build --tag vidxp:ci . + + - name: Smoke container + if: steps.scope.outputs.run_container == 'true' + shell: bash + run: | + docker run --detach --name vidxp-ci \ + --publish 127.0.0.1:8501:8501 vidxp:ci + trap 'docker rm --force vidxp-ci >/dev/null 2>&1 || true' EXIT + + docker exec vidxp-ci vidxp --version + docker exec vidxp-ci python -c \ + 'import clip, face_recognition, whisperx' + docker exec vidxp-ci sh -c \ + 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' + + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:8501/_stcore/health; then + exit 0 + fi + if [[ "$(docker inspect --format='{{.State.Running}}' vidxp-ci)" != "true" ]]; then + break + fi + sleep 2 + done + + docker logs vidxp-ci + exit 1 diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index b5085fd..d6fbf96 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -15,6 +15,8 @@ jobs: ci: if: github.ref == 'refs/heads/main' uses: ./.github/workflows/ci.yml + with: + container: true permissions: contents: read @@ -24,6 +26,11 @@ jobs: runs-on: ubuntu-latest environment: name: pypi + outputs: + commit_sha: ${{ steps.release.outputs.commit_sha }} + released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} steps: - uses: actions/checkout@v7 @@ -52,3 +59,63 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ + + publish-container: + if: >- + github.ref == 'refs/heads/main' && + needs.release.outputs.released == 'true' + needs: release + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.commit_sha }} + + - uses: docker/setup-buildx-action@v3 + + - name: Generate container metadata + id: metadata + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},value=${{ needs.release.outputs.tag }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.tag }} + type=raw,value=latest + labels: | + org.opencontainers.image.title=VidXP + org.opencontainers.image.description=Local-first video indexing and search + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ needs.release.outputs.commit_sha }} + org.opencontainers.image.version=${{ needs.release.outputs.version }} + org.opencontainers.image.licenses=MIT + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish stable container + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + + - name: Verify public container access + shell: bash + run: | + docker logout ghcr.io + image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}" + if ! docker buildx imagetools inspect "$image"; then + echo "::error::Make the GHCR package public, then rerun this failed job." + exit 1 + fi From eab3ecfabfed9f1d5ec2ed83c799190d3352853a Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 28 Jul 2026 14:35:43 +0500 Subject: [PATCH 43/44] ci(release): publish validated artifacts once --- .github/workflows/ci.yml | 30 ++---- .github/workflows/release-to-pypi.yml | 102 ++++++++++++++++----- .github/workflows/release-to-test-pypi.yml | 54 ++++++++--- 3 files changed, 131 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c186f..41c3545 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,13 +8,6 @@ on: - reopened - labeled - unlabeled - workflow_call: - inputs: - container: - description: Build and smoke-test the stable container surface. - required: false - type: boolean - default: false concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -52,24 +45,17 @@ jobs: shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - CONTAINER_REQUESTED: ${{ inputs.container }} run: | - run_container=false - if [[ "${{ github.event_name }}" != "pull_request" ]]; then + changed_files="$(git diff --name-only "$BASE_SHA" HEAD)" + if grep -Eq '^(\.github/workflows/ci\.yml$|src/|tests/|utils/|LICENSE$|MANIFEST\.in$|pyproject\.toml$)' <<< "$changed_files"; then run_suite=true - if [[ "$CONTAINER_REQUESTED" == "true" ]]; then - run_container=true - fi else - changed_files="$(git diff --name-only "$BASE_SHA" HEAD)" - if grep -Eq '^(\.github/workflows/ci\.yml|src/|tests/|utils/|Dockerfile$|LICENSE$|MANIFEST\.in$|README\.md$|pyproject\.toml$)' <<< "$changed_files"; then - run_suite=true - else - run_suite=false - fi - if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then - run_container=true - fi + run_suite=false + fi + if grep -Eq '^(\.dockerignore$|\.github/workflows/ci\.yml$|Dockerfile$|compose\.yaml$|pyproject\.toml$|src/vidxp/(requirements/.*\.txt|capabilities/[^/]+/requirements\.txt)$)' <<< "$changed_files"; then + run_container=true + else + run_container=false fi echo "run_suite=$run_suite" >> "$GITHUB_OUTPUT" echo "run_container=$run_container" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-to-pypi.yml b/.github/workflows/release-to-pypi.yml index d6fbf96..e2d9b08 100644 --- a/.github/workflows/release-to-pypi.yml +++ b/.github/workflows/release-to-pypi.yml @@ -4,28 +4,18 @@ on: workflow_dispatch: permissions: - contents: write - id-token: write + contents: read concurrency: group: pypi-main cancel-in-progress: false jobs: - ci: - if: github.ref == 'refs/heads/main' - uses: ./.github/workflows/ci.yml - with: - container: true - permissions: - contents: read - release: if: github.ref == 'refs/heads/main' - needs: ci runs-on: ubuntu-latest - environment: - name: pypi + permissions: + contents: write outputs: commit_sha: ${{ steps.release.outputs.commit_sha }} released: ${{ steps.release.outputs.released }} @@ -54,8 +44,45 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: semantic-release version --no-changelog - - name: Publish to PyPI + - name: Smoke distribution if: steps.release.outputs.released == 'true' + shell: bash + run: | + python -m venv .release-smoke + .release-smoke/bin/python -m pip install dist/*.whl + .release-smoke/bin/python -m pip check + .release-smoke/bin/vidxp --version + + - name: Upload GitHub release assets + if: steps.release.outputs.released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: semantic-release publish --tag "${{ steps.release.outputs.tag }}" + + - name: Preserve distribution + if: steps.release.outputs.released == 'true' + uses: actions/upload-artifact@v4 + with: + name: pypi-distribution + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish-pypi: + if: needs.release.outputs.released == 'true' + needs: release + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + + steps: + - uses: actions/download-artifact@v5 + with: + name: pypi-distribution + path: dist/ + + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ @@ -94,6 +121,43 @@ jobs: org.opencontainers.image.version=${{ needs.release.outputs.version }} org.opencontainers.image.licenses=MIT + - name: Build stable container + uses: docker/build-push-action@v6 + with: + context: . + load: true + platforms: linux/amd64 + push: false + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + + - name: Smoke stable container + shell: bash + run: | + image="ghcr.io/${{ github.repository }}:${{ needs.release.outputs.version }}" + docker run --detach --name vidxp-release \ + --publish 127.0.0.1:8501:8501 "$image" + trap 'docker rm --force vidxp-release >/dev/null 2>&1 || true' EXIT + + docker exec vidxp-release vidxp --version + docker exec vidxp-release python -c \ + 'import clip, face_recognition, whisperx' + docker exec vidxp-release sh -c \ + 'test ! -d "$HOME/.cache/huggingface" && test ! -d "$HOME/.cache/clip"' + + for attempt in {1..30}; do + if curl --fail --silent http://127.0.0.1:8501/_stcore/health; then + exit 0 + fi + if [[ "$(docker inspect --format='{{.State.Running}}' vidxp-release)" != "true" ]]; then + break + fi + sleep 2 + done + + docker logs vidxp-release + exit 1 + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: @@ -101,14 +165,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and publish stable container - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64 - push: true - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} + - name: Publish stable container + run: docker push --all-tags "ghcr.io/${{ github.repository }}" - name: Verify public container access shell: bash diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml index adf74fc..a233dd9 100644 --- a/.github/workflows/release-to-test-pypi.yml +++ b/.github/workflows/release-to-test-pypi.yml @@ -13,27 +13,22 @@ on: - "LICENSE" - "MANIFEST.in" - "utils/**" - - ".github/workflows/ci.yml" - ".github/workflows/release-to-test-pypi.yml" permissions: - contents: write - id-token: write + contents: read concurrency: group: testpypi-${{ github.ref }} cancel-in-progress: false jobs: - ci: - uses: ./.github/workflows/ci.yml - permissions: - contents: read - prerelease: - needs: ci runs-on: ubuntu-latest - environment: testpypi + permissions: + contents: write + outputs: + released: ${{ steps.release.outputs.released }} steps: - uses: actions/checkout@v7 @@ -56,8 +51,45 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: semantic-release version --as-prerelease --prerelease-token b --no-changelog - - name: Publish to TestPyPI + - name: Smoke distribution + if: steps.release.outputs.released == 'true' + shell: bash + run: | + python -m venv .release-smoke + .release-smoke/bin/python -m pip install dist/*.whl + .release-smoke/bin/python -m pip check + .release-smoke/bin/vidxp --version + + - name: Upload GitHub release assets + if: steps.release.outputs.released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: semantic-release publish --tag "${{ steps.release.outputs.tag }}" + + - name: Preserve distribution if: steps.release.outputs.released == 'true' + uses: actions/upload-artifact@v4 + with: + name: testpypi-distribution + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish-testpypi: + if: needs.prerelease.outputs.released == 'true' + needs: prerelease + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + + steps: + - uses: actions/download-artifact@v5 + with: + name: testpypi-distribution + path: dist/ + + - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ From a9ee0b5863bef01084b97185d20f506b6cf49ce3 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 28 Jul 2026 14:35:48 +0500 Subject: [PATCH 44/44] build(deps): raise setuptools build floor --- pyproject.toml | 2 +- src/vidxp/capabilities/scene/requirements.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5c2806a..02f6db6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68", "wheel"] +requires = ["setuptools>=83", "wheel"] build-backend = "setuptools.build_meta" [project] diff --git a/src/vidxp/capabilities/scene/requirements.txt b/src/vidxp/capabilities/scene/requirements.txt index 6c01cd3..213eddc 100644 --- a/src/vidxp/capabilities/scene/requirements.txt +++ b/src/vidxp/capabilities/scene/requirements.txt @@ -2,5 +2,6 @@ clip-anytorch==2.6.0 numpy>=2.1,<3 opencv-python Pillow>=7.0.0 +# clip-anytorch still imports pkg_resources, removed in setuptools 81. setuptools<81 torch