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 +1 -0
- videoctx/captions.py +67 -0
- videoctx/cli.py +70 -0
- videoctx/frames.py +46 -0
- videoctx/indexer.py +93 -0
- videoctx/mcp_server.py +161 -0
- videoctx/pricing.py +58 -0
- videoctx/probe.py +35 -0
- videoctx/query.py +271 -0
- videoctx/schema.py +45 -0
- videoctx/shots.py +54 -0
- videoctx/textview.py +71 -0
- videoctx/transcribe.py +42 -0
- videoctx-0.1.0.dist-info/METADATA +130 -0
- videoctx-0.1.0.dist-info/RECORD +18 -0
- videoctx-0.1.0.dist-info/WHEEL +4 -0
- videoctx-0.1.0.dist-info/entry_points.txt +3 -0
- videoctx-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
)
|