videoctx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
videoctx/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
videoctx/captions.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ import anthropic
7
+
8
+ from .pricing import CostMeter
9
+ from .schema import VideoContext
10
+
11
+ CAPTION_MODEL = "claude-haiku-4-5"
12
+
13
+ CAPTION_PROMPT = (
14
+ "Describe this video frame in ONE dense line: subjects, actions, setting, "
15
+ "notable objects, and any readable on-screen text (quote it). No preamble."
16
+ )
17
+
18
+
19
+ def caption_shots(
20
+ index_dir: Path,
21
+ ctx: VideoContext,
22
+ model: str = CAPTION_MODEL,
23
+ client: anthropic.Anthropic | None = None,
24
+ max_captions: int = 150,
25
+ progress=lambda *_: None,
26
+ ) -> CostMeter:
27
+ """Caption shots' middle keyframes with a cheap VLM and write the captions
28
+ back into index.json. One-time cost, amortized over all queries.
29
+
30
+ Long fast-cut videos can have many hundreds of shots; captioning is capped
31
+ at max_captions, subsampled evenly, to bound both the one-time cost and
32
+ the text-view size."""
33
+ client = client or anthropic.Anthropic()
34
+ meter = CostMeter(model=model)
35
+
36
+ # max_captions is a per-INDEX total, not per-call: repeat calls must not
37
+ # keep adding captions past the cap (bloats the text view).
38
+ budget = max_captions - sum(1 for s in ctx.shots if s.caption)
39
+ if budget <= 0:
40
+ return meter
41
+ todo = [s for s in ctx.shots if not s.caption and s.keyframes]
42
+ if len(todo) > budget:
43
+ step = (len(todo) - 1) / (budget - 1) if budget > 1 else 0
44
+ todo = [todo[round(i * step)] for i in range(budget)]
45
+
46
+ for shot in todo:
47
+ kf = shot.keyframes[len(shot.keyframes) // 2]
48
+ data = base64.standard_b64encode((index_dir / kf.file).read_bytes()).decode()
49
+ response = client.messages.create(
50
+ model=model,
51
+ max_tokens=200,
52
+ messages=[{
53
+ "role": "user",
54
+ "content": [
55
+ {"type": "image", "source": {
56
+ "type": "base64", "media_type": "image/jpeg", "data": data}},
57
+ {"type": "text", "text": CAPTION_PROMPT},
58
+ ],
59
+ }],
60
+ )
61
+ meter.add(response.usage)
62
+ shot.caption = "".join(b.text for b in response.content if b.type == "text").strip()
63
+ shot.caption_model = model
64
+ progress(f" shot {shot.id}: {shot.caption[:70]}")
65
+
66
+ (index_dir / "index.json").write_text(ctx.model_dump_json(indent=2))
67
+ return meter
videoctx/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+
9
+ @click.group()
10
+ def main():
11
+ """videoctx: index videos once, query them cheaply with any LLM."""
12
+
13
+
14
+ @main.command()
15
+ @click.argument("video", type=click.Path(exists=True, path_type=Path))
16
+ @click.option("--out", type=click.Path(path_type=Path), default=None, help="Index output dir")
17
+ @click.option("--max-edge", default=768, show_default=True, help="Keyframe long-edge pixels")
18
+ @click.option("--whisper-model", default="small", show_default=True)
19
+ @click.option("--no-transcript", is_flag=True, help="Skip transcription")
20
+ def index(video: Path, out: Path | None, max_edge: int, whisper_model: str, no_transcript: bool):
21
+ """Index VIDEO into a .vctx directory."""
22
+ from .indexer import build_index
23
+
24
+ build_index(
25
+ video, out_dir=out, max_edge=max_edge,
26
+ whisper_model=whisper_model, skip_transcript=no_transcript,
27
+ progress=click.echo,
28
+ )
29
+
30
+
31
+ @main.command()
32
+ @click.argument("index_dir", type=click.Path(exists=True, path_type=Path))
33
+ def caption(index_dir: Path):
34
+ """Add visual captions to an index's shots (one cheap VLM call per shot)."""
35
+ from .captions import caption_shots
36
+ from .indexer import load_index
37
+
38
+ ctx = load_index(index_dir)
39
+ meter = caption_shots(index_dir, ctx, progress=click.echo)
40
+ click.echo(f"captioned; cost: {json.dumps(meter.summary())}")
41
+
42
+
43
+ @main.command()
44
+ @click.argument("index_dir", type=click.Path(exists=True, path_type=Path))
45
+ def show(index_dir: Path):
46
+ """Print the text view of an index (what the model reads)."""
47
+ from .indexer import load_index
48
+ from .textview import text_view
49
+
50
+ click.echo(text_view(load_index(index_dir)))
51
+
52
+
53
+ @main.command()
54
+ @click.argument("index_dir", type=click.Path(exists=True, path_type=Path))
55
+ @click.argument("question")
56
+ @click.option("--model", default="claude-opus-4-8", show_default=True)
57
+ def ask(index_dir: Path, question: str, model: str):
58
+ """Ask a question about an indexed video."""
59
+ from .indexer import load_index
60
+ from .query import ask as _ask
61
+
62
+ ctx = load_index(index_dir)
63
+ answer, meter, frames = _ask(index_dir, ctx, question, model=model)
64
+ click.echo(answer)
65
+ click.echo("---")
66
+ click.echo(f"cost: {json.dumps(meter.summary())} frames_fetched={frames}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
videoctx/frames.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .schema import Keyframe
7
+
8
+
9
+ def extract_frame(
10
+ video_path: Path,
11
+ t_s: float,
12
+ out_path: Path,
13
+ max_edge: int = 768,
14
+ quality: int = 4,
15
+ ) -> Keyframe:
16
+ """Extract one frame at t_s, scaled so the long edge is <= max_edge."""
17
+ scale = (
18
+ f"scale='if(gt(iw,ih),min(iw,{max_edge}),-2)'"
19
+ f":'if(gt(iw,ih),-2,min(ih,{max_edge}))'"
20
+ )
21
+ subprocess.run(
22
+ [
23
+ "ffmpeg", "-v", "error", "-y",
24
+ "-ss", f"{t_s:.3f}", "-i", str(video_path),
25
+ "-frames:v", "1", "-vf", scale, "-q:v", str(quality),
26
+ "-strict", "unofficial", # ffmpeg 9 mjpeg rejects non-full-range YUV otherwise
27
+ str(out_path),
28
+ ],
29
+ check=True,
30
+ )
31
+ width, height = _image_size(out_path)
32
+ return Keyframe(t_s=round(t_s, 3), file=f"frames/{out_path.name}", width=width, height=height)
33
+
34
+
35
+ def _image_size(path: Path) -> tuple[int, int]:
36
+ out = subprocess.run(
37
+ [
38
+ "ffprobe", "-v", "error",
39
+ "-select_streams", "v:0",
40
+ "-show_entries", "stream=width,height",
41
+ "-of", "csv=s=x:p=0", str(path),
42
+ ],
43
+ capture_output=True, text=True, check=True,
44
+ )
45
+ w, h = out.stdout.strip().split("x")
46
+ return int(w), int(h)
videoctx/indexer.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .frames import extract_frame
11
+ from .probe import probe
12
+ from .schema import VideoContext
13
+ from .shots import detect_shots, keyframe_times
14
+ from .transcribe import transcribe
15
+
16
+
17
+ def build_index(
18
+ video_path: Path,
19
+ out_dir: Path | None = None,
20
+ max_edge: int = 768,
21
+ whisper_model: str = "small",
22
+ skip_transcript: bool = False,
23
+ progress=print,
24
+ ) -> Path:
25
+ """Index a video into a .vctx directory. Returns the index path."""
26
+ video_path = video_path.resolve()
27
+ if out_dir is None:
28
+ out_dir = video_path.with_suffix("").with_name(video_path.stem + ".vctx")
29
+ frames_dir = out_dir / "frames"
30
+ frames_dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ t0 = time.monotonic()
33
+ progress(f"probing {video_path.name}")
34
+ info = probe(video_path)
35
+
36
+ progress(f"detecting shots ({info.duration_s:.0f}s video)")
37
+ shots = detect_shots(video_path, info.duration_s)
38
+ progress(f" {len(shots)} shots")
39
+
40
+ progress("extracting keyframes")
41
+ n_frames = 0
42
+ for shot in shots:
43
+ for t in keyframe_times(shot):
44
+ out_path = frames_dir / f"shot{shot.id:04d}_t{t:08.2f}.jpg"
45
+ shot.keyframes.append(extract_frame(video_path, t, out_path, max_edge=max_edge))
46
+ n_frames += 1
47
+ progress(f" {n_frames} keyframes")
48
+
49
+ if skip_transcript or not info.has_audio:
50
+ from .schema import Transcript
51
+ transcript = Transcript()
52
+ progress("skipping transcript" if skip_transcript else "no audio stream")
53
+ else:
54
+ progress(f"transcribing (faster-whisper/{whisper_model})")
55
+ transcript = transcribe(video_path, model_size=whisper_model)
56
+ progress(f" {len(transcript.segments)} segments, language={transcript.language}")
57
+
58
+ ctx = VideoContext(video=info, shots=shots, transcript=transcript)
59
+ (out_dir / "index.json").write_text(ctx.model_dump_json(indent=2))
60
+
61
+ manifest = {
62
+ "format": "vctx",
63
+ "format_version": "0.1",
64
+ "created_at": datetime.now(timezone.utc).isoformat(),
65
+ "producer": {"name": "videoctx", "version": __version__},
66
+ "params": {
67
+ "max_edge": max_edge,
68
+ "whisper_model": whisper_model,
69
+ "shot_detector": "scenedetect/AdaptiveDetector",
70
+ },
71
+ "source": {
72
+ "filename": video_path.name,
73
+ "path": str(video_path), # hint only; consumers must tolerate it moving
74
+ "sha256": _sha256(video_path),
75
+ "size_bytes": video_path.stat().st_size,
76
+ },
77
+ "indexing_wall_s": round(time.monotonic() - t0, 1),
78
+ }
79
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
80
+ progress(f"index written to {out_dir} ({manifest['indexing_wall_s']}s)")
81
+ return out_dir
82
+
83
+
84
+ def load_index(index_dir: Path) -> VideoContext:
85
+ return VideoContext.model_validate_json((Path(index_dir) / "index.json").read_text())
86
+
87
+
88
+ def _sha256(path: Path) -> str:
89
+ h = hashlib.sha256()
90
+ with open(path, "rb") as f:
91
+ for chunk in iter(lambda: f.read(1 << 20), b""):
92
+ h.update(chunk)
93
+ return h.hexdigest()
videoctx/mcp_server.py ADDED
@@ -0,0 +1,161 @@
1
+ """videoctx MCP server.
2
+
3
+ Gives any MCP client (Claude Code, Cursor, ...) the ability to read videos the
4
+ way it reads images: index once, then work from the cheap text layer and pull
5
+ frames only when needed. The calling agent's own model does the reasoning -
6
+ the core tools below require no API key and make no model calls.
7
+
8
+ Install into Claude Code:
9
+ claude mcp add videoctx -- /path/to/videoctx/.venv/bin/videoctx-mcp
10
+
11
+ Intended agent loop:
12
+ 1. video_overview(path) -> shot structure + captions + transcript (cheap)
13
+ 2. survey_video(path) -> one thumbnail per shot, when unsure where to look
14
+ 3. get_frames(path, t0, t1) -> full-res frames from the exact moment
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from mcp.server.mcpserver import Image, MCPServer
21
+
22
+ from .indexer import build_index, load_index
23
+ from .schema import VideoContext
24
+ from .textview import text_view
25
+
26
+ server = MCPServer(
27
+ "videoctx",
28
+ instructions=(
29
+ "Tools for reading video files. Call video_overview first: its text "
30
+ "layer (shots, captions, transcript) answers most questions without "
31
+ "any frames. Fetch frames only for visual details the text does not "
32
+ "cover, on the narrowest time range possible. Evidence rule: answer "
33
+ "visual questions only from frames you have actually seen; if fetched "
34
+ "frames do not show the answer, fetch a different range or higher "
35
+ "density instead of guessing."
36
+ ),
37
+ )
38
+
39
+ VIDEO_SUFFIXES = {".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v"}
40
+
41
+
42
+ def _resolve(path: str) -> tuple[Path, VideoContext]:
43
+ """Accept a video file or a .vctx index dir; auto-index videos on first use."""
44
+ p = Path(path).expanduser().resolve()
45
+ if p.is_dir() and (p / "index.json").exists():
46
+ return p, load_index(p)
47
+ if p.suffix.lower() in VIDEO_SUFFIXES and p.exists():
48
+ index_dir = p.parent / f"{p.stem}.vctx"
49
+ if not (index_dir / "index.json").exists():
50
+ build_index(p, out_dir=index_dir, progress=lambda *_: None)
51
+ return index_dir, load_index(index_dir)
52
+ raise ValueError(f"not a video file or .vctx index: {path}")
53
+
54
+
55
+ def _source_video(index_dir: Path) -> Path | None:
56
+ import json
57
+
58
+ manifest = index_dir / "manifest.json"
59
+ if manifest.exists():
60
+ source = json.loads(manifest.read_text())["source"]
61
+ for candidate in (Path(source.get("path", "")), index_dir.parent / source["filename"]):
62
+ if candidate.name and candidate.exists():
63
+ return candidate
64
+ return None
65
+
66
+
67
+ @server.tool()
68
+ def index_video(path: str) -> str:
69
+ """Index a video file for querying (shots, keyframes, speech transcript).
70
+ Runs locally; takes roughly 10-20% of the video's duration. Idempotent -
71
+ returns immediately if the video is already indexed. The other tools
72
+ auto-index too, so calling this first is only needed to control when the
73
+ wait happens."""
74
+ index_dir, ctx = _resolve(path)
75
+ return (
76
+ f"indexed: {index_dir}\n"
77
+ f"{ctx.video.duration_s:.0f}s, {len(ctx.shots)} shots, "
78
+ f"{len(ctx.transcript.segments)} transcript segments "
79
+ f"(language={ctx.transcript.language})"
80
+ )
81
+
82
+
83
+ @server.tool()
84
+ def video_overview(path: str) -> str:
85
+ """The cheap text layer of a video: shot list with timestamps and visual
86
+ captions (when present), interleaved with the speech transcript. Read this
87
+ before fetching any frames: it answers most questions by itself."""
88
+ _, ctx = _resolve(path)
89
+ return text_view(ctx)
90
+
91
+
92
+ @server.tool()
93
+ def survey_video(path: str) -> list:
94
+ """One small thumbnail per shot across the whole video (subsampled to at
95
+ most 24), each labeled with its timestamp. Cheap visual sweep for locating
96
+ the relevant moment before zooming in with get_frames."""
97
+ from .query import MAX_SURVEY_THUMBS, _thumb
98
+
99
+ index_dir, ctx = _resolve(path)
100
+ shots = [s for s in ctx.shots if s.keyframes]
101
+ if len(shots) > MAX_SURVEY_THUMBS:
102
+ step = (len(shots) - 1) / (MAX_SURVEY_THUMBS - 1)
103
+ shots = [shots[round(i * step)] for i in range(MAX_SURVEY_THUMBS)]
104
+ out: list = []
105
+ for shot in shots:
106
+ kf = shot.keyframes[len(shot.keyframes) // 2]
107
+ out.append(f"shot {shot.id} @ {kf.t_s:.1f}s:")
108
+ out.append(Image(path=_thumb(index_dir, kf)))
109
+ return out or ["no keyframes available"]
110
+
111
+
112
+ @server.tool()
113
+ def get_frames(path: str, start_s: float, end_s: float, max_frames: int = 4,
114
+ detail: str = "standard") -> list:
115
+ """Frames evenly spaced across [start_s, end_s]. Frames are expensive: use
116
+ the narrowest range the question allows, and raise max_frames (up to 8)
117
+ only when a sparse sample missed the moment. Set detail='high' (2x
118
+ resolution, ~4x token cost) when small text, colors, patterns, or fine
119
+ object details matter."""
120
+ from .query import extract_on_demand, select_frames
121
+
122
+ index_dir, ctx = _resolve(path)
123
+ max_frames = max(1, min(int(max_frames), 8))
124
+ edge = 1536 if detail == "high" else 768
125
+ video = _source_video(index_dir)
126
+ if video is not None:
127
+ frames = extract_on_demand(index_dir, video, ctx, start_s, end_s, max_frames, max_edge=edge)
128
+ else:
129
+ frames = select_frames(ctx, start_s, end_s, max_frames)
130
+ out: list = []
131
+ for kf in frames:
132
+ out.append(f"frame @ {kf.t_s:.1f}s:")
133
+ out.append(Image(path=index_dir / kf.file))
134
+ return out or ["no frames available in that range"]
135
+
136
+
137
+ @server.tool()
138
+ def transcript_search(path: str, query: str, context_s: float = 15.0) -> str:
139
+ """Case-insensitive search over the transcript. Returns matching segments
140
+ with surrounding context and timestamps: use the timestamps to target
141
+ get_frames."""
142
+ _, ctx = _resolve(path)
143
+ q = query.lower()
144
+ hits = []
145
+ segs = ctx.transcript.segments
146
+ for i, seg in enumerate(segs):
147
+ if q in seg.text.lower():
148
+ around = [s for s in segs if abs(s.start_s - seg.start_s) <= context_s]
149
+ block = " ".join(s.text for s in around)
150
+ hits.append(f"[{seg.start_s:.1f}s] ...{block}...")
151
+ if not hits:
152
+ return f"no transcript matches for {query!r}"
153
+ return "\n".join(hits[:10])
154
+
155
+
156
+ def main() -> None:
157
+ server.run()
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
videoctx/pricing.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ # USD per million tokens (input, output). Update as pricing changes.
6
+ PRICES: dict[str, tuple[float, float]] = {
7
+ "claude-opus-4-8": (5.00, 25.00),
8
+ "claude-sonnet-5": (3.00, 15.00),
9
+ "claude-haiku-4-5": (1.00, 5.00),
10
+ "gemini-native": (0.0, 0.0), # filled in by the eval baseline, provider-specific
11
+ }
12
+
13
+
14
+ @dataclass
15
+ class CostMeter:
16
+ """Accumulates token usage across API calls and prices it."""
17
+
18
+ model: str
19
+ input_tokens: int = 0
20
+ output_tokens: int = 0
21
+ cache_read_tokens: int = 0
22
+ cache_write_tokens: int = 0
23
+ calls: int = 0
24
+ notes: list[str] = field(default_factory=list)
25
+
26
+ cache_write_1h_tokens: int = 0 # 1h-TTL writes bill at 2x, not 1.25x
27
+
28
+ def add(self, usage) -> None:
29
+ self.calls += 1
30
+ self.input_tokens += usage.input_tokens
31
+ self.output_tokens += usage.output_tokens
32
+ self.cache_read_tokens += getattr(usage, "cache_read_input_tokens", 0) or 0
33
+ total_write = getattr(usage, "cache_creation_input_tokens", 0) or 0
34
+ breakdown = getattr(usage, "cache_creation", None)
35
+ h1 = (getattr(breakdown, "ephemeral_1h_input_tokens", 0) or 0) if breakdown else 0
36
+ self.cache_write_1h_tokens += h1
37
+ self.cache_write_tokens += total_write - h1
38
+
39
+ @property
40
+ def usd(self) -> float:
41
+ inp, out = PRICES.get(self.model, (0.0, 0.0))
42
+ cost = (self.input_tokens * inp + self.output_tokens * out) / 1_000_000
43
+ cost += self.cache_read_tokens * inp * 0.1 / 1_000_000
44
+ cost += self.cache_write_tokens * inp * 1.25 / 1_000_000 # 5m-TTL write premium
45
+ cost += self.cache_write_1h_tokens * inp * 2.0 / 1_000_000 # 1h-TTL write premium
46
+ return cost
47
+
48
+ def summary(self) -> dict:
49
+ return {
50
+ "model": self.model,
51
+ "calls": self.calls,
52
+ "input_tokens": self.input_tokens,
53
+ "output_tokens": self.output_tokens,
54
+ "cache_read_tokens": self.cache_read_tokens,
55
+ "cache_write_tokens": self.cache_write_tokens,
56
+ "cache_write_1h_tokens": self.cache_write_1h_tokens,
57
+ "usd": round(self.usd, 6),
58
+ }
videoctx/probe.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from .schema import VideoInfo
8
+
9
+
10
+ def probe(video_path: Path) -> VideoInfo:
11
+ """Read duration, dimensions, fps, and audio presence via ffprobe."""
12
+ out = subprocess.run(
13
+ [
14
+ "ffprobe", "-v", "error",
15
+ "-show_entries", "format=duration",
16
+ "-show_entries", "stream=codec_type,width,height,avg_frame_rate",
17
+ "-of", "json", str(video_path),
18
+ ],
19
+ capture_output=True, text=True, check=True,
20
+ )
21
+ data = json.loads(out.stdout)
22
+ duration = float(data["format"]["duration"])
23
+ video_stream = next(s for s in data["streams"] if s.get("codec_type") == "video")
24
+ has_audio = any(s.get("codec_type") == "audio" for s in data["streams"])
25
+
26
+ num, _, den = video_stream.get("avg_frame_rate", "0/1").partition("/")
27
+ fps = float(num) / float(den) if den and float(den) else 0.0
28
+
29
+ return VideoInfo(
30
+ duration_s=duration,
31
+ width=video_stream["width"],
32
+ height=video_stream["height"],
33
+ fps=round(fps, 3),
34
+ has_audio=has_audio,
35
+ )