vidclaude 0.2.0

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.
@@ -0,0 +1,163 @@
1
+ """Shared utilities: ffmpeg wrappers, image encoding, caching, logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from io import BytesIO
13
+ from pathlib import Path
14
+
15
+ from PIL import Image
16
+
17
+ logger = logging.getLogger("vidclaude")
18
+
19
+
20
+ def setup_logging(verbose: bool) -> None:
21
+ """Configure logging level and format."""
22
+ level = logging.DEBUG if verbose else logging.INFO
23
+ handler = logging.StreamHandler(sys.stderr)
24
+ handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
25
+ root = logging.getLogger("vidclaude")
26
+ root.setLevel(level)
27
+ root.addHandler(handler)
28
+
29
+
30
+ # --- ffmpeg / ffprobe ---
31
+
32
+ def check_ffmpeg() -> bool:
33
+ """Check if ffmpeg is available on PATH."""
34
+ try:
35
+ subprocess.run(
36
+ ["ffmpeg", "-version"],
37
+ capture_output=True, check=True, timeout=10,
38
+ )
39
+ return True
40
+ except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
41
+ return False
42
+
43
+
44
+ def run_ffmpeg(args: list[str], timeout: int = 300) -> subprocess.CompletedProcess:
45
+ """Run an ffmpeg command and return the result."""
46
+ cmd = ["ffmpeg"] + args
47
+ logger.debug("Running: %s", " ".join(cmd))
48
+ result = subprocess.run(
49
+ cmd, capture_output=True, text=True, timeout=timeout,
50
+ )
51
+ if result.returncode != 0:
52
+ logger.debug("ffmpeg stderr: %s", result.stderr[:500])
53
+ return result
54
+
55
+
56
+ def run_ffprobe(video_path: str) -> dict:
57
+ """Run ffprobe and return parsed JSON output."""
58
+ cmd = [
59
+ "ffprobe", "-v", "quiet",
60
+ "-print_format", "json",
61
+ "-show_format", "-show_streams",
62
+ str(video_path),
63
+ ]
64
+ logger.debug("Running: %s", " ".join(cmd))
65
+ result = subprocess.run(
66
+ cmd, capture_output=True, text=True, timeout=30,
67
+ )
68
+ if result.returncode != 0:
69
+ raise RuntimeError(f"ffprobe failed: {result.stderr[:300]}")
70
+ return json.loads(result.stdout)
71
+
72
+
73
+ # --- Image utilities ---
74
+
75
+ def resize_image(img: Image.Image, max_size: int = 1024) -> Image.Image:
76
+ """Resize image so the longest side is at most max_size pixels."""
77
+ w, h = img.size
78
+ if max(w, h) <= max_size:
79
+ return img
80
+ scale = max_size / max(w, h)
81
+ new_w, new_h = int(w * scale), int(h * scale)
82
+ return img.resize((new_w, new_h), Image.LANCZOS)
83
+
84
+
85
+ def image_to_base64(path: str, max_size: int = 1024) -> str:
86
+ """Load image, resize, encode as JPEG base64."""
87
+ img = Image.open(path).convert("RGB")
88
+ img = resize_image(img, max_size)
89
+ buf = BytesIO()
90
+ img.save(buf, format="JPEG", quality=85)
91
+ return base64.b64encode(buf.getvalue()).decode("ascii")
92
+
93
+
94
+ # --- Timestamp formatting ---
95
+
96
+ def format_timestamp(ms: int) -> str:
97
+ """Format milliseconds as MM:SS.mmm."""
98
+ total_sec = ms / 1000.0
99
+ minutes = int(total_sec // 60)
100
+ seconds = total_sec % 60
101
+ return f"{minutes:02d}:{seconds:06.3f}"
102
+
103
+
104
+ def ms_to_hhmmss(ms: int) -> str:
105
+ """Format milliseconds as HH:MM:SS.mmm for longer videos."""
106
+ total_sec = ms / 1000.0
107
+ hours = int(total_sec // 3600)
108
+ minutes = int((total_sec % 3600) // 60)
109
+ seconds = total_sec % 60
110
+ if hours > 0:
111
+ return f"{hours:02d}:{minutes:02d}:{seconds:06.3f}"
112
+ return f"{minutes:02d}:{seconds:06.3f}"
113
+
114
+
115
+ # --- Caching ---
116
+
117
+ def get_cache_key(video_path: str) -> str:
118
+ """Generate a cache key from video path + size + mtime."""
119
+ p = Path(video_path).resolve()
120
+ stat = p.stat()
121
+ identity = f"{p}|{stat.st_size}|{stat.st_mtime}"
122
+ return hashlib.sha256(identity.encode()).hexdigest()[:12]
123
+
124
+
125
+ def get_cache_dir(video_path: str, base_cache: str | None = None) -> Path:
126
+ """Get the cache directory for a video, creating it if needed."""
127
+ key = get_cache_key(video_path)
128
+ if base_cache:
129
+ cache_root = Path(base_cache)
130
+ else:
131
+ cache_root = Path(video_path).parent / ".vidcache"
132
+ cache_dir = cache_root / key
133
+ cache_dir.mkdir(parents=True, exist_ok=True)
134
+ (cache_dir / "frames").mkdir(exist_ok=True)
135
+ return cache_dir
136
+
137
+
138
+ def is_cached(cache_dir: Path) -> bool:
139
+ """Check if a cache directory has completed extraction."""
140
+ return (cache_dir / "meta.json").exists() and (cache_dir / "timeline.json").exists()
141
+
142
+
143
+ # --- File discovery ---
144
+
145
+ VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v"}
146
+
147
+
148
+ def find_videos(path: str) -> list[str]:
149
+ """Find video files in a path. If path is a file, return it. If directory, find all videos."""
150
+ p = Path(path)
151
+ if p.is_file():
152
+ if p.suffix.lower() in VIDEO_EXTENSIONS:
153
+ return [str(p)]
154
+ raise ValueError(f"Not a recognized video file: {p.suffix}")
155
+ if p.is_dir():
156
+ videos = []
157
+ for f in sorted(p.iterdir()):
158
+ if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS:
159
+ videos.append(str(f))
160
+ if not videos:
161
+ raise ValueError(f"No video files found in {p}")
162
+ return videos
163
+ raise FileNotFoundError(f"Path not found: {path}")
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """Multimodal video understanding powered by Claude.
3
+
4
+ Entry point for the vidclaude CLI tool.
5
+ Usage: python video_understand.py <video_or_folder> [options]
6
+ Run with --help for full usage information.
7
+ """
8
+
9
+ from vidclaude.cli import main
10
+
11
+ if __name__ == "__main__":
12
+ main()