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,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
@@ -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