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.
- package/README.md +237 -0
- package/SKILL.md +138 -0
- package/bin/setup.js +45 -0
- package/bin/vidclaude.js +27 -0
- package/package.json +31 -0
- package/requirements.txt +2 -0
- package/vidclaude/SKILL.md +138 -0
- package/vidclaude/__init__.py +3 -0
- package/vidclaude/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/audio.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/cli.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/ingest.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/intent.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/memory.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/models.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/ocr.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/reason.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/segment.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/timeline.cpython-313.pyc +0 -0
- package/vidclaude/__pycache__/util.cpython-313.pyc +0 -0
- package/vidclaude/audio.py +193 -0
- package/vidclaude/cli.py +389 -0
- package/vidclaude/ingest.py +80 -0
- package/vidclaude/intent.py +116 -0
- package/vidclaude/memory.py +174 -0
- package/vidclaude/models.py +162 -0
- package/vidclaude/ocr.py +110 -0
- package/vidclaude/reason.py +285 -0
- package/vidclaude/segment.py +239 -0
- package/vidclaude/timeline.py +95 -0
- package/vidclaude/util.py +163 -0
- package/video_understand.py +12 -0
|
@@ -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()
|