diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 0000000..fedd4bd
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "vidxp",
+ "interface": {
+ "displayName": "VidXP"
+ },
+ "plugins": [
+ {
+ "name": "vidxp",
+ "source": {
+ "source": "local",
+ "path": "./plugins/vidxp"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4d3a7b3..6eab959 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -206,10 +206,11 @@ jobs:
if: >-
needs.scope.outputs.run_suite == 'true' &&
matrix.python-version == '3.14' &&
- inputs.package_artifact_name != ''
+ (needs.scope.outputs.run_desktop == 'true' ||
+ inputs.package_artifact_name != '')
uses: actions/upload-artifact@v7
with:
- name: ${{ inputs.package_artifact_name }}
+ name: ${{ inputs.package_artifact_name || 'vidxp-python-dist' }}
path: dist/
if-no-files-found: error
retention-days: 30
@@ -268,10 +269,13 @@ jobs:
if: >-
github.event_name == 'pull_request' &&
needs.scope.outputs.run_desktop == 'true'
- needs: scope
+ needs:
+ - scope
+ - validate
uses: ./.github/workflows/desktop.yml
with:
checkout_ref: ${{ github.sha }}
+ package_artifact_name: ${{ inputs.package_artifact_name || 'vidxp-python-dist' }}
required:
if: always() && github.event_name == 'pull_request'
diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml
index 0afcf30..33f465d 100644
--- a/.github/workflows/desktop.yml
+++ b/.github/workflows/desktop.yml
@@ -17,6 +17,16 @@ on:
required: false
default: false
type: boolean
+ package_artifact_name:
+ description: Validated Python distribution to embed in every installer.
+ required: false
+ default: ""
+ type: string
+ plugin_marketplace_ref:
+ description: Git ref for release builds; empty keeps a local marketplace.
+ required: false
+ default: ""
+ type: string
workflow_dispatch:
inputs:
checkout_ref:
@@ -34,6 +44,16 @@ on:
required: false
default: false
type: boolean
+ package_artifact_name:
+ description: Existing Python distribution artifact; otherwise build from checkout.
+ required: false
+ default: ""
+ type: string
+ plugin_marketplace_ref:
+ description: Git ref for release builds; empty keeps a local marketplace.
+ required: false
+ default: ""
+ type: string
permissions:
contents: read
@@ -55,6 +75,30 @@ jobs:
with:
ref: ${{ inputs.checkout_ref || github.event.inputs.checkout_ref || github.sha }}
+ - uses: actions/download-artifact@v8
+ if: inputs.package_artifact_name != ''
+ with:
+ name: ${{ inputs.package_artifact_name }}
+ path: dist/
+
+ - uses: actions/setup-python@v7
+ if: inputs.package_artifact_name == ''
+ with:
+ python-version: "3.14"
+
+ - name: Build the Python distribution for a standalone Desktop build
+ if: inputs.package_artifact_name == ''
+ shell: bash
+ run: |
+ python -m pip install -r utils/build-requirements.txt
+ bash utils/build_package.sh
+
+ - name: Verify the embedded Python package input
+ shell: bash
+ run: |
+ [[ "$(find dist -maxdepth 1 -name '*.whl' | wc -l | tr -d ' ')" == "1" ]]
+ [[ "$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l | tr -d ' ')" == "1" ]]
+
- name: Install Linux desktop build dependencies
if: runner.os == 'Linux'
run: |
@@ -145,6 +189,7 @@ jobs:
working-directory: desktop
env:
VIDXP_DESKTOP_SIGN: ${{ (runner.os == 'macOS' && inputs.sign) && '1' || '' }}
+ VIDXP_PLUGIN_MARKETPLACE_REF: ${{ inputs.plugin_marketplace_ref }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
index d8bccb9..9ca543b 100644
--- a/.github/workflows/release-candidate.yml
+++ b/.github/workflows/release-candidate.yml
@@ -106,11 +106,15 @@ jobs:
run_containers: false
desktop:
- needs: contract
+ needs:
+ - contract
+ - core
uses: ./.github/workflows/desktop.yml
with:
artifact_retention_days: 30
checkout_ref: ${{ inputs.head_sha }}
+ package_artifact_name: vidxp-python-dist
+ plugin_marketplace_ref: ${{ inputs.target_branch }}
sign: true
secrets: inherit
diff --git a/Dockerfile b/Dockerfile
index b27524f..6722152 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -37,6 +37,7 @@ FROM python:3.14-slim-trixie AS runtime-base
LABEL org.opencontainers.image.title="VidXP" \
org.opencontainers.image.description="Video indexing and search" \
org.opencontainers.image.source="https://github.com/grayhatdevelopers/vidxp" \
+ org.opencontainers.image.vendor="Grayhat Developers PVT Ltd" \
org.opencontainers.image.licenses="MIT"
RUN apt-get update && apt-get install -y --no-install-recommends \
diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md
index 3b0243a..38e35b2 100644
--- a/INSTALLATION_GUIDE.md
+++ b/INSTALLATION_GUIDE.md
@@ -14,7 +14,7 @@ shape needs.
| Desktop app | Install the native package | Adopt a compatible local installation or create a private Desktop-managed runtime |
| Browser UI in Docker | Published `vidxp` image | One CPU worker/UI container |
| Public/self-hosted service | `compose.coolify.yaml` | API/MCP control plane, CPU worker, PostgreSQL, Chroma, tusd |
-| Embed one capability | `dialogue`, `scene`, or `actor` extra | Python indexing/retrieval code |
+| Embed one capability | `dialogue`, `scene`, `actor`, or `videoprism` extra | Python indexing/retrieval code |
Do not install the bare package and expect it to index video. Base `vidxp`
provides the lightweight command shell, configuration, and typed contracts.
@@ -227,7 +227,8 @@ Extras are composable:
| `dialogue` | Storage, transcription, dialogue embeddings | Scene/actor providers |
| `scene` | Storage, PyTorch, Transformers, OpenCV, Pillow | Dialogue/actor providers |
| `actor` | Storage, OpenCV, YuNet/SFace support | Dialogue/scene providers |
-| `all` | Dialogue, scene, and actor | Grounded-query model client and UI |
+| `videoprism` | Storage, VideoPrism, PyTorch, Transformers, Torchvision | Other model providers |
+| `all` | Every built-in search capability | Grounded-query model client and UI |
| `local-worker` | `all` plus grounded-query client | Browser UI, MCP SDK, HTTP server |
| `frontend` | Streamlit | Worker providers |
| `mcp` | MCP SDK | Worker providers |
diff --git a/LICENSE b/LICENSE
index b4255c5..5e28035 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 Grayhat Developers
+Copyright (c) 2026 Grayhat Developers PVT Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/MANIFEST.in b/MANIFEST.in
index cdda21d..a25a125 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,6 +3,8 @@ include LICENSE
include docs/images/logo.png
recursive-include src/vidxp/assets/upload_page *
recursive-include src/vidxp/assets/artifact_download *
+recursive-include src/vidxp/assets/mcp_app *
+recursive-include plugins/vidxp *
include web/upload-page/package.json
include web/upload-page/package-lock.json
include web/upload-page/scripts/build.mjs
diff --git a/README.md b/README.md
index e79b8b7..3d767ce 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
- Dialogue search · Scene search · Actor grouping
+ Dialogue search · Scene search · Temporal video search · Actor grouping
@@ -43,9 +43,13 @@ VidXP makes one video—or an entire collection—searchable by meaning:
matching moments.
- **Scene search:** describe what appeared on screen and find the closest
visual matches.
+- **VideoPrism search:** describe an action or event spanning a short clip.
- **Actor matching:** find recurring faces within a video and export a
highlighted video for a selected group.
+Existing indexes remain compatible. Index media with the `videoprism`
+capability selected before using temporal video search.
+
Use it to search years of family videos, add video search to an editing
workflow, or let an AI agent answer questions using evidence from your own
video library. Your videos can stay on your machine.
@@ -138,6 +142,9 @@ vidxp index create
# Find a visual moment
vidxp search scene "a yellow taxi on a city street"
+# Find an action spanning several frames
+vidxp search videoprism "a person opens a door and walks outside"
+
# Find something that was said
vidxp search dialogue "the bread just came out of the oven"
```
@@ -159,18 +166,29 @@ questions about a library, and return inspectable evidence such as boards,
frames, and clips. Clients can connect locally over stdio or to a self-hosted
VidXP server.
-### ChatGPT and Codex skills
+### Codex plugin and skills
-VidXP includes reusable skill source folders for the two common agent workflows:
+VidXP is distributed as a Codex plugin through a Git marketplace hosted in
+this GitHub repository. It includes three reusable agent workflows:
-- [Ingest and index videos](skills/vidxp-ingest-video/SKILL.md)
-- [Find moments and return inspectable evidence](skills/vidxp-find-video-evidence/SKILL.md)
+- [Install Desktop or CLI and connect Codex](plugins/vidxp/skills/vidxp-install/SKILL.md)
+- [Ingest and index videos](plugins/vidxp/skills/vidxp-ingest-video/SKILL.md)
+- [Find moments and return inspectable evidence](plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md)
-Download a skill folder and add it through a supported ChatGPT desktop or Codex
-Skills surface. The skills require a connected VidXP MCP server; installable
-plugin packaging for additional ChatGPT surfaces will follow separately.
+Paste this into Codex:
+
+```text
+Add https://github.com/grayhatdevelopers/vidxp as a Git plugin marketplace, install the VidXP plugin, then use its $vidxp-install skill to set up VidXP on this computer.
+```
+
+VidXP Desktop can perform the same setup from its **Set up in Codex** button.
+
+The MCP App resource gives compatible hosts an interactive upload and
+evidence-review view; every workflow continues to work through ordinary MCP
+tool results when a host has no UI.
- [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md)
+- [ChatGPT and Codex plugin integration](docs/integrations/openai-plugin.md)
- [Optional capability packages](INSTALLATION_GUIDE.md#optional-dependency-extras)
- [Coolify server setup](docs/deployment/coolify.md)
@@ -179,14 +197,19 @@ plugin packaging for additional ChatGPT surfaces will follow separately.
First setup downloads only the models needed for the capabilities you select.
VidXP shows the download size and destination before it starts.
+The Desktop-managed Python runtime and its selected dependencies can use
+approximately 3 GiB.
+
| Capability | Approximate model download |
|---|---:|
| Dialogue search | 2.64 GiB |
| Scene search | 1.43 GiB |
+| VideoPrism temporal search | 0.93 GiB |
| Actor matching | 37 MiB |
-Leave additional space for the VidXP runtime, indexes, source videos, and
-exported results.
+A full local Desktop setup with every search capability uses approximately
+8.1 GiB. Leave additional temporary space during installation and for indexes,
+source videos, and exported results.
By default, the CLI and desktop app share the same VidXP data directory:
@@ -231,14 +254,14 @@ Contributions are welcome. Read the
## Credits
-Built by Grayhat Developers PVT Ltd. and maintained by the community.
+Built by [Grayhat](https://grayhat.studio/) and maintained by the community.
Originally researched by students:
- [Abdullah Mansoor](https://github.com/abdullahmansoor321)
- [Muhammad Haroon](https://github.com/haroon10725)
- [Sarah Jawaid](https://github.com/sarr266)
- [Talha Ahmed](https://github.com/talhaahmed1234)
-Working with [Dr Shahab Tahzeeb](https://scholar.google.com/citations?user=cryeRB0AAAAJ&hl=en) ([NED University of Engineering and Technology](https://www.neduet.edu.pk/)) and [Saad Bazaz](https://scholar.google.com/citations?user=mrJo09oAAAAJ&hl=en) ([Grayhat](https://grayhat.studio)).
+Working with [Dr Shahab Tahzeeb](https://scholar.google.com/citations?user=cryeRB0AAAAJ&hl=en) ([NED University of Engineering and Technology](https://www.neduet.edu.pk/)) and [Saad Bazaz](https://scholar.google.com/citations?user=mrJo09oAAAAJ&hl=en) ([Grayhat](https://grayhat.studio/)).
Email: info@grayhat.studio
diff --git a/desktop/THIRD_PARTY_NOTICES.txt b/desktop/THIRD_PARTY_NOTICES.txt
index de97bba..be6f47e 100644
--- a/desktop/THIRD_PARTY_NOTICES.txt
+++ b/desktop/THIRD_PARTY_NOTICES.txt
@@ -9,7 +9,7 @@ VidXP Desktop and VidXP | MIT | https://github.com/grayhatdevelopers/vidxp
MIT License
-Copyright (c) 2026 Grayhat Developers
+Copyright (c) 2026 Grayhat Developers PVT Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/desktop/model-cache-catalog.json b/desktop/model-cache-catalog.json
index 9bdf1f6..8284bad 100644
--- a/desktop/model-cache-catalog.json
+++ b/desktop/model-cache-catalog.json
@@ -9,6 +9,11 @@
"label": "google/siglip2-base-patch16-224",
"relative_artifact": "models--google--siglip2-base-patch16-224/snapshots/75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2/model.safetensors"
},
+ {
+ "id": "google/videoprism-lvt-base-f16r288",
+ "label": "google/videoprism-lvt-base-f16r288",
+ "relative_artifact": "models--google--videoprism-lvt-base-f16r288/snapshots/fb6de9f0eb7bc285be86bdca1cf7daa3e3ef51ff/model.safetensors"
+ },
{
"id": "Qwen/Qwen3-Embedding-0.6B",
"label": "Qwen/Qwen3-Embedding-0.6B",
diff --git a/desktop/package.json b/desktop/package.json
index caa6492..b64ffd2 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -17,7 +17,7 @@
"model-catalog:check": "uv run --frozen python scripts/model-catalog.py --check",
"tauri": "tauri",
"sync:branding": "node scripts/sync-branding.mjs",
- "icons": "npm run sync:branding && tauri icon ../docs/images/logo.png --output src-tauri/icons",
+ "icons": "tauri icon ../docs/images/logo.png --output src-tauri/icons && npm run sync:branding",
"predesktop:dev": "npm run sync:branding",
"desktop:dev": "tauri dev",
"predesktop:build": "npm run icons",
diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json
index f43b85a..8f7d499 100644
--- a/desktop/runtime-manifest.json
+++ b/desktop/runtime-manifest.json
@@ -47,6 +47,11 @@
"extra": "actor",
"modality": "actor",
"label": "Actor recognition"
+ },
+ "videoprism": {
+ "extra": "videoprism",
+ "modality": "videoprism",
+ "label": "Temporal video search"
}
},
"media_runtime": {
diff --git a/desktop/scripts/sync-branding.mjs b/desktop/scripts/sync-branding.mjs
index 98968d2..624836f 100644
--- a/desktop/scripts/sync-branding.mjs
+++ b/desktop/scripts/sync-branding.mjs
@@ -6,7 +6,15 @@ const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source = resolve(desktopRoot, "../docs/images/logo.png");
const publicDirectory = resolve(desktopRoot, "public");
const favicon = resolve(publicDirectory, "icon.png");
+const pluginLogo = resolve(desktopRoot, "../plugins/vidxp/assets/logo.png");
+const artifactLogo = resolve(
+ desktopRoot,
+ "../src/vidxp/assets/artifact_download/vidxp-logo.png",
+);
+const generatedIcon = resolve(desktopRoot, "src-tauri/icons/128x128.png");
mkdirSync(publicDirectory, { recursive: true });
copyFileSync(source, favicon);
-console.log("Synced the VidXP desktop favicon from the shared icon.");
+copyFileSync(source, pluginLogo);
+copyFileSync(generatedIcon, artifactLogo);
+console.log("Synced VidXP branding from docs/images/logo.png.");
diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs
index 8af7082..3709052 100644
--- a/desktop/src-tauri/build.rs
+++ b/desktop/src-tauri/build.rs
@@ -9,6 +9,53 @@ fn main() {
let expected = manifest["uv_version"]
.as_str()
.expect("runtime manifest must contain uv_version");
+ let package_name = manifest["package_name"]
+ .as_str()
+ .expect("runtime manifest must contain package_name");
+ let package_version = manifest["package_version"]
+ .as_str()
+ .expect("runtime manifest must contain package_version");
+ let wheel_version = package_version.replace("-b.", "b").replace("-b", "b");
+ let wheel_prefix = format!("{}-{wheel_version}-", package_name.replace('-', "_"));
+ let distribution_directory = Path::new("../..").join("dist");
+ let wheels = std::fs::read_dir(&distribution_directory)
+ .unwrap_or_else(|error| {
+ panic!(
+ "{} is unavailable; build the Python distribution before Desktop: {error}",
+ distribution_directory.display()
+ )
+ })
+ .map(|entry| {
+ entry
+ .expect("the distribution directory must be readable")
+ .path()
+ })
+ .filter(|path| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.starts_with(&wheel_prefix) && name.ends_with(".whl"))
+ })
+ .collect::>();
+ assert_eq!(
+ wheels.len(),
+ 1,
+ "Desktop requires exactly one {package_name} {package_version} wheel in {}; found {}",
+ distribution_directory.display(),
+ wheels.len()
+ );
+ let wheel = &wheels[0];
+ let wheel_name = wheel
+ .file_name()
+ .and_then(|name| name.to_str())
+ .expect("the runtime wheel name must be valid UTF-8");
+ let wheel_bytes = std::fs::read(wheel).expect("the runtime wheel must be readable");
+ let wheel_digest =
+ Sha256::digest(&wheel_bytes)
+ .iter()
+ .fold(String::with_capacity(64), |mut encoded, byte| {
+ write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
+ encoded
+ });
let target = std::env::var("TARGET").expect("Cargo must provide TARGET");
let suffix = if target.contains("windows") {
".exe"
@@ -34,9 +81,22 @@ fn main() {
expected
);
- let constraints =
- PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR"))
- .join("runtime-constraints.txt");
+ let output_directory =
+ PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR"));
+ let constraints = output_directory.join("runtime-constraints.txt");
+ std::fs::write(output_directory.join("runtime-package.whl"), &wheel_bytes)
+ .expect("Cargo must be able to embed the runtime wheel");
+ std::fs::write(
+ output_directory.join("runtime-package-name.txt"),
+ wheel_name,
+ )
+ .expect("Cargo must be able to embed the runtime wheel name");
+ std::fs::write(
+ output_directory.join("runtime-package-sha256.txt"),
+ &wheel_digest,
+ )
+ .expect("Cargo must be able to embed the runtime wheel digest");
+ manifest["package_wheel_sha256"] = serde_json::Value::String(wheel_digest);
let project = Path::new("../..");
let export = std::process::Command::new(&sidecar)
.args([
@@ -90,6 +150,7 @@ fn main() {
.expect("Cargo must be able to write the embedded runtime manifest");
println!("cargo:rerun-if-changed=../../pyproject.toml");
println!("cargo:rerun-if-changed=../../uv.lock");
+ println!("cargo:rerun-if-changed=../../dist");
println!("cargo:rerun-if-changed=../runtime-manifest.json");
let attributes = tauri_build::Attributes::new();
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 9ec8f40..d699e42 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -18,7 +18,7 @@ use atomic_write_file::AtomicWriteFile;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{
- AppHandle, Manager, RunEvent, WindowEvent,
+ AppHandle, Emitter, Manager, RunEvent, WindowEvent,
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::TrayIconBuilder,
};
@@ -44,7 +44,15 @@ const RUNTIME_MANIFEST_BYTES: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/runtime-manifest.json"));
const RUNTIME_CONSTRAINTS_BYTES: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/runtime-constraints.txt"));
+const RUNTIME_PACKAGE_WHEEL_BYTES: &[u8] =
+ include_bytes!(concat!(env!("OUT_DIR"), "/runtime-package.whl"));
+const RUNTIME_PACKAGE_WHEEL_NAME: &str =
+ include_str!(concat!(env!("OUT_DIR"), "/runtime-package-name.txt"));
+const RUNTIME_PACKAGE_WHEEL_SHA256: &str =
+ include_str!(concat!(env!("OUT_DIR"), "/runtime-package-sha256.txt"));
const MODEL_CACHE_CATALOG_BYTES: &[u8] = include_bytes!("../../model-cache-catalog.json");
+const CODEX_PLUGIN_MARKETPLACE_SOURCE: &str = "grayhatdevelopers/vidxp";
+const CODEX_PLUGIN_MARKETPLACE_REF: Option<&str> = option_env!("VIDXP_PLUGIN_MARKETPLACE_REF");
const PRODUCT_DATA_DIRECTORY_NAME: &str = "VidXP";
const RUNTIME_CONSTRAINTS_FILE_NAME: &str = "runtime-constraints.txt";
const MAX_SETUP_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
@@ -117,6 +125,70 @@ struct InstallTransitionResult {
setup: target_profiles::TargetState,
}
+#[derive(Clone, Serialize)]
+struct ManagedSetupProgress {
+ draft_id: String,
+ current: u8,
+ total: u8,
+ stage: String,
+ message: String,
+ model_message: Option,
+ model_current: Option,
+ model_total: Option,
+}
+
+fn emit_managed_setup_progress(
+ app: &AppHandle,
+ draft_id: &str,
+ current: u8,
+ total: u8,
+ stage: &str,
+ message: &str,
+) {
+ let _ = app.emit(
+ "managed-setup-progress",
+ ManagedSetupProgress {
+ draft_id: draft_id.into(),
+ current,
+ total,
+ stage: stage.into(),
+ message: message.into(),
+ model_message: None,
+ model_current: None,
+ model_total: None,
+ },
+ );
+}
+
+fn emit_managed_model_progress(
+ app: &AppHandle,
+ draft_id: &str,
+ current: u8,
+ total: u8,
+ progress: &ManagedModelJobProgress,
+) {
+ let _ = app.emit(
+ "managed-setup-progress",
+ ManagedSetupProgress {
+ draft_id: draft_id.into(),
+ current,
+ total,
+ stage: "models".into(),
+ message: "Verifying and downloading selected model files".into(),
+ model_message: Some(progress.message.clone()),
+ model_current: progress.current,
+ model_total: progress.total,
+ },
+ );
+}
+
+#[derive(Deserialize)]
+struct ManagedModelJobProgress {
+ message: String,
+ current: Option,
+ total: Option,
+}
+
#[derive(Serialize)]
struct RuntimeStatus {
state: RuntimeState,
@@ -416,6 +488,17 @@ struct LocalWorkerStatus {
detail: String,
}
+#[derive(Clone, Debug, Deserialize, Serialize)]
+struct CodexPluginInstallResult {
+ plugin_name: String,
+ plugin_id: Option,
+ plugin_version: String,
+ marketplace_name: String,
+ marketplace_path: String,
+ installed_path: Option,
+ detail: String,
+}
+
#[derive(Clone)]
struct TrayMenuItems {
installation: MenuItem,
@@ -1077,6 +1160,19 @@ fn package_specification_for_version(
capabilities: &[String],
surfaces: &[String],
version: &str,
+) -> String {
+ let extras = package_extras(manifest, capabilities, surfaces);
+ if extras.is_empty() {
+ format!("{}=={}", manifest.package_name, version)
+ } else {
+ format!("{}[{}]=={}", manifest.package_name, extras, version)
+ }
+}
+
+fn package_extras(
+ manifest: &RuntimeManifest,
+ capabilities: &[String],
+ surfaces: &[String],
) -> String {
let local_worker_selected = surfaces.iter().any(|name| name == "worker");
let extras: BTreeSet<_> = manifest
@@ -1091,12 +1187,7 @@ fn package_specification_for_version(
.map(|name| manifest.capabilities[name].extra.clone()),
)
.collect();
- let extras = extras.into_iter().collect::>().join(",");
- if extras.is_empty() {
- format!("{}=={}", manifest.package_name, version)
- } else {
- format!("{}[{}]=={}", manifest.package_name, extras, version)
- }
+ extras.into_iter().collect::>().join(",")
}
fn external_installation_arguments(
@@ -1151,7 +1242,12 @@ fn base_package_specification(manifest: &RuntimeManifest) -> String {
format!("{}=={}", manifest.package_name, manifest.package_version)
}
-fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> Vec {
+fn package_acquisition_arguments(
+ manifest: &RuntimeManifest,
+ python: &Path,
+ wheel: &Path,
+) -> Vec {
+ let wheel_directory = wheel.parent().unwrap_or_else(|| Path::new("."));
vec![
"pip".into(),
"install".into(),
@@ -1159,14 +1255,30 @@ fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> V
python.to_string_lossy().into_owned(),
"--no-config".into(),
"--no-deps".into(),
- "--default-index".into(),
- manifest.dependency_index.clone(),
- "--index-strategy".into(),
- "first-index".into(),
+ "--no-index".into(),
+ "--find-links".into(),
+ wheel_directory.to_string_lossy().into_owned(),
base_package_specification(manifest),
]
}
+fn stage_runtime_package_wheel(runtime: &Path) -> Result {
+ let wheel_name = Path::new(RUNTIME_PACKAGE_WHEEL_NAME);
+ if wheel_name.file_name().and_then(|name| name.to_str()) != Some(RUNTIME_PACKAGE_WHEEL_NAME) {
+ return Err("The embedded runtime wheel name is invalid.".into());
+ }
+ let actual = hex::encode(Sha256::digest(RUNTIME_PACKAGE_WHEEL_BYTES));
+ if actual != RUNTIME_PACKAGE_WHEEL_SHA256 {
+ return Err(format!(
+ "The embedded runtime wheel has digest {actual}; expected {RUNTIME_PACKAGE_WHEEL_SHA256}."
+ ));
+ }
+ let wheel = runtime.join(wheel_name);
+ fs::write(&wheel, RUNTIME_PACKAGE_WHEEL_BYTES)
+ .map_err(|error| format!("Could not stage the embedded VidXP package: {error}"))?;
+ Ok(wheel)
+}
+
struct UvInvocation {
arguments: Vec,
working_directory: PathBuf,
@@ -1199,6 +1311,8 @@ fn dependency_installation_invocation(
manifest.dependency_index.clone(),
"--index-strategy".into(),
"first-index".into(),
+ "--find-links".into(),
+ ".".into(),
"--constraints".into(),
constraints_file_name.to_string_lossy().into_owned(),
];
@@ -1222,12 +1336,16 @@ fn capability_command_arguments(
.map(|name| manifest.capabilities[name].modality.as_str())
.collect::>()
.join(",");
- vec![
+ let mut arguments = vec![
operation.into(),
"--json".into(),
"--modalities".into(),
modalities,
- ]
+ ];
+ if operation == "prepare" {
+ arguments.push("--yes".into());
+ }
+ arguments
}
fn executable(runtime: &Path, name: &str) -> PathBuf {
@@ -1606,6 +1724,30 @@ fn managed_probe_error(message: impl Into) -> target_profiles::TargetErr
}
}
+fn validate_managed_runtime_identity(
+ runtime: &Path,
+ launcher: &Path,
+ identity: &target_profiles::RuntimeIdentity,
+) -> Result<(), target_profiles::TargetError> {
+ if !path_is_confined(launcher, runtime) {
+ return Err(managed_probe_error(format!(
+ "The managed launcher resolved outside the Desktop-owned runtime at {}.",
+ runtime.display()
+ )));
+ }
+ if !same_path(&identity.prefix, runtime) {
+ return Err(managed_probe_error(format!(
+ "The managed probe reported Python environment {}, but VidXP Desktop owns {}.",
+ identity.prefix.display(),
+ runtime.display()
+ )));
+ }
+ // POSIX virtual environments commonly symlink their Python executable to a shared base
+ // interpreter. The environment prefix, not that resolved interpreter target, establishes
+ // which environment the managed launcher is running from.
+ Ok(())
+}
+
fn validate_managed_projection(
paths: &DesktopPaths,
projection: &target_profiles::ManagedRuntimeProjection,
@@ -1636,14 +1778,7 @@ fn validate_managed_projection(
)));
}
}
- if !path_is_confined(&validated.executable, &runtime)
- || !path_is_confined(&validated.runtime.python_executable, &runtime)
- || !same_path(&validated.runtime.prefix, &runtime)
- {
- return Err(managed_probe_error(
- "The managed probe reported a launcher or Python runtime outside the active Desktop-owned environment.",
- ));
- }
+ validate_managed_runtime_identity(&runtime, &validated.executable, &validated.runtime)?;
Ok(validated)
}
@@ -1942,10 +2077,39 @@ async fn supervised_output(
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
- let detail = if stderr.is_empty() { stdout } else { stderr };
+ let detail = match (stdout.is_empty(), stderr.is_empty()) {
+ (false, false) => format!("{stdout}\n\nAdditional diagnostics:\n{stderr}"),
+ (false, true) => stdout,
+ (true, false) => stderr,
+ (true, true) => "The process did not return an error message.".into(),
+ };
Err(format!("{operation} failed ({}): {detail}", output.status))
}
+fn watch_managed_model_progress(
+ app: &AppHandle,
+ draft_id: &str,
+ progress_path: &Path,
+ current: u8,
+ total: u8,
+ stop: &AtomicBool,
+) {
+ let mut last_contents = None;
+ loop {
+ if let Ok(contents) = fs::read(progress_path)
+ && last_contents.as_deref() != Some(contents.as_slice())
+ && let Ok(progress) = serde_json::from_slice(&contents)
+ {
+ emit_managed_model_progress(app, draft_id, current, total, &progress);
+ last_contents = Some(contents);
+ }
+ if stop.load(Ordering::Acquire) {
+ break;
+ }
+ thread::sleep(Duration::from_millis(100));
+ }
+}
+
async fn uv_output(
app: &AppHandle,
paths: &DesktopPaths,
@@ -2683,17 +2847,26 @@ async fn install_runtime(
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("The system clock is invalid: {error}"))?
.as_nanos();
- let staging_name = format!(".staging-{profile_hash}-{timestamp}-{}", std::process::id());
- let staging = paths.runtimes.join(&staging_name);
- let constraints = staging.join(RUNTIME_CONSTRAINTS_FILE_NAME);
+ let profile = format!("{profile_hash}-{timestamp}");
+ let runtime = paths.runtimes.join(&profile);
+ let constraints = runtime.join(RUNTIME_CONSTRAINTS_FILE_NAME);
+ let progress_total = if request.prepare_models { 8 } else { 7 };
let install_result = async {
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ 2,
+ progress_total,
+ "python",
+ "Preparing an isolated Python runtime",
+ );
uv_output(
&app,
&paths,
vec![
"venv".into(),
- staging.to_string_lossy().into_owned(),
+ runtime.to_string_lossy().into_owned(),
"--python".into(),
manifest.python_version.clone(),
"--managed-python".into(),
@@ -2706,17 +2879,31 @@ async fn install_runtime(
.await?;
let constraints_path = constraints.clone();
- tauri::async_runtime::spawn_blocking(move || {
+ let wheel_runtime = runtime.clone();
+ let runtime_wheel = tauri::async_runtime::spawn_blocking(move || {
fs::write(&constraints_path, normalized_runtime_constraints().as_ref())
- .map_err(|error| format!("Could not write runtime constraints: {error}"))
+ .map_err(|error| format!("Could not write runtime constraints: {error}"))?;
+ stage_runtime_package_wheel(&wheel_runtime)
})
.await
.map_err(|error| format!("Runtime constraint staging stopped unexpectedly: {error}"))??;
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ 3,
+ progress_total,
+ "package",
+ "Acquiring the VidXP package",
+ );
uv_output(
&app,
&paths,
- package_acquisition_arguments(&manifest, &executable(&staging, "python")),
+ package_acquisition_arguments(
+ &manifest,
+ &executable(&runtime, "python"),
+ &runtime_wheel,
+ ),
None,
cancellation.token(),
"VidXP package acquisition",
@@ -2727,10 +2914,18 @@ async fn install_runtime(
&manifest,
&capabilities,
&surfaces,
- &executable(&staging, "python"),
+ &executable(&runtime, "python"),
&constraints,
!cfg!(target_os = "macos"),
)?;
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ 4,
+ progress_total,
+ "dependencies",
+ "Installing the selected search features",
+ );
uv_output(
&app,
&paths,
@@ -2740,9 +2935,22 @@ async fn install_runtime(
"VidXP package installation",
)
.await?;
+ if let Err(error) = fs::remove_file(&runtime_wheel) {
+ log::warn!(
+ "Installed the embedded VidXP package, but could not remove its staged wheel: {error}"
+ );
+ }
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ 5,
+ progress_total,
+ "media",
+ "Configuring FFmpeg and video codecs",
+ );
run_vidxp_supervised(
- &staging,
+ &runtime,
&paths,
&[
"init".into(),
@@ -2757,9 +2965,18 @@ async fn install_runtime(
)
.await?;
- let doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities);
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ 6,
+ progress_total,
+ "validation",
+ "Validating installed packages and video tools",
+ );
+ let mut doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities);
+ doctor_arguments.push("--no-models".into());
run_vidxp_supervised(
- &staging,
+ &runtime,
&paths,
&doctor_arguments,
cancellation.token(),
@@ -2768,48 +2985,87 @@ async fn install_runtime(
.await?;
if request.prepare_models {
- let prepare_arguments =
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ 7,
+ progress_total,
+ "models",
+ "Verifying and downloading selected model files",
+ );
+ let progress_path = runtime.join(".managed-model-progress.json");
+ let mut prepare_arguments =
capability_command_arguments(&manifest, "prepare", &capabilities);
- let mut worker = state.worker_stop.register(staging.clone(), paths.clone())?;
+ prepare_arguments.push("--progress-file".into());
+ prepare_arguments.push(progress_path.to_string_lossy().into_owned());
+ let mut worker = state.worker_stop.register(runtime.clone(), paths.clone())?;
+ let preparation_app = app.clone();
+ let preparation_draft_id = request.draft_id.clone();
+ let monitor_stop = Arc::new(AtomicBool::new(false));
+ let monitor_stop_worker = monitor_stop.clone();
+ let progress_path_worker = progress_path.clone();
+ let progress_monitor = thread::spawn(move || {
+ watch_managed_model_progress(
+ &preparation_app,
+ &preparation_draft_id,
+ &progress_path_worker,
+ 7,
+ progress_total,
+ &monitor_stop_worker,
+ );
+ });
let preparation = run_vidxp_supervised(
- &staging,
+ &runtime,
&paths,
&prepare_arguments,
cancellation.token(),
"VidXP model preparation",
)
.await;
+ monitor_stop.store(true, Ordering::Release);
+ let monitor_result = progress_monitor.join();
+ let _ = fs::remove_file(&progress_path);
worker.stop_before(Instant::now() + Duration::from_secs(5));
preparation?;
+ monitor_result
+ .map_err(|_| "VidXP model progress stopped unexpectedly".to_owned())?;
}
Ok::<(), String>(())
}
.await;
if let Err(error) = install_result {
- let failed_staging = staging.clone();
+ let failed_runtime = runtime.clone();
let cleanup_error = tauri::async_runtime::spawn_blocking(move || {
- if failed_staging.exists() {
- fs::remove_dir_all(&failed_staging).err()
+ if failed_runtime.exists() {
+ fs::remove_dir_all(&failed_runtime).err()
} else {
None
}
})
.await
- .map_err(|join| format!("{error}. Staged-runtime cleanup stopped unexpectedly: {join}"))?;
+ .map_err(|join| {
+ format!("{error}. Candidate-runtime cleanup stopped unexpectedly: {join}")
+ })?;
return Err(match cleanup_error {
Some(cleanup_error) => format!(
- "{error}. The previous active runtime was not changed. VidXP could not remove the failed staged runtime at {}: {cleanup_error}",
- staging.display()
+ "{error}. The previous active runtime was not changed. VidXP could not remove the failed candidate runtime at {}: {cleanup_error}",
+ runtime.display()
),
None => format!(
- "{error}. The previous active runtime was not changed, and the failed staged runtime was removed."
+ "{error}. The previous active runtime was not changed, and the failed candidate runtime was removed."
),
});
}
- let profile = format!("{profile_hash}-{timestamp}");
- let runtime = paths.runtimes.join(&profile);
+ emit_managed_setup_progress(
+ &app,
+ &request.draft_id,
+ progress_total,
+ progress_total,
+ "activation",
+ "Activating VidXP and cleaning up installation files",
+ );
let active = ActiveRuntime {
schema_version: 2,
manifest_sha256: manifest_digest(),
@@ -2820,29 +3076,20 @@ async fn install_runtime(
model_directory: paths.models.clone(),
};
let activation_app = app.clone();
+ let activation_cancellation = cancellation.token();
+ let cache_paths = paths.clone();
let activation_paths = paths;
let activation_manifest_version = manifest.desktop_version.clone();
let activation = tauri::async_runtime::spawn_blocking(move || {
let previous_active_bytes = read_active_runtime_snapshot(&activation_paths)?;
let previous_targets =
target_profiles::current_state(&activation_app).map_err(|error| error.to_string())?;
- if let Err(error) = fs::rename(&staging, &runtime) {
- let cleanup = fs::remove_dir_all(&staging);
- return Err(match cleanup {
- Ok(()) => format!("Could not finalize the validated runtime: {error}"),
- Err(cleanup) => format!(
- "Could not finalize the validated runtime: {error}. The staging directory at {} could not be removed: {cleanup}",
- staging.display()
- ),
- });
- }
-
let projection = managed_runtime_projection_for(&activation_paths, &active);
let validated = validate_managed_projection(
&activation_paths,
&projection,
&activation_manifest_version,
- Some(&cancellation.token()),
+ Some(&activation_cancellation),
);
let candidate_targets = match validated.and_then(|validated| {
target_profiles::prepare_managed_activation(
@@ -2936,6 +3183,18 @@ async fn install_runtime(
})
.await
.map_err(|error| format!("Managed activation stopped unexpectedly: {error}"))??;
+ if let Err(error) = uv_output(
+ &app,
+ &cache_paths,
+ vec!["cache".into(), "prune".into(), "--ci".into()],
+ None,
+ cancellation.token(),
+ "VidXP installation cache cleanup",
+ )
+ .await
+ {
+ log::warn!("VidXP was activated, but its installation cache could not be pruned: {error}");
+ }
stop_ui_process(&state);
stop_api_process(&state);
transition.commit_draft();
@@ -3412,6 +3671,63 @@ async fn mcp_client_config(
.map_err(|error| format!("MCP configuration stopped unexpectedly: {error}"))?
}
+#[tauri::command]
+async fn install_codex_plugin(
+ app: AppHandle,
+ state: tauri::State<'_, DesktopState>,
+) -> Result {
+ let _active = state.active_operations.register()?;
+ tauri::async_runtime::spawn_blocking(move || {
+ let (profile, paths) = selected_target_context(&app)?;
+ if !profile
+ .surfaces
+ .iter()
+ .any(|surface| surface == "mcp" || surface == "server")
+ {
+ return Err(
+ "The selected VidXP installation does not expose an installed MCP surface.".into(),
+ );
+ }
+ let installer_path = target_companion_executable(&profile, "vidxp-codex-plugin");
+ if !installer_path.is_file() {
+ return Err(format!(
+ "The selected installation did not provide {}. Update VidXP and try again.",
+ installer_path.display()
+ ));
+ }
+ let mut command = target_command(&profile, &paths, &installer_path);
+ if let Some(marketplace_ref) =
+ CODEX_PLUGIN_MARKETPLACE_REF.filter(|value| !value.is_empty())
+ {
+ command
+ .arg("--marketplace-source")
+ .arg(CODEX_PLUGIN_MARKETPLACE_SOURCE)
+ .arg("--marketplace-ref")
+ .arg(marketplace_ref)
+ .arg("--marketplace-sparse")
+ .arg(".agents/plugins")
+ .arg("--marketplace-sparse")
+ .arg("plugins/vidxp");
+ } else {
+ command
+ .arg("--marketplace-root")
+ .arg(paths.private_data.join("codex-marketplace"));
+ }
+ command
+ .arg("--repository")
+ .arg("default")
+ .arg("--index-directory")
+ .arg(&profile.repository_root)
+ .arg("--data-dir")
+ .arg(&profile.data_root);
+ let output = checked_output(command, "VidXP Codex plugin setup")?;
+ serde_json::from_slice(&output.stdout)
+ .map_err(|error| format!("VidXP returned invalid Codex setup details: {error}"))
+ })
+ .await
+ .map_err(|error| format!("Codex plugin setup stopped unexpectedly: {error}"))?
+}
+
fn execute_worker_action(app: &AppHandle, action: &str) -> Result {
let (profile, paths) = selected_target_context(app)?;
if !profile.surfaces.iter().any(|surface| surface == "worker") {
@@ -3910,44 +4226,87 @@ fn current_unix_seconds() -> u64 {
fn tray_installation_label(profile: Option<&target_profiles::TargetProfile>, now: u64) -> String {
let Some(profile) = profile else {
- return "No VidXP installation selected".into();
- };
- let state = match profile.validation_error.as_ref().map(|error| &error.code) {
- Some(target_profiles::TargetErrorCode::RuntimeUpdateRequired) => "Update required",
- Some(_) => "Needs attention",
- None if !profile.is_ready(now) => "Check required",
- None => "Ready",
+ return "No installation selected".into();
};
- format!("{} · {state}", profile.display_name)
+ match profile.validation_error.as_ref().map(|error| &error.code) {
+ Some(target_profiles::TargetErrorCode::RuntimeUpdateRequired) => {
+ format!("{} · Update required", profile.display_name)
+ }
+ Some(_) => format!("{} · Needs attention", profile.display_name),
+ None if !profile.is_ready(now) => format!("{} · Check setup", profile.display_name),
+ None => profile.display_name.clone(),
+ }
+}
+
+fn tray_capability_state(selected: bool, installed: bool, ready: bool) -> Option<&'static str> {
+ if !selected || !ready {
+ Some("Unavailable")
+ } else if !installed {
+ Some("Not installed")
+ } else {
+ None
+ }
}
-fn tray_browser_label(status: &BrowserServiceStatus) -> String {
+fn tray_browser_label(
+ status: &BrowserServiceStatus,
+ selected: bool,
+ installed: bool,
+ ready: bool,
+ status_known: bool,
+) -> String {
+ if let Some(state) = tray_capability_state(selected, installed, ready) {
+ return format!("Browser · {state}");
+ }
+ if !status_known {
+ return "Browser · Status unknown".into();
+ }
if !status.running {
- return "Browser interface · Stopped".into();
+ return "Browser · Off".into();
}
if status.shared {
- return format!(
- "Browser interface · Shared · {}",
- status
- .network_url
- .as_deref()
- .unwrap_or("address unavailable")
- );
+ return "Browser · Shared".into();
}
- format!(
- "Browser interface · Private · {}",
- status.local_url.as_deref().unwrap_or("address unavailable")
- )
+ "Browser · Private".into()
+}
+
+fn tray_worker_label(
+ status: Option<&Result>,
+ selected: bool,
+ installed: bool,
+ ready: bool,
+) -> String {
+ if let Some(state) = tray_capability_state(selected, installed, ready) {
+ return format!("Processing · {state}");
+ }
+ match status {
+ Some(Ok(status)) if status.running => "Processing · On",
+ Some(Ok(_)) => "Processing · Off",
+ Some(Err(_)) => "Processing · Status unknown",
+ None => "Processing · Checking…",
+ }
+ .into()
}
-fn tray_server_label(status: &LocalServerStatus) -> String {
+fn tray_server_label(
+ status: &LocalServerStatus,
+ selected: bool,
+ installed: bool,
+ ready: bool,
+ status_known: bool,
+) -> String {
+ if let Some(state) = tray_capability_state(selected, installed, ready) {
+ return format!("App integration · {state}");
+ }
+ if !status_known {
+ return "App integration · Status unknown".into();
+ }
if !status.running {
- return "App integration service · Stopped".into();
+ return "App integration · Off".into();
}
format!(
- "App integration service · {} · {}",
- if status.shared { "Shared" } else { "Private" },
- status.origin.as_deref().unwrap_or("address unavailable")
+ "App integration · {}",
+ if status.shared { "Shared" } else { "Private" }
)
}
@@ -3961,17 +4320,23 @@ fn refresh_tray_menu(app: &AppHandle) {
let profile = target_state
.as_ref()
.and_then(target_profiles::TargetState::selected_profile);
+ let selected = profile.is_some();
let ready = profile.is_some_and(|profile| profile.is_ready(current_unix_seconds()));
- let browser_available = ready && profile.is_some_and(|profile| profile.frontend.launchable);
- let worker_available = ready
- && profile
- .is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "worker"));
- let server_available = ready
- && profile
- .is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "server"));
- let browser = inspect_browser_service(&state)
+ let browser_installed = profile.is_some_and(|profile| profile.frontend.launchable);
+ let worker_installed =
+ profile.is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "worker"));
+ let server_installed =
+ profile.is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "server"));
+ let browser_available = ready && browser_installed;
+ let worker_available = ready && worker_installed;
+ let server_available = ready && server_installed;
+ let browser_result = inspect_browser_service(&state);
+ let browser_status_known = browser_result.is_ok();
+ let browser = browser_result
.unwrap_or_else(|error| stopped_browser_status(format!("Status unavailable: {error}")));
- let server = inspect_local_server(&state)
+ let server_result = inspect_local_server(&state);
+ let server_status_known = server_result.is_ok();
+ let server = server_result
.unwrap_or_else(|error| stopped_server_status(format!("Status unavailable: {error}")));
let worker = profile.and_then(|profile| {
state.worker_status.lock().ok().and_then(|cached| {
@@ -3985,21 +4350,36 @@ fn refresh_tray_menu(app: &AppHandle) {
let _ = items
.installation
.set_text(tray_installation_label(profile, current_unix_seconds()));
- let _ = items.browser.set_text(tray_browser_label(&browser));
+ let _ = items.browser.set_text(tray_browser_label(
+ &browser,
+ selected,
+ browser_installed,
+ ready,
+ browser_status_known,
+ ));
let _ = items.browser.set_enabled(browser_available);
+ let _ = items.open_browser.set_text(if browser.running {
+ "Open VidXP"
+ } else {
+ "Start and open VidXP"
+ });
let _ = items.open_browser.set_enabled(browser_available);
+ let _ = items.share_browser.set_text(if browser.running {
+ "Share on local network"
+ } else {
+ "Start and share"
+ });
let _ = items
.share_browser
.set_enabled(browser_available && !browser.shared);
let _ = items.stop_browser.set_enabled(browser.running);
- let worker_label = match worker.as_ref() {
- Some(Ok(status)) if status.running => "Local video processing · Running",
- Some(Ok(_)) => "Local video processing · Stopped",
- Some(Err(_)) => "Local video processing · Needs attention",
- None => "Local video processing · Checking…",
- };
- let _ = items.worker.set_text(worker_label);
+ let _ = items.worker.set_text(tray_worker_label(
+ worker.as_ref(),
+ selected,
+ worker_installed,
+ ready,
+ ));
let _ = items.worker.set_enabled(worker_available);
let _ = items.start_worker.set_enabled(
worker_available
@@ -4013,7 +4393,13 @@ fn refresh_tray_menu(app: &AppHandle) {
.is_some_and(|status| status.as_ref().is_ok_and(|status| status.running)),
);
- let _ = items.server.set_text(tray_server_label(&server));
+ let _ = items.server.set_text(tray_server_label(
+ &server,
+ selected,
+ server_installed,
+ ready,
+ server_status_known,
+ ));
let _ = items.server.set_enabled(server_available);
let _ = items.start_server.set_text(if server.shared {
"Make private"
@@ -4026,6 +4412,11 @@ fn refresh_tray_menu(app: &AppHandle) {
let _ = items
.share_server
.set_enabled(server_available && !server.shared);
+ let _ = items.share_server.set_text(if server.running {
+ "Share on local network"
+ } else {
+ "Start and share"
+ });
let _ = items.stop_server.set_enabled(server.running);
}
@@ -4163,16 +4554,10 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> {
true,
None::<&str>,
)?;
- let stop_browser = MenuItem::with_id(
- app,
- "stop-browser",
- "Stop browser interface",
- false,
- None::<&str>,
- )?;
+ let stop_browser = MenuItem::with_id(app, "stop-browser", "Stop browser", false, None::<&str>)?;
let browser = Submenu::with_items(
app,
- "Browser interface",
+ "Browser · Checking…",
true,
&[&share_browser, &stop_browser],
)?;
@@ -4182,7 +4567,7 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> {
MenuItem::with_id(app, "stop-worker", "Stop processing", false, None::<&str>)?;
let worker = Submenu::with_items(
app,
- "Local video processing",
+ "Processing · Checking…",
true,
&[&start_worker, &stop_worker],
)?;
@@ -4195,10 +4580,11 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> {
true,
None::<&str>,
)?;
- let stop_server = MenuItem::with_id(app, "stop-server", "Stop service", false, None::<&str>)?;
+ let stop_server =
+ MenuItem::with_id(app, "stop-server", "Stop integration", false, None::<&str>)?;
let server = Submenu::with_items(
app,
- "App integration service",
+ "App integration · Checking…",
true,
&[&start_server, &share_server, &stop_server],
)?;
@@ -4372,6 +4758,7 @@ pub fn run() {
target_doctor,
configure_external_installation,
mcp_client_config,
+ install_codex_plugin,
local_worker_status,
start_local_worker,
stop_local_worker,
@@ -4418,17 +4805,18 @@ mod tests {
use super::{
ActivationJournal, ActivationRecovery, ActivationStage, ActiveRuntime, DesktopAction,
DesktopActivation, DesktopCloseAction, DesktopState, DraftPhase, DraftRecord,
- ManagedSetupDraft, RUNTIME_CONSTRAINTS_FILE_NAME, TargetTransitionCoordinator,
- TransitionKind, UiProcessAction, WorkerStopSupervisor, action_for_activation,
- activation_recovery, base_package_specification, capability_command_arguments,
- claim_browser_open, clean_environment_from, close_action, configure_ui_service_command,
- configured_runtime_status, dependency_installation_invocation, desktop_paths_from_roots,
- display_command, external_installation_arguments, external_installation_version,
- inventory_model_directory, manifest, manifest_digest, normalize_line_endings,
- normalized_runtime_constraints, package_acquisition_arguments, package_specification,
- read_active_runtime_snapshot, reconcile_managed_runtime_storage, required_encoder_missing,
- restore_active_runtime, selected_capabilities, selected_surfaces, ui_process_action,
- write_activation_journal, write_active_runtime,
+ ManagedSetupDraft, RUNTIME_CONSTRAINTS_FILE_NAME, RUNTIME_PACKAGE_WHEEL_NAME,
+ TargetTransitionCoordinator, TransitionKind, UiProcessAction, WorkerStopSupervisor,
+ action_for_activation, activation_recovery, base_package_specification,
+ capability_command_arguments, claim_browser_open, clean_environment_from, close_action,
+ configure_ui_service_command, configured_runtime_status,
+ dependency_installation_invocation, desktop_paths_from_roots, display_command,
+ external_installation_arguments, external_installation_version, inventory_model_directory,
+ manifest, manifest_digest, normalize_line_endings, normalized_runtime_constraints,
+ package_acquisition_arguments, package_specification, read_active_runtime_snapshot,
+ reconcile_managed_runtime_storage, required_encoder_missing, restore_active_runtime,
+ selected_capabilities, selected_surfaces, ui_process_action,
+ validate_managed_runtime_identity, write_activation_journal, write_active_runtime,
};
use std::{
ffi::OsStr,
@@ -4470,6 +4858,39 @@ mod tests {
);
}
+ #[test]
+ fn managed_runtime_accepts_a_shared_posix_base_interpreter() {
+ let root = std::env::temp_dir().join(format!(
+ "vidxp-managed-runtime-identity-{}",
+ std::process::id()
+ ));
+ let runtime = root.join("runtimes").join("profile");
+ let launcher = runtime.join("bin").join("vidxp");
+ fs::create_dir_all(launcher.parent().expect("launcher parent")).expect("runtime");
+ fs::write(&launcher, b"launcher").expect("launcher");
+ let identity = crate::target_profiles::RuntimeIdentity {
+ python_executable: root
+ .join("python")
+ .join("cpython")
+ .join("bin")
+ .join("python3"),
+ python_version: "3.13.5".into(),
+ implementation: "CPython".into(),
+ prefix: runtime.clone(),
+ base_prefix: root.join("python").join("cpython"),
+ };
+
+ assert!(validate_managed_runtime_identity(&runtime, &launcher, &identity).is_ok());
+ assert!(
+ validate_managed_runtime_identity(&runtime, &root.join("other-vidxp"), &identity)
+ .is_err()
+ );
+ let mut wrong_prefix = identity;
+ wrong_prefix.prefix = root.join("other-environment");
+ assert!(validate_managed_runtime_identity(&runtime, &launcher, &wrong_prefix).is_err());
+ let _ = fs::remove_dir_all(root);
+ }
+
#[test]
fn managed_commands_discard_hostile_inherited_environment_and_restore_owned_roots() {
let paths =
@@ -4928,11 +5349,16 @@ mod tests {
let manifest = manifest().expect("manifest");
let selected = selected_capabilities(
&manifest,
- &["scene".into(), "dialogue".into(), "scene".into()],
+ &[
+ "scene".into(),
+ "videoprism".into(),
+ "dialogue".into(),
+ "scene".into(),
+ ],
)
.expect("selection");
- assert_eq!(selected, ["dialogue", "scene"]);
+ assert_eq!(selected, ["dialogue", "scene", "videoprism"]);
assert!(selected_capabilities(&manifest, &["other".into()]).is_err());
}
@@ -5048,12 +5474,13 @@ mod tests {
}
#[test]
- fn package_and_dependencies_use_channel_specific_indexes() {
+ fn managed_install_uses_the_bundled_package_and_public_dependency_index() {
let manifest = manifest().expect("manifest");
let python = Path::new("managed-python");
let constraints = Path::new("staging").join(RUNTIME_CONSTRAINTS_FILE_NAME);
+ let wheel = Path::new("staging").join(RUNTIME_PACKAGE_WHEEL_NAME);
let selected_package_index = manifest.dependency_index.as_str();
- let acquisition = package_acquisition_arguments(&manifest, python);
+ let acquisition = package_acquisition_arguments(&manifest, python, &wheel);
let dependency_installation = dependency_installation_invocation(
&manifest,
&["scene".into()],
@@ -5068,8 +5495,18 @@ mod tests {
assert_eq!(selected_package_index, "https://pypi.org/simple");
assert_eq!(manifest.dependency_index, "https://pypi.org/simple");
assert!(acquisition.iter().any(|item| item == "--no-deps"));
+ assert!(acquisition.iter().any(|item| item == "--no-index"));
assert!(
acquisition
+ .windows(2)
+ .any(|items| items == ["--find-links", "staging"])
+ );
+ assert_eq!(
+ acquisition.last(),
+ Some(&base_package_specification(&manifest))
+ );
+ assert!(
+ !acquisition
.iter()
.any(|item| item == selected_package_index)
);
@@ -5085,6 +5522,15 @@ mod tests {
.windows(2)
.any(|items| items == ["--constraints", "runtime-constraints.txt"])
);
+ assert!(
+ dependencies
+ .windows(2)
+ .any(|items| items == ["--find-links", "."])
+ );
+ assert_eq!(
+ dependencies.last(),
+ Some(&package_specification(&manifest, &["scene".into()], &[]))
+ );
assert_eq!(
dependency_installation.working_directory,
Path::new("staging")
@@ -5102,7 +5548,6 @@ mod tests {
.join("runtimes")
.join("staging")
.join(RUNTIME_CONSTRAINTS_FILE_NAME);
-
let invocation = dependency_installation_invocation(
&manifest,
&["scene".into()],
@@ -5131,6 +5576,10 @@ mod tests {
capability_command_arguments(&manifest, "doctor", &["dialogue".into(), "scene".into()]),
["doctor", "--json", "--modalities", "dialogue,scene"]
);
+ assert_eq!(
+ capability_command_arguments(&manifest, "prepare", &["scene".into()]),
+ ["prepare", "--json", "--modalities", "scene", "--yes"]
+ );
}
#[test]
@@ -5262,7 +5711,7 @@ mod tests {
}
#[test]
- fn tray_service_labels_surface_scope_and_addresses() {
+ fn tray_service_labels_are_compact_and_distinguish_availability() {
let browser = super::BrowserServiceStatus {
state: "ready",
running: true,
@@ -5285,16 +5734,58 @@ mod tests {
};
assert_eq!(
- super::tray_browser_label(&browser),
- "Browser interface · Shared · http://192.168.1.20:43124"
+ super::tray_browser_label(&browser, true, true, true, true),
+ "Browser · Shared"
);
assert_eq!(
- super::tray_server_label(&server),
- "App integration service · Private · http://127.0.0.1:43125"
+ super::tray_server_label(&server, true, true, true, true),
+ "App integration · Private"
+ );
+ assert_eq!(
+ super::tray_browser_label(&browser, true, false, true, true),
+ "Browser · Not installed"
+ );
+ assert_eq!(
+ super::tray_server_label(&server, true, true, false, true),
+ "App integration · Unavailable"
+ );
+ assert_eq!(
+ super::tray_browser_label(&browser, true, true, true, false),
+ "Browser · Status unknown"
);
assert_eq!(
super::tray_installation_label(None, 0),
- "No VidXP installation selected"
+ "No installation selected"
+ );
+ }
+
+ #[test]
+ fn tray_worker_labels_report_actual_state() {
+ let running = Ok(super::LocalWorkerStatus {
+ running: true,
+ detail: String::new(),
+ });
+ let stopped = Ok(super::LocalWorkerStatus {
+ running: false,
+ detail: String::new(),
+ });
+ let unavailable = Err("timed out".into());
+
+ assert_eq!(
+ super::tray_worker_label(Some(&running), true, true, true),
+ "Processing · On"
+ );
+ assert_eq!(
+ super::tray_worker_label(Some(&stopped), true, true, true),
+ "Processing · Off"
+ );
+ assert_eq!(
+ super::tray_worker_label(Some(&unavailable), true, true, true),
+ "Processing · Status unknown"
+ );
+ assert_eq!(
+ super::tray_worker_label(None, true, false, true),
+ "Processing · Not installed"
);
}
diff --git a/desktop/src-tauri/src/target_profiles.rs b/desktop/src-tauri/src/target_profiles.rs
index 183f985..6739e04 100644
--- a/desktop/src-tauri/src/target_profiles.rs
+++ b/desktop/src-tauri/src/target_profiles.rs
@@ -594,9 +594,14 @@ fn validate_executable_with(
let request_id = challenge_for(&canonical)?;
let output = run_probe(&canonical, desktop_version, &request_id)?;
if !output.success {
+ let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
return Err(TargetError::new(
TargetErrorCode::ProbeFailed,
- "The selected executable rejected the VidXP compatibility probe.",
+ if detail.is_empty() {
+ "The selected executable rejected the VidXP compatibility probe.".into()
+ } else {
+ format!("The compatibility probe failed: {detail}")
+ },
));
}
let document: ProbeDocument = serde_json::from_slice(&output.stdout).map_err(|_| {
@@ -1705,18 +1710,16 @@ mod tests {
.code,
TargetErrorCode::ProbeTimeout
);
- assert_eq!(
- validate_executable_with(&executable, "0.4.0-b", |_, _, _| {
- Ok(ProbeOutput {
- success: false,
- stdout: Vec::new(),
- stderr: Vec::new(),
- })
+ let failed = validate_executable_with(&executable, "0.4.0-b", |_, _, _| {
+ Ok(ProbeOutput {
+ success: false,
+ stdout: Vec::new(),
+ stderr: b"embedded interpreter path is unavailable".to_vec(),
})
- .expect_err("failed")
- .code,
- TargetErrorCode::ProbeFailed
- );
+ })
+ .expect_err("failed");
+ assert_eq!(failed.code, TargetErrorCode::ProbeFailed);
+ assert!(failed.message.contains("embedded interpreter path"));
}
#[test]
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index f328a1e..e4d9a19 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -34,6 +34,8 @@
"bundle": {
"active": true,
"targets": "all",
+ "publisher": "Grayhat Developers PVT Ltd",
+ "copyright": "Copyright © 2026 Grayhat Developers PVT Ltd",
"resources": [
"../THIRD_PARTY_NOTICES.txt",
"../../LICENSE"
diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx
index b29b02e..608c678 100644
--- a/desktop/src/App.test.tsx
+++ b/desktop/src/App.test.tsx
@@ -9,10 +9,10 @@ const mocks = vi.hoisted(() => ({
chooseLocalExecutable: vi.fn(), inspectLocalTarget: vi.fn(), activateLocalTarget: vi.fn(),
selectTargetProfile: vi.fn(), deleteTargetProfile: vi.fn(), confirmForgetTarget: vi.fn(), beginManagedSetup: vi.fn(),
cancelManagedSetup: vi.fn(), installMediaRuntime: vi.fn(), installRuntime: vi.fn(),
- prepareManagedModels: vi.fn(),
+ prepareManagedModels: vi.fn(), onManagedSetupProgress: vi.fn(),
runtimeManifest: vi.fn(), runtimeStatus: vi.fn(), launchUi: vi.fn(),
chooseModelDirectory: vi.fn(), modelDirectoryInventory: vi.fn(),
- targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(),
+ targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), installCodexPlugin: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(),
startLocalServer: vi.fn(), startSharedServer: vi.fn(), stopLocalServer: vi.fn(), startSharedBrowser: vi.fn(), stopBrowserService: vi.fn(), startLocalWorker: vi.fn(), stopLocalWorker: vi.fn(), configureExternalInstallation: vi.fn(),
}));
@@ -98,6 +98,7 @@ describe('desktop target lifecycle', () => {
mocks.runtimeStatus.mockResolvedValue({ state: 'never_configured', ready: false, runtime_profile: null, package_version: '0.4.0', capabilities: [], surfaces: [], model_directory: 'C:\\Models', detail: 'No managed runtime yet.' });
mocks.modelDirectoryInventory.mockResolvedValue({ directory: 'C:\\Models', exists: false, readable: true, total_bytes: 0, file_count: 0, recognized_models: [], empty: true, verification_required: false, truncated: false, detail: 'Empty.' });
mocks.installMediaRuntime.mockResolvedValue({ ready: true });
+ mocks.onManagedSetupProgress.mockResolvedValue(vi.fn());
mocks.installRuntime.mockResolvedValue({
install: { package_version: '0.4.0', capabilities: ['scene'], surfaces: ['worker', 'browser'], model_directory: 'C:\\Models', prepared: true },
setup: { profiles: [managedProfile], selected_profile_id: managedProfile.id, issues: [] },
@@ -106,6 +107,12 @@ describe('desktop target lifecycle', () => {
mocks.launchUi.mockResolvedValue(undefined);
mocks.targetDoctor.mockResolvedValue({ ok: true, modalities: ['scene'], checks: [{ capability: 'media', kind: 'distribution', name: 'ffmpeg', ok: true }] });
mocks.mcpClientConfig.mockResolvedValue('{"mcpServers":{"vidxp":{"command":"vidxp-mcp"}}}');
+ mocks.installCodexPlugin.mockResolvedValue({
+ plugin_name: 'vidxp', plugin_id: 'vidxp@vidxp-local', plugin_version: '0.4.0+codex.1234',
+ marketplace_name: 'vidxp-local', marketplace_path: 'C:\\Data\\codex-marketplace\\.agents\\plugins\\marketplace.json',
+ installed_path: 'C:\\Users\\test\\.codex\\plugins\\vidxp',
+ detail: 'VidXP is installed in Codex with its MCP server and skills. Start a new Codex chat to use the updated plugin.',
+ });
mocks.browserServiceStatus.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' });
mocks.startSharedBrowser.mockResolvedValue({ state: 'ready', running: true, shared: true, port: 8501, local_url: 'http://127.0.0.1:8501', network_url: 'http://192.168.1.20:8501', detail: 'Shared.' });
mocks.stopBrowserService.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' });
@@ -175,7 +182,11 @@ describe('desktop target lifecycle', () => {
expect(await screen.findByText('VidXP is ready')).toBeVisible();
expect(mocks.targetDoctor).toHaveBeenCalledTimes(1);
- await user.click(screen.getByRole('button', { name: 'Set up connection' }));
+ await user.click(screen.getByRole('button', { name: 'Set up in Codex' }));
+ expect(await screen.findByText('VidXP is installed in Codex with its MCP server and skills. Start a new Codex chat to use the updated plugin.')).toBeVisible();
+ expect(mocks.installCodexPlugin).toHaveBeenCalledTimes(1);
+
+ await user.click(screen.getByRole('button', { name: 'Copy MCP setup' }));
expect(await screen.findByRole('heading', { name: 'Connect an AI assistant' })).toBeVisible();
expect(mocks.mcpClientConfig).toHaveBeenCalledTimes(1);
@@ -195,6 +206,30 @@ describe('desktop target lifecycle', () => {
expect(mocks.stopLocalServer).toHaveBeenCalledTimes(1);
});
+ it('keeps worker timeouts with the worker control and clears them after recovery', async () => {
+ const operational = {
+ ...managedProfile,
+ frontend,
+ surfaces: ['worker', 'browser'],
+ validation_error: null,
+ };
+ const state = { profiles: [operational], selected_profile_id: operational.id, issues: [] };
+ mocks.targetSetupState.mockResolvedValue(state);
+ mocks.recheckTargetState.mockResolvedValue(state);
+ mocks.localWorkerStatus.mockRejectedValue('VidXP local processing failed: the operation exceeded 120 seconds');
+ const user = userEvent.setup();
+ renderApp();
+
+ const workerFailure = await screen.findByRole('alert', { name: 'Local processing status could not be checked' });
+ expect(workerFailure).toHaveTextContent('the operation exceeded 120 seconds');
+ expect(screen.queryByRole('alert', { name: 'That did not work' })).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Start processing' }));
+
+ expect(await screen.findByRole('button', { name: 'Stop processing' })).toBeVisible();
+ expect(screen.queryByRole('alert', { name: 'Local processing status could not be checked' })).not.toBeInTheDocument();
+ });
+
it('adds optional features to the selected existing installation', async () => {
const updated = { ...localProfile, surfaces: ['worker', 'browser', 'mcp'] };
const updatedState = { profiles: [updated], selected_profile_id: updated.id, issues: [] };
@@ -210,7 +245,8 @@ describe('desktop target lifecycle', () => {
await user.click(screen.getByRole('button', { name: 'Apply changes' }));
await waitFor(() => expect(mocks.configureExternalInstallation).toHaveBeenCalledWith([], ['worker', 'browser', 'mcp']));
- expect(await screen.findByRole('button', { name: 'Set up connection' })).toBeVisible();
+ expect(await screen.findByRole('button', { name: 'Set up in Codex' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Copy MCP setup' })).toBeVisible();
});
it('offers an in-place manifest update when the selected runtime contract is too old', async () => {
@@ -358,9 +394,41 @@ describe('desktop target lifecycle', () => {
it('directs a managed target without browser surface back to managed setup', async () => {
const setup = { profiles: [managedProfile], selected_profile_id: managedProfile.id, issues: [] };
mocks.targetSetupState.mockResolvedValue(setup); mocks.recheckTargetState.mockResolvedValue(setup);
- renderApp();
+ const user = userEvent.setup(); renderApp();
expect(await screen.findByText('The browser interface is not enabled')).toBeVisible();
- expect(screen.getByRole('button', { name: 'Setup options' })).toBeEnabled();
+ const setupOptions = screen.getByRole('button', { name: 'Setup options' });
+ await waitFor(() => expect(setupOptions).toBeEnabled());
+ await user.click(setupOptions);
+ expect(await screen.findByRole('heading', { name: 'Choose your VidXP features' })).toBeVisible();
+ expect(screen.queryByRole('heading', { name: 'Install and manage VidXP on this computer?' })).not.toBeInTheDocument();
+ });
+
+ it('opens a broken managed installation directly on a visible repair action', async () => {
+ const broken = {
+ ...managedProfile,
+ validation_error: {
+ code: 'validation_required',
+ message: 'The desktop runtime needs to be installed for this app version.',
+ },
+ };
+ const setup = { profiles: [broken], selected_profile_id: broken.id, issues: [] };
+ mocks.targetSetupState.mockResolvedValue(setup);
+ mocks.recheckTargetState.mockResolvedValue(setup);
+ mocks.runtimeStatus.mockResolvedValue({
+ state: 'broken', ready: false, runtime_profile: 'runtime-a', package_version: '0.4.0',
+ capabilities: ['scene'], surfaces: [], model_directory: 'C:\\Models',
+ detail: 'The desktop runtime needs to be installed for this app version.',
+ });
+ const user = userEvent.setup(); renderApp();
+
+ const repair = await screen.findByRole('button', { name: 'Repair VidXP' });
+ await waitFor(() => expect(repair).toBeEnabled());
+ await user.click(repair);
+
+ expect(await screen.findByRole('heading', { name: 'Choose your VidXP features' })).toBeVisible();
+ expect(screen.queryByRole('heading', { name: 'Install and manage VidXP on this computer?' })).not.toBeInTheDocument();
+ expect(screen.getByText('VidXP needs attention')).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Repair now' })).toBeVisible();
});
it('keeps ready managed settings read-only until a draft is dirty, then offers Apply and Reset', async () => {
@@ -395,6 +463,7 @@ describe('desktop target lifecycle', () => {
expect(screen.getByRole('checkbox', { name: /VidXP app|Browser interface/i })).not.toBeChecked();
await user.click(screen.getByText('Storage location'));
expect(screen.getByText('D:\\CustomModels')).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Repair now' })).toBeVisible();
expect(screen.getByRole('button', { name: 'Repair VidXP' })).toBeEnabled();
});
@@ -447,6 +516,63 @@ describe('desktop target lifecycle', () => {
expect(mocks.launchUi).not.toHaveBeenCalled();
});
+ it('keeps a managed installation failure visible in the setup dialog until it is acknowledged', async () => {
+ mocks.installRuntime.mockRejectedValueOnce('The installed runtime failed its compatibility check.');
+ const user = userEvent.setup(); renderApp(); await enterManaged(user);
+
+ await user.click(screen.getByRole('button', { name: 'Install VidXP' }));
+
+ expect(await screen.findByRole('dialog', { name: 'Setup could not finish' })).toBeVisible();
+ expect(screen.getByRole('alert', { name: 'VidXP was not installed' })).toHaveTextContent('The installed runtime failed its compatibility check.');
+ expect(screen.getByText(/model files already downloaded remain cached/i)).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Review setup' }));
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Setup could not finish' })).not.toBeInTheDocument());
+ });
+
+ it('blocks setup interaction and reports managed installation stages', async () => {
+ const media = deferred<{ ready: boolean }>();
+ mocks.installMediaRuntime.mockReturnValue(media.promise);
+ let reportProgress: ((progress: { draft_id: string; current: number; total: number; stage: string; message: string; model_message?: string; model_current?: number; model_total?: number }) => void) | undefined;
+ mocks.onManagedSetupProgress.mockImplementation(async (handler) => {
+ reportProgress = handler;
+ return vi.fn();
+ });
+ const user = userEvent.setup(); renderApp(); await enterManaged(user);
+
+ await user.click(screen.getByRole('button', { name: 'Install VidXP' }));
+
+ expect(screen.getByRole('dialog', { name: 'Setting up VidXP' })).toBeVisible();
+ expect(screen.getByText('Step 1 of 8')).toBeVisible();
+ expect(screen.getByText('Checking FFmpeg and required video codecs')).toBeVisible();
+ reportProgress?.({ draft_id: 'draft-1', current: 4, total: 8, stage: 'dependencies', message: 'Installing the selected search features' });
+ expect(await screen.findByText('Step 4 of 8')).toBeVisible();
+ expect(screen.getByText('Installing the selected search features')).toBeVisible();
+ reportProgress?.({
+ draft_id: 'draft-1',
+ current: 7,
+ total: 8,
+ stage: 'models',
+ message: 'Verifying and downloading selected model files',
+ model_message: 'Preparing model artifacts.',
+ });
+ expect(await screen.findByText('Preparing model artifacts.')).toBeVisible();
+ reportProgress?.({
+ draft_id: 'draft-1',
+ current: 7,
+ total: 8,
+ stage: 'models',
+ message: 'Verifying and downloading selected model files',
+ model_message: 'Downloading dialogue transcription model.',
+ model_current: 512 * 1024 * 1024,
+ model_total: 1024 * 1024 * 1024,
+ });
+ expect(await screen.findByText('Downloading dialogue transcription model.')).toBeVisible();
+ expect(screen.getByText('512.0 MiB of 1.00 GiB')).toBeVisible();
+ expect(screen.getByRole('progressbar', { name: 'Current model download progress' })).toHaveAttribute('aria-valuenow', '50');
+
+ media.resolve({ ready: true });
+ });
+
it('coalesces duplicate managed Continue actions', async () => {
const pending = deferred<{ id: string; previous_profile_id: null }>();
mocks.beginManagedSetup.mockReturnValue(pending.promise);
@@ -488,7 +614,6 @@ describe('desktop target lifecycle', () => {
mocks.runtimeStatus.mockResolvedValue({ state: 'ready', ready: true, runtime_profile: 'runtime-a', package_version: '0.4.0', capabilities: ['scene'], surfaces: [], model_directory: 'C:\\Models', detail: 'Ready.' });
const user = userEvent.setup(); renderApp();
await user.click(await screen.findByRole('button', { name: 'Setup options' }));
- await user.click(screen.getByRole('button', { name: 'Choose features' }));
await screen.findByRole('heading', { name: 'Choose your VidXP features' });
expect(screen.getByRole('button', { name: 'Apply update' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Check downloaded models' }));
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index 1de0591..578b4bb 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -229,7 +229,7 @@ export function App() {
{state.stage === 'local' && dispatch({ type: 'navigate', stage: 'choice' })} onActivated={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} />}
{state.stage === 'managed-confirm' && } disabled={operationPending} onClick={() => dispatch({ type: 'navigate', stage: 'choice' })}>BackSET UP VIDXP
Install and manage VidXP on this computer? You choose the features. VidXP checks the new setup before switching to it, so your current installation stays available. dispatch({ type: 'navigate', stage: 'choice' })}>Cancel void beginManaged()}>Choose features
}
{state.stage === 'managed' && state.draft && dispatch({ type: 'operationSettled', setup, draft: null, stage: 'summary' })} />}
- {state.stage === 'summary' && profile && recheck()} onManageManaged={() => dispatch({ type: 'navigate', stage: 'managed-confirm' })} onSetupChanged={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} onChooseAnother={() => dispatch({ type: 'navigate', stage: 'choice', choice: null })} onOpen={openBrowser} />}
+ {state.stage === 'summary' && profile && recheck()} onManageManaged={() => void beginManaged()} onSetupChanged={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} onChooseAnother={() => dispatch({ type: 'navigate', stage: 'choice', choice: null })} onOpen={openBrowser} />}
Your VidXP settings stay on this computer. Desktop only stops services that it starts.
diff --git a/desktop/src/components/LocalSetup.tsx b/desktop/src/components/LocalSetup.tsx
index 6be770f..592ddb3 100644
--- a/desktop/src/components/LocalSetup.tsx
+++ b/desktop/src/components/LocalSetup.tsx
@@ -64,6 +64,7 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) {
const [busy, setBusy] = useState<'discover' | 'browse' | 'activate' | null>('discover');
const [failure, setFailure] = useState(null);
const candidateGeneration = useRef(new Map());
+ const failureAlert = useRef(null);
async function discover() {
setBusy('discover');
@@ -90,6 +91,12 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) {
void discover();
}, []);
+ useEffect(() => {
+ if (!failure) return;
+ failureAlert.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
+ failureAlert.current?.focus({ preventScroll: true });
+ }, [failure]);
+
async function checkCandidate(path: string) {
const generation = (candidateGeneration.current.get(path) ?? 0) + 1;
candidateGeneration.current.set(path, generation);
@@ -166,6 +173,8 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) {
Choose the VidXP installation you already use. Connecting it here will not change or update it.
+ {failure && } color="red" title="Could not continue" role="alert">{failure}
}
+
@@ -256,7 +265,6 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) {
{busy === 'activate' && Connecting this VidXP installation…
}
- {failure && } color="red" title="Could not continue" role="alert">{failure}}
);
}
diff --git a/desktop/src/components/ManagedSetup.tsx b/desktop/src/components/ManagedSetup.tsx
index e00b747..59b9d86 100644
--- a/desktop/src/components/ManagedSetup.tsx
+++ b/desktop/src/components/ManagedSetup.tsx
@@ -5,6 +5,8 @@ import {
Checkbox,
Group,
Loader,
+ Modal,
+ Progress,
Stack,
Switch,
Text,
@@ -21,12 +23,14 @@ import {
installRuntime,
launchUi,
modelDirectoryInventory,
+ onManagedSetupProgress,
prepareManagedModels,
runtimeManifest,
runtimeStatus,
type RuntimeManifest,
type RuntimeStatus,
type ModelDirectoryInventory,
+ type ManagedSetupProgress,
type TargetSetupState,
} from '../tauri';
import { useExclusiveOperation } from '../useAsyncAction';
@@ -51,7 +55,11 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
const [operation, setOperation] = useState('load');
const [message, setMessage] = useState('Loading VidXP options…');
const [failure, setFailure] = useState(null);
+ const [installFailure, setInstallFailure] = useState(null);
+ const [setupProgress, setSetupProgress] = useState(null);
+ const [setupElapsed, setSetupElapsed] = useState(0);
const operations = useExclusiveOperation();
+ const failureAlert = useRef(null);
const initialLoad = useRef {
+ let active = true;
+ let stop: (() => void) | undefined;
+ void onManagedSetupProgress((progress) => {
+ if (active && progress.draft_id === draftId) setSetupProgress(progress);
+ }).then((unlisten) => {
+ if (active) stop = unlisten;
+ else unlisten();
+ });
+ return () => {
+ active = false;
+ stop?.();
+ };
+ }, [draftId]);
+
+ useEffect(() => {
+ if (operation !== 'install') {
+ setSetupElapsed(0);
+ return undefined;
+ }
+ const started = Date.now();
+ const timer = window.setInterval(() => setSetupElapsed(Math.floor((Date.now() - started) / 1000)), 1000);
+ return () => window.clearInterval(timer);
+ }, [operation]);
+
+ useEffect(() => {
+ if (!failure || installFailure) return;
+ failureAlert.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
+ failureAlert.current?.focus({ preventScroll: true });
+ }, [failure, installFailure]);
+
function toggleValue(value: string, checked: boolean, setter: (next: string[]) => void, current: string[]) {
setter(checked ? [...current, value] : current.filter((item) => item !== value));
}
@@ -173,6 +212,14 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
draft_id: draftId,
};
setFailure(null);
+ setInstallFailure(null);
+ setSetupProgress({
+ draft_id: draftId,
+ current: 1,
+ total: captured.prepare_models ? 8 : 7,
+ stage: 'video-tools',
+ message: 'Checking FFmpeg and required video codecs',
+ });
try {
setMessage('Checking FFmpeg and required codecs…');
await installMediaRuntime(draftId);
@@ -193,9 +240,12 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
setMessage(result.install.prepared ? 'VidXP and the selected search features are ready.' : 'VidXP is installed. Search files can be downloaded later.');
onCommitted(result.setup);
} catch (error) {
- setFailure(errorMessage(error, 'Setup did not finish. Your previous VidXP installation is unchanged.'));
+ const detail = errorMessage(error, 'Setup did not finish. Your previous VidXP installation is unchanged.');
+ setFailure(detail);
+ setInstallFailure(detail);
} finally {
settleOperation(operationId);
+ setSetupProgress(null);
}
}
@@ -263,6 +313,13 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
const isBusy = operation !== null;
const attentionTitle = /ffmpeg|ffprobe/i.test(message) ? 'Video tools need attention' : 'VidXP needs attention';
+ const progressCurrent = setupProgress?.current ?? 1;
+ const progressTotal = setupProgress?.total ?? (prepareDuringInstall ? 8 : 7);
+
+ function dismissInstallFailure() {
+ setInstallFailure(null);
+ setFailure(null);
+ }
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
@@ -285,6 +342,30 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
Choose what VidXP can search, where video work runs, and how you want to open or connect to it. You can change these later.
+ {failure && !installFailure && } color="red" title="Could not continue" role="alert">{failure}
}
+
+ {status?.state === 'broken' && operation !== 'install' && (
+ <>
+ }
+ color="yellow"
+ title={corruptPointer ? 'VidXP could not read the saved setup' : attentionTitle}
+ role="alert"
+ >
+ {corruptPointer
+ ? Review the options below and rebuild VidXP. Your saved setup is not changed until the new one is ready.
+ : <>Repair this Desktop-managed installation now, or review its saved features below first. Technical details {message} >}
+ void install()}>
+ {corruptPointer ? 'Rebuild now' : dirty ? 'Apply update now' : 'Repair now'}
+
+
+
+ {dirty ? 'VidXP switches to the updated setup only after it has been installed and checked.' : 'VidXP first repairs the video tools, then restores this installation only if needed.'}
+
+ >
+ )}
+
{!manifest ? (
Loading setup options…
) : (
@@ -329,6 +410,9 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
Downloaded model storage VidXP keeps the files needed by your selected search features here. {modelDirectory && Storage location {displayPath(modelDirectory)} }
} loading={operation === 'folder'} disabled={isBusy} onClick={() => void chooseFolder()}>Change location…
+
+ The managed runtime can use approximately 3 GiB. Models add 37 MiB to 4.11 GiB depending on the selected search features. A full local setup uses approximately 7.1 GiB, plus temporary installation space, indexes, and videos.
+
{operation === 'load' || operation === 'folder' || operation === 'reset' ? (
Checking cached model files…
@@ -362,20 +446,6 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
{status.detail}
)}
- {status?.state === 'broken' && operation !== 'install' && (
-
}
- color="yellow"
- title={corruptPointer ? 'VidXP could not read the saved setup' : attentionTitle}
- role="alert"
- >
- {corruptPointer
- ? 'Review the options above and rebuild VidXP. Your saved setup is not changed until the new one is ready.'
- : <>
Use the repair action below to check and restore this installation. Technical details {message}>}
-
- )}
-
{status?.state === 'ready' && (
VidXP is installed
@@ -385,23 +455,72 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
)}
- {recoverableConfiguration && (dirty || status?.state === 'broken') &&
{dirty ? 'VidXP switches to the updated setup only after it has been installed and checked.' : 'VidXP first repairs the video tools, then restores this installation only if needed.'} }
+ {recoverableConfiguration && dirty && status?.state !== 'broken' &&
VidXP switches to the updated setup only after it has been installed and checked. }
{status?.ready && !displayedRuntimeSelected &&
Switch back to this installation before preparing models or opening VidXP. }
-
{isBusy && }{(isBusy || status?.ready) && message}
+
{isBusy && operation !== 'install' && }{(isBusy || status?.ready) && message}
{recoverableConfiguration ? (
void resetDraft()}>Reset changes
- void install()}>{status?.state === 'broken' && !dirty ? 'Repair VidXP' : 'Apply update'}
+ void install()}>{status?.state === 'broken' && !dirty ? 'Repair VidXP' : 'Apply update'}
void prepareModels()}>Check downloaded models
} loading={operation === 'launch'} disabled={!status?.ready || dirty || !displayedRuntimeSelected || isBusy} onClick={() => void launch()}>Open VidXP
) : (
-
void install()}>{corruptPointer ? 'Rebuild VidXP' : 'Install VidXP'}
+
void install()}>{corruptPointer ? 'Rebuild VidXP' : 'Install VidXP'}
)}
- {failure &&
} color="red" title="Could not continue" role="alert">{failure}}
+
{ if (operation !== 'install') dismissInstallFailure(); }}
+ title={installFailure ? 'Setup could not finish' : 'Setting up VidXP'}
+ size="md"
+ closeOnClickOutside={operation !== 'install'}
+ closeOnEscape={operation !== 'install'}
+ withCloseButton={operation !== 'install'}
+ >
+ {installFailure ? (
+
+ } color="red" title="VidXP was not installed" role="alert">{installFailure}
+ Any model files already downloaded remain cached and will be reused when you retry.
+ Review setup
+
+ ) : (
+
+
+ Step {progressCurrent} of {progressTotal}
+ {setupElapsed}s elapsed
+
+
+ {setupProgress?.stage === 'models'
+ && setupProgress.model_message && (
+
+
+ {setupProgress.model_message}
+ {setupProgress.model_current != null && setupProgress.model_total != null
+ ?
+ {formatBytes(setupProgress.model_current)} of {formatBytes(setupProgress.model_total)}
+
+ : }
+
+ {setupProgress.model_current != null && setupProgress.model_total != null && (
+
+ )}
+
+ )}
+
+ {setupProgress?.message ?? 'Starting managed setup'}
+ The existing installation remains active until every step has completed and the replacement passes validation.
+
+
+ )}
+
);
}
diff --git a/desktop/src/components/TargetSummary.tsx b/desktop/src/components/TargetSummary.tsx
index cd5d9bd..06cee6f 100644
--- a/desktop/src/components/TargetSummary.tsx
+++ b/desktop/src/components/TargetSummary.tsx
@@ -1,11 +1,12 @@
import { Alert, Badge, Button, Checkbox, Code, Group, Loader, Modal, Stack, Text, Title } from '@mantine/core';
-import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react';
-import { useEffect, useState } from 'react';
+import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlugConnected, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react';
+import { useEffect, useRef, useState } from 'react';
import {
errorMessage,
browserServiceStatus,
configureExternalInstallation,
+ installCodexPlugin,
localServerStatus,
localWorkerStatus,
mcpClientConfig,
@@ -20,6 +21,7 @@ import {
targetDoctor,
type DoctorReport,
type BrowserServiceStatus,
+ type CodexPluginInstallResult,
type LocalServerStatus,
type LocalWorkerStatus,
type RuntimeManifest,
@@ -46,8 +48,14 @@ const CAPABILITY_LABELS: Record
= {
dialogue: 'Dialogue search',
media: 'Video tools',
scene: 'Visual scene search',
+ videoprism: 'Temporal video search',
};
+interface WorkerFailure {
+ title: string;
+ detail: string;
+}
+
export function TargetSummary({ profile, validationError, checking, operationPending, opening, onRecheck, onManageManaged, onSetupChanged, onChooseAnother, onOpen }: TargetSummaryProps) {
const executable = profile.display_executable;
const [doctor, setDoctor] = useState(null);
@@ -55,8 +63,12 @@ export function TargetSummary({ profile, validationError, checking, operationPen
const [browser, setBrowser] = useState(null);
const [worker, setWorker] = useState(null);
const [mcpConfig, setMcpConfig] = useState(null);
- const [busy, setBusy] = useState<'doctor' | 'config' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null);
+ const [codexSetup, setCodexSetup] = useState(null);
+ const [busy, setBusy] = useState<'doctor' | 'config' | 'codex' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null);
const [runtimeFailure, setRuntimeFailure] = useState(null);
+ const [workerFailure, setWorkerFailure] = useState(null);
+ const workerStatusRequest = useRef(0);
+ const workerActionActive = useRef(false);
const [copied, setCopied] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [externalSetupOpened, setExternalSetupOpened] = useState(false);
@@ -81,6 +93,7 @@ export function TargetSummary({ profile, validationError, checking, operationPen
useEffect(() => {
setDoctor(null);
setMcpConfig(null);
+ setCodexSetup(null);
setRuntimeFailure(null);
if (!serverAvailable) {
setServer(null);
@@ -127,20 +140,40 @@ export function TargetSummary({ profile, validationError, checking, operationPen
useEffect(() => {
if (!workerAvailable) {
setWorker(null);
+ setWorkerFailure(null);
return;
}
let active = true;
+ let timer: ReturnType | null = null;
const poll = async () => {
+ if (workerActionActive.current) {
+ if (active) timer = setTimeout(() => void poll(), 5000);
+ return;
+ }
+ const request = ++workerStatusRequest.current;
try {
const status = await localWorkerStatus();
- if (active) setWorker(status);
+ if (active && request === workerStatusRequest.current) {
+ setWorker(status);
+ setWorkerFailure(null);
+ }
} catch (error) {
- if (active) setRuntimeFailure(errorMessage(error, 'Local video processing status could not be checked.'));
+ if (active && request === workerStatusRequest.current) {
+ setWorkerFailure({
+ title: 'Local processing status could not be checked',
+ detail: errorMessage(error, 'VidXP could not check local video processing.'),
+ });
+ }
+ } finally {
+ if (active) timer = setTimeout(() => void poll(), 5000);
}
};
void poll();
- const timer = setInterval(() => void poll(), 5000);
- return () => { active = false; clearInterval(timer); };
+ return () => {
+ active = false;
+ workerStatusRequest.current += 1;
+ if (timer) clearTimeout(timer);
+ };
}, [profile.id, workerAvailable]);
useEffect(() => {
@@ -178,6 +211,19 @@ export function TargetSummary({ profile, validationError, checking, operationPen
}
}
+ async function setupCodex() {
+ setBusy('codex');
+ setRuntimeFailure(null);
+ setCodexSetup(null);
+ try {
+ setCodexSetup(await installCodexPlugin());
+ } catch (error) {
+ setRuntimeFailure(errorMessage(error, 'VidXP could not install the Codex plugin.'));
+ } finally {
+ setBusy(null);
+ }
+ }
+
async function openExternalSetup() {
setBusy('features');
setRuntimeFailure(null);
@@ -254,12 +300,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen
async function setWorkerRunning(running: boolean) {
setBusy(running ? 'worker-start' : 'worker-stop');
- setRuntimeFailure(null);
+ setWorkerFailure(null);
+ workerActionActive.current = true;
+ workerStatusRequest.current += 1;
try {
setWorker(await (running ? startLocalWorker() : stopLocalWorker()));
} catch (error) {
- setRuntimeFailure(errorMessage(error, 'VidXP could not change local video processing.'));
+ setWorkerFailure({
+ title: running ? 'Local processing could not be started' : 'Local processing could not be stopped',
+ detail: errorMessage(error, 'VidXP could not change local video processing.'),
+ });
} finally {
+ workerActionActive.current = false;
setBusy(null);
}
}
@@ -304,6 +356,9 @@ export function TargetSummary({ profile, validationError, checking, operationPen
{validationError.message}
Technical details {validationError.code}
+ {profile.kind === 'managed' && (
+ Repair VidXP
+ )}
)}
{runtimeFailure && {runtimeFailure} }
@@ -357,15 +412,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen
)}
- {workerAvailable &&
-
- Local video processing
- {worker?.running ? 'Ready to process indexing, search, and model jobs on this computer.' : 'Starts automatically when VidXP needs to process a video. You can also start it now.'}
-
- {worker?.running
- ? } loading={busy === 'worker-stop'} disabled={operationPending || busy !== null} onClick={() => void setWorkerRunning(false)}>Stop processing
- : } loading={busy === 'worker-start'} disabled={operationPending || busy !== null} onClick={() => void setWorkerRunning(true)}>Start processing}
- }
+ {workerAvailable &&
+
+
+ Local video processing
+ {worker?.running ? 'Ready to process indexing, search, and model jobs on this computer.' : 'Starts automatically when VidXP needs to process a video. You can also start it now.'}
+
+ {worker?.running
+ ? } loading={busy === 'worker-stop'} disabled={operationPending || busy !== null} onClick={() => void setWorkerRunning(false)}>Stop processing
+ : } loading={busy === 'worker-start'} disabled={operationPending || busy !== null} onClick={() => void setWorkerRunning(true)}>Start processing}
+
+ {workerFailure &&
{workerFailure.detail} }
+
}
{browserAvailable &&
@@ -379,10 +437,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen
}
{mcpAvailable &&
- AI assistant integration Create the MCP setup needed to use VidXP from a compatible AI assistant.
- } loading={busy === 'config'} disabled={operationPending || busy !== null} onClick={() => void loadMcpConfig()}>Set up connection
+ AI assistant integration Install VidXP's MCP server and skills in Codex, or copy the MCP setup for another compatible assistant.
+
+ } loading={busy === 'codex'} disabled={operationPending || busy !== null} onClick={() => void setupCodex()}>Set up in Codex
+ } loading={busy === 'config'} disabled={operationPending || busy !== null} onClick={() => void loadMcpConfig()}>Copy MCP setup
+
}
+ {codexSetup &&
+ {codexSetup.detail}
+ Installation details Plugin {codexSetup.plugin_version} from {codexSetup.marketplace_name} {codexSetup.installed_path && {codexSetup.installed_path}}
+ }
+
{serverAvailable &&
App integration service
diff --git a/desktop/src/tauri.test.ts b/desktop/src/tauri.test.ts
index 1e79c45..658dbd6 100644
--- a/desktop/src/tauri.test.ts
+++ b/desktop/src/tauri.test.ts
@@ -1,17 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
+const { invoke, listen } = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn() }));
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
+vi.mock('@tauri-apps/api/event', () => ({ listen }));
import {
beginManagedSetup,
displayPath,
installRuntime,
+ installCodexPlugin,
configureExternalInstallation,
browserServiceStatus,
localServerStatus,
localWorkerStatus,
+ onManagedSetupProgress,
mcpClientConfig,
recheckTargetState,
startLocalServer,
@@ -25,7 +28,10 @@ import {
targetSetupState,
} from './tauri';
-beforeEach(() => invoke.mockReset());
+beforeEach(() => {
+ invoke.mockReset();
+ listen.mockReset();
+});
describe('displayPath', () => {
it('prettifies extended Windows drive and UNC paths', () => {
@@ -84,11 +90,26 @@ describe('desktop IPC adapter', () => {
expect(invoke).toHaveBeenNthCalledWith(2, 'install_runtime', { request });
});
+ it('maps managed setup progress events to their payload', async () => {
+ const stop = vi.fn();
+ listen.mockResolvedValue(stop);
+ const handler = vi.fn();
+
+ await expect(onManagedSetupProgress(handler)).resolves.toBe(stop);
+ const listener = listen.mock.calls[0][1];
+ const payload = { draft_id: 'draft-1', current: 3, total: 8, stage: 'package', message: 'Acquiring VidXP' };
+ listener({ payload });
+
+ expect(listen).toHaveBeenCalledWith('managed-setup-progress', expect.any(Function));
+ expect(handler).toHaveBeenCalledWith(payload);
+ });
+
it('maps runtime health, MCP configuration, and service lifecycle commands', async () => {
invoke.mockResolvedValue({});
await targetDoctor();
await mcpClientConfig();
+ await installCodexPlugin();
await localWorkerStatus();
await startLocalWorker();
await stopLocalWorker();
@@ -102,16 +123,17 @@ describe('desktop IPC adapter', () => {
expect(invoke).toHaveBeenNthCalledWith(1, 'target_doctor');
expect(invoke).toHaveBeenNthCalledWith(2, 'mcp_client_config');
- expect(invoke).toHaveBeenNthCalledWith(3, 'local_worker_status');
- expect(invoke).toHaveBeenNthCalledWith(4, 'start_local_worker');
- expect(invoke).toHaveBeenNthCalledWith(5, 'stop_local_worker');
- expect(invoke).toHaveBeenNthCalledWith(6, 'browser_service_status');
- expect(invoke).toHaveBeenNthCalledWith(7, 'start_shared_browser');
- expect(invoke).toHaveBeenNthCalledWith(8, 'stop_browser_service');
- expect(invoke).toHaveBeenNthCalledWith(9, 'local_server_status');
- expect(invoke).toHaveBeenNthCalledWith(10, 'start_local_server');
- expect(invoke).toHaveBeenNthCalledWith(11, 'start_shared_server');
- expect(invoke).toHaveBeenNthCalledWith(12, 'stop_local_server');
+ expect(invoke).toHaveBeenNthCalledWith(3, 'install_codex_plugin');
+ expect(invoke).toHaveBeenNthCalledWith(4, 'local_worker_status');
+ expect(invoke).toHaveBeenNthCalledWith(5, 'start_local_worker');
+ expect(invoke).toHaveBeenNthCalledWith(6, 'stop_local_worker');
+ expect(invoke).toHaveBeenNthCalledWith(7, 'browser_service_status');
+ expect(invoke).toHaveBeenNthCalledWith(8, 'start_shared_browser');
+ expect(invoke).toHaveBeenNthCalledWith(9, 'stop_browser_service');
+ expect(invoke).toHaveBeenNthCalledWith(10, 'local_server_status');
+ expect(invoke).toHaveBeenNthCalledWith(11, 'start_local_server');
+ expect(invoke).toHaveBeenNthCalledWith(12, 'start_shared_server');
+ expect(invoke).toHaveBeenNthCalledWith(13, 'stop_local_server');
});
it('adds optional surfaces to the selected existing installation', async () => {
diff --git a/desktop/src/tauri.ts b/desktop/src/tauri.ts
index 558b8ee..78df41e 100644
--- a/desktop/src/tauri.ts
+++ b/desktop/src/tauri.ts
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
+import { listen } from '@tauri-apps/api/event';
export type TargetKind = 'existing_local' | 'managed';
export type LifecycleOwnership = 'external' | 'desktop';
@@ -186,6 +187,17 @@ export interface InstallRuntimeRequest {
draft_id: string;
}
+export interface ManagedSetupProgress {
+ draft_id: string;
+ current: number;
+ total: number;
+ stage: string;
+ message: string;
+ model_message?: string | null;
+ model_current?: number | null;
+ model_total?: number | null;
+}
+
export interface InstallRuntimeResult {
package_version: string;
capabilities: string[];
@@ -242,6 +254,16 @@ export interface LocalWorkerStatus {
detail: string;
}
+export interface CodexPluginInstallResult {
+ plugin_name: string;
+ plugin_id: string | null;
+ plugin_version: string;
+ marketplace_name: string;
+ marketplace_path: string;
+ installed_path: string | null;
+ detail: string;
+}
+
interface WireInstallTransitionResult {
install: InstallRuntimeResult;
setup: WireTargetState;
@@ -348,6 +370,11 @@ export function installRuntime(request: InstallRuntimeRequest): Promise
void,
+): Promise<() => void> {
+ return listen('managed-setup-progress', (event) => handler(event.payload));
+}
export function prepareManagedModels(draftId: string): Promise {
return invoke('prepare_managed_models', { draftId }).then(normalizeState);
}
@@ -357,6 +384,7 @@ export function configureExternalInstallation(capabilities: string[], surfaces:
return invoke('configure_external_installation', { capabilities, surfaces }).then(normalizeState);
}
export function mcpClientConfig(): Promise { return invoke('mcp_client_config'); }
+export function installCodexPlugin(): Promise { return invoke('install_codex_plugin'); }
export function localWorkerStatus(): Promise { return invoke('local_worker_status'); }
export function startLocalWorker(): Promise { return invoke('start_local_worker'); }
export function stopLocalWorker(): Promise { return invoke('stop_local_worker'); }
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 803f2f9..b52260d 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -162,6 +162,7 @@ npm --prefix desktop ci
npm --prefix desktop run model-catalog:check
npm --prefix desktop run notices:check
npm --prefix desktop run check
+python -m build
npm --prefix desktop run sidecar:windows
cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml
```
diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md
index aecfdbf..d4251ff 100644
--- a/docs/architecture/platform.md
+++ b/docs/architecture/platform.md
@@ -297,15 +297,17 @@ Remote API/MCP responses never expose these internal paths.
- original filename
- byte size
- declared and detected MIME/container
-- duration, streams and codecs from ffprobe
+- duration, streams and codecs from ffprobe when the asset is `ready`
- managed storage key or approved external source reference
- repository and owner/principal where applicable
-- ingest state
+- ingest state (`pending`, `ready`, or `failed`)
- associated `video_id`
- creation and retention timestamps
Checksum is calculated once during ingest and reused for deduplication and indexing.
-Untrusted content is not published into the catalog until ffprobe validation succeeds.
+The catalog may record `pending` or `failed` ingest metadata. Untrusted bytes are
+not published into managed storage until ffprobe validation succeeds, and only
+`ready` media can be indexed or materialized.
### 9.2 Local CLI and desktop
@@ -405,8 +407,8 @@ retain explicit cancellation and manual cleanup for abandoned tus resources.
The hook endpoint is private to the Compose network. Client authorization is read
from the hook request body and redacted; client tokens are never stored in tus
metadata. Only the tus upload route is public. Hooks remain enqueue-only; recovery
-runs in the API's existing ingestion coordinator. A completed upload is not a
-`MediaAsset` until durable probe/import succeeds.
+runs in the API's existing ingestion coordinator. A completed upload is not
+`ready` media until durable probe/import succeeds.
The supported server topology uses tusd filestore on a named quarantine volume
shared read-only with the hook service and worker. The API intentionally does not
@@ -1150,8 +1152,9 @@ uncancellable model thread inside the UI process.
The Phase 11 adapter is a small Tauri v2 shell. Its first-run configuration
selects capability extras, optional interfaces, model preparation, and model
-storage, while the processing application is the exact published VidXP package
-installed into a versioned uv-managed environment. When selected, the Streamlit
+storage, while the processing application is the exact release VidXP wheel
+embedded in the installer and installed into a versioned uv-managed
+environment. When selected, the Streamlit
adapter is the local human interface on a random loopback port; remote loopback
content receives no Tauri IPC access. Runtime activation is atomic and a failed
configuration retains the prior environment. Tauri owns the Streamlit process
diff --git a/docs/desktop.md b/docs/desktop.md
index c0f1d35..7ed7311 100644
--- a/docs/desktop.md
+++ b/docs/desktop.md
@@ -20,6 +20,10 @@ implementation:
- managed repositories and models use the same platform VidXP data directory
as the CLI; adopted targets retain their reported roots, while managed Python
and package environments use private desktop application-data directories;
+- Desktop-managed launchers stay private and are restored through their saved
+ managed profile rather than being added to `PATH` or offered for external
+ adoption. This preserves Desktop's lifecycle ownership, version checks, and
+ controlled launch environment without shadowing a separately installed CLI;
- the existing DBOS worker remains the durable execution boundary; and
- closing the desktop process stops the exact browser and API processes it launched,
while broad worker shutdown remains limited to desktop-owned runtimes.
@@ -131,19 +135,21 @@ Target profiles use a versioned desktop-private schema. Profile content and the
selected profile identity are stored separately. No credentials or remote tokens
are stored; remote targets are intentionally outside this release.
-Users select dialogue, scene, and actor search features independently. Product
-choices map to package extras as follows: **Local video processing** adds
-`local-worker` and includes all built-in search features, **Browser interface**
-adds `frontend`, **AI assistant integration** adds the stdio `mcp` transport,
-and **App integration service** adds the loopback API plus Streamable HTTP MCP
-through `server`. These package names stay out of the normal product flow. Model preparation
-can be deferred, and a native folder picker
+Users select dialogue, scene, VideoPrism temporal search, and actor features
+independently. Product choices map to package extras as follows: **Local video
+processing** adds `local-worker` and includes all built-in search features,
+**Browser interface** adds `frontend`, **AI assistant integration** adds the
+stdio `mcp` transport, and **App integration service** adds the loopback API
+plus Streamable HTTP MCP through `server`. These package names stay out of the
+normal product flow. Model preparation can be deferred, and a native folder picker
can select a model-cache directory before any model is downloaded.
-The managed runtime acquires the exact VidXP package with dependency resolution
-disabled, then resolves that package's selected extras. Beta and stable desktop
-releases use production PyPI for both steps, so a pinned prerelease and its
-normal dependencies come from one authoritative index. TestPyPI is used only
-for package-only nightly validation and is never a desktop runtime source.
+The managed runtime acquires the exact VidXP package from the wheel embedded in
+the Desktop installer with dependency resolution disabled, then resolves that
+local package's selected extras and constrained dependencies from production
+PyPI. Release candidates embed the same already-smoke-tested wheel retained for
+publication, so fresh managed setup can be validated before that version exists
+on the public index. TestPyPI is used only for package-only nightly validation
+and is never a desktop runtime source.
The release contract classifies prerelease versions as beta and ordinary
versions as stable, and the bundled manifest pins the matching Python runtime.
This release does not include an automatic Desktop updater, so there is not yet
@@ -185,8 +191,18 @@ one browser tab. Closing a configured control panel hides it to the tray.
action, and **Quit VidXP** runs supervised shutdown for the interface and any
Desktop-owned repository worker. The active-target panel also reports and
controls local video processing through the existing `JobService` worker
-lifecycle, reports the app integration service, creates AI-assistant MCP config,
-and presents the existing doctor result as product health rather than raw output.
+lifecycle, reports the app integration service, creates copyable AI-assistant
+MCP config, installs the VidXP plugin and local MCP server into Codex, and
+presents the existing doctor result as product health rather than raw output.
+Release builds add the repository's Git marketplace from `main` for beta or
+`release` for stable, then install `vidxp@vidxp`. Development and pull-request
+builds export the packaged plugin to a dedicated Desktop-private
+`vidxp-local` marketplace. Desktop uses the supported Codex CLI commands to
+register the marketplace, plugin, and selected runtime's absolute `vidxp-mcp`
+command with its repository and data paths. Re-running setup refreshes these
+registrations; a successful release setup removes the obsolete local
+marketplace. A new Codex task is required before refreshed skills and tools are
+available.
The browser and app integration service remain loopback-only by default.
Explicit sharing controls compose their existing `--share` modes: browser
sharing reports its LAN URL and warns that it is unauthenticated, while API/MCP
diff --git a/docs/integrations/openai-plugin.md b/docs/integrations/openai-plugin.md
new file mode 100644
index 0000000..9f5783d
--- /dev/null
+++ b/docs/integrations/openai-plugin.md
@@ -0,0 +1,108 @@
+# ChatGPT and Codex plugin integration
+
+VidXP ships one versioned plugin from this repository. It provides the install,
+ingest, and evidence-search skills, while an installed VidXP runtime provides
+the local MCP server and its optional interactive MCP App view.
+
+The sole editable plugin source lives at `plugins/vidxp/` and contains:
+
+- `.codex-plugin/plugin.json`, the plugin manifest;
+- `assets/logo.png`, used for the plugin logo and composer icon; and
+- `skills/`, the canonical install, ingest, and evidence workflows.
+
+The repository marketplace is declared at `.agents/plugins/marketplace.json`.
+Other product code in the repository does not affect the marketplace: Codex
+reads only that manifest and the plugin path it declares. In a repository
+checkout the marketplace is discoverable as an available local marketplace; it
+is not installed merely because the repository was opened. Packaging copies
+the canonical plugin into the Python wheel at build time, and Release Please
+keeps its manifest version aligned with the Python package.
+
+## Install in Codex
+
+### Ask Codex to install it
+
+```text
+Add https://github.com/grayhatdevelopers/vidxp as a Git plugin marketplace, install the VidXP plugin, then use its $vidxp-install skill to set up VidXP on this computer.
+```
+
+### VidXP Desktop button
+
+With **AI assistant integration** enabled, VidXP Desktop shows two distinct
+actions:
+
+- **Set up in Codex** installs the plugin and registers the selected
+ installation's absolute `vidxp-mcp` command, repository, and data paths, so
+ Codex does not depend on its process `PATH`.
+- **Copy MCP setup** retains the transport-only JSON flow for other compatible
+ local MCP clients.
+
+Signed beta builds register `grayhatdevelopers/vidxp` at `main`; signed stable
+builds use `release`. Both use sparse checkout for `.agents/plugins` and
+`plugins/vidxp`, so unrelated repository content is not downloaded into the
+plugin cache. Development and pull-request builds export the packaged plugin to
+the Desktop-private `vidxp-local` marketplace instead. The action uses the
+documented `codex plugin marketplace add`, `codex plugin add`, and
+`codex mcp add` commands. A successful Git install removes the obsolete managed
+`vidxp-local` registration. Start a new Codex task after setup.
+
+### Manual fallback
+
+If an agent does not have an authorized local shell, add the beta marketplace
+manually with:
+
+```text
+codex plugin marketplace add grayhatdevelopers/vidxp --ref main \
+ --sparse .agents/plugins --sparse plugins/vidxp
+codex plugin add vidxp@vidxp
+```
+
+## Interactive MCP App
+
+`create_media_upload` and `get_job_evidence` advertise the same
+`ui://vidxp/evidence-review-v1.html` resource. A compatible host can render it
+inline to:
+
+- open VidXP's short-lived HTTPS upload page and refresh session progress;
+- review answer claims and annotated evidence-board pages;
+- select up to ten ranked evidence IDs; and
+- request exact keyframes or clips with `materialize_job_evidence` without
+ rerunning retrieval.
+
+The component completes the MCP Apps `ui/initialize` handshake, then uses the
+standard `tools/call`, `ui/open-link`, `ui/request-display-mode`,
+`ui/update-model-context`, and resize messages. ChatGPT-specific `window.openai`
+helpers are feature-detected only as compatibility fallbacks and for ephemeral
+widget state. VidXP remains authoritative for upload, job, search, and artifact
+state, and non-UI clients receive the existing text, image, and resource-link
+content.
+
+The resource is self-contained, uses system fonts, has no remote script or
+style dependencies, and publishes an explicit empty resource/connect CSP. Add
+only exact HTTPS origins if future component assets or requests require them.
+
+## Connect ChatGPT
+
+The Git marketplace described above is for Codex's local plugin system. A
+ChatGPT connection still requires a publicly reachable Streamable HTTP endpoint
+(VidXP serves `/mcp`),
+an HTTPS deployment or secure development tunnel, and registration in ChatGPT
+Developer Mode.
+
+Do not add a placeholder `.app.json`. That file can reference only the real app
+identifier issued after the remote MCP connection is registered. Once that ID
+exists, add the descriptor to the plugin bundle and validate the deployed app
+through ChatGPT Developer Mode.
+
+Before public submission, also complete the current OpenAI review requirements,
+including organization verification, public endpoint availability, privacy and
+support URLs, accurate tool metadata, and CSP validation.
+
+## Official references
+
+- [Plugin architecture](https://developers.openai.com/plugins/concepts/plugins)
+- [Package a plugin](https://developers.openai.com/plugins/build/plugins)
+- [Add a ChatGPT UI](https://developers.openai.com/plugins/build/chatgpt-ui)
+- [UI guidelines](https://developers.openai.com/plugins/concepts/ui-guidelines)
+- [Connect from ChatGPT](https://developers.openai.com/plugins/deploy/connect-chatgpt)
+- [App review](https://developers.openai.com/plugins/deploy/app-review)
diff --git a/docs/releasing.md b/docs/releasing.md
index cddcddd..16618d7 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -23,7 +23,8 @@ The candidate reuses the normal CI, desktop, and container workflows. It:
1. validates the exact Release Please head against the current target branch;
2. runs the full Python/provider suite and retains its tested wheel and sdist;
-3. builds and tests all three desktop installers and retains them;
+3. embeds that exact tested wheel into, builds, and tests all three desktop
+ installers, then retains them;
4. builds and smokes the three container targets once, pushes temporary
candidate tags, and records their immutable digests; and
5. records a `release/candidate` commit status linked to the Actions run.
@@ -68,9 +69,12 @@ filenames from the validated candidate and preserves the generated changelog
below it. Re-running publication updates the same marked section instead of
duplicating release notes.
-Beta packages intentionally use real PyPI so the desktop-managed runtime can
-resolve its pinned prerelease and normal dependencies from one index. TestPyPI
-is reserved for unique nightly package validation.
+Desktop-managed setup installs its pinned VidXP package from the exact candidate
+wheel embedded in the installer, so an unpublished candidate can complete a
+fresh setup before the release PR is merged. Beta and stable packages are still
+published to real PyPI, and selected extras plus their normal dependencies
+resolve from that production index. TestPyPI is reserved for unique nightly
+package validation.
Publication is resumable. An existing Python version must have the exact same
filenames and SHA-256 values; immutable container tags must resolve to the
diff --git a/plugins/vidxp/.codex-plugin/plugin.json b/plugins/vidxp/.codex-plugin/plugin.json
new file mode 100644
index 0000000..e845b81
--- /dev/null
+++ b/plugins/vidxp/.codex-plugin/plugin.json
@@ -0,0 +1,32 @@
+{
+ "name": "vidxp",
+ "version": "0.4.0-b.3",
+ "description": "Ingest, index, search, and inspect video evidence with VidXP.",
+ "author": {
+ "name": "Grayhat",
+ "url": "https://grayhat.studio/"
+ },
+ "homepage": "https://github.com/grayhatdevelopers/vidxp",
+ "repository": "https://github.com/grayhatdevelopers/vidxp",
+ "license": "MIT",
+ "keywords": ["video", "search", "evidence", "mcp"],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "VidXP",
+ "shortDescription": "Search video and inspect grounded evidence.",
+ "longDescription": "Use VidXP to ingest and index videos, search dialogue and scenes, answer grounded questions, and review evidence boards, frames, and clips.",
+ "developerName": "Grayhat",
+ "category": "Productivity",
+ "capabilities": ["Read", "Write", "Interactive"],
+ "websiteURL": "https://grayhat.studio/",
+ "defaultPrompt": [
+ "Find and verify moments in my indexed videos.",
+ "Set up VidXP on this computer.",
+ "Ingest and index a video with VidXP."
+ ],
+ "brandColor": "#6D5EF7",
+ "composerIcon": "./assets/logo.png",
+ "logo": "./assets/logo.png",
+ "logoDark": "./assets/logo.png"
+ }
+}
diff --git a/plugins/vidxp/assets/logo.png b/plugins/vidxp/assets/logo.png
new file mode 100644
index 0000000..f9c9d21
Binary files /dev/null and b/plugins/vidxp/assets/logo.png differ
diff --git a/plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md b/plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md
new file mode 100644
index 0000000..d78fe75
--- /dev/null
+++ b/plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: vidxp-find-video-evidence
+description: Use VidXP to search indexed videos and surface inspectable evidence boards, keyframes, and clips before analysis. Trigger for requests such as "find where X appears," "when does Y happen," "what is said," "what happens," or "show me the matching clip," even when the user does not name VidXP. Favor one-pass evidence delivery and only add brief accuracy feedback; do not trigger for ingesting new media or ordinary video editing.
+---
+
+# Find video evidence with VidXP
+
+## Workflow
+
+1. Resolve the `vidxp` MCP tools, then call `get_workspace`. If the requested
+ video is not indexed, explain that it must be indexed first.
+2. Submit one retrieval job. Use `search_moments` to locate moments; use
+ `query_video` only when the user asks for a synthesized answer. Use
+ `command.query` with `search_moments` and `command.question` with
+ `query_video`. Set `command.media_id` when the user means one video.
+3. In that initial job, put exactly this inside `command`:
+ `"evidence_delivery": {"mode": "keyframes_and_clips", "max_items": 3}`.
+ This prepares the ranked board, standalone keyframes, and clips without a
+ second retrieval pass. Never send `command.materialize`.
+4. Call `wait_job` for bounded waits. Pass its `observation_token` as
+ `after_observation_token` on the next wait. When terminal, call
+ `get_job_evidence` once. It returns the concise evidence index and visual
+ content without the full structured job dump. Search and query may take
+ time; update the user when the stage changes or about once per minute, never
+ after every wait and never with an invented ETA.
+5. Surface the returned board, keyframes, and clips immediately. Do not call
+ `get_job`, repeat the search, materialize more evidence, create another
+ board, or perform a self-directed verification loop before showing the
+ initial evidence.
+6. Stop after the first evidence delivery. Only when the user explicitly asks
+ for another selection or format, use tile evidence IDs with
+ `materialize_job_evidence`, or use `create_evidence_board` for a custom
+ selection or `next_start_rank` continuation.
+
+## Actor scope
+
+- Actor data is available through `query_video`, not name search. It represents
+ anonymous, video-scoped face clusters—not a named or cross-video identity.
+- Treat its image as a representative full frame, not an exact face crop or
+ proof of continuous presence. Do not claim exhaustive named appearances.
+- For named-person requests, surface the best scene candidates immediately and
+ label uncertain matches as candidates. Do not delay delivery while trying to
+ prove identity through additional searches.
+
+## Output
+
+- Lead with evidence, not a search narrative: first embed the returned board or
+ frame or provide its working resource link, then list the ready clips and
+ keyframes. Use the returned `local_path` or `download_url`; never write an
+ unlinked label such as “View evidence board.” Use `get_artifact_download`
+ only if neither is returned.
+- After the evidence, add at most a brief accuracy note. State uncertainty or
+ visible mismatches without launching another search. Accuracy feedback must
+ not replace or precede the evidence.
+- Preserve the source job and evidence IDs. Describe scores as retrieval scores,
+ and distinguish a visible appearance from a dialogue or caption mention.
+- Stop waiting on success, failure, or cancellation. An empty result means no
+ matching indexed evidence was found, not that the event is absent from the
+ original video.
diff --git a/skills/vidxp-find-video-evidence/agents/openai.yaml b/plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml
similarity index 52%
rename from skills/vidxp-find-video-evidence/agents/openai.yaml
rename to plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml
index 8324467..4d2a39f 100644
--- a/skills/vidxp-find-video-evidence/agents/openai.yaml
+++ b/plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml
@@ -1,7 +1,7 @@
interface:
display_name: "Find Video Evidence with VidXP"
- short_description: "Search indexed videos and return verifiable evidence"
- default_prompt: "Use $vidxp-find-video-evidence to find and verify moments in my indexed videos."
+ short_description: "Surface video boards, frames, and clips first"
+ default_prompt: "Use $vidxp-find-video-evidence to surface the best board, keyframes, and clips before brief accuracy feedback."
policy:
allow_implicit_invocation: true
diff --git a/skills/vidxp-ingest-video/SKILL.md b/plugins/vidxp/skills/vidxp-ingest-video/SKILL.md
similarity index 89%
rename from skills/vidxp-ingest-video/SKILL.md
rename to plugins/vidxp/skills/vidxp-ingest-video/SKILL.md
index 198023f..3a45354 100644
--- a/skills/vidxp-ingest-video/SKILL.md
+++ b/plugins/vidxp/skills/vidxp-ingest-video/SKILL.md
@@ -10,8 +10,9 @@ description: Use VidXP to upload, import, register, and automatically index vide
1. Resolve the `vidxp` MCP tools and call `get_workspace`. Do not import a video
that is already registered or indexed.
2. Choose indexable modalities from the workspace. Use `dialogue` and `scene`
- for ordinary content retrieval. Add `actor` only when anonymous recurring-face
- clusters are wanted; it does not identify people by name.
+ for ordinary content retrieval. Add `videoprism` when the request depends on
+ actions or events spanning multiple frames. Add `actor` only when anonymous
+ recurring-face clusters are wanted; it does not identify people by name.
3. Call `get_runtime_readiness`. If selected models are missing, submit
`prepare_models`, use `wait_job` with its observation token for subsequent
bounded waits, then fetch `get_job` once when terminal.
diff --git a/skills/vidxp-ingest-video/agents/openai.yaml b/plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml
similarity index 100%
rename from skills/vidxp-ingest-video/agents/openai.yaml
rename to plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml
diff --git a/plugins/vidxp/skills/vidxp-install/SKILL.md b/plugins/vidxp/skills/vidxp-install/SKILL.md
new file mode 100644
index 0000000..605c420
--- /dev/null
+++ b/plugins/vidxp/skills/vidxp-install/SKILL.md
@@ -0,0 +1,87 @@
+---
+name: vidxp-install
+description: Install, update, or repair VidXP Desktop or the VidXP CLI and connect its local MCP server to Codex. Use when a user asks to install VidXP, choose between Desktop and CLI, enable local video search features, configure VidXP for an agent, or fix a missing VidXP runtime. This bootstrap skill does not require VidXP to already be installed.
+---
+
+# Install VidXP
+
+Reuse a working local VidXP setup when the user approves it. Otherwise, help them choose a setup, install only what they approve, verify it, and connect Codex to its local MCP server.
+
+## Check for an existing installation first
+
+Before offering a new install, look for every `vidxp` executable available to the local shell. Do not modify any candidate. For each one, run:
+
+```text
+ --version
+ desktop-probe --json --desktop-version codex-plugin --request-id codex-install
+```
+
+For each successful probe, show the user its executable, version, `data_root`, `repository_root`, and `model_root`. These are the installation's effective paths; existing downloaded models under the reported `model_root` can be reused. Ask the user to confirm whether to reuse that installation and those paths.
+
+If the user approves reuse, skip installation and upgrades. Use executables from that same environment, preserve the reported paths when registering MCP, and repair a missing surface or dependency only with approval. If no compatible installation is found or the user declines reuse, continue with the setup choice.
+
+## Start with the choice
+
+Determine which surface the user wants before downloading anything:
+
+- Recommend **Desktop** when they want a guided installer, managed runtime, feature selection, or browser interface.
+- Recommend **CLI** when they want scripting, terminal control, automation, or a minimal agent-only setup.
+- Clarify that both keep video processing local. The plugin itself is only the bootstrap and agent guidance; VidXP provides the actual MCP server after installation.
+- A browser-only ChatGPT session cannot install native software. Continue only when the agent has an authorized local shell, or give the user the exact manual steps.
+
+Ask before starting an installer, changing a tool environment, or downloading models. Do not download model weights until the user has chosen the search capabilities that need them.
+
+## Desktop path
+
+1. Identify the operating system and architecture.
+2. Use the latest applicable release from `https://github.com/grayhatdevelopers/vidxp/releases`:
+ - Windows x86-64: signed setup executable.
+ - Apple Silicon macOS: signed and notarized DMG.
+ - Linux x86-64: AppImage.
+3. Prefer the stable release unless the user explicitly requests beta. Verify any published checksum before launching the artifact.
+4. Let the user complete the native installer and choose the VidXP capabilities in Desktop. Do not silently select model-heavy features.
+5. In VidXP Desktop, use **Set up Codex** after the runtime reports healthy. Desktop registers this plugin and the exact private-runtime `vidxp-mcp` command.
+6. Start a new Codex task, then verify the VidXP MCP tools are available.
+
+## CLI path
+
+1. Verify that `uv` is installed from `https://docs.astral.sh/uv/getting-started/installation/`.
+2. Install the CPU edition with MCP support:
+
+ ```text
+ uv tool install --python 3.14 --torch-backend cpu "vidxp[local-worker,mcp]"
+ ```
+
+ Add `frontend` to the extras only when the user wants the browser interface.
+3. Initialize runtime dependencies:
+
+ ```text
+ vidxp init
+ ```
+
+4. Run `vidxp prepare` only after the user approves the required model downloads. Prefer capability-specific preparation when their choice is narrower than the default set.
+5. Validate the installation:
+
+ ```text
+ vidxp doctor
+ ```
+
+6. Resolve the installed `vidxp-mcp` executable to an absolute path. Register it through the supported Codex CLI instead of editing configuration files by hand:
+
+ ```text
+ codex mcp add vidxp -- --repository default
+ ```
+
+ When reusing an installation, also pass its reported `data_root` as `--data-dir` and a non-default `repository_root` as `--index-directory`. If `model_root` is not `/models`, add `--env VIDXP_MODEL_CACHE=` before `vidxp` so Codex launches MCP against the same model cache.
+
+7. Run `codex mcp get vidxp --json` and start a new Codex task before testing VidXP tools.
+
+## Updates and repairs
+
+- Desktop: use a current installer for the same channel, then re-run **Set up Codex** so the private runtime path and plugin source are refreshed.
+- CLI: use `uv tool upgrade vidxp`, run `vidxp doctor`, and re-register the resolved `vidxp-mcp` command if its path changed.
+- If Codex reports duplicate VidXP plugins, keep the Git-backed `vidxp` marketplace for release installs and remove the obsolete `vidxp@vidxp-local` entry only after the Git-backed plugin works.
+
+## Finish with evidence
+
+Report the selected surface and release channel, exact installer or command used, `vidxp doctor` result, registered MCP command path, and whether a new Codex task can see the VidXP tools. Never call an unverified installation successful.
diff --git a/plugins/vidxp/skills/vidxp-install/agents/openai.yaml b/plugins/vidxp/skills/vidxp-install/agents/openai.yaml
new file mode 100644
index 0000000..5ffa3fc
--- /dev/null
+++ b/plugins/vidxp/skills/vidxp-install/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Install VidXP"
+ short_description: "Install VidXP Desktop or CLI and connect Codex"
+ default_prompt: "Use $vidxp-install to reuse an existing VidXP installation if I approve it, or install my chosen setup, then register its local MCP server and verify it."
diff --git a/pyproject.toml b/pyproject.toml
index e042bca..b93c2df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,6 +54,7 @@ vidxp-worker = "vidxp.workflow_worker:main"
vidxp-database = "vidxp.database_cli:main"
vidxp-hooks = "vidxp.hook_cli:main"
vidxp-mcp = "vidxp.mcp_cli:main"
+vidxp-codex-plugin = "vidxp.codex_plugin_cli:main"
[project.urls]
Homepage = "https://github.com/grayhatdevelopers/vidxp"
@@ -71,6 +72,7 @@ include = ["vidxp*"]
[tool.setuptools.package-data]
vidxp = [
"assets/artifact_download/*",
+ "assets/mcp_app/*",
"assets/upload_page/*",
"benchmarks/requirements.txt",
"capabilities/*/requirements.txt",
@@ -93,11 +95,16 @@ actor = { file = [
"src/vidxp/requirements/storage.txt",
"src/vidxp/capabilities/actor/requirements.txt",
] }
+videoprism = { file = [
+ "src/vidxp/requirements/storage.txt",
+ "src/vidxp/capabilities/videoprism/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",
+ "src/vidxp/capabilities/videoprism/requirements.txt",
] }
local-worker = { file = [
"src/vidxp/requirements/storage.txt",
@@ -105,6 +112,7 @@ local-worker = { file = [
"src/vidxp/capabilities/dialogue/requirements.txt",
"src/vidxp/capabilities/scene/requirements.txt",
"src/vidxp/capabilities/actor/requirements.txt",
+ "src/vidxp/capabilities/videoprism/requirements.txt",
] }
mcp = { file = ["src/vidxp/requirements/mcp.txt"] }
slm = { file = ["src/vidxp/requirements/slm.txt"] }
@@ -119,6 +127,7 @@ server-worker = { file = [
"src/vidxp/capabilities/dialogue/requirements.txt",
"src/vidxp/capabilities/scene/requirements.txt",
"src/vidxp/capabilities/actor/requirements.txt",
+ "src/vidxp/capabilities/videoprism/requirements.txt",
] }
test = { file = ["src/vidxp/requirements/test.txt"] }
frontend = { file = ["src/vidxp/requirements/frontend.txt"] }
@@ -134,6 +143,9 @@ select = ["E4", "E7", "E9", "F"]
torch = [
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
]
+torchvision = [
+ { index = "pytorch-cpu", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
[[tool.uv.index]]
name = "pytorch-cpu"
diff --git a/release-please-config.json b/release-please-config.json
index 0eba41b..43e6a57 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -16,6 +16,11 @@
"path": "uv.lock",
"type": "generic"
},
+ {
+ "jsonpath": "$.version",
+ "path": "plugins/vidxp/.codex-plugin/plugin.json",
+ "type": "json"
+ },
{
"path": "desktop/src-tauri/Cargo.toml",
"type": "generic"
diff --git a/release-please-config.stable.json b/release-please-config.stable.json
index 3c95045..dbff67c 100644
--- a/release-please-config.stable.json
+++ b/release-please-config.stable.json
@@ -19,6 +19,11 @@
"path": "uv.lock",
"type": "generic"
},
+ {
+ "jsonpath": "$.version",
+ "path": "plugins/vidxp/.codex-plugin/plugin.json",
+ "type": "json"
+ },
{
"path": "desktop/src-tauri/Cargo.toml",
"type": "generic"
diff --git a/setup.py b/setup.py
index 31da439..5226952 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from shutil import copyfile
+from shutil import copyfile, copytree
from setuptools import setup
from setuptools.command.build_py import build_py
@@ -13,5 +13,10 @@ def run(self):
target.parent.mkdir(parents=True, exist_ok=True)
copyfile(source, target)
+ plugin_source = Path(__file__).parent / "plugins" / "vidxp"
+ plugin_target = Path(self.build_lib) / "vidxp" / "bundled_plugins" / "vidxp"
+ copytree(plugin_source, plugin_target, dirs_exist_ok=True)
+ copyfile(source, plugin_target / "assets" / "logo.png")
+
setup(cmdclass={"build_py": BuildPy})
diff --git a/skills/vidxp-find-video-evidence/SKILL.md b/skills/vidxp-find-video-evidence/SKILL.md
deleted file mode 100644
index 0f8027b..0000000
--- a/skills/vidxp-find-video-evidence/SKILL.md
+++ /dev/null
@@ -1,56 +0,0 @@
----
-name: vidxp-find-video-evidence
-description: Use VidXP to search indexed videos, answer grounded questions about video content, locate when people, actions, dialogue, or scenes occur, and return inspectable evidence boards, keyframes, or clips. Trigger for requests such as "find where X appears," "when does Y happen," "what is said," "what happens," or "show me the matching clip," even when the user does not name VidXP. Do not trigger for ingesting new media or ordinary video editing.
----
-
-# Find video evidence with VidXP
-
-## Workflow
-
-1. Resolve the `vidxp` MCP tools, then call `get_workspace`. If the requested
- video is not indexed, explain that it must be indexed first.
-2. Use `search_moments` to locate moments or `query_video` for a synthesized,
- grounded answer. Use `command.query` with `search_moments` and
- `command.question` with `query_video`. Set `command.media_id` when the user
- means one video.
-3. Omit `command.evidence_delivery` for the normal path. The completed job
- includes an annotated board covering the ranked results.
-4. Call `wait_job` for bounded waits. Pass its `observation_token` as
- `after_observation_token` on the next wait. When terminal, call
- `get_job_evidence` once. It returns the concise evidence index and visual
- content without the full structured job dump. Use `get_job` only when exact
- machine fields not present in that index are actually needed. Search and
- query may take time; update the user
- when the stage changes or about once per minute, never after every wait and
- never with an invented ETA.
-5. Inspect and show the returned board before making visual claims. Use its tile
- evidence IDs for follow-up:
- - `materialize_job_evidence` accepts up to ten selected IDs and returns
- model-visible standalone keyframes or clip links without rerunning retrieval.
- - `create_evidence_board` is only for a custom selection or the
- `next_start_rank` continuation; wait on its returned job ID the same way.
-6. When standalone artifacts are required in the initial job, put exactly this
- inside `command`: `"evidence_delivery": {"mode":
- "keyframes_and_clips", "max_items": 3}`. Never send
- `command.materialize`.
-
-## Actor scope
-
-- Actor data is available through `query_video`, not name search. It represents
- anonymous, video-scoped face clusters—not a named or cross-video identity.
-- Treat its image as a representative full frame, not an exact face crop or
- proof of continuous presence. Do not claim exhaustive named appearances.
-- Use scene evidence plus board inspection for named-person requests, and label
- uncertain matches as candidates.
-
-## Output
-
-- The final response must visibly embed a returned board or frame, or include a
- working downloadable resource link—not timestamps alone. Use the returned
- `local_path` or `download_url`; never write an unlinked label such as “View
- evidence board.” Use `get_artifact_download` only if neither is returned.
-- Preserve the source job and evidence IDs. Describe scores as retrieval scores,
- and distinguish a visible appearance from a dialogue or caption mention.
-- Stop waiting on success, failure, or cancellation. An empty result means no
- matching indexed evidence was found, not that the event is absent from the
- original video.
diff --git a/src/vidxp/application_models.py b/src/vidxp/application_models.py
index d6c64e6..585205d 100644
--- a/src/vidxp/application_models.py
+++ b/src/vidxp/application_models.py
@@ -364,13 +364,26 @@ class MediaAsset(ApplicationModel):
sha256: Sha256
byte_size: int = Field(gt=0)
declared_mime_type: MimeType | None = None
- detected_mime_type: MimeType
- container: str = Field(min_length=1)
- duration_seconds: float = Field(gt=0)
- streams: tuple[MediaStream, ...] = Field(min_length=1)
+ detected_mime_type: MimeType | None = None
+ container: str | None = Field(default=None, min_length=1)
+ duration_seconds: float | None = Field(default=None, gt=0)
+ streams: tuple[MediaStream, ...] = ()
state: MediaState
created_at: AwareDatetime
+ @model_validator(mode="after")
+ def _require_ready_probe(self) -> "MediaAsset":
+ if self.state != MediaState.ready:
+ return self
+ if (
+ self.detected_mime_type is None
+ or self.container is None
+ or self.duration_seconds is None
+ or not any(stream.kind == "video" for stream in self.streams)
+ ):
+ raise ValueError("ready media must contain a video stream")
+ return self
+
class ListMediaCommand(ApplicationModel):
page_size: int = Field(
@@ -767,7 +780,7 @@ class WorkspaceMediaCapability(ApplicationModel):
class WorkspaceMedia(ApplicationModel):
media_id: MediaId
original_filename: str = Field(min_length=1)
- duration_seconds: float = Field(gt=0)
+ duration_seconds: float | None = Field(default=None, gt=0)
state: MediaState
in_active_snapshot: bool
capabilities: tuple[WorkspaceMediaCapability, ...] = ()
diff --git a/src/vidxp/assets/mcp_app/index.html b/src/vidxp/assets/mcp_app/index.html
new file mode 100644
index 0000000..3a46add
--- /dev/null
+++ b/src/vidxp/assets/mcp_app/index.html
@@ -0,0 +1,469 @@
+
+
+
+
+
+ VidXP evidence review
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/vidxp/capabilities/actor/definition.py b/src/vidxp/capabilities/actor/definition.py
index c9ba378..e05c293 100644
--- a/src/vidxp/capabilities/actor/definition.py
+++ b/src/vidxp/capabilities/actor/definition.py
@@ -32,21 +32,18 @@
)
from vidxp.capabilities.visual import index_capabilities
from vidxp.core.contracts import IndexConfig, VideoSource
-from vidxp.core.indexing_common import ProgressCallback
+from vidxp.core.indexing_common import ProgressCallback, report_preparation
def prepare_models(
context: PreparationContext,
progress: ProgressCallback | None,
) -> tuple[str, ...]:
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "actor_models",
- "message": "Preparing OpenCV Zoo YuNet and SFace models.",
- }
- )
+ report_preparation(
+ progress,
+ "actor_models",
+ "Preparing OpenCV Zoo YuNet and SFace models.",
+ )
get_actor_models(context.runtime, download=True, progress=progress)
return (YUNET_MODEL.filename, SFACE_MODEL.filename)
diff --git a/src/vidxp/capabilities/actor/models.py b/src/vidxp/capabilities/actor/models.py
index 3673fd8..2a487b3 100644
--- a/src/vidxp/capabilities/actor/models.py
+++ b/src/vidxp/capabilities/actor/models.py
@@ -4,6 +4,7 @@
from typing import Any, Callable
from vidxp.ports import ModelRuntimePort
+from vidxp.core.indexing_common import report_preparation
from vidxp.capabilities.actor.specs import (
SFACE_MODEL,
YUNET_MODEL,
@@ -38,14 +39,11 @@ def load() -> ActorModels:
download=download,
progress=progress,
)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "loading_model",
- "message": "Loading OpenCV Zoo YuNet and SFace models.",
- }
- )
+ report_preparation(
+ progress,
+ "loading_model",
+ "Loading OpenCV Zoo YuNet and SFace models.",
+ )
models = ActorModels(
detector=cv2.FaceDetectorYN.create(
str(detector_path),
diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py
index ddb9ef4..059babc 100644
--- a/src/vidxp/capabilities/contracts.py
+++ b/src/vidxp/capabilities/contracts.py
@@ -1,6 +1,8 @@
from __future__ import annotations
-from importlib import import_module
+import json
+import subprocess
+import sys
from types import MappingProxyType
from typing import Any, Callable, Mapping
@@ -26,6 +28,7 @@
CAPABILITY_CONTRACT_VERSION = 1
+MODULE_IMPORT_TIMEOUT_SECONDS = 180
class _ContractModel(BaseModel):
@@ -295,12 +298,27 @@ def module_import_check(
*attributes: str,
) -> RuntimeCheck:
def check() -> None:
- module = import_module(module_name)
- for attribute in attributes:
- if not hasattr(module, attribute):
- raise AttributeError(
- f"{module_name} does not expose {attribute}."
- )
+ probe = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ (
+ "import importlib,json,sys;"
+ "name,attrs=json.loads(sys.argv[1]);"
+ "module=importlib.import_module(name);"
+ "missing=[attr for attr in attrs if not hasattr(module,attr)];"
+ "sys.exit(1 if missing else 0)"
+ ),
+ json.dumps((module_name, attributes)),
+ ],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=MODULE_IMPORT_TIMEOUT_SECONDS,
+ check=False,
+ )
+ if probe.returncode != 0:
+ raise RuntimeError(f"{module_name} import probe failed.")
return RuntimeCheck(label=label, check=check)
diff --git a/src/vidxp/capabilities/dialogue/definition.py b/src/vidxp/capabilities/dialogue/definition.py
index 950bd8c..0474773 100644
--- a/src/vidxp/capabilities/dialogue/definition.py
+++ b/src/vidxp/capabilities/dialogue/definition.py
@@ -26,7 +26,7 @@
)
from vidxp.capabilities.schemas import SearchInput, SearchResult
from vidxp.core.contracts import IndexConfig, VideoSource
-from vidxp.core.indexing_common import ProgressCallback
+from vidxp.core.indexing_common import ProgressCallback, report_preparation
def filter_requirements_for_source(
source: VideoSource,
requirements: tuple[Requirement, ...],
@@ -49,14 +49,7 @@ def prepare_models(
prepared = []
def report(stage: str, message: str) -> None:
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": stage,
- "message": message,
- }
- )
+ report_preparation(progress, stage, message)
report(
"dialogue_model",
diff --git a/src/vidxp/capabilities/dialogue/models.py b/src/vidxp/capabilities/dialogue/models.py
index 7a671ea..6979fd3 100644
--- a/src/vidxp/capabilities/dialogue/models.py
+++ b/src/vidxp/capabilities/dialogue/models.py
@@ -3,6 +3,7 @@
from typing import Any, Callable
from vidxp.ports import ModelRuntimePort
+from vidxp.core.indexing_common import report_preparation
from vidxp.model_contracts import loaded_compute_precision
from vidxp.capabilities.dialogue.specs import (
FASTER_WHISPER_MODEL,
@@ -28,16 +29,11 @@ def load() -> Any:
download=download,
progress=progress,
)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "loading_model",
- "message": (
- f"Loading {QWEN3_EMBEDDING_MODEL.model_id}."
- ),
- }
- )
+ report_preparation(
+ progress,
+ "loading_model",
+ f"Loading {QWEN3_EMBEDDING_MODEL.model_id}.",
+ )
model = SentenceTransformer(
str(snapshot),
device=device,
@@ -74,16 +70,11 @@ def load() -> Any:
download=download,
progress=progress,
)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "loading_model",
- "message": (
- f"Loading {FASTER_WHISPER_MODEL.model_id}."
- ),
- }
- )
+ report_preparation(
+ progress,
+ "loading_model",
+ f"Loading {FASTER_WHISPER_MODEL.model_id}.",
+ )
model = WhisperModel(
str(snapshot),
device=device.split(":", 1)[0],
diff --git a/src/vidxp/capabilities/registry.py b/src/vidxp/capabilities/registry.py
index 5e82f82..681d814 100644
--- a/src/vidxp/capabilities/registry.py
+++ b/src/vidxp/capabilities/registry.py
@@ -503,8 +503,9 @@ def _builtin_plugins() -> tuple[CapabilityPlugin, ...]:
from vidxp.capabilities.actor.definition import PLUGIN as actor
from vidxp.capabilities.dialogue.definition import PLUGIN as dialogue
from vidxp.capabilities.scene.definition import PLUGIN as scene
+ from vidxp.capabilities.videoprism.definition import PLUGIN as videoprism
- return dialogue, scene, actor
+ return dialogue, scene, actor, videoprism
def _external_entry_points(allowlist: tuple[str, ...]) -> tuple[EntryPoint, ...]:
diff --git a/src/vidxp/capabilities/scene/definition.py b/src/vidxp/capabilities/scene/definition.py
index 366ad4e..9bc3621 100644
--- a/src/vidxp/capabilities/scene/definition.py
+++ b/src/vidxp/capabilities/scene/definition.py
@@ -19,23 +19,18 @@
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
+from vidxp.core.indexing_common import ProgressCallback, report_preparation
def prepare_models(
context: PreparationContext,
progress: ProgressCallback | None,
) -> tuple[str, ...]:
SceneConfig.model_validate(context.settings)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "scene_model",
- "message": (
- f"Preparing scene model: SigLIP2 {SIGLIP2_MODEL.model_id}"
- ),
- }
- )
+ report_preparation(
+ progress,
+ "scene_model",
+ f"Preparing scene model: SigLIP2 {SIGLIP2_MODEL.model_id}",
+ )
get_scene_model(context.runtime, download=True, progress=progress)
return (SIGLIP2_MODEL.model_id,)
diff --git a/src/vidxp/capabilities/scene/models.py b/src/vidxp/capabilities/scene/models.py
index 50d8da8..07f0555 100644
--- a/src/vidxp/capabilities/scene/models.py
+++ b/src/vidxp/capabilities/scene/models.py
@@ -4,6 +4,7 @@
from typing import Any, Callable
from vidxp.ports import ModelRuntimePort
+from vidxp.core.indexing_common import report_preparation
from vidxp.model_contracts import loaded_compute_precision
from vidxp.capabilities.scene.specs import SIGLIP2_MODEL
@@ -32,14 +33,11 @@ def load() -> SceneModel:
download=download,
progress=progress,
)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "loading_model",
- "message": f"Loading {SIGLIP2_MODEL.model_id}.",
- }
- )
+ report_preparation(
+ progress,
+ "loading_model",
+ f"Loading {SIGLIP2_MODEL.model_id}.",
+ )
common = {
"cache_dir": str(runtime.model_cache),
"local_files_only": True,
diff --git a/src/vidxp/capabilities/videoprism/__init__.py b/src/vidxp/capabilities/videoprism/__init__.py
new file mode 100644
index 0000000..7e891a3
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/__init__.py
@@ -0,0 +1 @@
+"""VideoPrism temporal video search capability."""
diff --git a/src/vidxp/capabilities/videoprism/config.py b/src/vidxp/capabilities/videoprism/config.py
new file mode 100644
index 0000000..f415e2a
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/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 VideoPrismConfig(CapabilityConfig):
+ batch_size: int = Field(default=1, gt=0)
+ sample_fps: float = Field(default=2.0, gt=0)
+
+
+def videoprism_config(config: IndexConfig) -> VideoPrismConfig:
+ return VideoPrismConfig.model_validate(config.options_for("videoprism"))
diff --git a/src/vidxp/capabilities/videoprism/definition.py b/src/vidxp/capabilities/videoprism/definition.py
new file mode 100644
index 0000000..b4acc2e
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/definition.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.application_models import CapabilityRole
+from vidxp.capabilities.contracts import (
+ CapabilityDefinition,
+ CapabilityExecutor,
+ CapabilityPlugin,
+ OperationDefinition,
+ PreparationContext,
+ module_import_check,
+)
+from vidxp.capabilities.schemas import SearchInput, SearchResult
+from vidxp.capabilities.videoprism.config import VideoPrismConfig
+from vidxp.capabilities.videoprism.indexing import VISUAL_PROCESSOR
+from vidxp.capabilities.videoprism.models import get_videoprism_model
+from vidxp.capabilities.videoprism.operations import search_operation
+from vidxp.capabilities.videoprism.specs import VIDEOPRISM_MODEL
+from vidxp.capabilities.visual import index_capabilities
+from vidxp.core.contracts import IndexConfig, VideoSource
+from vidxp.core.indexing_common import ProgressCallback, report_preparation
+
+
+def prepare_models(
+ context: PreparationContext,
+ progress: ProgressCallback | None,
+) -> tuple[str, ...]:
+ VideoPrismConfig.model_validate(context.settings)
+ report_preparation(
+ progress,
+ "videoprism_model",
+ f"Preparing VideoPrism {VIDEOPRISM_MODEL.model_id}",
+ )
+ get_videoprism_model(context.runtime, download=True, progress=progress)
+ return (VIDEOPRISM_MODEL.model_id,)
+
+
+def model_manifest(
+ config: IndexConfig,
+ _sources: tuple[VideoSource, ...],
+) -> Mapping[str, Any]:
+ return {"videoprism": VIDEOPRISM_MODEL.identity()}
+
+
+DEFINITION = CapabilityDefinition(
+ name="videoprism",
+ description="Index and search temporal video clips with VideoPrism.",
+ extra="videoprism",
+ config_model=VideoPrismConfig,
+ collection_name="videoprism",
+ index_stage="visual_indexing",
+ execution_group="visual",
+ prepares_models=True,
+ roles=(CapabilityRole.searchable, CapabilityRole.queryable),
+ model_specs=(VIDEOPRISM_MODEL,),
+ operations={
+ "search": OperationDefinition(
+ input_model=SearchInput,
+ output_model=SearchResult,
+ )
+ },
+)
+
+
+def create_executor() -> CapabilityExecutor:
+ return CapabilityExecutor(
+ indexer=index_capabilities,
+ index_processor=VISUAL_PROCESSOR,
+ operations={"search": search_operation},
+ prepare=prepare_models,
+ model_manifest=model_manifest,
+ runtime_checks=(
+ module_import_check("OpenCV import", "cv2", "VideoCapture"),
+ module_import_check("Torch import", "torch"),
+ module_import_check("Torchvision import", "torchvision"),
+ module_import_check(
+ "Transformers VideoPrism import",
+ "transformers",
+ "VideoPrismClipModel",
+ "VideoPrismProcessor",
+ ),
+ module_import_check(
+ "Hugging Face Hub import",
+ "huggingface_hub",
+ "snapshot_download",
+ ),
+ ),
+ )
+
+
+PLUGIN = CapabilityPlugin(
+ definition=DEFINITION,
+ executor_factory=create_executor,
+)
diff --git a/src/vidxp/capabilities/videoprism/indexing.py b/src/vidxp/capabilities/videoprism/indexing.py
new file mode 100644
index 0000000..53df5f4
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/indexing.py
@@ -0,0 +1,217 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Sequence
+
+from vidxp.capabilities.videoprism.config import videoprism_config
+from vidxp.capabilities.videoprism.models import (
+ VideoPrismModel,
+ get_videoprism_model,
+ normalize_pooled_output,
+)
+from vidxp.capabilities.videoprism.specs import VIDEOPRISM_MODEL
+from vidxp.core.contracts import (
+ CancellationToken,
+ IndexConfig,
+ StorageRecord,
+ batched,
+ stable_source_id,
+)
+from vidxp.core.indexing_common import ProgressCallback, report_progress
+from vidxp.core.video import FrameSample, FrameSampling
+from vidxp.ports import IndexStore, ModelRuntimePort
+
+
+CLIP_FRAMES = 16
+
+
+@dataclass
+class VideoPrismIndexState:
+ provider: VideoPrismModel
+ pending: list[FrameSample] = field(default_factory=list)
+ stored_clips: int = 0
+ video_info: Any | None = None
+
+
+def videoprism_sampling(config: IndexConfig, info) -> FrameSampling:
+ return FrameSampling(
+ source_fps=info.fps,
+ target_fps=videoprism_config(config).sample_fps,
+ )
+
+
+def encode_video_clips(
+ clips: Sequence[Sequence[FrameSample]],
+ provider: VideoPrismModel,
+) -> list[list[float]]:
+ import torch
+
+ inputs = provider.processor(
+ videos=[[sample.frame for sample in clip] for clip in clips],
+ do_sample_frames=False,
+ return_tensors="pt",
+ )
+ inputs = {name: value.to(provider.device) for name, value in inputs.items()}
+ with torch.inference_mode():
+ features = provider.model.get_video_features(**inputs).pooler_output
+ features = normalize_pooled_output(features)
+ return features.cpu().numpy().tolist()
+
+
+def videoprism_records(
+ clips: Sequence[Sequence[FrameSample]],
+ vectors: Sequence[Sequence[float]],
+ info,
+ config: IndexConfig,
+) -> list[StorageRecord]:
+ records = []
+ cadence = 1 / min(info.fps, videoprism_config(config).sample_fps)
+ for clip, vector in zip(clips, vectors):
+ first, last = clip[0], clip[-1]
+ end = min(info.duration, last.timestamp + cadence)
+ if end <= first.timestamp:
+ end = first.timestamp + 1 / info.fps
+ source_id = stable_source_id(
+ config.run_id,
+ str(config.video_id),
+ "videoprism",
+ f"f{first.frame_index:012d}-f{last.frame_index:012d}",
+ generation_id=config.generation_id,
+ )
+ records.append(
+ StorageRecord(
+ source_id=source_id,
+ embedding=list(vector),
+ metadata={
+ **config.record_identity("videoprism", source_id),
+ "frame_index": first.frame_index,
+ "end_frame_index": last.frame_index,
+ "timestamp": first.timestamp,
+ "start": first.timestamp,
+ "end": end,
+ "fps": info.fps,
+ "duration": info.duration,
+ "sample_count": len(clip),
+ },
+ )
+ )
+ return records
+
+
+def _store_clips(
+ clips: Sequence[Sequence[FrameSample]],
+ *,
+ state: VideoPrismIndexState,
+ info,
+ config: IndexConfig,
+ storage: IndexStore,
+ cancellation: CancellationToken,
+) -> None:
+ settings = videoprism_config(config)
+ for group in batched(clips, settings.batch_size):
+ cancellation.raise_if_cancelled()
+ model_clips = [
+ list(clip) + [clip[-1]] * (CLIP_FRAMES - len(clip))
+ for clip in group
+ ]
+ vectors = encode_video_clips(model_clips, state.provider)
+ state.stored_clips += storage.upsert(
+ "videoprism",
+ videoprism_records(group, vectors, info, config),
+ batch_size=config.storage_batch_size,
+ cancellation=cancellation,
+ )
+
+
+def process_videoprism_samples(
+ samples: Sequence[FrameSample],
+ *,
+ state: VideoPrismIndexState,
+ info,
+ config: IndexConfig,
+ storage: IndexStore,
+ cancellation: CancellationToken,
+) -> None:
+ state.video_info = info
+ state.pending.extend(samples)
+ complete = len(state.pending) // CLIP_FRAMES
+ if not complete:
+ return
+ consumed = complete * CLIP_FRAMES
+ clips = [
+ state.pending[start : start + CLIP_FRAMES]
+ for start in range(0, consumed, CLIP_FRAMES)
+ ]
+ del state.pending[:consumed]
+ _store_clips(
+ clips,
+ state=state,
+ info=info,
+ config=config,
+ storage=storage,
+ cancellation=cancellation,
+ )
+
+
+class VideoPrismVisualProcessor:
+ def sampling(self, config: IndexConfig, info) -> FrameSampling:
+ return videoprism_sampling(config, info)
+
+ def batch_size(self, config: IndexConfig) -> int:
+ return CLIP_FRAMES * videoprism_config(config).batch_size
+
+ def prepare(
+ self,
+ config: IndexConfig,
+ runtime: ModelRuntimePort,
+ progress: ProgressCallback | None,
+ ) -> VideoPrismIndexState:
+ report_progress(
+ progress,
+ "preparing_videoprism_model",
+ f"Preparing VideoPrism {VIDEOPRISM_MODEL.model_id}.",
+ )
+ return VideoPrismIndexState(get_videoprism_model(runtime))
+
+ def process(
+ self,
+ samples,
+ *,
+ state: VideoPrismIndexState,
+ info,
+ config: IndexConfig,
+ storage: IndexStore,
+ cancellation: CancellationToken,
+ ) -> None:
+ process_videoprism_samples(
+ samples,
+ state=state,
+ info=info,
+ config=config,
+ storage=storage,
+ cancellation=cancellation,
+ )
+
+ def finalize(
+ self,
+ state: VideoPrismIndexState,
+ *,
+ config: IndexConfig,
+ storage: IndexStore,
+ ) -> tuple[dict[str, Any], int]:
+ if state.pending:
+ if state.video_info is None:
+ raise RuntimeError("VideoPrism indexing is missing video metadata.")
+ _store_clips(
+ [state.pending],
+ state=state,
+ info=state.video_info,
+ config=config,
+ storage=storage,
+ cancellation=CancellationToken(),
+ )
+ state.pending.clear()
+ return {"videoprism_clips": state.stored_clips}, state.stored_clips
+
+
+VISUAL_PROCESSOR = VideoPrismVisualProcessor()
diff --git a/src/vidxp/capabilities/videoprism/models.py b/src/vidxp/capabilities/videoprism/models.py
new file mode 100644
index 0000000..dde6e0f
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/models.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Callable
+
+from vidxp.capabilities.videoprism.specs import VIDEOPRISM_MODEL
+from vidxp.core.indexing_common import report_preparation
+from vidxp.model_contracts import loaded_compute_precision
+from vidxp.ports import ModelRuntimePort
+
+
+@dataclass(frozen=True)
+class VideoPrismModel:
+ model: Any
+ processor: Any
+ device: str
+
+
+def normalize_pooled_output(features: Any) -> Any:
+ import torch
+
+ return torch.nn.functional.normalize(features.flatten(start_dim=1), dim=-1)
+
+
+def get_videoprism_model(
+ runtime: ModelRuntimePort,
+ *,
+ download: bool = False,
+ progress: Callable[[dict[str, Any]], None] | None = None,
+) -> VideoPrismModel:
+ device = runtime.device_for("videoprism")
+ key = VIDEOPRISM_MODEL.key(device)
+
+ def load() -> VideoPrismModel:
+ from transformers import VideoPrismClipModel, VideoPrismProcessor
+
+ snapshot = runtime.resolve_model(
+ VIDEOPRISM_MODEL,
+ download=download,
+ progress=progress,
+ )
+ report_preparation(
+ progress,
+ "loading_model",
+ f"Loading {VIDEOPRISM_MODEL.model_id}.",
+ )
+ common = {
+ "local_files_only": True,
+ }
+ model = VideoPrismClipModel.from_pretrained(snapshot, **common).to(
+ device
+ )
+ model.eval()
+ runtime.record_compute_precision(
+ VIDEOPRISM_MODEL.capability,
+ loaded_compute_precision(
+ model,
+ fallback=VIDEOPRISM_MODEL.weights_precision,
+ ),
+ )
+ return VideoPrismModel(
+ model=model,
+ processor=VideoPrismProcessor.from_pretrained(snapshot, **common),
+ device=device,
+ )
+
+ return runtime.get_or_load(key, load)
diff --git a/src/vidxp/capabilities/videoprism/operations.py b/src/vidxp/capabilities/videoprism/operations.py
new file mode 100644
index 0000000..dc7b804
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/operations.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from vidxp.capabilities.contracts import CapabilityContext
+from vidxp.capabilities.schemas import SearchInput, SearchResult
+from vidxp.capabilities.search import search_embeddings
+from vidxp.capabilities.videoprism.models import (
+ get_videoprism_model,
+ normalize_pooled_output,
+)
+from vidxp.core.contracts import IndexConfig
+from vidxp.ports import IndexStore, ModelRuntimePort
+
+
+REQUIRED_METADATA = frozenset(
+ {
+ "dataset",
+ "split",
+ "run_id",
+ "video_id",
+ "source_id",
+ "start",
+ "end",
+ "frame_index",
+ "timestamp",
+ "fps",
+ "duration",
+ "modality",
+ }
+)
+
+
+def videoprism_embedding(
+ query: str,
+ runtime: ModelRuntimePort,
+) -> list[float]:
+ import torch
+
+ provider = get_videoprism_model(runtime)
+ inputs = provider.processor(
+ text=[query],
+ padding="max_length",
+ max_length=64,
+ truncation=True,
+ return_tensors="pt",
+ )
+ inputs = {name: value.to(provider.device) for name, value in inputs.items()}
+ with torch.inference_mode():
+ features = provider.model.get_text_features(**inputs).pooler_output
+ features = normalize_pooled_output(features)
+ return features.cpu().numpy().tolist()[0]
+
+
+def search_videoprism(
+ query: str,
+ *,
+ config: IndexConfig,
+ runtime: ModelRuntimePort,
+ top_k: int = 10,
+ video_id: str | None = None,
+ query_id: str | None = None,
+ filters: Mapping[str, Any] | None = None,
+ storage: IndexStore,
+) -> 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,
+ "videoprism",
+ videoprism_embedding(cleaned, runtime),
+ 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_videoprism(
+ request.query,
+ config=config,
+ top_k=request.top_k,
+ video_id=request.media_id or config.video_id,
+ runtime=context.runtime,
+ storage=context.require_storage(),
+ )
diff --git a/src/vidxp/capabilities/videoprism/requirements.txt b/src/vidxp/capabilities/videoprism/requirements.txt
new file mode 100644
index 0000000..d0bfdf8
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/requirements.txt
@@ -0,0 +1,6 @@
+numpy>=2.3,<3
+opencv-python-headless>=5.0.0.93,<6
+torch>=2.13,<3
+torchvision>=0.28,<1
+transformers>=5.14.1,<6
+huggingface-hub>=1.25.1,<2
diff --git a/src/vidxp/capabilities/videoprism/specs.py b/src/vidxp/capabilities/videoprism/specs.py
new file mode 100644
index 0000000..f2f9209
--- /dev/null
+++ b/src/vidxp/capabilities/videoprism/specs.py
@@ -0,0 +1,16 @@
+from vidxp.model_contracts import ModelSpec
+
+
+VIDEOPRISM_MODEL = ModelSpec(
+ capability="videoprism",
+ provider="transformers",
+ model_id="google/videoprism-lvt-base-f16r288",
+ revision="fb6de9f0eb7bc285be86bdca1cf7daa3e3ef51ff",
+ download_size_bytes=993_993_146,
+ weights_file="model.safetensors",
+ weights_sha256=(
+ "7d64ac2364d3473c0dd9fde35fb09e3cfb3b43153c3e9af79d7e49f1c8387cf5"
+ ),
+ license="Apache-2.0",
+ weights_precision="float32",
+)
diff --git a/src/vidxp/cli_commands/media.py b/src/vidxp/cli_commands/media.py
index 83f2746..dcd3234 100644
--- a/src/vidxp/cli_commands/media.py
+++ b/src/vidxp/cli_commands/media.py
@@ -89,12 +89,18 @@ def list_media(
table.add_column("Filename")
table.add_column("Duration", justify="right")
table.add_column("Size", justify="right")
+ table.add_column("State")
for asset in assets:
table.add_row(
asset.media_id,
asset.original_filename,
- f"{asset.duration_seconds:.3f}s",
+ (
+ "-"
+ if asset.duration_seconds is None
+ else f"{asset.duration_seconds:.3f}s"
+ ),
f"{asset.byte_size:,}",
+ asset.state.value
)
Console().print(table)
diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py
index 93575ed..ba80f0c 100644
--- a/src/vidxp/cli_commands/runtime.py
+++ b/src/vidxp/cli_commands/runtime.py
@@ -13,6 +13,7 @@
DependencyCheckCommand,
DependencyKind,
ErrorCategory,
+ Job,
PrepareModelsCommand,
)
from vidxp.cli_support import (
@@ -26,6 +27,7 @@
require_media_runtime,
state_from_context,
)
+from vidxp.core.manifest import write_json_atomic
from vidxp.media_runtime import (
MediaRuntimeStatus,
inspect_media_runtime,
@@ -197,6 +199,13 @@ def doctor(
bool,
typer.Option("--json", help="Emit machine-readable JSON."),
] = False,
+ include_models: Annotated[
+ bool,
+ typer.Option(
+ "--models/--no-models",
+ help="Include downloaded model artifacts in the readiness check.",
+ ),
+ ] = True,
) -> None:
"""Validate selected indexing dependencies without downloading models."""
@@ -247,7 +256,7 @@ def show_check_complete(
result = state.service.check_dependencies(
DependencyCheckCommand(
modalities=selected,
- include_models=True,
+ include_models=include_models,
),
on_check_start=(
show_check_start if output_format == OutputFormat.rich else None
@@ -406,6 +415,10 @@ def prepare(
help="Confirm the displayed model download and cache size.",
),
] = False,
+ progress_file: Annotated[
+ Path | None,
+ typer.Option("--progress-file", hidden=True),
+ ] = None,
) -> None:
"""Download and cache selected runtime models before indexing."""
@@ -481,9 +494,21 @@ def prepare(
)
)
if not detach:
+
+ def report_progress(job: Job) -> None:
+ if show_progress:
+ emit_job_progress(job)
+ if progress_file is not None and job.progress is not None:
+ write_json_atomic(
+ progress_file,
+ job.progress.model_dump(mode="json"),
+ )
+
job = state.jobs.wait(
job.job_id,
- progress=emit_job_progress if show_progress else None,
+ progress=(
+ report_progress if show_progress or progress_file is not None else None
+ ),
)
if output_format == OutputFormat.json:
emit_json(job.model_dump(mode="json"))
diff --git a/src/vidxp/codex_plugin.py b/src/vidxp/codex_plugin.py
new file mode 100644
index 0000000..4f2bfa0
--- /dev/null
+++ b/src/vidxp/codex_plugin.py
@@ -0,0 +1,513 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+import tomllib
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any, Callable, Mapping, Sequence
+
+from vidxp import __version__
+from vidxp.mcp_cli import stdio_client_config
+
+
+PLUGIN_NAME = "vidxp"
+MARKETPLACE_NAME = "vidxp-local"
+GIT_MARKETPLACE_NAME = "vidxp"
+MANAGED_MARKER = ".vidxp-managed-marketplace"
+MARKETPLACE_MANIFEST = Path(".agents") / "plugins" / "marketplace.json"
+
+
+class CodexPluginInstallError(RuntimeError):
+ """Raised when the bundled Codex plugin cannot be installed safely."""
+
+
+@dataclass(frozen=True)
+class CodexPluginExport:
+ marketplace_root: str
+ marketplace_path: str
+ marketplace_name: str
+ plugin_name: str
+ plugin_version: str
+
+ def to_dict(self) -> dict[str, str]:
+ return asdict(self)
+
+
+@dataclass(frozen=True)
+class CodexPluginInstall:
+ plugin_name: str
+ plugin_id: str | None
+ plugin_version: str
+ marketplace_name: str
+ marketplace_path: str
+ installed_path: str | None
+ detail: str
+
+ def to_dict(self) -> dict[str, str | None]:
+ return asdict(self)
+
+
+def bundled_codex_plugin() -> Path:
+ packaged = Path(__file__).resolve().parent / "bundled_plugins" / PLUGIN_NAME
+ if (packaged / ".codex-plugin" / "plugin.json").is_file():
+ return packaged
+ return Path(__file__).resolve().parents[2] / "plugins" / PLUGIN_NAME
+
+
+def _json_bytes(payload: Any) -> bytes:
+ return (
+ json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
+ ).encode("utf-8")
+
+
+def _write_json(path: Path, payload: Any) -> None:
+ path.write_bytes(_json_bytes(payload))
+
+
+def _bundle_digest(source: Path) -> str:
+ digest = hashlib.sha256()
+ for path in sorted(item for item in source.rglob("*") if item.is_file()):
+ digest.update(path.relative_to(source).as_posix().encode("utf-8"))
+ digest.update(path.read_bytes())
+ return digest.hexdigest()[:12]
+
+
+def _validated_marketplace_root(marketplace_root: Path) -> Path:
+ root = marketplace_root.expanduser().resolve()
+ if root == Path(root.anchor):
+ raise CodexPluginInstallError(
+ "The Codex marketplace cannot be written at a filesystem root."
+ )
+ marker = root / MANAGED_MARKER
+ if root.exists() and any(root.iterdir()) and not marker.is_file():
+ raise CodexPluginInstallError(
+ f"Refusing to replace the unmanaged marketplace directory at {root}."
+ )
+ return root
+
+
+def export_codex_plugin(
+ marketplace_root: Path,
+ *,
+ registry: str | None = None,
+ repository: str = "default",
+ index_directory: str | None = None,
+ data_directory: Path | None = None,
+ device: str | None = None,
+) -> CodexPluginExport:
+ """Export a target-specific copy of VidXP's bundled Codex plugin."""
+
+ source = bundled_codex_plugin()
+ if not (source / ".codex-plugin" / "plugin.json").is_file():
+ raise CodexPluginInstallError(
+ "This VidXP installation does not contain the bundled Codex plugin."
+ )
+ root = _validated_marketplace_root(marketplace_root)
+ plugins_root = root / "plugins"
+ plugins_root.mkdir(parents=True, exist_ok=True)
+ plugin_root = plugins_root / PLUGIN_NAME
+ marker = root / MANAGED_MARKER
+
+ plugin_version = (
+ f"{__version__.split('+', 1)[0]}+codex."
+ f"{_bundle_digest(source)}"
+ )
+
+ staging_parent = Path(tempfile.mkdtemp(prefix=".vidxp-plugin-", dir=plugins_root))
+ staging_plugin = staging_parent / PLUGIN_NAME
+ backup = plugins_root / ".vidxp-plugin-backup"
+ try:
+ shutil.copytree(source, staging_plugin)
+ manifest_path = staging_plugin / ".codex-plugin" / "plugin.json"
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ manifest["version"] = plugin_version
+ _write_json(manifest_path, manifest)
+
+ if backup.exists():
+ shutil.rmtree(backup)
+ if plugin_root.exists():
+ if not marker.is_file():
+ raise CodexPluginInstallError(
+ f"Refusing to replace the unmanaged plugin at {plugin_root}."
+ )
+ os.replace(plugin_root, backup)
+ try:
+ os.replace(staging_plugin, plugin_root)
+ except Exception:
+ if backup.exists() and not plugin_root.exists():
+ os.replace(backup, plugin_root)
+ raise
+ if backup.exists():
+ shutil.rmtree(backup)
+ finally:
+ if staging_parent.exists():
+ shutil.rmtree(staging_parent)
+
+ marketplace = {
+ "name": MARKETPLACE_NAME,
+ "interface": {"displayName": "VidXP Local"},
+ "plugins": [
+ {
+ "name": PLUGIN_NAME,
+ "source": {
+ "source": "local",
+ "path": f"./plugins/{PLUGIN_NAME}",
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL",
+ },
+ "category": "Productivity",
+ }
+ ],
+ }
+ marketplace_path = root / MARKETPLACE_MANIFEST
+ marketplace_path.parent.mkdir(parents=True, exist_ok=True)
+ _write_json(marketplace_path, marketplace)
+ legacy_marketplace_path = root / "marketplace.json"
+ if marker.is_file() and legacy_marketplace_path.is_file():
+ legacy_marketplace_path.unlink()
+ marker.write_text("Managed by VidXP Desktop.\n", encoding="utf-8")
+ return CodexPluginExport(
+ marketplace_root=str(root),
+ marketplace_path=str(marketplace_path),
+ marketplace_name=MARKETPLACE_NAME,
+ plugin_name=PLUGIN_NAME,
+ plugin_version=plugin_version,
+ )
+
+
+CommandRunner = Callable[..., subprocess.CompletedProcess[str]]
+
+
+def _run_codex_json(
+ command: str,
+ arguments: Sequence[str],
+ *,
+ runner: CommandRunner,
+) -> dict[str, Any]:
+ try:
+ completed = runner(
+ [command, *arguments],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=60,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise CodexPluginInstallError(f"Codex could not be started: {exc}") from exc
+ if completed.returncode != 0:
+ detail = (completed.stderr or completed.stdout).strip()
+ raise CodexPluginInstallError(
+ detail or f"Codex exited with status {completed.returncode}."
+ )
+ try:
+ payload = json.loads(completed.stdout)
+ except json.JSONDecodeError as exc:
+ raise CodexPluginInstallError(
+ "Codex did not return valid plugin installation details."
+ ) from exc
+ if not isinstance(payload, dict):
+ raise CodexPluginInstallError(
+ "Codex returned an unexpected plugin installation response."
+ )
+ return payload
+
+
+def _run_codex(
+ command: str,
+ arguments: Sequence[str],
+ *,
+ runner: CommandRunner,
+) -> subprocess.CompletedProcess[str]:
+ try:
+ completed = runner(
+ [command, *arguments],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=60,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ raise CodexPluginInstallError(f"Codex could not be started: {exc}") from exc
+ if completed.returncode != 0:
+ detail = (completed.stderr or completed.stdout).strip()
+ raise CodexPluginInstallError(
+ detail or f"Codex exited with status {completed.returncode}."
+ )
+ return completed
+
+
+def _plugin_version(plugin_root: Path) -> str:
+ manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ version = manifest["version"]
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
+ raise CodexPluginInstallError(
+ "This VidXP installation does not contain a valid plugin manifest."
+ ) from exc
+ if not isinstance(version, str) or not version:
+ raise CodexPluginInstallError("The VidXP plugin version is invalid.")
+ return version
+
+
+def _remove_legacy_local_plugin(
+ command: str,
+ *,
+ runner: CommandRunner,
+) -> None:
+ plugins = _run_codex_json(
+ command,
+ ["plugin", "list", "--json"],
+ runner=runner,
+ ).get("installed", [])
+ if any(
+ isinstance(plugin, dict)
+ and plugin.get("pluginId") == f"{PLUGIN_NAME}@{MARKETPLACE_NAME}"
+ for plugin in plugins
+ ):
+ _run_codex_json(
+ command,
+ [
+ "plugin",
+ "remove",
+ f"{PLUGIN_NAME}@{MARKETPLACE_NAME}",
+ "--json",
+ ],
+ runner=runner,
+ )
+
+ marketplaces = _run_codex_json(
+ command,
+ ["plugin", "marketplace", "list", "--json"],
+ runner=runner,
+ ).get("marketplaces", [])
+ if any(
+ isinstance(marketplace, dict)
+ and marketplace.get("name") == MARKETPLACE_NAME
+ for marketplace in marketplaces
+ ):
+ _run_codex_json(
+ command,
+ ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--json"],
+ runner=runner,
+ )
+
+
+def _configured_codex_command(
+ environment: Mapping[str, str],
+) -> str | None:
+ codex_home = Path(
+ environment.get("CODEX_HOME", str(Path.home() / ".codex"))
+ ).expanduser()
+ try:
+ config = tomllib.loads(
+ (codex_home / "config.toml").read_text(encoding="utf-8")
+ )
+ except (OSError, tomllib.TOMLDecodeError):
+ return None
+ servers = config.get("mcp_servers")
+ node_repl = servers.get("node_repl") if isinstance(servers, dict) else None
+ node_repl_environment = (
+ node_repl.get("env") if isinstance(node_repl, dict) else None
+ )
+ policy = config.get("shell_environment_policy")
+ policy_environment = policy.get("set") if isinstance(policy, dict) else None
+ for configured in (node_repl_environment, policy_environment):
+ command = (
+ configured.get("CODEX_CLI_PATH")
+ if isinstance(configured, dict)
+ else None
+ )
+ if isinstance(command, str) and command.strip():
+ return command
+ return None
+
+
+def resolve_codex_command(
+ *,
+ environment: Mapping[str, str] | None = None,
+ which: Callable[[str], str | None] = shutil.which,
+) -> str | None:
+ """Locate the current CLI bundled with Codex or available on PATH."""
+ current_environment = os.environ if environment is None else environment
+ candidates: list[str] = []
+ environment_command = current_environment.get("CODEX_CLI_PATH")
+ if environment_command:
+ candidates.append(environment_command)
+ configured_command = _configured_codex_command(current_environment)
+ if configured_command:
+ candidates.append(configured_command)
+
+ local_app_data = current_environment.get("LOCALAPPDATA")
+ if local_app_data:
+ bin_directory = Path(local_app_data) / "OpenAI" / "Codex" / "bin"
+ if bin_directory.is_dir():
+ versioned = sorted(
+ bin_directory.glob("*/codex.exe"),
+ key=lambda path: path.stat().st_mtime,
+ reverse=True,
+ )
+ candidates.extend(str(path) for path in versioned)
+ candidates.append(str(bin_directory / "codex.exe"))
+
+ path_command = which("codex")
+ if path_command:
+ candidates.append(path_command)
+
+ seen: set[str] = set()
+ for candidate in candidates:
+ normalized = os.path.normcase(os.path.abspath(os.path.expanduser(candidate)))
+ if normalized in seen:
+ continue
+ seen.add(normalized)
+ if Path(candidate).expanduser().is_file():
+ return str(Path(candidate).expanduser())
+ return None
+
+
+def install_codex_plugin(
+ marketplace_root: Path | None,
+ *,
+ marketplace_source: str | None = None,
+ marketplace_ref: str | None = None,
+ marketplace_sparse: Sequence[str] = (),
+ registry: str | None = None,
+ repository: str = "default",
+ index_directory: str | None = None,
+ data_directory: Path | None = None,
+ device: str | None = None,
+ codex_command: str | None = None,
+ runner: CommandRunner = subprocess.run,
+) -> CodexPluginInstall:
+ """Install VidXP's plugin and register its target-specific MCP command."""
+
+ if marketplace_source and marketplace_root is not None:
+ raise CodexPluginInstallError(
+ "Choose either a Git marketplace source or a local marketplace root."
+ )
+ if not marketplace_source and marketplace_root is None:
+ raise CodexPluginInstallError(
+ "A Git marketplace source or local marketplace root is required."
+ )
+
+ command = codex_command or resolve_codex_command()
+ if command is None:
+ raise CodexPluginInstallError(
+ "The Codex CLI was not found. Install or update the ChatGPT desktop "
+ "app, make the codex command available, and try again."
+ )
+
+ if marketplace_source:
+ marketplace_arguments = [
+ "plugin",
+ "marketplace",
+ "add",
+ marketplace_source,
+ ]
+ if marketplace_ref:
+ marketplace_arguments.extend(("--ref", marketplace_ref))
+ for sparse_path in marketplace_sparse:
+ marketplace_arguments.extend(("--sparse", sparse_path))
+ marketplace_arguments.append("--json")
+ marketplace_path = (
+ f"{marketplace_source}@{marketplace_ref}"
+ if marketplace_ref
+ else marketplace_source
+ )
+ marketplace_name_fallback = GIT_MARKETPLACE_NAME
+ plugin_version = _plugin_version(bundled_codex_plugin())
+ else:
+ assert marketplace_root is not None
+ exported = export_codex_plugin(
+ marketplace_root,
+ registry=registry,
+ repository=repository,
+ index_directory=index_directory,
+ data_directory=data_directory,
+ device=device,
+ )
+ marketplace_arguments = [
+ "plugin",
+ "marketplace",
+ "add",
+ exported.marketplace_root,
+ "--json",
+ ]
+ marketplace_path = exported.marketplace_path
+ marketplace_name_fallback = exported.marketplace_name
+ plugin_version = exported.plugin_version
+
+ marketplace_result = _run_codex_json(
+ command,
+ marketplace_arguments,
+ runner=runner,
+ )
+ marketplace_name = str(
+ marketplace_result.get("marketplaceName") or marketplace_name_fallback
+ )
+ plugin_result = _run_codex_json(
+ command,
+ [
+ "plugin",
+ "add",
+ f"{PLUGIN_NAME}@{marketplace_name}",
+ "--json",
+ ],
+ runner=runner,
+ )
+
+ mcp = stdio_client_config(
+ registry=registry,
+ repository=repository,
+ index_directory=index_directory,
+ data_directory=data_directory,
+ device=device,
+ )["mcpServers"][PLUGIN_NAME]
+ _run_codex(
+ command,
+ [
+ "mcp",
+ "add",
+ PLUGIN_NAME,
+ "--",
+ str(mcp["command"]),
+ *(str(argument) for argument in mcp["args"]),
+ ],
+ runner=runner,
+ )
+ if marketplace_source:
+ _remove_legacy_local_plugin(command, runner=runner)
+
+ return CodexPluginInstall(
+ plugin_name=str(plugin_result.get("name") or PLUGIN_NAME),
+ plugin_id=(
+ None
+ if plugin_result.get("pluginId") is None
+ else str(plugin_result["pluginId"])
+ ),
+ plugin_version=str(
+ plugin_result.get("version") or plugin_version
+ ),
+ marketplace_name=str(
+ plugin_result.get("marketplaceName") or marketplace_name
+ ),
+ marketplace_path=marketplace_path,
+ installed_path=(
+ None
+ if plugin_result.get("installedPath") is None
+ else str(plugin_result["installedPath"])
+ ),
+ detail=(
+ "VidXP is installed in Codex with its skills and local MCP server. "
+ "Start a new Codex task to use the updated plugin."
+ ),
+ )
diff --git a/src/vidxp/codex_plugin_cli.py b/src/vidxp/codex_plugin_cli.py
new file mode 100644
index 0000000..ae0195b
--- /dev/null
+++ b/src/vidxp/codex_plugin_cli.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Sequence
+
+from vidxp.codex_plugin import CodexPluginInstallError, install_codex_plugin
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Install VidXP's bundled MCP server and skills in Codex."
+ )
+ parser.add_argument(
+ "--marketplace-root",
+ type=Path,
+ help="Dedicated local marketplace directory managed by VidXP Desktop.",
+ )
+ parser.add_argument(
+ "--marketplace-source",
+ help="Git marketplace source, such as owner/repository.",
+ )
+ parser.add_argument("--marketplace-ref", help="Git ref to fetch.")
+ parser.add_argument(
+ "--marketplace-sparse",
+ action="append",
+ default=[],
+ help="Sparse checkout path for a Git marketplace; repeat as needed.",
+ )
+ parser.add_argument("--registry")
+ parser.add_argument("--repository", default="default")
+ parser.add_argument("--index-directory")
+ parser.add_argument("--data-dir", type=Path)
+ parser.add_argument("--device")
+ return parser
+
+
+def main(arguments: Sequence[str] | None = None) -> None:
+ parser = _parser()
+ options = parser.parse_args(arguments)
+ try:
+ result = install_codex_plugin(
+ options.marketplace_root,
+ marketplace_source=options.marketplace_source,
+ marketplace_ref=options.marketplace_ref,
+ marketplace_sparse=options.marketplace_sparse,
+ registry=options.registry,
+ repository=options.repository,
+ index_directory=options.index_directory,
+ data_directory=options.data_dir,
+ device=options.device,
+ )
+ except (CodexPluginInstallError, OSError, ValueError) as exc:
+ parser.exit(1, f"VidXP could not set up Codex: {exc}\n")
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/vidxp/control_plane.py b/src/vidxp/control_plane.py
index 1897080..08278d0 100644
--- a/src/vidxp/control_plane.py
+++ b/src/vidxp/control_plane.py
@@ -244,7 +244,7 @@ def preflight_index(self, command: CreateIndexCommand) -> None:
selected,
command.capability_options,
)
- self.get_media(command.media_id)
+ self.media.require_record(command.media_id)
self.require_models(selected)
@application_boundary
diff --git a/src/vidxp/core/indexing_common.py b/src/vidxp/core/indexing_common.py
index 9758c7c..b1b1b75 100644
--- a/src/vidxp/core/indexing_common.py
+++ b/src/vidxp/core/indexing_common.py
@@ -6,6 +6,28 @@
ProgressCallback = Callable[[dict[str, Any]], None]
+def report_preparation(
+ callback: ProgressCallback | None,
+ stage: str,
+ message: str,
+ *,
+ current: int | None = None,
+ total: int | None = None,
+) -> None:
+ if callback is None:
+ return
+ event: dict[str, Any] = {
+ "state": "preparing",
+ "stage": stage,
+ "message": message,
+ }
+ if current is not None:
+ event["current"] = current
+ if total is not None:
+ event["total"] = total
+ callback(event)
+
+
def report_progress(
callback: ProgressCallback | None,
stage: str,
diff --git a/src/vidxp/core/media.py b/src/vidxp/core/media.py
index 95b6baf..6fbf7e7 100644
--- a/src/vidxp/core/media.py
+++ b/src/vidxp/core/media.py
@@ -42,7 +42,9 @@ class MediaUnavailableError(FileNotFoundError):
class MediaState(StrEnum):
+ pending = "pending"
ready = "ready"
+ failed = "failed"
class _MediaModel(BaseModel):
@@ -132,10 +134,10 @@ class MediaRecord(_MediaModel):
original_filename: str = Field(min_length=1, max_length=255)
byte_size: int = Field(gt=0)
declared_mime_type: MimeType | None = None
- detected_mime_type: MimeType
- container: str = Field(min_length=1)
- duration_seconds: float = Field(gt=0)
- streams: tuple[MediaStream, ...] = Field(min_length=1)
+ detected_mime_type: MimeType | None = None
+ container: str | None = Field(default=None, min_length=1)
+ duration_seconds: float | None = Field(default=None, gt=0)
+ streams: tuple[MediaStream, ...] = ()
storage_key: str = Field(min_length=1)
state: MediaState = MediaState.ready
created_at: AwareDatetime
@@ -151,8 +153,15 @@ def _validate_storage_key(cls, value: str) -> str:
return validate_storage_key(value)
@model_validator(mode="after")
- def _require_video_stream(self) -> "MediaRecord":
- if not any(stream.kind == "video" for stream in self.streams):
+ def _require_ready_probe(self) -> "MediaRecord":
+ if self.state != MediaState.ready:
+ return self
+ if (
+ self.detected_mime_type is None
+ or self.container is None
+ or self.duration_seconds is None
+ or not any(stream.kind == "video" for stream in self.streams)
+ ):
raise ValueError("ready media must contain a video stream")
return self
diff --git a/src/vidxp/frontend.py b/src/vidxp/frontend.py
index 0260d7e..b9f095d 100644
--- a/src/vidxp/frontend.py
+++ b/src/vidxp/frontend.py
@@ -28,6 +28,7 @@
)
from vidxp.branding import PROJECT_URL, icon_path
from vidxp.composition import create_application, create_job_service
+from vidxp.core.media import MediaState
from vidxp.index_state import IndexNotReadyError
from vidxp.job_service import JobService
from vidxp.settings import LocalExecutionSettings, VidXPSettings
@@ -120,6 +121,24 @@ def _settings_from_arguments(
1.0: "Balanced — every second",
2.0: "Detailed — twice per second",
}
+VIDEOPRISM_SAMPLE_FPS_DEFAULT = 2.0
+VIDEOPRISM_DETAIL_PRESETS = (1.0, VIDEOPRISM_SAMPLE_FPS_DEFAULT, 4.0)
+VIDEOPRISM_DETAIL_LABELS = {
+ 1.0: "Long actions — about 16 seconds per clip",
+ 2.0: "Balanced — about 8 seconds per clip",
+ 4.0: "Short actions — about 4 seconds per clip",
+}
+CAPABILITY_LABELS = {
+ "actor": "Actor groups",
+ "dialogue": "Dialogue search",
+ "natural-language": "Ask a question",
+ "scene": "Scene search",
+ "videoprism": "Temporal action search (VideoPrism)",
+}
+
+
+def _capability_label(name: str) -> str:
+ return CAPABILITY_LABELS.get(name, name.replace("-", " ").title())
def _format_bytes(size: int) -> str:
@@ -191,7 +210,10 @@ def _render_summary(summary):
(
f"Media: {summary.get('media_count', 0):,}",
"Capabilities: "
- + ", ".join(summary.get("modalities", ())),
+ + ", ".join(
+ _capability_label(name)
+ for name in summary.get("modalities", ())
+ ),
)
)
)
@@ -347,6 +369,7 @@ def _run_indexing(
modalities,
*,
scene_sample_fps: float | None = None,
+ videoprism_sample_fps: float | None = None,
):
service = _configured_service()
temporary_path = None
@@ -383,6 +406,15 @@ def _run_indexing(
media_id=media_id,
modalities=modalities,
scene_sample_fps=scene_sample_fps,
+ capability_options=(
+ {
+ "videoprism": {
+ "sample_fps": videoprism_sample_fps,
+ }
+ }
+ if videoprism_sample_fps is not None
+ else {}
+ ),
)
)
st.session_state[INDEX_JOB_ID_KEY] = job.job_id
@@ -428,6 +460,28 @@ def _scene_sample_fps_control(
)
+def _videoprism_sample_fps_control(
+ modalities: tuple[str, ...],
+ *,
+ disabled: bool,
+) -> float | None:
+ if "videoprism" not in modalities:
+ return None
+ return float(
+ st.selectbox(
+ "Temporal clip length",
+ VIDEOPRISM_DETAIL_PRESETS,
+ index=1,
+ format_func=VIDEOPRISM_DETAIL_LABELS.__getitem__,
+ disabled=disabled,
+ help=(
+ "VideoPrism embeds 16 sampled frames per clip. Longer clips "
+ "cover slower actions; shorter clips localize quick actions."
+ ),
+ )
+ )
+
+
def _run_search(search_type, query, media_id=None):
service = _configured_service()
try:
@@ -654,6 +708,8 @@ def poll_search_job():
result_label = (
"Closest sampled scene"
if search_type == "scene"
+ else "Closest temporal action clip"
+ if search_type == "videoprism"
else "Closest supporting evidence"
if search_type == "natural-language"
else f"Closest {search_type} match"
@@ -665,6 +721,11 @@ def poll_search_job():
"It does not identify the first occurrence and is not reliable "
"for counting people."
)
+ elif search_type == "videoprism":
+ st.caption(
+ "VideoPrism ranks short multi-frame clips, making it better suited "
+ "to actions and events than single-frame scene search."
+ )
st.video(
str(resource.path),
start_time=timestamp,
@@ -690,7 +751,11 @@ def _import_local_video(service, raw_path):
def _select_video(busy, media_id, media_page):
service = _configured_service()
st.subheader("Video")
- assets = tuple(media_page.items) if media_page is not None else ()
+ assets = tuple(
+ asset
+ for asset in (media_page.items if media_page is not None else ())
+ if asset.state == MediaState.ready
+ )
media_id = _default_media_id(media_id, assets)
if media_id is not None:
st.session_state[MEDIA_ID_KEY] = media_id
@@ -797,6 +862,7 @@ def _search_controls(ready, uploaded_video, available_modalities):
search_type = st.selectbox(
"Search type",
["natural-language", *available_modalities],
+ format_func=_capability_label,
disabled=not ready,
)
with query_column:
@@ -813,6 +879,8 @@ def _search_controls(ready, uploaded_video, available_modalities):
if search_type == "actor"
else "For example: What happens after the taxi arrives?"
if search_type == "natural-language"
+ else "For example: A person opens a door and walks out."
+ if search_type == "videoprism"
else "For example: Chef makes pizza and cuts it up."
),
disabled=not ready,
@@ -838,7 +906,9 @@ def run():
st.logo(application_icon, size="large", link=PROJECT_URL)
service = _configured_service()
st.title("VidXP")
- st.caption("Index and search video by dialogue, scene, and actor.")
+ st.caption(
+ "Index and search video by dialogue, scene, temporal clips, and actor."
+ )
st.caption(f"Index repository: {service.layout.root}")
if notice := st.session_state.pop(MEDIA_NOTICE_KEY, None):
st.success(notice)
@@ -963,6 +1033,7 @@ def run():
"Capabilities",
installed_modalities,
default=installed_modalities,
+ format_func=_capability_label,
disabled=busy,
help="Install another capability extra to make it available here.",
)
@@ -971,6 +1042,10 @@ def run():
selected_modalities,
disabled=busy,
)
+ videoprism_sample_fps = _videoprism_sample_fps_control(
+ selected_modalities,
+ disabled=busy,
+ )
model_readiness = (
service.model_readiness(selected_modalities)
if selected_modalities
@@ -1209,6 +1284,7 @@ def poll_index_status():
status,
selected_modalities,
scene_sample_fps=scene_sample_fps,
+ videoprism_sample_fps=videoprism_sample_fps,
)
diff --git a/src/vidxp/infrastructure/sql_catalog.py b/src/vidxp/infrastructure/sql_catalog.py
index 076c722..c433665 100644
--- a/src/vidxp/infrastructure/sql_catalog.py
+++ b/src/vidxp/infrastructure/sql_catalog.py
@@ -22,7 +22,7 @@
from sqlalchemy.pool import NullPool
from vidxp.core.artifacts import ArtifactRecord, ArtifactState
-from vidxp.core.media import MediaRecord, utc_now
+from vidxp.core.media import MediaRecord, MediaState, utc_now
from vidxp.core.uploads import (
UploadIntentRecord,
UploadSessionFileRecord,
@@ -49,6 +49,7 @@
UploadState.processing.value,
UploadState.failed.value,
}
+_REPLACEABLE_MEDIA_STATES = {MediaState.pending, MediaState.failed}
_UPLOAD_QUOTA_ID = "1"
_EXPECTED_VALUE_UNSET = object()
@@ -258,6 +259,30 @@ def put_media(self, record: MediaRecord) -> MediaRecord:
raise
return record
+ def replace_media(self, record: MediaRecord) -> MediaRecord:
+ with self._write_transaction() as connection:
+ existing = self._media_by_id(connection, record.media_id)
+ if existing is None:
+ raise FileNotFoundError(
+ f"Media {record.media_id} is not cataloged."
+ )
+ if existing.sha256 != record.sha256:
+ raise FileExistsError(
+ f"Media {record.media_id} already has another record."
+ )
+ if existing == record:
+ return existing
+ if existing.state not in _REPLACEABLE_MEDIA_STATES:
+ raise FileExistsError(
+ f"Media {record.media_id} already has another record."
+ )
+ connection.execute(
+ update(media)
+ .where(media.c.media_id == record.media_id)
+ .values(payload=record.model_dump(mode="json"))
+ )
+ return record
+
@staticmethod
def _media_by_id(
connection: Connection,
diff --git a/src/vidxp/local_probe.py b/src/vidxp/local_probe.py
index f57697e..1c0bcfa 100644
--- a/src/vidxp/local_probe.py
+++ b/src/vidxp/local_probe.py
@@ -110,7 +110,9 @@ def _surface_capabilities(
return {
"worker": _surface_capability(
installed=(
- {"dialogue", "scene", "actor"}.issubset(search_capabilities)
+ {"dialogue", "scene", "actor", "videoprism"}.issubset(
+ search_capabilities
+ )
and _module_available("pydantic_ai")
),
media_ready=media_ready,
diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py
index 991afb7..c2f25ed 100644
--- a/src/vidxp/mcp.py
+++ b/src/vidxp/mcp.py
@@ -103,6 +103,11 @@
scoped_job_id,
scoped_request_key,
)
+from vidxp.mcp_app import (
+ MCP_APP_MIME_TYPE,
+ MCP_APP_RESOURCE_URI,
+ load_mcp_app_html,
+)
from vidxp.core.identifiers import ArtifactId
from vidxp.evidence_delivery import (
EvidenceDeliveryService,
@@ -149,6 +154,15 @@
)
+def _mcp_app_tool_meta(invoking: str, invoked: str) -> dict[str, object]:
+ return {
+ "ui": {"resourceUri": MCP_APP_RESOURCE_URI},
+ "openai/outputTemplate": MCP_APP_RESOURCE_URI,
+ "openai/toolInvocation/invoking": invoking,
+ "openai/toolInvocation/invoked": invoked,
+ }
+
+
class PrincipalBridge:
"""Carry the principal validated by the outer ASGI boundary into tools."""
@@ -547,6 +561,27 @@ async def lifecycle(_server):
lifespan=lifecycle,
)
+ @server.resource(
+ MCP_APP_RESOURCE_URI,
+ name="vidxp_mcp_app",
+ title="VidXP video workspace",
+ description=(
+ "Interactive upload progress and evidence review for MCP Apps hosts."
+ ),
+ mime_type=MCP_APP_MIME_TYPE,
+ meta={
+ "ui": {
+ "prefersBorder": True,
+ "csp": {
+ "connectDomains": [],
+ "resourceDomains": [],
+ },
+ }
+ },
+ )
+ async def read_mcp_app() -> str:
+ return load_mcp_app_html()
+
async def artifact_bytes(
artifact_id: ArtifactId,
*,
@@ -1061,9 +1096,125 @@ def evidence_index(
)
return "\n".join(lines)
- async def evidence_content(
+ def evidence_app_payload(
+ *,
job: Job,
- ) -> list[ImageContent | ResourceLink | TextContent]:
+ source_job_id: JobId,
+ delivery: EvidenceDeliveryResult,
+ query_result: QueryAnswer | None,
+ ) -> dict[str, object]:
+ board = delivery.board
+ pages: list[dict[str, object]] = []
+ tiles: list[dict[str, object]] = []
+ if board is not None:
+ for page in board.pages:
+ artifact = page.artifact
+ delivery_info = artifact.delivery
+ pages.append(
+ {
+ "page_number": page.page_number,
+ "media_id": page.media_id,
+ "width": page.width,
+ "height": page.height,
+ "tile_ids": list(page.tile_ids),
+ "resource_uri": artifact.resource_uri,
+ "download_url": (
+ delivery_info.download_url
+ if delivery_info is not None
+ else None
+ ),
+ }
+ )
+ for tile in board.tiles:
+ tiles.append(
+ {
+ "evidence_id": tile.evidence_id,
+ "rank": tile.rank,
+ "page_number": tile.page_number,
+ "position": tile.position,
+ "media_id": tile.media_id,
+ "modalities": list(tile.modalities),
+ "start": tile.start,
+ "end": tile.end,
+ "display_text": concise_text(tile.display_text),
+ "state": tile.state.value,
+ }
+ )
+ requested_count = board.requested_count
+ rendered_count = board.rendered_count
+ failed_count = board.failed_count
+ next_start_rank = board.next_start_rank
+ else:
+ for item in delivery.items:
+ resolved = item.range
+ tiles.append(
+ {
+ "evidence_id": item.evidence_id,
+ "rank": item.rank,
+ "page_number": None,
+ "position": item.rank,
+ "media_id": item.media_id,
+ "modalities": list(item.modalities),
+ "start": (
+ resolved.source_start_seconds
+ if resolved is not None
+ else 0.0
+ ),
+ "end": (
+ resolved.source_end_seconds
+ if resolved is not None
+ else 0.0
+ ),
+ "display_text": None,
+ "state": item.state.value,
+ }
+ )
+ requested_count = len(delivery.items)
+ rendered_count = sum(
+ item.state.value == "ready" for item in delivery.items
+ )
+ failed_count = requested_count - rendered_count
+ next_start_rank = None
+
+ answer: dict[str, object] | None = None
+ if query_result is not None:
+ answer = {
+ "mode": query_result.mode.value,
+ "claims": [
+ {
+ "text": concise_text(claim.text, limit=512) or "",
+ "evidence_ids": list(claim.evidence_ids),
+ }
+ for claim in query_result.claims
+ ],
+ "fallback_reason": concise_text(
+ query_result.fallback_reason,
+ limit=512,
+ ),
+ }
+
+ return {
+ "view": "evidence",
+ "job_id": job.job_id,
+ "source_job_id": source_job_id,
+ "job_kind": job.kind.value,
+ "answer": answer,
+ "board": {
+ "requested_count": requested_count,
+ "rendered_count": rendered_count,
+ "failed_count": failed_count,
+ "next_start_rank": next_start_rank,
+ "pages": pages,
+ "tiles": tiles,
+ },
+ }
+
+ async def evidence_presentation(
+ job: Job,
+ ) -> tuple[
+ dict[str, object],
+ list[ImageContent | ResourceLink | TextContent],
+ ]:
query_result = None
if job.kind in {JobKind.search, JobKind.query}:
result = job.result.result
@@ -1082,18 +1233,27 @@ async def evidence_content(
delivery=projected_delivery,
query_result=query_result,
)
+ source_job_id = job.job_id
else:
board = job.result.result
projected_board, blocks = await project_evidence_board(board)
+ projected_delivery = EvidenceDeliveryResult(
+ policy=EvidenceDeliveryPolicy(mode=EvidenceDeliveryMode.none),
+ items=(),
+ board=projected_board,
+ )
index = evidence_index(
source_job_id=board.source_job_id,
- delivery=EvidenceDeliveryResult(
- policy=EvidenceDeliveryPolicy(mode=EvidenceDeliveryMode.none),
- items=(),
- board=projected_board,
- ),
+ delivery=projected_delivery,
)
- return [TextContent(type="text", text=index), *blocks]
+ source_job_id = board.source_job_id
+ payload = evidence_app_payload(
+ job=job,
+ source_job_id=source_job_id,
+ delivery=projected_delivery,
+ query_result=query_result,
+ )
+ return payload, [TextContent(type="text", text=index), *blocks]
def completed_evidence_result(
source_job_id: JobId,
@@ -1227,6 +1387,10 @@ async def get_media(media_id: MediaId) -> MediaAsset:
"Automatic indexing defaults on. Poll only get_media_upload."
),
annotations=_SUBMIT,
+ meta=_mcp_app_tool_meta(
+ "Creating a VidXP upload session…",
+ "VidXP upload session ready.",
+ ),
structured_output=True,
)
async def create_media_upload(
@@ -1826,10 +1990,14 @@ async def get_job(job_id: JobId) -> Job:
description=(
"Present a completed search, query, or evidence-board job as a "
"concise evidence index plus model-visible board images and resource "
- "links. This intentionally omits structuredContent; use get_job only "
- "when the full machine record is actually needed."
+ "links. The compact structured result drives the optional VidXP "
+ "evidence-review UI without exposing the full machine record."
),
annotations=_READ_ONLY,
+ meta=_mcp_app_tool_meta(
+ "Opening VidXP evidence…",
+ "VidXP evidence ready.",
+ ),
)
async def get_job_evidence(job_id: JobId) -> CallToolResult:
def completed_evidence_job(_actor: Principal) -> Job:
@@ -1855,10 +2023,13 @@ def completed_evidence_job(_actor: Principal) -> Job:
operation=completed_evidence_job,
)
try:
- blocks = await evidence_content(job)
+ structured_content, blocks = await evidence_presentation(job)
except ApplicationError as exc:
raise _application_error(exc) from exc
- return CallToolResult(content=blocks)
+ return CallToolResult(
+ content=blocks,
+ structured_content=structured_content,
+ )
@server.tool(
title="Get compact job status",
diff --git a/src/vidxp/mcp_app.py b/src/vidxp/mcp_app.py
new file mode 100644
index 0000000..8794824
--- /dev/null
+++ b/src/vidxp/mcp_app.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from functools import lru_cache
+from importlib.resources import files
+
+
+MCP_APP_RESOURCE_URI = "ui://vidxp/evidence-review-v1.html"
+MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"
+
+
+@lru_cache(maxsize=1)
+def load_mcp_app_html() -> str:
+ """Load the self-contained MCP App resource shipped with VidXP."""
+
+ return (
+ files("vidxp")
+ .joinpath("assets", "mcp_app", "index.html")
+ .read_text(encoding="utf-8")
+ )
diff --git a/src/vidxp/media_service.py b/src/vidxp/media_service.py
index af33145..e208efa 100644
--- a/src/vidxp/media_service.py
+++ b/src/vidxp/media_service.py
@@ -183,43 +183,72 @@ def _publish_import(
declared_mime_type: str | None,
staged: StagedMedia,
) -> MediaAsset:
- if existing := self.catalog.get_media_by_checksum(staged.sha256):
+ existing = self.catalog.get_media_by_checksum(staged.sha256)
+ if existing is not None and existing.state == MediaState.ready:
self.store.publish(
staged.model_copy(
update={"storage_key": existing.storage_key}
)
)
return media_asset(existing)
- probe = self.probe.probe(staged.path)
- stored = self.store.publish(staged)
- media_id = uuid4().hex
- record = MediaRecord(
+ media_id = existing.media_id if existing is not None else uuid4().hex
+ pending = MediaRecord(
media_id=media_id,
video_id=media_id,
- sha256=stored.sha256,
+ sha256=staged.sha256,
original_filename=original_filename,
- byte_size=stored.byte_size,
+ byte_size=staged.byte_size,
declared_mime_type=declared_mime_type,
- detected_mime_type=probe.detected_mime_type,
- container=probe.container,
- duration_seconds=probe.duration_seconds,
- streams=probe.streams,
- storage_key=stored.storage_key,
- state=MediaState.ready,
- created_at=utc_now(),
+ storage_key=staged.storage_key,
+ state=MediaState.pending,
+ created_at=(
+ existing.created_at if existing is not None else utc_now()
+ ),
+ )
+ if existing is None:
+ pending = self.catalog.put_media(pending)
+ if pending.state == MediaState.ready:
+ self.store.publish(
+ staged.model_copy(
+ update={"storage_key": pending.storage_key}
+ )
+ )
+ return media_asset(pending)
+ elif existing != pending:
+ pending = self.catalog.replace_media(pending)
+ try:
+ probe = self.probe.probe(staged.path)
+ except BaseException:
+ self._mark_failed(pending)
+ raise
+ try:
+ stored = self.store.publish(staged)
+ except BaseException:
+ self._mark_failed(pending)
+ raise
+ ready = pending.model_copy(
+ update={
+ "detected_mime_type": probe.detected_mime_type,
+ "container": probe.container,
+ "duration_seconds": probe.duration_seconds,
+ "streams": probe.streams,
+ "storage_key": stored.storage_key,
+ "state": MediaState.ready,
+ }
)
try:
- authoritative = self.catalog.put_media(record)
+ authoritative = self.catalog.replace_media(ready)
except BaseException:
try:
retained = self.catalog.get_media_by_checksum(stored.sha256)
except Exception:
retained = None
- if retained is None:
+ if retained is None or retained.state != MediaState.ready:
try:
self.store.delete(stored.storage_key)
except OSError:
pass
+ self._mark_failed(pending)
raise
if authoritative.storage_key != stored.storage_key:
try:
@@ -228,8 +257,21 @@ def _publish_import(
pass
return media_asset(authoritative)
+ def _mark_failed(self, pending: MediaRecord) -> None:
+ if pending.state == MediaState.failed:
+ return
+ try:
+ self.catalog.replace_media(
+ pending.model_copy(update={"state": MediaState.failed})
+ )
+ except Exception:
+ pass
+
def get(self, media_id: str) -> MediaAsset:
- return media_asset(self.require_record(media_id))
+ record = self.catalog.get_media(media_id)
+ if record is None:
+ raise MediaUnavailableError("The media asset is unavailable.")
+ return media_asset(record)
def list(self, command: ListMediaCommand) -> MediaPage:
scope = hashlib.sha256(
diff --git a/src/vidxp/model_contracts.py b/src/vidxp/model_contracts.py
index b1ab521..e3852db 100644
--- a/src/vidxp/model_contracts.py
+++ b/src/vidxp/model_contracts.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
+import hashlib
from pathlib import Path
import re
from typing import Any
@@ -174,4 +175,23 @@ def model_artifact_cached(
cache: Path,
spec: ModelSpec | ArtifactSpec,
) -> bool:
- return model_artifact_path(cache, spec).is_file()
+ return model_artifact_valid(model_artifact_path(cache, spec), spec)
+
+
+def model_artifact_valid(
+ path: Path,
+ spec: ModelSpec | ArtifactSpec,
+) -> bool:
+ if not path.is_file():
+ return False
+ expected = (
+ spec.weights_sha256 if isinstance(spec, ModelSpec) else spec.sha256
+ )
+ digest = hashlib.sha256()
+ try:
+ with path.open("rb") as stream:
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(block)
+ except OSError:
+ return False
+ return digest.hexdigest() == expected
diff --git a/src/vidxp/ports.py b/src/vidxp/ports.py
index 66475d4..18c3ad2 100644
--- a/src/vidxp/ports.py
+++ b/src/vidxp/ports.py
@@ -97,6 +97,8 @@ def get_media_by_checksum(self, sha256: str) -> MediaRecord | None: ...
def put_media(self, record: MediaRecord) -> MediaRecord: ...
+ def replace_media(self, record: MediaRecord) -> MediaRecord: ...
+
def list_media(
self,
*,
diff --git a/src/vidxp/runtime.py b/src/vidxp/runtime.py
index abda32c..a02ba49 100644
--- a/src/vidxp/runtime.py
+++ b/src/vidxp/runtime.py
@@ -1,13 +1,12 @@
from __future__ import annotations
import platform
-import hashlib
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
from contextlib import contextmanager
from threading import BoundedSemaphore, Lock, RLock
from time import monotonic, sleep
-from typing import Any, Callable, Iterator
+from typing import Any, Callable, Iterator, TypeVar
from pathlib import Path
from vidxp.application_models import RuntimeProfile
@@ -17,7 +16,9 @@
ModelArtifactUnavailableError,
ModelKey,
ModelSpec,
+ model_artifact_valid,
)
+from vidxp.core.indexing_common import report_preparation
from vidxp.settings import VidXPSettings
@@ -32,6 +33,7 @@ class _ModelDownloadVerificationError(RuntimeError):
_MODEL_DOWNLOAD_ATTEMPTS = 3
_DOWNLOAD_HEARTBEAT_SECONDS = 5.0
_MINIMUM_PROGRESS_BYTES = 1024 * 1024
+_DownloadResult = TypeVar("_DownloadResult")
def _download_failure_reason(exc: Exception) -> str:
@@ -83,6 +85,39 @@ def _download_failure_retryable(
return None
+def _download_with_retries(
+ spec: ModelSpec | ArtifactSpec,
+ download: Callable[[int], _DownloadResult],
+ *,
+ resumable: bool,
+ hash_mismatch_is_retryable: bool = False,
+ on_retry: Callable[[int], None] | None = None,
+) -> _DownloadResult:
+ for attempt in range(1, _MODEL_DOWNLOAD_ATTEMPTS + 1):
+ try:
+ return download(attempt)
+ except Exception as exc:
+ retryable = _download_failure_retryable(
+ exc,
+ hash_mismatch_is_retryable=hash_mismatch_is_retryable,
+ )
+ if retryable is None:
+ raise
+ if attempt >= _MODEL_DOWNLOAD_ATTEMPTS or not retryable:
+ raise ModelArtifactDownloadError(
+ spec.capability,
+ spec.model_id,
+ attempts=attempt,
+ reason=_download_failure_reason(exc),
+ resumable=resumable,
+ retryable=retryable,
+ ) from exc
+ if on_retry is not None:
+ on_retry(attempt + 1)
+ sleep(2 ** (attempt - 1))
+ raise AssertionError("Model download retry loop did not terminate.")
+
+
def _torch_accelerators() -> tuple[bool, bool]:
try:
import torch
@@ -138,14 +173,6 @@ def resolve_backends(requested: str) -> RuntimeProfile:
)
-def _sha256(path: Path) -> str:
- digest = hashlib.sha256()
- with path.open("rb") as stream:
- for block in iter(lambda: stream.read(1024 * 1024), b""):
- digest.update(block)
- return digest.hexdigest()
-
-
class ResourceScheduler:
"""Bound concurrent model work without owning workflow state."""
@@ -259,21 +286,19 @@ def update(self, n=1):
state["message"] = f"Downloading {spec.model_id}."
return result
- def download() -> str:
+ def download_snapshot() -> str:
snapshot = Path(
snapshot_download(
repo_id=spec.model_id,
revision=spec.revision,
cache_dir=str(cache),
local_files_only=False,
+ ignore_patterns=("*.h5", "*.msgpack", "*.npz", "*.ot"),
tqdm_class=ReportingTqdm,
)
)
weights = snapshot / spec.weights_file
- if (
- not weights.is_file()
- or _sha256(weights) != spec.weights_sha256
- ):
+ if not model_artifact_valid(weights, spec):
raise _ModelDownloadVerificationError
return str(snapshot)
@@ -296,21 +321,17 @@ def report(*, force: bool = False):
heartbeat = now - reported_at >= _DOWNLOAD_HEARTBEAT_SECONDS
if not force and reported_current >= 0 and not advanced and not heartbeat:
return
- progress(
- {
- "state": "preparing",
- "stage": "downloading_model",
- **event,
- }
+ report_preparation(
+ progress,
+ "downloading_model",
+ event["message"],
+ current=event["current"],
+ total=event["total"],
)
reported_at = now
reported_current = current
- last_error: Exception | None = None
- last_retryable = False
- attempts = 0
- for attempt in range(1, _MODEL_DOWNLOAD_ATTEMPTS + 1):
- attempts = attempt
+ def download(attempt: int) -> str:
with state_lock:
state["message"] = (
f"Connecting to download {spec.model_id}."
@@ -321,49 +342,33 @@ def report(*, force: bool = False):
)
)
report(force=True)
- try:
- with ThreadPoolExecutor(max_workers=1) as pool:
- future = pool.submit(download)
- while True:
- try:
- snapshot = future.result(timeout=0.5)
- break
- except FutureTimeout:
- report()
- except Exception as exc:
- last_error = exc
- retryable = _download_failure_retryable(exc)
- if retryable is None:
- raise
- last_retryable = retryable
- if (
- attempt >= _MODEL_DOWNLOAD_ATTEMPTS
- or not retryable
- ):
- break
- with state_lock:
- state["message"] = (
- f"Download interrupted for {spec.model_id}; cached "
- "partial files will be resumed."
- )
- report(force=True)
- sleep(2 ** (attempt - 1))
- continue
+ with ThreadPoolExecutor(max_workers=1) as pool:
+ future = pool.submit(download_snapshot)
+ while True:
+ try:
+ return future.result(timeout=0.5)
+ except FutureTimeout:
+ report()
+
+ def report_retry(_next_attempt: int) -> None:
with state_lock:
- state["current"] = spec.download_size_bytes
- state["message"] = f"Downloaded {spec.model_id}."
+ state["message"] = (
+ f"Download interrupted for {spec.model_id}; cached "
+ "partial files will be resumed."
+ )
report(force=True)
- return Path(snapshot)
-
- assert last_error is not None
- raise ModelArtifactDownloadError(
- spec.capability,
- spec.model_id,
- attempts=attempts,
- reason=_download_failure_reason(last_error),
+
+ snapshot = _download_with_retries(
+ spec,
+ download,
resumable=True,
- retryable=last_retryable,
- ) from last_error
+ on_retry=report_retry,
+ )
+ with state_lock:
+ state["current"] = spec.download_size_bytes
+ state["message"] = f"Downloaded {spec.model_id}."
+ report(force=True)
+ return Path(snapshot)
def resolve_model(
self,
@@ -390,8 +395,7 @@ def resolve_model(
local_weights = local_snapshot / spec.weights_file
snapshot = (
local_snapshot
- if local_weights.is_file()
- and _sha256(local_weights) == spec.weights_sha256
+ if model_artifact_valid(local_weights, spec)
else None
)
except Exception:
@@ -424,88 +428,59 @@ def resolve_artifact(
try:
destination = self.settings.model_cache / spec.provider
path = destination / spec.filename
- if not path.is_file() or _sha256(path) != spec.sha256:
+ if not model_artifact_valid(path, spec):
if not download or not self.settings.allow_model_downloads:
raise ModelArtifactUnavailableError(spec.capability)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "downloading_model",
- "message": f"Downloading {spec.model_id}.",
- "current": 0,
- "total": spec.download_size_bytes,
- }
- )
+ report_preparation(
+ progress,
+ "downloading_model",
+ f"Downloading {spec.model_id}.",
+ current=0,
+ total=spec.download_size_bytes,
+ )
import pooch
- last_error = None
- for attempt in range(1, _MODEL_DOWNLOAD_ATTEMPTS + 1):
- try:
- resolved = Path(
- pooch.retrieve(
- url=spec.url,
- known_hash=f"sha256:{spec.sha256}",
- fname=spec.filename,
- path=destination,
- progressbar=False,
- )
- )
- break
- except Exception as exc:
- last_error = exc
- retryable = _download_failure_retryable(
- exc,
- hash_mismatch_is_retryable=True,
+ def download_artifact(_attempt: int) -> Path:
+ return Path(
+ pooch.retrieve(
+ url=spec.url,
+ known_hash=f"sha256:{spec.sha256}",
+ fname=spec.filename,
+ path=destination,
+ progressbar=False,
)
- if retryable is None:
- raise
- if (
- attempt >= _MODEL_DOWNLOAD_ATTEMPTS
- or not retryable
- ):
- raise ModelArtifactDownloadError(
- spec.capability,
- spec.model_id,
- attempts=attempt,
- reason=_download_failure_reason(exc),
- resumable=False,
- retryable=retryable,
- ) from exc
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "downloading_model",
- "message": (
- f"Download interrupted for "
- f"{spec.model_id}; retrying attempt "
- f"{attempt + 1} of "
- f"{_MODEL_DOWNLOAD_ATTEMPTS}. This "
- "file will restart from zero."
- ),
- "current": 0,
- "total": spec.download_size_bytes,
- }
- )
- sleep(2 ** (attempt - 1))
- else:
- assert last_error is not None
- raise last_error
+ )
+
+ def report_retry(next_attempt: int) -> None:
+ report_preparation(
+ progress,
+ "downloading_model",
+ f"Download interrupted for {spec.model_id}; "
+ f"retrying attempt {next_attempt} of "
+ f"{_MODEL_DOWNLOAD_ATTEMPTS}. This file will "
+ "restart from zero.",
+ current=0,
+ total=spec.download_size_bytes,
+ )
+
+ resolved = _download_with_retries(
+ spec,
+ download_artifact,
+ resumable=False,
+ hash_mismatch_is_retryable=True,
+ on_retry=report_retry,
+ )
else:
resolved = path
- if not resolved.is_file() or _sha256(resolved) != spec.sha256:
+ if not model_artifact_valid(resolved, spec):
raise ModelArtifactUnavailableError(spec.capability)
- if progress is not None:
- progress(
- {
- "state": "preparing",
- "stage": "downloading_model",
- "message": f"Verified {spec.model_id}.",
- "current": spec.download_size_bytes,
- "total": spec.download_size_bytes,
- }
- )
+ report_preparation(
+ progress,
+ "downloading_model",
+ f"Verified {spec.model_id}.",
+ current=spec.download_size_bytes,
+ total=spec.download_size_bytes,
+ )
except (ModelArtifactDownloadError, ModelArtifactUnavailableError):
raise
except Exception as exc:
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index 8a9d8b5..9edd0d7 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -16,6 +16,7 @@
CapabilityProvenance,
OperationDefinition,
RuntimeCheck,
+ module_import_check,
)
from vidxp.capabilities.dialogue.config import DialogueConfig
from vidxp.capabilities.registry import (
@@ -24,6 +25,7 @@
)
from vidxp.capability_service import CapabilityService
from vidxp.capabilities.scene.config import SceneConfig
+from vidxp.capabilities.videoprism.config import VideoPrismConfig
from vidxp.core.contracts import IndexConfig
from vidxp.core.runner import _index_groups
@@ -40,15 +42,33 @@ class CapabilityTests(unittest.TestCase):
def setUp(self):
self.registry = create_capability_registry()
+ def test_module_import_checks_run_in_an_isolated_process(self):
+ with patch(
+ "vidxp.capabilities.contracts.subprocess.run",
+ return_value=SimpleNamespace(returncode=0),
+ ) as run:
+ result = module_import_check(
+ "OpenCV import",
+ "cv2",
+ "VideoCapture",
+ ).inspect()
+
+ self.assertTrue(result["ok"])
+ command = run.call_args.args[0]
+ self.assertEqual(command[1], "-c")
+ self.assertIn('"cv2"', command[3])
+ self.assertIn('"VideoCapture"', command[3])
+ self.assertEqual(run.call_args.kwargs["timeout"], 180)
+
def test_registry_drives_capability_metadata(self):
self.assertEqual(
self.registry.names(),
- ("dialogue", "scene", "actor"),
+ ("dialogue", "scene", "actor", "videoprism"),
)
self.assertEqual(self.registry.index_names(), self.registry.names())
self.assertEqual(
self.registry.preparable_names(),
- ("dialogue", "scene", "actor"),
+ ("dialogue", "scene", "actor", "videoprism"),
)
self.assertEqual(
self.registry.collection_names(),
@@ -56,6 +76,7 @@ def test_registry_drives_capability_metadata(self):
"dialogue": "dialogue",
"scene": "scene",
"actor": "actor",
+ "videoprism": "videoprism",
},
)
@@ -121,6 +142,10 @@ def test_built_in_settings_are_owned_and_validated(self):
)
self.assertIs(self.registry.get("scene").config_model, SceneConfig)
self.assertIs(self.registry.get("actor").config_model, ActorConfig)
+ self.assertIs(
+ self.registry.get("videoprism").config_model,
+ VideoPrismConfig,
+ )
options = self.registry.validate_options(
("scene",),
@@ -188,10 +213,10 @@ def test_operation_only_capability_needs_no_index_metadata(self):
def test_visual_execution_group_is_explicit(self):
self.assertEqual(
_index_groups(
- ("dialogue", "scene", "actor"),
+ ("dialogue", "scene", "actor", "videoprism"),
self.registry,
),
- (("dialogue",), ("scene", "actor")),
+ (("dialogue",), ("scene", "actor", "videoprism")),
)
self.assertIsNotNone(
self.registry.executor("scene").index_processor
diff --git a/tests/test_ci_scope.py b/tests/test_ci_scope.py
index 54aa0ee..edae7c9 100644
--- a/tests/test_ci_scope.py
+++ b/tests/test_ci_scope.py
@@ -41,6 +41,7 @@ def test_desktop_uses_stable_product_and_packaging_boundaries(self):
"pyproject.toml",
"uv.lock",
".github/workflows/desktop.yml",
+ "plugins/vidxp/skills/vidxp-ingest-video/SKILL.md",
):
with self.subTest(path=path):
self.assertTrue(classify([path]).run_desktop)
@@ -48,7 +49,7 @@ def test_desktop_uses_stable_product_and_packaging_boundaries(self):
for path in (
"README.md",
"docs/releasing.md",
- "skills/vidxp-ingest-video/SKILL.md",
+ ".agents/plugins/marketplace.json",
"web/upload-page/src/app.js",
):
with self.subTest(path=path):
diff --git a/tests/test_cli.py b/tests/test_cli.py
index c9086aa..f88d467 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -36,9 +36,11 @@
IndexStatusSummary,
Job,
JobKind,
+ JobProgress,
JobQueue,
JobState,
MediaAsset,
+ MediaPage,
PrepareModelsResult,
PrepareModelsJobResult,
QueryAnswer,
@@ -391,6 +393,78 @@ def test_worker_lifecycle_commands_use_the_existing_job_service(self):
},
)
+ def test_media_list_shows_media_state(self):
+ self.service.list_media.return_value = MediaPage(
+ items=(
+ MediaAsset(
+ schema_version=1,
+ media_id=MEDIA_ID,
+ video_id=MEDIA_ID,
+ original_filename="video.mp4",
+ sha256="1" * 64,
+ byte_size=5,
+ detected_mime_type="video/mp4",
+ container="mp4",
+ duration_seconds=1,
+ streams=(
+ MediaStream(
+ index=0,
+ kind="video",
+ codec="h264",
+ width=1,
+ height=1,
+ ),
+ ),
+ state=MediaState.ready,
+ created_at=datetime.now(timezone.utc),
+ ),
+ ),
+ next_cursor=None,
+ total =1,
+ )
+
+ result = self.invoke(["media", "list"])
+
+ self.assertEqual(result.exit_code, 0, result.output)
+ self.assertIn("State", result.output)
+ self.assertIn("ready", result.output)
+
+ def test_media_list_shows_pending_and_failed_states(self):
+ failed_id = "223456781234423481234567890abcde"
+ self.service.list_media.return_value = MediaPage(
+ items=(
+ MediaAsset(
+ schema_version=1,
+ media_id=MEDIA_ID,
+ video_id=MEDIA_ID,
+ original_filename="pending.mp4",
+ sha256="1" * 64,
+ byte_size=5,
+ state=MediaState.pending,
+ created_at=datetime.now(timezone.utc),
+ ),
+ MediaAsset(
+ schema_version=1,
+ media_id=failed_id,
+ video_id=failed_id,
+ original_filename="failed.mp4",
+ sha256="2" * 64,
+ byte_size=7,
+ state=MediaState.failed,
+ created_at=datetime.now(timezone.utc),
+ ),
+ ),
+ next_cursor=None,
+ total=2,
+ )
+
+ result = self.invoke(["media", "list"])
+
+ self.assertEqual(result.exit_code, 0, result.output)
+ self.assertIn("pending", result.output)
+ self.assertIn("failed", result.output)
+ self.assertIn("-", result.output)
+
def test_ui_share_uses_streamlit_wildcard_bind_and_warns(self):
with (
patch(
@@ -868,7 +942,22 @@ def test_doctor_accepts_repeated_modality_options(self):
command = self.service.check_dependencies.call_args.args[0]
self.assertEqual(command.modalities, ("dialogue", "scene"))
- def test_prepare_announces_start_and_subscribes_to_job_progress(self):
+ def test_doctor_can_skip_model_readiness_for_install_validation(self):
+ self.service.check_dependencies.return_value = DependencyCheckResult(
+ ok=True,
+ modalities=("scene",),
+ checks=(),
+ )
+
+ result = self.invoke(
+ ["doctor", "--modalities", "scene", "--no-models", "--json"]
+ )
+
+ self.assertEqual(result.exit_code, 0, result.output)
+ command = self.service.check_dependencies.call_args.args[0]
+ self.assertFalse(command.include_models)
+
+ def test_prepare_announces_start_and_writes_job_progress(self):
self.service.model_readiness.return_value = DependencyCheckResult(
ok=False,
modalities=("scene",),
@@ -898,18 +987,43 @@ def test_prepare_announces_start_and_subscribes_to_job_progress(self):
state=JobState.queued,
queue=JobQueue.cpu,
)
- self.jobs.wait.return_value = Job(
+ completed = Job(
job_id=JOB_ID,
kind=JobKind.prepare_models,
state=JobState.succeeded,
queue=JobQueue.cpu,
result=PrepareModelsJobResult(result=prepared),
)
-
- result = self.invoke(
- ["prepare", "--modalities", "scene", "--yes"]
+ expected_progress = JobProgress(
+ stage="scene_model",
+ message="Preparing scene model.",
+ updated_at=datetime.now(timezone.utc),
)
+ def wait(_job_id, **kwargs):
+ kwargs["progress"](
+ self.jobs.submit_prepare_models.return_value.model_copy(
+ update={"progress": expected_progress}
+ )
+ )
+ return completed
+
+ self.jobs.wait.side_effect = wait
+
+ with TemporaryDirectory() as temporary_directory:
+ progress_path = Path(temporary_directory) / "progress.json"
+ result = self.invoke(
+ [
+ "prepare",
+ "--modalities",
+ "scene",
+ "--yes",
+ "--progress-file",
+ str(progress_path),
+ ]
+ )
+ written_progress = json.loads(progress_path.read_text())
+
self.assertEqual(result.exit_code, 0, result.output)
self.assertIn("1.43 GiB", result.output)
self.assertRegex(
@@ -918,6 +1032,10 @@ def test_prepare_announces_start_and_subscribes_to_job_progress(self):
r"scene\.",
)
self.assertTrue(callable(self.jobs.wait.call_args.kwargs["progress"]))
+ self.assertEqual(
+ written_progress,
+ expected_progress.model_dump(mode="json"),
+ )
def test_prepare_distinguishes_cached_model_verification(self):
self.service.model_readiness.return_value = DependencyCheckResult(
diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py
new file mode 100644
index 0000000..5fecbc3
--- /dev/null
+++ b/tests/test_codex_plugin.py
@@ -0,0 +1,281 @@
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+import pytest
+
+from vidxp.codex_plugin import (
+ CodexPluginInstallError,
+ export_codex_plugin,
+ install_codex_plugin,
+ resolve_codex_command,
+)
+
+
+def test_export_codex_plugin_materializes_the_canonical_skill_bundle() -> None:
+ with TemporaryDirectory() as directory:
+ root = Path(directory) / "codex-marketplace"
+ index_directory = Path("C:/VidXP/repositories/default")
+ data_directory = Path("C:/VidXP")
+ exported = export_codex_plugin(
+ root,
+ repository="default",
+ index_directory=str(index_directory),
+ data_directory=data_directory,
+ )
+
+ plugin_root = root / "plugins" / "vidxp"
+ manifest = json.loads(
+ (plugin_root / ".codex-plugin" / "plugin.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ marketplace_path = root / ".agents" / "plugins" / "marketplace.json"
+ marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
+
+ assert manifest["name"] == "vidxp"
+ assert manifest["version"] == exported.plugin_version
+ assert "+codex." in exported.plugin_version
+ assert (plugin_root / "skills" / "vidxp-ingest-video" / "SKILL.md").is_file()
+ assert (
+ plugin_root / "skills" / "vidxp-find-video-evidence" / "SKILL.md"
+ ).is_file()
+ assert (plugin_root / "skills" / "vidxp-install" / "SKILL.md").is_file()
+ assert not (plugin_root / ".mcp.json").exists()
+ assert marketplace["name"] == "vidxp-local"
+ assert marketplace["plugins"][0]["source"]["path"] == "./plugins/vidxp"
+ assert marketplace["plugins"][0]["policy"] == {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL",
+ }
+ assert exported.marketplace_path == str(marketplace_path)
+ assert not (root / "marketplace.json").exists()
+
+
+def test_export_migrates_the_legacy_managed_marketplace_layout() -> None:
+ with TemporaryDirectory() as directory:
+ root = Path(directory) / "marketplace"
+ root.mkdir()
+ (root / ".vidxp-managed-marketplace").write_text(
+ "Managed by VidXP Desktop.\n",
+ encoding="utf-8",
+ )
+ legacy_path = root / "marketplace.json"
+ legacy_path.write_text("{}\n", encoding="utf-8")
+
+ exported = export_codex_plugin(root)
+
+ assert Path(exported.marketplace_path).is_file()
+ assert not legacy_path.exists()
+
+
+def test_install_codex_plugin_registers_marketplace_then_installs_bundle() -> None:
+ calls: list[list[str]] = []
+
+ def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
+ calls.append(command)
+ if command[1:4] == ["plugin", "marketplace", "add"]:
+ payload = {"marketplaceName": "vidxp-local", "alreadyAdded": False}
+ elif command[1:3] == ["plugin", "add"]:
+ payload = {
+ "pluginId": "vidxp@vidxp-local",
+ "name": "vidxp",
+ "marketplaceName": "vidxp-local",
+ "version": "0.4.0+codex.example",
+ "installedPath": "/codex/cache/vidxp",
+ }
+ else:
+ return subprocess.CompletedProcess(command, 0, "Added MCP server", "")
+ return subprocess.CompletedProcess(command, 0, json.dumps(payload), "")
+
+ with TemporaryDirectory() as directory:
+ result = install_codex_plugin(
+ Path(directory) / "marketplace",
+ codex_command="codex-test",
+ runner=runner,
+ )
+
+ assert calls[0][0:4] == ["codex-test", "plugin", "marketplace", "add"]
+ assert calls[0][-1] == "--json"
+ assert calls[1] == [
+ "codex-test",
+ "plugin",
+ "add",
+ "vidxp@vidxp-local",
+ "--json",
+ ]
+ assert calls[2][0:5] == ["codex-test", "mcp", "add", "vidxp", "--"]
+ assert Path(calls[2][5]).name.lower() in {"vidxp-mcp", "vidxp-mcp.exe"}
+ assert calls[2][6:] == ["--repository", "default"]
+ assert result.plugin_id == "vidxp@vidxp-local"
+ assert result.installed_path == "/codex/cache/vidxp"
+ assert "skills and local MCP server" in result.detail
+
+
+def test_install_codex_plugin_uses_git_marketplace_and_migrates_local_source() -> None:
+ calls: list[list[str]] = []
+
+ def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
+ calls.append(command)
+ arguments = command[1:]
+ if arguments[:3] == ["plugin", "marketplace", "add"]:
+ payload = {"marketplaceName": "vidxp"}
+ elif arguments[:2] == ["plugin", "add"]:
+ payload = {
+ "pluginId": "vidxp@vidxp",
+ "name": "vidxp",
+ "marketplaceName": "vidxp",
+ "version": "0.4.0-b.3",
+ }
+ elif arguments == ["plugin", "list", "--json"]:
+ payload = {"installed": [{"pluginId": "vidxp@vidxp-local"}]}
+ elif arguments == ["plugin", "marketplace", "list", "--json"]:
+ payload = {"marketplaces": [{"name": "vidxp-local"}]}
+ elif "--json" in arguments:
+ payload = {}
+ else:
+ return subprocess.CompletedProcess(command, 0, "ok", "")
+ return subprocess.CompletedProcess(command, 0, json.dumps(payload), "")
+
+ result = install_codex_plugin(
+ None,
+ marketplace_source="grayhatdevelopers/vidxp",
+ marketplace_ref="main",
+ marketplace_sparse=(".agents/plugins", "plugins/vidxp"),
+ codex_command="codex-test",
+ runner=runner,
+ )
+
+ assert calls[0] == [
+ "codex-test",
+ "plugin",
+ "marketplace",
+ "add",
+ "grayhatdevelopers/vidxp",
+ "--ref",
+ "main",
+ "--sparse",
+ ".agents/plugins",
+ "--sparse",
+ "plugins/vidxp",
+ "--json",
+ ]
+ assert calls[1] == ["codex-test", "plugin", "add", "vidxp@vidxp", "--json"]
+ assert calls[2][1:5] == ["mcp", "add", "vidxp", "--"]
+ assert calls[4] == [
+ "codex-test",
+ "plugin",
+ "remove",
+ "vidxp@vidxp-local",
+ "--json",
+ ]
+ assert calls[6] == [
+ "codex-test",
+ "plugin",
+ "marketplace",
+ "remove",
+ "vidxp-local",
+ "--json",
+ ]
+ assert result.marketplace_name == "vidxp"
+ assert result.marketplace_path == "grayhatdevelopers/vidxp@main"
+
+
+def test_export_refuses_to_replace_an_unmanaged_marketplace() -> None:
+ with TemporaryDirectory() as directory:
+ root = Path(directory) / "marketplace"
+ root.mkdir()
+ (root / "keep.txt").write_text("user data", encoding="utf-8")
+
+ with pytest.raises(CodexPluginInstallError, match="unmanaged marketplace"):
+ export_codex_plugin(root)
+
+ assert (root / "keep.txt").read_text(encoding="utf-8") == "user data"
+
+
+def test_resolve_codex_command_prefers_desktop_configured_cli() -> None:
+ with TemporaryDirectory() as directory:
+ root = Path(directory)
+ codex_home = root / ".codex"
+ configured = root / "current" / "codex.exe"
+ stale = root / "OpenAI" / "Codex" / "bin" / "codex.exe"
+ configured.parent.mkdir(parents=True)
+ stale.parent.mkdir(parents=True)
+ configured.touch()
+ stale.touch()
+ codex_home.mkdir()
+ escaped_command = str(configured).replace("\\", "\\\\")
+ (codex_home / "config.toml").write_text(
+ "[mcp_servers.node_repl.env]\n"
+ f'CODEX_CLI_PATH = "{escaped_command}"\n',
+ encoding="utf-8",
+ )
+
+ resolved = resolve_codex_command(
+ environment={
+ "CODEX_HOME": str(codex_home),
+ "LOCALAPPDATA": str(root),
+ },
+ which=lambda _: None,
+ )
+
+ assert resolved == str(configured)
+
+
+def test_resolve_codex_command_uses_desktop_environment_path() -> None:
+ with TemporaryDirectory() as directory:
+ command = Path(directory) / "codex.exe"
+ command.touch()
+
+ resolved = resolve_codex_command(
+ environment={"CODEX_CLI_PATH": str(command)},
+ which=lambda _: None,
+ )
+
+ assert resolved == str(command)
+
+
+def test_resolve_codex_command_falls_back_to_local_app_install() -> None:
+ with TemporaryDirectory() as directory:
+ root = Path(directory)
+ command = root / "OpenAI" / "Codex" / "bin" / "codex.exe"
+ command.parent.mkdir(parents=True)
+ command.touch()
+
+ resolved = resolve_codex_command(
+ environment={
+ "CODEX_HOME": str(root / "missing-codex-home"),
+ "LOCALAPPDATA": str(root),
+ },
+ which=lambda _: None,
+ )
+
+ assert resolved == str(command)
+
+
+def test_resolve_codex_command_prefers_newest_versioned_local_cli() -> None:
+ with TemporaryDirectory() as directory:
+ root = Path(directory)
+ bin_directory = root / "OpenAI" / "Codex" / "bin"
+ stable = bin_directory / "codex.exe"
+ older = bin_directory / "old" / "codex.exe"
+ current = bin_directory / "current" / "codex.exe"
+ for command in (stable, older, current):
+ command.parent.mkdir(parents=True, exist_ok=True)
+ command.touch()
+ os.utime(older, (1, 1))
+ os.utime(current, (2, 2))
+
+ resolved = resolve_codex_command(
+ environment={
+ "CODEX_HOME": str(root / "missing-codex-home"),
+ "LOCALAPPDATA": str(root),
+ },
+ which=lambda _: None,
+ )
+
+ assert resolved == str(current)
diff --git a/tests/test_control_plane.py b/tests/test_control_plane.py
index a6ae69b..05a7d8d 100644
--- a/tests/test_control_plane.py
+++ b/tests/test_control_plane.py
@@ -16,7 +16,7 @@
from vidxp.capabilities.registry import create_capability_registry
from vidxp.capability_service import CapabilityService
from vidxp.control_plane import ControlPlaneApplication
-from vidxp.core.media import MediaState, MediaStream
+from vidxp.core.media import MediaState, MediaStream, MediaUnavailableError
from vidxp.core.snapshots import GenerationReference, IndexSnapshot
from vidxp.repository_layout import RepositoryLayout
@@ -77,7 +77,40 @@ def test_index_preflight_rejects_unknown_capability_with_next_action(self):
self.assertEqual(error["reason"], "capability_unknown")
self.assertEqual(error["requested"], ["unknown"])
self.assertIn("get_workspace", error["next_action"])
- media.get.assert_not_called()
+ media.require_record.assert_not_called()
+
+ def test_index_preflight_rejects_non_ready_media(self):
+ with TemporaryDirectory() as directory:
+ root = Path(directory)
+ media = Mock()
+ media.require_record.side_effect = MediaUnavailableError(
+ "The media asset is unavailable."
+ )
+ application = ControlPlaneApplication(
+ layout=RepositoryLayout(root=root),
+ capabilities=CapabilityService(create_capability_registry()),
+ media=media,
+ artifacts=Mock(),
+ index_status=lambda: None,
+ model_cache=root / "models",
+ )
+
+ for state in (MediaState.pending, MediaState.failed):
+ with self.subTest(state=state):
+ media.require_record.reset_mock()
+ with self.assertRaises(ApplicationError) as raised:
+ application.preflight_index(
+ CreateIndexCommand(
+ media_id=MEDIA_ID,
+ modalities=("scene",),
+ )
+ )
+
+ self.assertEqual(
+ raised.exception.to_dict()["category"],
+ "not_found",
+ )
+ media.require_record.assert_called_once_with(MEDIA_ID)
def test_workspace_projects_index_coverage_roles_and_next_actions(self):
indexed = media_asset(MEDIA_ID, "indexed.mp4")
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
index 9991b0a..b720506 100644
--- a/tests/test_frontend.py
+++ b/tests/test_frontend.py
@@ -21,6 +21,7 @@
from vidxp.capabilities.registry import create_capability_registry
from vidxp.capability_service import CapabilityService
from vidxp.control_plane import ControlPlaneApplication
+from vidxp.core.media import MediaState
from vidxp.repository_layout import RepositoryLayout
from vidxp.settings import LocalExecutionSettings, VidXPSettings
@@ -89,12 +90,12 @@ def test_query_modalities_use_real_capability_service_contracts(self):
return_value=service,
):
available = frontend._available_query_modalities(
- ("dialogue", "scene", "actor"),
+ ("dialogue", "scene", "actor", "videoprism"),
)
self.assertEqual(
available,
- ("dialogue", "scene", "actor"),
+ ("dialogue", "scene", "actor", "videoprism"),
)
def tearDown(self):
@@ -201,6 +202,7 @@ def test_busy_video_layout_keeps_controls_and_preview_stable(self):
media_id=MEDIA_ID,
original_filename="video.mp4",
duration_seconds=27.2,
+ state=MediaState.ready,
),
),
next_cursor=None,
@@ -245,6 +247,64 @@ def test_busy_video_layout_keeps_controls_and_preview_stable(self):
self.assertTrue(uploader.call_args.kwargs["disabled"])
video.assert_called_once_with("video.mp4", width=560)
+ def test_registered_video_selector_lists_only_ready_media(self):
+ service = Mock()
+ ready_id = MEDIA_ID
+ pending_id = "223456781234423481234567890abcde"
+ media_page = SimpleNamespace(
+ items=(
+ SimpleNamespace(
+ media_id=ready_id,
+ original_filename="ready.mp4",
+ duration_seconds=12.0,
+ state=MediaState.ready,
+ ),
+ SimpleNamespace(
+ media_id=pending_id,
+ original_filename="pending.mp4",
+ duration_seconds=None,
+ state=MediaState.pending,
+ ),
+ ),
+ next_cursor=None,
+ )
+ with (
+ patch.object(
+ frontend,
+ "_configured_service",
+ return_value=service,
+ ),
+ patch.object(frontend.st, "session_state", {}),
+ patch.object(frontend.st, "subheader"),
+ patch.object(
+ frontend.st,
+ "selectbox",
+ return_value=ready_id,
+ ) as selectbox,
+ patch.object(
+ frontend.st,
+ "expander",
+ return_value=nullcontext(),
+ ),
+ patch.object(frontend.st, "caption"),
+ patch.object(frontend.st, "text_input", return_value=""),
+ patch.object(frontend.st, "button", return_value=False),
+ patch.object(
+ frontend.st,
+ "file_uploader",
+ return_value=None,
+ ),
+ patch.object(frontend.st, "video"),
+ ):
+ _uploaded, media_id = frontend._select_video(
+ False,
+ pending_id,
+ media_page,
+ )
+
+ self.assertEqual(media_id, ready_id)
+ self.assertEqual(selectbox.call_args.args[1], (ready_id,))
+
def test_local_path_import_uses_the_shared_application_command(self):
service = Mock()
service.import_media.return_value = SimpleNamespace(media_id=MEDIA_ID)
@@ -277,7 +337,7 @@ def check(command):
):
available = frontend._available_index_modalities()
- self.assertEqual(available, ("dialogue", "scene"))
+ self.assertEqual(available, ("dialogue", "scene", "videoprism"))
self.assertTrue(
all(
not call.args[0].include_runtime_checks
@@ -339,6 +399,47 @@ def test_indexing_submits_selected_scene_sample_rate(self):
self.assertEqual(command.scene_sample_fps, 2.0)
service.require_models.assert_not_called()
+ def test_videoprism_clip_control_is_conditional_and_configures_index(self):
+ with patch.object(
+ frontend.st,
+ "selectbox",
+ return_value=4.0,
+ ) as selectbox:
+ selected = frontend._videoprism_sample_fps_control(
+ ("videoprism",),
+ disabled=False,
+ )
+
+ self.assertEqual(selected, 4.0)
+ self.assertEqual(
+ selectbox.call_args.args[:2],
+ ("Temporal clip length", (1.0, 2.0, 4.0)),
+ )
+ self.assertEqual(selectbox.call_args.kwargs["index"], 1)
+
+ jobs = Mock()
+ jobs.submit_index.return_value = SimpleNamespace(job_id="job-1")
+ session_state = {frontend.MEDIA_ID_KEY: MEDIA_ID}
+ with (
+ patch.object(frontend, "_configured_service", return_value=Mock()),
+ patch.object(frontend, "_configured_jobs", return_value=jobs),
+ patch.object(frontend.st, "session_state", session_state),
+ patch.object(frontend.st, "query_params", {}),
+ patch.object(frontend.st, "rerun"),
+ ):
+ frontend._run_indexing(
+ None,
+ {},
+ ("videoprism",),
+ videoprism_sample_fps=selected,
+ )
+
+ command = jobs.submit_index.call_args.args[0]
+ self.assertEqual(
+ command.capability_options,
+ {"videoprism": {"sample_fps": 4.0}},
+ )
+
def test_indexing_omits_scene_sample_rate_without_scene(self):
jobs = Mock()
jobs.submit_index.return_value = SimpleNamespace(job_id="job-1")
diff --git a/tests/test_frontend_app.py b/tests/test_frontend_app.py
index 833d2fa..0b74bcd 100644
--- a/tests/test_frontend_app.py
+++ b/tests/test_frontend_app.py
@@ -161,7 +161,7 @@ def ready_status() -> IndexStatus:
snapshot_id=SNAPSHOT_ID,
media_count=1,
media_ids=(MEDIA_ID,),
- modalities=("dialogue", "scene", "actor"),
+ modalities=("dialogue", "scene", "actor", "videoprism"),
),
)
@@ -269,6 +269,27 @@ def test_ready_page_rejects_empty_search_without_disabling_form(self):
self.assertEqual(app.warning[-1].value, "Enter a search query.")
self.assertEqual(jobs.submitted_searches, [])
+ def test_ready_page_exposes_videoprism_as_temporal_action_search(self):
+ service = FrontendApplicationStub(self.root, ready_status())
+ jobs = FrontendJobStub()
+ app = self.app(service, jobs).run()
+
+ capability_picker = self.widget(app.multiselect, "Capabilities")
+ self.assertIn(
+ "Temporal action search (VideoPrism)",
+ capability_picker.options,
+ )
+ temporal_control = self.widget(app.selectbox, "Temporal clip length")
+ self.assertEqual(temporal_control.value, 2.0)
+
+ search_type = self.widget(app.selectbox, "Search type")
+ search_type.select("videoprism")
+ app.text_input(key="video_search_query").input("a person walks out")
+ self.widget(app.button, "Search").click()
+ app.run()
+
+ self.assertEqual(jobs.submitted_searches[0].modalities, ("videoprism",))
+
def test_running_index_keeps_one_preview_and_disables_mutations(self):
service = FrontendApplicationStub(self.root, ready_status())
jobs = FrontendJobStub()
diff --git a/tests/test_local_probe.py b/tests/test_local_probe.py
index 62accbf..2832c40 100644
--- a/tests/test_local_probe.py
+++ b/tests/test_local_probe.py
@@ -37,7 +37,7 @@ def build(self, **overrides):
patch("vidxp.local_probe._module_available", return_value=True),
patch(
"vidxp.local_probe._installed_search_capabilities",
- return_value=["actor", "dialogue", "scene"],
+ return_value=["actor", "dialogue", "scene", "videoprism"],
),
patch(
"vidxp.local_probe.media_runtime_is_initialized",
@@ -85,7 +85,7 @@ def test_probe_reports_stable_identity_and_contract_compatibility(self):
)
self.assertEqual(
payload["search_capabilities"],
- ["actor", "dialogue", "scene"],
+ ["actor", "dialogue", "scene", "videoprism"],
)
self.assertTrue(all(surface["launchable"] for surface in payload["surfaces"].values()))
@@ -247,11 +247,12 @@ def test_non_windows_launcher_resolution_does_not_add_executable_suffix(self):
def test_desktop_model_catalog_is_derived_from_canonical_specs(self):
catalog = desktop_model_cache_catalog()
- self.assertEqual(len(catalog), 5)
+ self.assertEqual(len(catalog), 6)
self.assertEqual(
{item["id"] for item in catalog},
{
"google/siglip2-base-patch16-224",
+ "google/videoprism-lvt-base-f16r288",
"Qwen/Qwen3-Embedding-0.6B",
"dropbox-dash/faster-whisper-large-v3-turbo",
"yunet",
diff --git a/tests/test_mcp.py b/tests/test_mcp.py
index c6f1834..c0d04eb 100644
--- a/tests/test_mcp.py
+++ b/tests/test_mcp.py
@@ -72,6 +72,7 @@
SearchMomentsPlanStep,
WorkspaceOverview,
)
+from vidxp.mcp_app import MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI
from vidxp.authentication import (
AuthenticatedBearer,
OIDCBearerAuthenticator,
@@ -439,12 +440,38 @@ async def test_curated_tools_publish_their_intended_output_contracts(self):
async with Client(server) as client:
discovered = await client.list_tools()
result = await client.call_tool("list_capabilities", {})
+ app_resource = await client.read_resource(MCP_APP_RESOURCE_URI)
self.assertEqual(
[tool.name for tool in discovered.tools],
MCP_TOOL_NAMES,
)
tools = {tool.name: tool for tool in discovered.tools}
+ for name in ("create_media_upload", "get_job_evidence"):
+ self.assertEqual(
+ tools[name].meta["ui"]["resourceUri"],
+ MCP_APP_RESOURCE_URI,
+ )
+ self.assertEqual(
+ tools[name].meta["openai/outputTemplate"],
+ MCP_APP_RESOURCE_URI,
+ )
+ app_contents = app_resource.contents[0]
+ self.assertEqual(app_contents.mime_type, MCP_APP_MIME_TYPE)
+ self.assertEqual(
+ app_contents.meta["ui"]["csp"],
+ {"connectDomains": [], "resourceDomains": []},
+ )
+ self.assertIn("ui/notifications/tool-result", app_contents.text)
+ self.assertIn('request("ui/initialize"', app_contents.text)
+ self.assertIn('notify("ui/notifications/initialized"', app_contents.text)
+ self.assertIn('request("tools/call"', app_contents.text)
+ self.assertIn('request("ui/open-link"', app_contents.text)
+ self.assertIn('request("ui/request-display-mode"', app_contents.text)
+ self.assertIn('request("ui/update-model-context"', app_contents.text)
+ self.assertIn("window.openai?.requestDisplayMode", app_contents.text)
+ self.assertIn("materialize_job_evidence", app_contents.text)
+ self.assertNotIn("