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,193 @@
|
|
|
1
|
+
"""Layer D: Audio understanding — extract audio, transcribe with faster-whisper.
|
|
2
|
+
|
|
3
|
+
Uses faster-whisper (CTranslate2) with large-v3 by default for strong
|
|
4
|
+
multilingual support including Hindi, Urdu, etc.
|
|
5
|
+
Falls back to openai-whisper if faster-whisper is not installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .models import VideoMeta, TranscriptChunk
|
|
14
|
+
from .util import run_ffmpeg
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("vidclaude")
|
|
17
|
+
|
|
18
|
+
# Model selection per processing mode
|
|
19
|
+
MODE_MODELS = {
|
|
20
|
+
"quick": "base",
|
|
21
|
+
"standard": "large-v3",
|
|
22
|
+
"deep": "large-v3",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def has_audio_stream(meta: VideoMeta) -> bool:
|
|
27
|
+
"""Check if video has at least one audio track."""
|
|
28
|
+
return meta.audio_tracks > 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_audio(meta: VideoMeta, cache_dir: Path) -> str | None:
|
|
32
|
+
"""Extract audio from video as 16kHz mono WAV.
|
|
33
|
+
|
|
34
|
+
Returns path to WAV file, or None if no audio stream.
|
|
35
|
+
"""
|
|
36
|
+
if not has_audio_stream(meta):
|
|
37
|
+
logger.info("No audio stream detected, skipping audio extraction")
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
audio_path = str(cache_dir / "audio.wav")
|
|
41
|
+
logger.info("Extracting audio...")
|
|
42
|
+
|
|
43
|
+
result = run_ffmpeg([
|
|
44
|
+
"-i", meta.path,
|
|
45
|
+
"-vn", # no video
|
|
46
|
+
"-acodec", "pcm_s16le", # raw PCM
|
|
47
|
+
"-ar", "16000", # 16kHz sample rate
|
|
48
|
+
"-ac", "1", # mono
|
|
49
|
+
"-y",
|
|
50
|
+
audio_path,
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
logger.warning("Audio extraction failed: %s", result.stderr[:200])
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if not Path(audio_path).exists():
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
logger.info("Audio extracted to %s", audio_path)
|
|
61
|
+
return audio_path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def transcribe(
|
|
65
|
+
meta: VideoMeta,
|
|
66
|
+
cache_dir: Path,
|
|
67
|
+
no_audio: bool = False,
|
|
68
|
+
model_name: str | None = None,
|
|
69
|
+
mode: str = "standard",
|
|
70
|
+
) -> list[TranscriptChunk]:
|
|
71
|
+
"""Transcribe video audio using faster-whisper (preferred) or openai-whisper.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
meta: Video metadata.
|
|
75
|
+
cache_dir: Directory for intermediate files.
|
|
76
|
+
no_audio: If True, skip transcription entirely.
|
|
77
|
+
model_name: Whisper model override. If None, selected by mode.
|
|
78
|
+
mode: Processing mode (quick/standard/deep).
|
|
79
|
+
|
|
80
|
+
Returns list of TranscriptChunk with timestamps.
|
|
81
|
+
"""
|
|
82
|
+
if no_audio:
|
|
83
|
+
logger.info("Audio transcription skipped (--no-audio)")
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
# Extract audio first
|
|
87
|
+
audio_path = extract_audio(meta, cache_dir)
|
|
88
|
+
if audio_path is None:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
# Select model by mode if not overridden
|
|
92
|
+
if model_name is None:
|
|
93
|
+
model_name = MODE_MODELS.get(mode, "large-v3")
|
|
94
|
+
|
|
95
|
+
# Try faster-whisper first (preferred)
|
|
96
|
+
chunks = _transcribe_faster_whisper(audio_path, model_name)
|
|
97
|
+
if chunks is not None:
|
|
98
|
+
return chunks
|
|
99
|
+
|
|
100
|
+
# Fall back to openai-whisper
|
|
101
|
+
chunks = _transcribe_openai_whisper(audio_path, model_name)
|
|
102
|
+
if chunks is not None:
|
|
103
|
+
return chunks
|
|
104
|
+
|
|
105
|
+
logger.warning(
|
|
106
|
+
"No whisper implementation found. Skipping transcription.\n"
|
|
107
|
+
"Install with: pip install faster-whisper (recommended)\n"
|
|
108
|
+
" or: pip install openai-whisper"
|
|
109
|
+
)
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _transcribe_faster_whisper(
|
|
114
|
+
audio_path: str, model_name: str,
|
|
115
|
+
) -> list[TranscriptChunk] | None:
|
|
116
|
+
"""Transcribe using faster-whisper (CTranslate2 backend)."""
|
|
117
|
+
try:
|
|
118
|
+
from faster_whisper import WhisperModel
|
|
119
|
+
except ImportError:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
logger.info("Transcribing with faster-whisper (model=%s)...", model_name)
|
|
123
|
+
|
|
124
|
+
model = WhisperModel(model_name, device="auto", compute_type="auto")
|
|
125
|
+
segments, info = model.transcribe(
|
|
126
|
+
audio_path,
|
|
127
|
+
word_timestamps=True,
|
|
128
|
+
vad_filter=True, # filter out non-speech
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
logger.info(
|
|
132
|
+
"Detected language: %s (probability: %.2f)",
|
|
133
|
+
info.language, info.language_probability,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
chunks: list[TranscriptChunk] = []
|
|
137
|
+
for i, segment in enumerate(segments):
|
|
138
|
+
text = segment.text.strip()
|
|
139
|
+
if not text:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
start_ms = int(segment.start * 1000)
|
|
143
|
+
end_ms = int(segment.end * 1000)
|
|
144
|
+
|
|
145
|
+
chunks.append(TranscriptChunk(
|
|
146
|
+
chunk_id=f"tc_{i:04d}",
|
|
147
|
+
start_ms=start_ms,
|
|
148
|
+
end_ms=end_ms,
|
|
149
|
+
text=text,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
logger.info("Transcribed %d segment(s), total %d chars",
|
|
153
|
+
len(chunks), sum(len(c.text) for c in chunks))
|
|
154
|
+
return chunks
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _transcribe_openai_whisper(
|
|
158
|
+
audio_path: str, model_name: str,
|
|
159
|
+
) -> list[TranscriptChunk] | None:
|
|
160
|
+
"""Transcribe using openai-whisper (fallback)."""
|
|
161
|
+
try:
|
|
162
|
+
import whisper
|
|
163
|
+
except ImportError:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# openai-whisper doesn't support "large-v3" name, map it
|
|
167
|
+
ow_model = model_name
|
|
168
|
+
if model_name == "large-v3":
|
|
169
|
+
ow_model = "large"
|
|
170
|
+
|
|
171
|
+
logger.info("Transcribing with openai-whisper (model=%s)...", ow_model)
|
|
172
|
+
model = whisper.load_model(ow_model)
|
|
173
|
+
result = model.transcribe(audio_path, word_timestamps=True)
|
|
174
|
+
|
|
175
|
+
chunks: list[TranscriptChunk] = []
|
|
176
|
+
for i, segment in enumerate(result.get("segments", [])):
|
|
177
|
+
text = segment.get("text", "").strip()
|
|
178
|
+
if not text:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
start_ms = int(segment["start"] * 1000)
|
|
182
|
+
end_ms = int(segment["end"] * 1000)
|
|
183
|
+
|
|
184
|
+
chunks.append(TranscriptChunk(
|
|
185
|
+
chunk_id=f"tc_{i:04d}",
|
|
186
|
+
start_ms=start_ms,
|
|
187
|
+
end_ms=end_ms,
|
|
188
|
+
text=text,
|
|
189
|
+
))
|
|
190
|
+
|
|
191
|
+
logger.info("Transcribed %d segment(s), total %d chars",
|
|
192
|
+
len(chunks), sum(len(c.text) for c in chunks))
|
|
193
|
+
return chunks
|
package/vidclaude/cli.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""CLI orchestration: argparse, pipeline execution, caching, batch processing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .models import Evidence, save_json, load_json
|
|
12
|
+
from .util import (
|
|
13
|
+
setup_logging, check_ffmpeg, get_cache_dir, is_cached, find_videos,
|
|
14
|
+
)
|
|
15
|
+
from .ingest import ingest
|
|
16
|
+
from .segment import detect_shots, extract_frames
|
|
17
|
+
from .audio import transcribe
|
|
18
|
+
from .ocr import extract_ocr
|
|
19
|
+
from .intent import classify_intent
|
|
20
|
+
from .timeline import build_timeline
|
|
21
|
+
from .memory import build_summaries
|
|
22
|
+
from .reason import generate_evidence_md, call_claude_api
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("vidclaude")
|
|
25
|
+
|
|
26
|
+
# Bundled SKILL.md path (inside the package)
|
|
27
|
+
SKILL_MD_SOURCE = Path(__file__).parent / "SKILL.md"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="vidclaude",
|
|
33
|
+
description="Multimodal video understanding powered by Claude.",
|
|
34
|
+
epilog=(
|
|
35
|
+
"Quick start:\n"
|
|
36
|
+
" vidclaude video.mp4 # extract + analyze\n"
|
|
37
|
+
" vidclaude video.mp4 --mode quick # fast mode\n"
|
|
38
|
+
" vidclaude ./videos/ # batch folder\n"
|
|
39
|
+
" vidclaude --install-skill # set up Claude Code skill\n"
|
|
40
|
+
),
|
|
41
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"input", nargs="?", default=None,
|
|
46
|
+
help="Video file or folder of videos",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--install-skill", action="store_true",
|
|
50
|
+
help="Copy SKILL.md to current directory for Claude Code integration",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--extract", action="store_true",
|
|
54
|
+
help="Extract evidence only (for Claude Code skill mode). "
|
|
55
|
+
"Outputs cache path for Claude to read.",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"-q", "--question", default=None,
|
|
59
|
+
help="Question to answer about the video",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-f", "--fps", type=float, default=None,
|
|
63
|
+
help="Frames per second to extract (overrides mode default)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"-m", "--max-frames", type=int, default=None,
|
|
67
|
+
help="Maximum number of frames to extract (overrides mode default)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--no-audio", action="store_true",
|
|
71
|
+
help="Skip audio transcription",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--no-ocr", action="store_true",
|
|
75
|
+
help="Skip OCR extraction",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"-o", "--output", default=None,
|
|
79
|
+
help="Write output to file instead of stdout",
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--verbose", action="store_true",
|
|
83
|
+
help="Print detailed progress information",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--mode", choices=["quick", "standard", "deep"], default="standard",
|
|
87
|
+
help="Processing mode (default: standard)",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--batch-summary", action="store_true",
|
|
91
|
+
help="Generate a cross-video summary when processing a folder",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--no-cache", action="store_true",
|
|
95
|
+
help="Force re-extraction even if cache exists",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--api", action="store_true",
|
|
99
|
+
help="Use Anthropic API for reasoning (standalone mode, needs API key)",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def install_skill() -> None:
|
|
106
|
+
"""Copy SKILL.md to the current directory for Claude Code integration."""
|
|
107
|
+
dest = Path.cwd() / "SKILL.md"
|
|
108
|
+
|
|
109
|
+
if SKILL_MD_SOURCE.exists():
|
|
110
|
+
source = SKILL_MD_SOURCE
|
|
111
|
+
else:
|
|
112
|
+
# Fallback: look relative to the package root
|
|
113
|
+
source = Path(__file__).parent.parent / "SKILL.md"
|
|
114
|
+
|
|
115
|
+
if not source.exists():
|
|
116
|
+
print("Error: SKILL.md not found in package.", file=sys.stderr)
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
|
|
119
|
+
if dest.exists():
|
|
120
|
+
print(f"SKILL.md already exists at {dest}")
|
|
121
|
+
response = input("Overwrite? [y/N] ").strip().lower()
|
|
122
|
+
if response != "y":
|
|
123
|
+
print("Skipped.")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
shutil.copy2(source, dest)
|
|
127
|
+
print(f"Installed SKILL.md to {dest}")
|
|
128
|
+
print()
|
|
129
|
+
print("You're all set! In Claude Code, just say:")
|
|
130
|
+
print(' "analyze the video at path/to/video.mp4"')
|
|
131
|
+
print()
|
|
132
|
+
print("Or from the command line:")
|
|
133
|
+
print(" vidclaude video.mp4 --mode standard --verbose")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def process_video(
|
|
137
|
+
video_path: str,
|
|
138
|
+
args: argparse.Namespace,
|
|
139
|
+
) -> tuple[str, Evidence | None]:
|
|
140
|
+
"""Process a single video through the extraction pipeline.
|
|
141
|
+
|
|
142
|
+
Returns (cache_dir_path, evidence_object).
|
|
143
|
+
"""
|
|
144
|
+
# Ingest
|
|
145
|
+
meta = ingest(video_path)
|
|
146
|
+
|
|
147
|
+
# Get or create cache directory
|
|
148
|
+
cache_dir = get_cache_dir(video_path)
|
|
149
|
+
|
|
150
|
+
# Check cache
|
|
151
|
+
if not args.no_cache and is_cached(cache_dir):
|
|
152
|
+
logger.info("Using cached extraction from %s", cache_dir)
|
|
153
|
+
return str(cache_dir), _load_cached_evidence(cache_dir, args.question)
|
|
154
|
+
|
|
155
|
+
# Classify intent from question
|
|
156
|
+
intent = classify_intent(args.question)
|
|
157
|
+
logger.info("Intent: %s", intent)
|
|
158
|
+
|
|
159
|
+
# Detect shots
|
|
160
|
+
shots = detect_shots(meta)
|
|
161
|
+
save_json([s.to_dict() for s in shots], cache_dir / "shots.json")
|
|
162
|
+
|
|
163
|
+
# Extract frames (adaptive sampling)
|
|
164
|
+
frames = extract_frames(
|
|
165
|
+
meta, shots, args.mode, cache_dir,
|
|
166
|
+
fps_override=args.fps,
|
|
167
|
+
max_frames_override=args.max_frames,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Transcribe audio (model selected by mode: quick=base, standard/deep=large-v3)
|
|
171
|
+
transcript = transcribe(
|
|
172
|
+
meta, cache_dir,
|
|
173
|
+
no_audio=args.no_audio,
|
|
174
|
+
mode=args.mode,
|
|
175
|
+
)
|
|
176
|
+
save_json([t.to_dict() for t in transcript], cache_dir / "transcript.json")
|
|
177
|
+
|
|
178
|
+
# OCR
|
|
179
|
+
ocr_results = extract_ocr(frames, no_ocr=args.no_ocr, mode=args.mode)
|
|
180
|
+
save_json([o.to_dict() for o in ocr_results], cache_dir / "ocr.json")
|
|
181
|
+
|
|
182
|
+
# Build timeline
|
|
183
|
+
timeline = build_timeline(shots, frames, transcript, ocr_results)
|
|
184
|
+
save_json([e.to_dict() for e in timeline], cache_dir / "timeline.json")
|
|
185
|
+
|
|
186
|
+
# Build summaries
|
|
187
|
+
summaries = build_summaries(timeline, meta.duration_sec, mode=args.mode)
|
|
188
|
+
save_json(summaries, cache_dir / "summaries.json")
|
|
189
|
+
|
|
190
|
+
# Save metadata
|
|
191
|
+
save_json(meta.to_dict(), cache_dir / "meta.json")
|
|
192
|
+
|
|
193
|
+
# Build evidence object
|
|
194
|
+
evidence = Evidence(
|
|
195
|
+
video_meta=meta,
|
|
196
|
+
question=args.question or "",
|
|
197
|
+
intent=intent,
|
|
198
|
+
frames=frames,
|
|
199
|
+
transcript_chunks=transcript,
|
|
200
|
+
ocr_results=ocr_results,
|
|
201
|
+
timeline_events=timeline,
|
|
202
|
+
scene_summaries=summaries.get("scene_summaries", []),
|
|
203
|
+
global_summary=summaries.get("global_summary", ""),
|
|
204
|
+
cache_dir=str(cache_dir),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Generate evidence.md (always, for skill mode)
|
|
208
|
+
generate_evidence_md(evidence, cache_dir)
|
|
209
|
+
|
|
210
|
+
return str(cache_dir), evidence
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _load_cached_evidence(cache_dir: Path, question: str | None) -> Evidence:
|
|
214
|
+
"""Load evidence from cached files."""
|
|
215
|
+
from .models import (
|
|
216
|
+
VideoMeta, Frame, TranscriptChunk, OCRResult, TimelineEvent,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
meta = VideoMeta.from_dict(load_json(cache_dir / "meta.json"))
|
|
220
|
+
|
|
221
|
+
# Load frames from the frames directory
|
|
222
|
+
frames_dir = cache_dir / "frames"
|
|
223
|
+
frames = []
|
|
224
|
+
if frames_dir.exists():
|
|
225
|
+
for img_path in sorted(frames_dir.glob("frame_*.jpg")):
|
|
226
|
+
parts = img_path.stem.split("_")
|
|
227
|
+
# frame_NNNN_TTTTTTTT.jpg
|
|
228
|
+
if len(parts) >= 3:
|
|
229
|
+
seq = int(parts[1])
|
|
230
|
+
ts_ms = int(parts[2])
|
|
231
|
+
frames.append(Frame(
|
|
232
|
+
frame_id=f"f_{seq:04d}",
|
|
233
|
+
timestamp_ms=ts_ms,
|
|
234
|
+
shot_id="",
|
|
235
|
+
sampling_reason=["cached"],
|
|
236
|
+
image_path=str(img_path),
|
|
237
|
+
))
|
|
238
|
+
|
|
239
|
+
transcript = [
|
|
240
|
+
TranscriptChunk.from_dict(d)
|
|
241
|
+
for d in load_json(cache_dir / "transcript.json")
|
|
242
|
+
] if (cache_dir / "transcript.json").exists() else []
|
|
243
|
+
|
|
244
|
+
ocr_results = [
|
|
245
|
+
OCRResult.from_dict(d)
|
|
246
|
+
for d in load_json(cache_dir / "ocr.json")
|
|
247
|
+
] if (cache_dir / "ocr.json").exists() else []
|
|
248
|
+
|
|
249
|
+
timeline = [
|
|
250
|
+
TimelineEvent.from_dict(d)
|
|
251
|
+
for d in load_json(cache_dir / "timeline.json")
|
|
252
|
+
] if (cache_dir / "timeline.json").exists() else []
|
|
253
|
+
|
|
254
|
+
summaries = load_json(cache_dir / "summaries.json") if (
|
|
255
|
+
cache_dir / "summaries.json"
|
|
256
|
+
).exists() else {}
|
|
257
|
+
|
|
258
|
+
intent = classify_intent(question)
|
|
259
|
+
|
|
260
|
+
return Evidence(
|
|
261
|
+
video_meta=meta,
|
|
262
|
+
question=question or "",
|
|
263
|
+
intent=intent,
|
|
264
|
+
frames=frames,
|
|
265
|
+
transcript_chunks=transcript,
|
|
266
|
+
ocr_results=ocr_results,
|
|
267
|
+
timeline_events=timeline,
|
|
268
|
+
scene_summaries=summaries.get("scene_summaries", []),
|
|
269
|
+
global_summary=summaries.get("global_summary", ""),
|
|
270
|
+
cache_dir=str(cache_dir),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main() -> None:
|
|
275
|
+
"""Main entry point for the CLI."""
|
|
276
|
+
parser = build_parser()
|
|
277
|
+
args = parser.parse_args()
|
|
278
|
+
|
|
279
|
+
# Handle --install-skill before anything else
|
|
280
|
+
if args.install_skill:
|
|
281
|
+
install_skill()
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Require input for all other operations
|
|
285
|
+
if args.input is None:
|
|
286
|
+
parser.print_help()
|
|
287
|
+
sys.exit(1)
|
|
288
|
+
|
|
289
|
+
setup_logging(args.verbose)
|
|
290
|
+
|
|
291
|
+
# Preflight checks
|
|
292
|
+
if not check_ffmpeg():
|
|
293
|
+
print(
|
|
294
|
+
"Error: ffmpeg not found on PATH.\n"
|
|
295
|
+
"Install from https://ffmpeg.org or via your package manager.\n"
|
|
296
|
+
" Windows: winget install ffmpeg\n"
|
|
297
|
+
" macOS: brew install ffmpeg\n"
|
|
298
|
+
" Linux: sudo apt install ffmpeg",
|
|
299
|
+
file=sys.stderr,
|
|
300
|
+
)
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
|
|
303
|
+
# Find videos to process
|
|
304
|
+
try:
|
|
305
|
+
video_paths = find_videos(args.input)
|
|
306
|
+
except (FileNotFoundError, ValueError) as e:
|
|
307
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
|
|
310
|
+
logger.info("Found %d video(s) to process", len(video_paths))
|
|
311
|
+
|
|
312
|
+
results: list[tuple[str, str, Evidence | None]] = [] # (path, cache_dir, evidence)
|
|
313
|
+
|
|
314
|
+
for i, video_path in enumerate(video_paths):
|
|
315
|
+
if len(video_paths) > 1:
|
|
316
|
+
print(f"\n--- Processing [{i+1}/{len(video_paths)}]: {video_path} ---",
|
|
317
|
+
file=sys.stderr)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
cache_dir, evidence = process_video(video_path, args)
|
|
321
|
+
results.append((video_path, cache_dir, evidence))
|
|
322
|
+
except KeyboardInterrupt:
|
|
323
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
324
|
+
sys.exit(130)
|
|
325
|
+
except Exception as e:
|
|
326
|
+
logger.error("Failed to process %s: %s", video_path, e)
|
|
327
|
+
if args.verbose:
|
|
328
|
+
import traceback
|
|
329
|
+
traceback.print_exc()
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
if not results:
|
|
333
|
+
print("No videos were successfully processed.", file=sys.stderr)
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
# Output
|
|
337
|
+
output_text = ""
|
|
338
|
+
|
|
339
|
+
if args.extract:
|
|
340
|
+
# Skill mode: print cache paths and summary
|
|
341
|
+
for video_path, cache_dir, evidence in results:
|
|
342
|
+
print(f"Extracted: {video_path}")
|
|
343
|
+
print(f" Cache: {cache_dir}")
|
|
344
|
+
if evidence:
|
|
345
|
+
print(f" Frames: {len(evidence.frames)}")
|
|
346
|
+
print(f" Transcript chunks: {len(evidence.transcript_chunks)}")
|
|
347
|
+
print(f" OCR results: {len(evidence.ocr_results)}")
|
|
348
|
+
print(f" Timeline events: {len(evidence.timeline_events)}")
|
|
349
|
+
print(f" Evidence report: {cache_dir}/evidence.md")
|
|
350
|
+
|
|
351
|
+
elif args.api:
|
|
352
|
+
# Standalone API mode
|
|
353
|
+
for video_path, cache_dir, evidence in results:
|
|
354
|
+
if evidence:
|
|
355
|
+
try:
|
|
356
|
+
answer = call_claude_api(evidence)
|
|
357
|
+
output_text += f"## {video_path}\n\n{answer}\n\n"
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error("API call failed for %s: %s", video_path, e)
|
|
360
|
+
output_text += f"## {video_path}\n\nError: {e}\n\n"
|
|
361
|
+
|
|
362
|
+
else:
|
|
363
|
+
# Default: extract mode behavior (most useful)
|
|
364
|
+
for video_path, cache_dir, evidence in results:
|
|
365
|
+
print(f"Extracted: {video_path}")
|
|
366
|
+
print(f" Cache: {cache_dir}")
|
|
367
|
+
if evidence:
|
|
368
|
+
print(f" Frames: {len(evidence.frames)}")
|
|
369
|
+
print(f" Transcript chunks: {len(evidence.transcript_chunks)}")
|
|
370
|
+
print(f" Timeline events: {len(evidence.timeline_events)}")
|
|
371
|
+
print(f" Evidence report: {cache_dir}/evidence.md")
|
|
372
|
+
|
|
373
|
+
# Batch summary
|
|
374
|
+
if args.batch_summary and len(results) > 1:
|
|
375
|
+
output_text += "\n## Batch Summary\n\n"
|
|
376
|
+
for video_path, cache_dir, evidence in results:
|
|
377
|
+
if evidence:
|
|
378
|
+
output_text += f"- **{Path(video_path).name}**: "
|
|
379
|
+
output_text += f"{len(evidence.frames)} frames, "
|
|
380
|
+
output_text += f"{len(evidence.transcript_chunks)} transcript segments, "
|
|
381
|
+
output_text += f"{len(evidence.timeline_events)} timeline events\n"
|
|
382
|
+
|
|
383
|
+
# Write output
|
|
384
|
+
if output_text:
|
|
385
|
+
if args.output:
|
|
386
|
+
Path(args.output).write_text(output_text, encoding="utf-8")
|
|
387
|
+
print(f"Output written to {args.output}", file=sys.stderr)
|
|
388
|
+
else:
|
|
389
|
+
print(output_text)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Layer A: Video ingestion — validate format, extract metadata via ffprobe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .models import VideoMeta
|
|
9
|
+
from .util import run_ffprobe, VIDEO_EXTENSIONS
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("vidclaude")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ingest(video_path: str) -> VideoMeta:
|
|
15
|
+
"""Validate video file and extract metadata.
|
|
16
|
+
|
|
17
|
+
Returns VideoMeta with duration, fps, resolution, audio track info.
|
|
18
|
+
Raises on invalid file or ffprobe failure.
|
|
19
|
+
"""
|
|
20
|
+
p = Path(video_path).resolve()
|
|
21
|
+
|
|
22
|
+
if not p.exists():
|
|
23
|
+
raise FileNotFoundError(f"Video not found: {video_path}")
|
|
24
|
+
if not p.is_file():
|
|
25
|
+
raise ValueError(f"Not a file: {video_path}")
|
|
26
|
+
if p.suffix.lower() not in VIDEO_EXTENSIONS:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"Unsupported format '{p.suffix}'. "
|
|
29
|
+
f"Supported: {', '.join(sorted(VIDEO_EXTENSIONS))}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
probe = run_ffprobe(str(p))
|
|
33
|
+
|
|
34
|
+
# Extract format info
|
|
35
|
+
fmt = probe.get("format", {})
|
|
36
|
+
duration = float(fmt.get("duration", 0))
|
|
37
|
+
file_size = int(fmt.get("size", 0))
|
|
38
|
+
format_name = fmt.get("format_name", "unknown")
|
|
39
|
+
|
|
40
|
+
# Find video stream
|
|
41
|
+
video_stream = None
|
|
42
|
+
audio_count = 0
|
|
43
|
+
for stream in probe.get("streams", []):
|
|
44
|
+
if stream.get("codec_type") == "video" and video_stream is None:
|
|
45
|
+
video_stream = stream
|
|
46
|
+
elif stream.get("codec_type") == "audio":
|
|
47
|
+
audio_count += 1
|
|
48
|
+
|
|
49
|
+
if video_stream is None:
|
|
50
|
+
raise ValueError(f"No video stream found in {video_path}")
|
|
51
|
+
|
|
52
|
+
# Parse fps from r_frame_rate (e.g. "30000/1001" or "30/1")
|
|
53
|
+
fps_str = video_stream.get("r_frame_rate", "30/1")
|
|
54
|
+
try:
|
|
55
|
+
num, den = fps_str.split("/")
|
|
56
|
+
fps = float(num) / float(den)
|
|
57
|
+
except (ValueError, ZeroDivisionError):
|
|
58
|
+
fps = 30.0
|
|
59
|
+
|
|
60
|
+
width = int(video_stream.get("width", 0))
|
|
61
|
+
height = int(video_stream.get("height", 0))
|
|
62
|
+
|
|
63
|
+
meta = VideoMeta(
|
|
64
|
+
path=str(p),
|
|
65
|
+
duration_sec=duration,
|
|
66
|
+
fps=fps,
|
|
67
|
+
resolution=(width, height),
|
|
68
|
+
audio_tracks=audio_count,
|
|
69
|
+
format=format_name,
|
|
70
|
+
file_size_bytes=file_size,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
logger.info(
|
|
74
|
+
"Ingested: %.1fs, %.1ffps, %dx%d, %d audio track(s), %s",
|
|
75
|
+
meta.duration_sec, meta.fps,
|
|
76
|
+
width, height,
|
|
77
|
+
audio_count, format_name,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return meta
|