arkaos 4.31.0 → 4.32.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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/THE-ARKAOS-GUIDE.md +1 -1
  3. package/VERSION +1 -1
  4. package/arka/SKILL.md +1 -1
  5. package/config/claude-agents/brand-director.md +1 -1
  6. package/config/claude-agents/content-strategist.md +1 -1
  7. package/config/claude-agents/frontend-dev.md +1 -1
  8. package/config/claude-agents/marketing-director.md +1 -1
  9. package/config/claude-agents/scriptwriter.md +1 -1
  10. package/config/claude-agents/trends-analyst.md +1 -1
  11. package/config/claude-agents/video-producer.md +1 -1
  12. package/config/skills-curated.yaml +2 -1
  13. package/config/skills-provenance.yaml +6 -0
  14. package/core/keys.py +17 -5
  15. package/departments/brand/agents/brand-director.yaml +1 -0
  16. package/departments/content/agents/content-strategist.yaml +1 -0
  17. package/departments/content/agents/production/trends-analyst.yaml +1 -0
  18. package/departments/content/agents/production/video-producer.yaml +1 -0
  19. package/departments/content/agents/scriptwriter.yaml +1 -0
  20. package/departments/dev/agents/frontend-dev.yaml +1 -0
  21. package/departments/dev/skills/animated-website/SKILL.md +14 -1
  22. package/departments/dev/skills/watch/SKILL.md +162 -0
  23. package/departments/dev/skills/watch/references/claude-video.LICENSE +21 -0
  24. package/departments/dev/skills/watch/scripts/config.py +145 -0
  25. package/departments/dev/skills/watch/scripts/download.py +179 -0
  26. package/departments/dev/skills/watch/scripts/frames.py +762 -0
  27. package/departments/dev/skills/watch/scripts/setup.py +373 -0
  28. package/departments/dev/skills/watch/scripts/transcribe.py +95 -0
  29. package/departments/dev/skills/watch/scripts/watch.py +531 -0
  30. package/departments/dev/skills/watch/scripts/whisper.py +466 -0
  31. package/departments/marketing/agents/marketing-director.yaml +1 -0
  32. package/departments/marketing/skills/ad-creative/SKILL.md +5 -0
  33. package/harness/codex/AGENTS.md +1 -1
  34. package/harness/copilot/copilot-instructions.md +1 -1
  35. package/harness/cursor/rules/arkaos.mdc +2 -2
  36. package/harness/gemini/GEMINI.md +1 -1
  37. package/harness/opencode/AGENTS.md +1 -1
  38. package/harness/zed/.rules +1 -1
  39. package/installer/doctor.js +38 -4
  40. package/knowledge/agents-registry-v2.json +8 -1
  41. package/knowledge/skills-manifest.json +14 -1
  42. package/package.json +1 -1
  43. package/pyproject.toml +1 -1
@@ -0,0 +1,762 @@
1
+ #!/usr/bin/env python3
2
+ """Probe video metadata and extract frames at an auto-scaled fps.
3
+
4
+ Auto-fps targets a frame budget, not a fixed rate. Token cost scales with frame
5
+ count, so budget-by-duration keeps short videos dense and long videos capped.
6
+ When a user-specified range is passed, focused-mode budgets denser (they are
7
+ zooming in for detail).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ MAX_FPS = 2.0
20
+ SCENE_THRESHOLD = 0.20
21
+ # Keep scene-detection results once we have at least this many distinct shots.
22
+ # Below this the video is effectively static (screen recording, talking head),
23
+ # so we fall back to uniform sampling. Matching the reference fork's behaviour,
24
+ # this is a low floor — NOT the frame budget — so normal videos with cuts use
25
+ # the (single-pass) scene engine instead of paying for a wasted second decode.
26
+ SCENE_MIN_FRAMES = 8
27
+ # Below this many decoded keyframes a clip is too sparse for keyframe coverage
28
+ # (very short or oddly encoded), so the cheap tier falls back to uniform.
29
+ KEYFRAME_MIN = 4
30
+ MAX_READ_DIMENSION = 1998
31
+ # Frame-delta dedup: downscale each frame to a DEDUP_THUMB x DEDUP_THUMB
32
+ # grayscale thumbnail and treat two frames as near-identical when their mean
33
+ # per-pixel difference (0-255) is at or below DEDUP_THRESHOLD. Conservative on
34
+ # purpose: only collapses frames that are visually the same shot, so a code diff
35
+ # / scrolling terminal / slide-gaining-a-bullet survives. Unlike a within-frame
36
+ # perceptual hash, this distinguishes flat frames (solid slides, fades) by luma.
37
+ DEDUP_THUMB = 16
38
+ DEDUP_THRESHOLD = 2.0
39
+ SHOWINFO_TS_RE = re.compile(r"pts_time:([0-9.]+)")
40
+
41
+
42
+ def _scale_filter(resolution: int) -> str:
43
+ return (
44
+ f"scale=w='min({resolution},iw)':h='min({MAX_READ_DIMENSION},ih)':"
45
+ "force_original_aspect_ratio=decrease:force_divisible_by=2"
46
+ )
47
+
48
+
49
+ def _clamp_fps(fps: float, duration_seconds: float, max_frames: int) -> tuple[float, int]:
50
+ fps = min(fps, MAX_FPS)
51
+ target = min(max_frames, max(1, round(fps * duration_seconds)))
52
+ return fps, target
53
+
54
+
55
+ def parse_time(value: str | float | int | None) -> float | None:
56
+ """Parse SS, MM:SS, or HH:MM:SS (with optional .ms) into seconds."""
57
+ if value is None:
58
+ return None
59
+ if isinstance(value, (int, float)):
60
+ return float(value)
61
+ s = str(value).strip()
62
+ if not s:
63
+ return None
64
+ parts = s.split(":")
65
+ try:
66
+ if len(parts) == 1:
67
+ return float(parts[0])
68
+ if len(parts) == 2:
69
+ return int(parts[0]) * 60 + float(parts[1])
70
+ if len(parts) == 3:
71
+ return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
72
+ except ValueError:
73
+ pass
74
+ raise SystemExit(f"Cannot parse time value: {value!r} (expected SS, MM:SS, or HH:MM:SS)")
75
+
76
+
77
+ def format_time(seconds: float) -> str:
78
+ total = round(seconds)
79
+ hours, rem = divmod(total, 3600)
80
+ minutes, sec = divmod(rem, 60)
81
+ if hours:
82
+ return f"{hours}:{minutes:02d}:{sec:02d}"
83
+ return f"{minutes:02d}:{sec:02d}"
84
+
85
+
86
+ def get_metadata(video_path: str) -> dict:
87
+ if shutil.which("ffprobe") is None:
88
+ raise SystemExit("ffprobe is not installed. Install with: brew install ffmpeg")
89
+
90
+ result = subprocess.run(
91
+ [
92
+ "ffprobe",
93
+ "-v", "quiet",
94
+ "-print_format", "json",
95
+ "-show_format",
96
+ "-show_streams",
97
+ str(Path(video_path).resolve()),
98
+ ],
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ if result.returncode != 0:
103
+ raise SystemExit(f"ffprobe failed: {result.stderr.strip()}")
104
+
105
+ data = json.loads(result.stdout or "{}")
106
+ streams = data.get("streams", [])
107
+ fmt = data.get("format", {})
108
+ video_stream = next((s for s in streams if s.get("codec_type") == "video"), {})
109
+ audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None)
110
+
111
+ duration = float(fmt.get("duration") or video_stream.get("duration") or 0)
112
+ return {
113
+ "duration_seconds": duration,
114
+ "width": video_stream.get("width"),
115
+ "height": video_stream.get("height"),
116
+ "codec": video_stream.get("codec_name"),
117
+ "size_bytes": int(fmt.get("size") or 0),
118
+ "has_audio": audio_stream is not None,
119
+ }
120
+
121
+
122
+ def auto_fps(duration_seconds: float, max_frames: int = 100) -> tuple[float, int]:
123
+ """Pick fps that targets a sensible frame budget for full-video scans."""
124
+ if duration_seconds <= 0:
125
+ return 1.0, 1
126
+
127
+ if duration_seconds <= 30:
128
+ target = min(max_frames, max(12, round(duration_seconds)))
129
+ elif duration_seconds <= 60:
130
+ target = min(max_frames, 40)
131
+ elif duration_seconds <= 180: # 3 min
132
+ target = min(max_frames, 60)
133
+ elif duration_seconds <= 600: # 10 min
134
+ target = min(max_frames, 80)
135
+ else:
136
+ target = max_frames
137
+
138
+ return _clamp_fps(target / duration_seconds, duration_seconds, max_frames)
139
+
140
+
141
+ def auto_fps_focus(duration_seconds: float, max_frames: int = 100) -> tuple[float, int]:
142
+ """Denser budget for user-specified ranges — they are zooming in for detail."""
143
+ if duration_seconds <= 0:
144
+ return min(MAX_FPS, 2.0), 2
145
+
146
+ if duration_seconds <= 5:
147
+ target = min(max_frames, max(10, round(duration_seconds * 6)))
148
+ elif duration_seconds <= 15:
149
+ target = min(max_frames, max(30, round(duration_seconds * 4)))
150
+ elif duration_seconds <= 30:
151
+ target = min(max_frames, 60)
152
+ elif duration_seconds <= 60:
153
+ target = min(max_frames, 80)
154
+ elif duration_seconds <= 180:
155
+ target = max_frames
156
+ else:
157
+ target = max_frames
158
+
159
+ return _clamp_fps(target / duration_seconds, duration_seconds, max_frames)
160
+
161
+
162
+ def extract(
163
+ video_path: str,
164
+ out_dir: Path,
165
+ fps: float,
166
+ resolution: int = 512,
167
+ max_frames: int = 100,
168
+ start_seconds: float | None = None,
169
+ end_seconds: float | None = None,
170
+ ) -> list[dict]:
171
+ if shutil.which("ffmpeg") is None:
172
+ raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
173
+
174
+ out_dir.mkdir(parents=True, exist_ok=True)
175
+ for existing in out_dir.glob("frame_*.jpg"):
176
+ existing.unlink()
177
+
178
+ output_pattern = str(out_dir / "frame_%04d.jpg")
179
+ cmd: list[str] = [
180
+ "ffmpeg",
181
+ "-hide_banner",
182
+ "-loglevel", "error",
183
+ "-y",
184
+ ]
185
+
186
+ # -ss before -i = fast seek (keyframe-snap, good enough for preview frames).
187
+ if start_seconds is not None:
188
+ cmd += ["-ss", f"{start_seconds:.3f}"]
189
+ if end_seconds is not None:
190
+ cmd += ["-to", f"{end_seconds:.3f}"]
191
+
192
+ cmd += [
193
+ "-i", str(Path(video_path).resolve()),
194
+ "-vf", f"fps={fps},{_scale_filter(resolution)}",
195
+ "-frames:v", str(max_frames),
196
+ "-q:v", "4",
197
+ output_pattern,
198
+ ]
199
+
200
+ result = subprocess.run(cmd, capture_output=True, text=True)
201
+ if result.returncode != 0:
202
+ raise SystemExit(f"ffmpeg frame extraction failed: {result.stderr.strip()}")
203
+
204
+ offset = start_seconds or 0.0
205
+ frames = sorted(out_dir.glob("frame_*.jpg"))
206
+ return [
207
+ {
208
+ "index": i,
209
+ "timestamp_seconds": round(offset + (i / fps if fps > 0 else 0.0), 2),
210
+ "path": str(p),
211
+ "reason": "uniform",
212
+ }
213
+ for i, p in enumerate(frames)
214
+ ]
215
+
216
+
217
+ def extract_scene_candidates(
218
+ video_path: str,
219
+ out_dir: Path,
220
+ resolution: int = 512,
221
+ max_frames: int | None = 100,
222
+ start_seconds: float | None = None,
223
+ end_seconds: float | None = None,
224
+ threshold: float = SCENE_THRESHOLD,
225
+ ) -> list[dict]:
226
+ """Extract first frame plus ffmpeg scene-change frames.
227
+
228
+ When ``max_frames`` is set, ``-frames:v`` lets ffmpeg stop decoding once it
229
+ has emitted that many frames (early exit) and avoids writing extras that we
230
+ would only delete afterwards. ``None`` (uncapped "complete" detail) keeps
231
+ every detected shot, as the user explicitly opted in.
232
+ """
233
+ if shutil.which("ffmpeg") is None:
234
+ raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
235
+
236
+ out_dir.mkdir(parents=True, exist_ok=True)
237
+ for existing in out_dir.glob("frame_*.jpg"):
238
+ existing.unlink()
239
+
240
+ output_pattern = str(out_dir / "frame_%04d.jpg")
241
+ cmd: list[str] = [
242
+ "ffmpeg",
243
+ "-hide_banner",
244
+ "-loglevel", "info",
245
+ "-y",
246
+ ]
247
+ if start_seconds is not None:
248
+ cmd += ["-ss", f"{start_seconds:.3f}"]
249
+ if end_seconds is not None:
250
+ cmd += ["-to", f"{end_seconds:.3f}"]
251
+
252
+ vf = f"select='eq(n\\,0)+gt(scene\\,{threshold})',{_scale_filter(resolution)},showinfo"
253
+ cmd += [
254
+ "-i", str(Path(video_path).resolve()),
255
+ "-vf", vf,
256
+ "-vsync", "vfr",
257
+ ]
258
+ if max_frames is not None:
259
+ cmd += ["-frames:v", str(max_frames)]
260
+ cmd += [
261
+ "-q:v", "4",
262
+ output_pattern,
263
+ ]
264
+ result = subprocess.run(cmd, capture_output=True, text=True)
265
+ if result.returncode != 0:
266
+ raise SystemExit(f"ffmpeg scene extraction failed: {result.stderr.strip()}")
267
+
268
+ offset = start_seconds or 0.0
269
+ timestamps = [
270
+ round(offset + float(match.group(1)), 2)
271
+ for match in SHOWINFO_TS_RE.finditer(result.stderr)
272
+ ]
273
+ frames = sorted(out_dir.glob("frame_*.jpg"))
274
+ out: list[dict] = []
275
+ for i, path in enumerate(frames):
276
+ ts = timestamps[i] if i < len(timestamps) else offset
277
+ out.append({
278
+ "index": i,
279
+ "timestamp_seconds": ts,
280
+ "path": str(path),
281
+ "reason": "first-frame" if i == 0 else "scene-change",
282
+ })
283
+ return out
284
+
285
+
286
+ def _even_indices(count: int, n: int) -> list[int]:
287
+ """Indices of ``n`` evenly-spaced items out of ``count`` (first + last kept).
288
+
289
+ ``n >= count`` returns every index; ``n == 1`` returns just the first.
290
+ """
291
+ if n >= count:
292
+ return list(range(count))
293
+ if n <= 1:
294
+ return [0]
295
+ return [round(i * (count - 1) / (n - 1)) for i in range(n)]
296
+
297
+
298
+ def parse_timestamps(value: str | None) -> list[float]:
299
+ """Parse a comma-separated list of times (SS, MM:SS, HH:MM:SS) into a
300
+ sorted, de-duplicated list of seconds. Empty/blank tokens are skipped;
301
+ an unparseable token raises (via :func:`parse_time`)."""
302
+ if not value:
303
+ return []
304
+ out: list[float] = []
305
+ for token in value.split(","):
306
+ token = token.strip()
307
+ if not token:
308
+ continue
309
+ seconds = parse_time(token)
310
+ if seconds is not None:
311
+ out.append(float(seconds))
312
+ return sorted(set(out))
313
+
314
+
315
+ def merge_frames(primary: list[dict], pinned: list[dict]) -> list[dict]:
316
+ """Combine two frame lists into one chronological list and reindex 0..n-1.
317
+
318
+ ``pinned`` frames (transcript cues) are never dropped — this is a plain
319
+ union, so the cap is enforced upstream by reserving budget for the cues.
320
+ """
321
+ merged = sorted([*primary, *pinned], key=lambda f: f["timestamp_seconds"])
322
+ for i, frame in enumerate(merged):
323
+ frame["index"] = i
324
+ return merged
325
+
326
+
327
+ def extract_at_timestamps(
328
+ video_path: str,
329
+ out_dir: Path,
330
+ timestamps: list[float],
331
+ resolution: int = 512,
332
+ max_frames: int | None = None,
333
+ start_seconds: float | None = None,
334
+ end_seconds: float | None = None,
335
+ ) -> tuple[list[dict], dict]:
336
+ """Grab exactly one frame at each requested timestamp (transcript cues).
337
+
338
+ Timestamps are absolute source seconds. Any falling outside an active
339
+ ``[start, end]`` focus window are dropped. Files use a ``cue_*.jpg`` prefix
340
+ so they sit alongside detail-engine ``frame_*.jpg`` output without either
341
+ clobbering the other. When more cues than ``max_frames`` survive, they are
342
+ even-sampled (first + last kept) before extraction.
343
+ """
344
+ if shutil.which("ffmpeg") is None:
345
+ raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
346
+
347
+ out_dir.mkdir(parents=True, exist_ok=True)
348
+ for existing in out_dir.glob("cue_*.jpg"):
349
+ existing.unlink()
350
+
351
+ lo = start_seconds or 0.0
352
+ hi = end_seconds if end_seconds is not None else float("inf")
353
+ requested = sorted(set(round(float(t), 2) for t in timestamps))
354
+ in_window = [t for t in requested if lo <= t <= hi]
355
+ dropped = len(requested) - len(in_window)
356
+
357
+ if max_frames is not None and len(in_window) > max_frames:
358
+ points = [in_window[i] for i in _even_indices(len(in_window), max_frames)]
359
+ else:
360
+ points = in_window
361
+
362
+ out: list[dict] = []
363
+ for t in points:
364
+ path = out_dir / f"cue_{len(out):04d}.jpg"
365
+ cmd = [
366
+ "ffmpeg",
367
+ "-hide_banner",
368
+ "-loglevel", "error",
369
+ "-y",
370
+ "-ss", f"{t:.3f}",
371
+ "-i", str(Path(video_path).resolve()),
372
+ "-frames:v", "1",
373
+ "-vf", _scale_filter(resolution),
374
+ "-q:v", "4",
375
+ str(path),
376
+ ]
377
+ result = subprocess.run(cmd, capture_output=True, text=True)
378
+ if result.returncode == 0 and path.exists():
379
+ out.append({
380
+ "index": len(out),
381
+ "timestamp_seconds": t,
382
+ "path": str(path),
383
+ "reason": "transcript-cue",
384
+ })
385
+
386
+ meta = {
387
+ "engine": "timestamps",
388
+ "candidate_count": len(requested),
389
+ "selected_count": len(out),
390
+ "dropped_out_of_window": dropped,
391
+ "fallback": False,
392
+ }
393
+ return out, meta
394
+
395
+
396
+ def _even_sample(candidates: list[dict], n: int) -> list[dict]:
397
+ """Pick ``n`` evenly-spaced candidates (always including first and last),
398
+ delete the JPEGs we drop, and reindex the survivors 0..len-1.
399
+
400
+ Shared by every capped engine so all detail modes sample the same way:
401
+ detect all candidates across the full range, then thin down to the cap.
402
+ ``n >= len(candidates)`` keeps everything (the uncapped / under-cap case).
403
+ """
404
+ selected = [candidates[i] for i in _even_indices(len(candidates), n)]
405
+
406
+ keep_paths = {sel["path"] for sel in selected}
407
+ for cand in candidates:
408
+ if cand["path"] not in keep_paths:
409
+ with contextlib.suppress(OSError):
410
+ Path(cand["path"]).unlink()
411
+ for i, frame in enumerate(selected):
412
+ frame["index"] = i
413
+ return selected
414
+
415
+
416
+ def _frame_delta(a: bytes, b: bytes) -> float:
417
+ """Mean absolute per-pixel difference (0-255) between two grayscale
418
+ thumbnails. Mismatched lengths are treated as maximally different so a
419
+ decode hiccup never collapses distinct frames."""
420
+ if not a or len(a) != len(b):
421
+ return float("inf")
422
+ return sum(abs(x - y) for x, y in zip(a, b, strict=False)) / len(a)
423
+
424
+
425
+ def _thumb_frames(paths: list[Path]) -> list[bytes]:
426
+ """Decode every frame in ``paths`` to a small grayscale thumbnail via one
427
+ ffmpeg pass over the JPEG sequence.
428
+
429
+ ffmpeg does the pixel decode (keeps us pure-stdlib); we slice the raw
430
+ grayscale stream into one ``DEDUP_THUMB``-square thumbnail per frame.
431
+ Fail-open: any ffmpeg error, an unrecognized name, or a byte-count mismatch
432
+ returns ``[]`` so the caller skips dedup rather than breaking extraction.
433
+ """
434
+ if not paths:
435
+ return []
436
+ paths = [Path(p) for p in paths]
437
+ m = re.match(r"(.*?)(\d+)(\.[A-Za-z0-9]+)$", paths[0].name)
438
+ if m is None:
439
+ return []
440
+ prefix, digits, ext = m.group(1), m.group(2), m.group(3)
441
+ pattern = str(paths[0].parent / f"{prefix}%0{len(digits)}d{ext}")
442
+
443
+ cmd = [
444
+ "ffmpeg",
445
+ "-hide_banner",
446
+ "-loglevel", "error",
447
+ "-start_number", str(int(digits)),
448
+ "-i", pattern,
449
+ "-vf", f"scale={DEDUP_THUMB}:{DEDUP_THUMB},format=gray",
450
+ "-f", "rawvideo",
451
+ "-",
452
+ ]
453
+ result = subprocess.run(cmd, capture_output=True)
454
+ if result.returncode != 0:
455
+ return []
456
+
457
+ chunk = DEDUP_THUMB * DEDUP_THUMB
458
+ data = result.stdout
459
+ if len(data) != chunk * len(paths):
460
+ return []
461
+ return [data[i * chunk:(i + 1) * chunk] for i in range(len(paths))]
462
+
463
+
464
+ def dedupe_perceptual(
465
+ candidates: list[dict], threshold: float = DEDUP_THRESHOLD
466
+ ) -> tuple[list[dict], int]:
467
+ """Drop near-identical frames from a chronological candidate list.
468
+
469
+ Thumbnails the extracted JPEGs and greedily removes frames whose mean
470
+ per-pixel difference from the last kept one is within ``threshold``. Returns
471
+ ``(survivors, dropped_count)``; a no-op (unchanged list) when thumbnails are
472
+ unavailable or there are fewer than two candidates.
473
+ """
474
+ if len(candidates) <= 1:
475
+ return candidates, 0
476
+ thumbs = _thumb_frames([Path(c["path"]) for c in candidates])
477
+ return _dedupe_by_deltas(candidates, thumbs, threshold)
478
+
479
+
480
+ def _dedupe_by_deltas(
481
+ candidates: list[dict], thumbs: list[bytes], threshold: float = DEDUP_THRESHOLD
482
+ ) -> tuple[list[dict], int]:
483
+ """Greedily drop frames within ``threshold`` mean per-pixel difference of the
484
+ last *kept* frame. Deletes dropped JPEGs and reindexes survivors 0..n-1 (same
485
+ cleanup contract as :func:`_even_sample`). Fail-open: if ``thumbs`` does not
486
+ line up 1:1 with ``candidates``, return them unchanged.
487
+ """
488
+ if len(thumbs) != len(candidates) or len(candidates) <= 1:
489
+ return candidates, 0
490
+
491
+ kept = [candidates[0]]
492
+ last = thumbs[0]
493
+ dropped: list[dict] = []
494
+ for cand, thumb in zip(candidates[1:], thumbs[1:], strict=False):
495
+ if _frame_delta(thumb, last) <= threshold:
496
+ dropped.append(cand)
497
+ else:
498
+ kept.append(cand)
499
+ last = thumb
500
+
501
+ for cand in dropped:
502
+ with contextlib.suppress(OSError):
503
+ Path(cand["path"]).unlink()
504
+ for i, frame in enumerate(kept):
505
+ frame["index"] = i
506
+ return kept, len(dropped)
507
+
508
+
509
+ def extract_scene_or_uniform(
510
+ video_path: str,
511
+ out_dir: Path,
512
+ fps: float,
513
+ target_frames: int,
514
+ resolution: int = 512,
515
+ max_frames: int | None = 100,
516
+ start_seconds: float | None = None,
517
+ end_seconds: float | None = None,
518
+ dedup: bool = True,
519
+ ) -> tuple[list[dict], dict]:
520
+ """Prefer scene selection, falling back to uniform only when the video is
521
+ effectively static (fewer than ``SCENE_MIN_FRAMES`` detected shots).
522
+
523
+ Scene cuts are detected across the *whole* range (uncapped), near-identical
524
+ frames are dropped (:func:`dedupe_perceptual`, unless ``dedup`` is False),
525
+ and the survivors are even-sampled down to ``max_frames`` via
526
+ :func:`_even_sample`, exactly like the keyframe engine. This costs a full
527
+ decode, but it guarantees coverage spans the entire clip — capping detection
528
+ with ``-frames:v`` instead would keep only the first ``max_frames`` cuts and
529
+ drop the tail of long videos (and could even fall below ``SCENE_MIN_FRAMES``
530
+ and misfire the uniform fallback on a cut-heavy clip).
531
+ """
532
+ scene_frames = extract_scene_candidates(
533
+ video_path,
534
+ out_dir,
535
+ resolution=resolution,
536
+ max_frames=None,
537
+ start_seconds=start_seconds,
538
+ end_seconds=end_seconds,
539
+ )
540
+ scene_count = len(scene_frames)
541
+ if scene_count >= SCENE_MIN_FRAMES:
542
+ deduped, n_dropped = dedupe_perceptual(scene_frames) if dedup else (scene_frames, 0)
543
+ cap = len(deduped) if max_frames is None else max_frames
544
+ selected = _even_sample(deduped, cap)
545
+ return selected, {
546
+ "engine": "scene",
547
+ "candidate_count": scene_count,
548
+ "deduped_count": n_dropped,
549
+ "selected_count": len(selected),
550
+ "fallback": False,
551
+ }
552
+
553
+ fallback_cap = target_frames if max_frames is None else min(max_frames, target_frames)
554
+ frames = extract(
555
+ video_path,
556
+ out_dir,
557
+ fps=fps,
558
+ resolution=resolution,
559
+ max_frames=fallback_cap,
560
+ start_seconds=start_seconds,
561
+ end_seconds=end_seconds,
562
+ )
563
+ n_dropped = 0
564
+ if dedup:
565
+ frames, n_dropped = dedupe_perceptual(frames)
566
+ return frames, {
567
+ "engine": "uniform",
568
+ "candidate_count": scene_count,
569
+ "deduped_count": n_dropped,
570
+ "selected_count": len(frames),
571
+ "fallback": True,
572
+ }
573
+
574
+
575
+ def extract_keyframes(
576
+ video_path: str,
577
+ out_dir: Path,
578
+ resolution: int = 512,
579
+ max_frames: int | None = 50,
580
+ start_seconds: float | None = None,
581
+ end_seconds: float | None = None,
582
+ dedup: bool = True,
583
+ ) -> tuple[list[dict], dict]:
584
+ """Decode only keyframes (I-frames) — the cheap, near-instant tier.
585
+
586
+ ``-skip_frame nokey`` makes ffmpeg reconstruct only keyframes, skipping all
587
+ P/B frames. Encoders emit keyframes at scene cuts, so these already
588
+ approximate "distinct moments". Near-identical frames are dropped
589
+ (:func:`dedupe_perceptual`, unless ``dedup`` is False); over-cap →
590
+ even-sample first→last; too few keyframes → uniform fallback.
591
+ """
592
+ if shutil.which("ffmpeg") is None:
593
+ raise SystemExit("ffmpeg is not installed. Install with: brew install ffmpeg")
594
+
595
+ out_dir.mkdir(parents=True, exist_ok=True)
596
+ for existing in out_dir.glob("frame_*.jpg"):
597
+ existing.unlink()
598
+
599
+ output_pattern = str(out_dir / "frame_%04d.jpg")
600
+ cmd: list[str] = [
601
+ "ffmpeg",
602
+ "-hide_banner",
603
+ "-loglevel", "info",
604
+ "-y",
605
+ ]
606
+ if start_seconds is not None:
607
+ cmd += ["-ss", f"{start_seconds:.3f}"]
608
+ if end_seconds is not None:
609
+ cmd += ["-to", f"{end_seconds:.3f}"]
610
+ cmd += [
611
+ "-skip_frame", "nokey",
612
+ "-i", str(Path(video_path).resolve()),
613
+ "-vf", f"{_scale_filter(resolution)},showinfo",
614
+ "-vsync", "vfr",
615
+ "-q:v", "4",
616
+ output_pattern,
617
+ ]
618
+ result = subprocess.run(cmd, capture_output=True, text=True)
619
+ if result.returncode != 0:
620
+ raise SystemExit(f"ffmpeg keyframe extraction failed: {result.stderr.strip()}")
621
+
622
+ offset = start_seconds or 0.0
623
+ timestamps = [
624
+ round(offset + float(m.group(1)), 2)
625
+ for m in SHOWINFO_TS_RE.finditer(result.stderr)
626
+ ]
627
+ files = sorted(out_dir.glob("frame_*.jpg"))
628
+ candidates: list[dict] = []
629
+ for i, path in enumerate(files):
630
+ ts = timestamps[i] if i < len(timestamps) else offset
631
+ candidates.append({
632
+ "index": i,
633
+ "timestamp_seconds": ts,
634
+ "path": str(path),
635
+ "reason": "keyframe",
636
+ })
637
+
638
+ # Too few keyframes → uniform fallback over the same range.
639
+ if len(candidates) < KEYFRAME_MIN:
640
+ for cand in candidates:
641
+ with contextlib.suppress(OSError):
642
+ Path(cand["path"]).unlink()
643
+ meta = get_metadata(video_path)
644
+ full_duration = meta["duration_seconds"]
645
+ eff_start = start_seconds or 0.0
646
+ eff_end = end_seconds if end_seconds is not None else full_duration
647
+ eff_duration = max(0.0, eff_end - eff_start)
648
+ budget = max_frames if max_frames is not None else 100
649
+ fps, _ = auto_fps(eff_duration, max_frames=budget)
650
+ frames_out = extract(
651
+ video_path,
652
+ out_dir,
653
+ fps=fps,
654
+ resolution=resolution,
655
+ max_frames=budget,
656
+ start_seconds=start_seconds,
657
+ end_seconds=end_seconds,
658
+ )
659
+ n_dropped = 0
660
+ if dedup:
661
+ frames_out, n_dropped = dedupe_perceptual(frames_out)
662
+ return frames_out, {
663
+ "engine": "uniform",
664
+ "candidate_count": len(candidates),
665
+ "deduped_count": n_dropped,
666
+ "selected_count": len(frames_out),
667
+ "fallback": True,
668
+ }
669
+
670
+ # Detect-all, drop near-duplicates, then even-sample down to the cap (first +
671
+ # last always kept). ``max_frames is None`` (uncapped) keeps every keyframe.
672
+ candidate_count = len(candidates)
673
+ deduped, n_dropped = dedupe_perceptual(candidates) if dedup else (candidates, 0)
674
+ cap = len(deduped) if max_frames is None else max_frames
675
+ selected = _even_sample(deduped, cap)
676
+ return selected, {
677
+ "engine": "keyframe",
678
+ "candidate_count": candidate_count,
679
+ "deduped_count": n_dropped,
680
+ "selected_count": len(selected),
681
+ "fallback": False,
682
+ }
683
+
684
+
685
+ if __name__ == "__main__":
686
+ if len(sys.argv) < 3:
687
+ print(
688
+ "usage: frames.py <video-path> <out-dir> [--fps F] [--resolution W] "
689
+ "[--max-frames N] [--start T] [--end T] [--no-dedup]",
690
+ file=sys.stderr,
691
+ )
692
+ raise SystemExit(2)
693
+
694
+ video = sys.argv[1]
695
+ out = Path(sys.argv[2])
696
+ args = sys.argv[3:]
697
+
698
+ fps_override = None
699
+ resolution = 512
700
+ max_frames = 100
701
+ start_arg = None
702
+ end_arg = None
703
+ dedup = True
704
+ i = 0
705
+ while i < len(args):
706
+ if args[i] == "--fps":
707
+ fps_override = float(args[i + 1])
708
+ i += 2
709
+ elif args[i] == "--resolution":
710
+ resolution = int(args[i + 1])
711
+ i += 2
712
+ elif args[i] == "--max-frames":
713
+ max_frames = int(args[i + 1])
714
+ i += 2
715
+ elif args[i] == "--start":
716
+ start_arg = args[i + 1]
717
+ i += 2
718
+ elif args[i] == "--end":
719
+ end_arg = args[i + 1]
720
+ i += 2
721
+ elif args[i] == "--no-dedup":
722
+ dedup = False
723
+ i += 1
724
+ else:
725
+ i += 1
726
+
727
+ meta = get_metadata(video)
728
+ start_sec = parse_time(start_arg)
729
+ end_sec = parse_time(end_arg)
730
+ full_duration = meta["duration_seconds"]
731
+
732
+ effective_start = start_sec if start_sec is not None else 0.0
733
+ effective_end = end_sec if end_sec is not None else full_duration
734
+ effective_duration = max(0.0, effective_end - effective_start)
735
+
736
+ focused = start_sec is not None or end_sec is not None
737
+ if focused:
738
+ fps, target = auto_fps_focus(effective_duration, max_frames=max_frames)
739
+ else:
740
+ fps, target = auto_fps(effective_duration, max_frames=max_frames)
741
+ if fps_override is not None:
742
+ fps = fps_override
743
+ target = max(1, round(fps * effective_duration))
744
+
745
+ frames = extract(
746
+ video, out,
747
+ fps=fps,
748
+ resolution=resolution,
749
+ max_frames=max_frames,
750
+ start_seconds=start_sec,
751
+ end_seconds=end_sec,
752
+ )
753
+ deduped_count = 0
754
+ if dedup:
755
+ frames, deduped_count = dedupe_perceptual(frames)
756
+ print(json.dumps(
757
+ {
758
+ "meta": meta, "fps": fps, "target": target, "focused": focused,
759
+ "deduped_count": deduped_count, "frames": frames,
760
+ },
761
+ indent=2,
762
+ ))