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,531 @@
1
+ #!/usr/bin/env python3
2
+ """/watch entry point: download video, extract frames, parse transcript.
3
+
4
+ Prints a markdown report to stdout listing frame paths + transcript. The
5
+ agent then Reads each frame path to see the video.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ import tempfile
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ SCRIPT_DIR = Path(__file__).parent.resolve()
16
+ sys.path.insert(0, str(SCRIPT_DIR))
17
+
18
+ from download import download, fetch_captions, is_url # noqa: E402
19
+ from frames import ( # noqa: E402
20
+ MAX_FPS,
21
+ auto_fps,
22
+ auto_fps_focus,
23
+ extract_at_timestamps,
24
+ extract_keyframes,
25
+ extract_scene_or_uniform,
26
+ format_time,
27
+ get_metadata,
28
+ merge_frames,
29
+ parse_time,
30
+ parse_timestamps,
31
+ )
32
+ from transcribe import filter_range, format_transcript, parse_vtt # noqa: E402
33
+ from whisper import load_api_key, transcribe_video # noqa: E402
34
+
35
+ from config import frame_cap, get_config, record_telemetry # noqa: E402
36
+
37
+
38
+ @dataclass
39
+ class Run:
40
+ """Mutable state threaded through the watch stages."""
41
+
42
+ args: argparse.Namespace
43
+ work: Path
44
+ detail: str
45
+ max_frames: int | None
46
+ budget_cap: int
47
+ cue_timestamps: list[float]
48
+ url_source: bool = False
49
+ dl: dict = field(
50
+ default_factory=lambda: {"subtitle_path": None, "info": {}, "downloaded": False}
51
+ )
52
+ video_path: str | None = None
53
+ meta: dict = field(default_factory=dict)
54
+ segments: list[dict] = field(default_factory=list)
55
+ transcript_text: str | None = None
56
+ transcript_source: str | None = None
57
+ start_sec: float | None = None
58
+ end_sec: float | None = None
59
+ effective_start: float = 0.0
60
+ effective_end: float = 0.0
61
+ effective_duration: float = 0.0
62
+ focused: bool = False
63
+ fps: float = 0.0
64
+ target: int = 0
65
+ frames: list[dict] = field(default_factory=list)
66
+ frame_meta: dict = field(
67
+ default_factory=lambda: {
68
+ "engine": "none", "candidate_count": 0, "selected_count": 0, "fallback": False,
69
+ }
70
+ )
71
+ cue_frames: list[dict] = field(default_factory=list)
72
+ cue_meta: dict = field(default_factory=dict)
73
+ detail_budget: int | None = None
74
+
75
+ @property
76
+ def full_duration(self) -> float:
77
+ return float(self.meta.get("duration_seconds") or 0.0)
78
+
79
+
80
+ def build_parser() -> argparse.ArgumentParser:
81
+ ap = argparse.ArgumentParser(
82
+ prog="watch",
83
+ description="Download a video, extract auto-scaled frames, and surface the transcript.",
84
+ )
85
+ ap.add_argument("source", help="Video URL or local file path")
86
+ ap.add_argument("--max-frames", type=int, default=None, help="Override frame cap")
87
+ ap.add_argument(
88
+ "--resolution", type=int, default=512, help="Frame width in pixels (default 512)"
89
+ )
90
+ ap.add_argument("--fps", type=float, default=None, help="Override auto-fps")
91
+ ap.add_argument(
92
+ "--detail",
93
+ choices=["transcript", "efficient", "balanced", "token-burner"],
94
+ default=None,
95
+ help="Fidelity/speed dial: transcript (no frames), efficient (fast keyframes, cap 50), "
96
+ "balanced (scene, cap 100), token-burner (scene, uncapped).",
97
+ )
98
+ ap.add_argument(
99
+ "--timestamps",
100
+ type=str,
101
+ default=None,
102
+ help="Comma-separated absolute timestamps (SS, MM:SS, HH:MM:SS) to grab a frame at, "
103
+ "e.g. transcript-flagged 'look here' moments. Added on top of the detail frames "
104
+ "(reserved against the cap); with --detail transcript these become the only frames.",
105
+ )
106
+ ap.add_argument("--start", type=str, default=None, help="Range start (SS, MM:SS, or HH:MM:SS)")
107
+ ap.add_argument("--end", type=str, default=None, help="Range end (SS, MM:SS, or HH:MM:SS)")
108
+ ap.add_argument("--out-dir", type=str, default=None, help="Working directory (default: tmp)")
109
+ ap.add_argument(
110
+ "--no-whisper",
111
+ action="store_true",
112
+ help="Disable Whisper fallback. Report frames-only if no captions available.",
113
+ )
114
+ ap.add_argument(
115
+ "--whisper",
116
+ choices=["groq", "openai"],
117
+ default=None,
118
+ help="Force a specific Whisper backend. Default: prefer Groq, fall back to OpenAI.",
119
+ )
120
+ ap.add_argument(
121
+ "--no-dedup",
122
+ action="store_true",
123
+ help="Disable near-duplicate frame removal. Keeps visually identical "
124
+ "frames (static screen recordings, held slides) instead of collapsing them.",
125
+ )
126
+ return ap
127
+
128
+
129
+ def prepare_run(args: argparse.Namespace) -> Run:
130
+ config = get_config()
131
+ detail = args.detail or str(config["detail"])
132
+ configured_cap = frame_cap(detail)
133
+ max_frames = args.max_frames if args.max_frames is not None else configured_cap
134
+ if max_frames is not None and max_frames < 1:
135
+ raise SystemExit("--max-frames must be greater than zero")
136
+ budget_cap = max_frames if max_frames is not None else 100
137
+ cue_timestamps = parse_timestamps(args.timestamps)
138
+
139
+ if args.out_dir:
140
+ work = Path(args.out_dir).expanduser().resolve()
141
+ else:
142
+ work = Path(tempfile.mkdtemp(prefix="watch-"))
143
+ work.mkdir(parents=True, exist_ok=True)
144
+ print(f"[watch] working dir: {work}", file=sys.stderr)
145
+
146
+ return Run(
147
+ args=args,
148
+ work=work,
149
+ detail=detail,
150
+ max_frames=max_frames,
151
+ budget_cap=budget_cap,
152
+ cue_timestamps=cue_timestamps,
153
+ url_source=is_url(args.source),
154
+ )
155
+
156
+
157
+ def captions_stage(run: Run) -> None:
158
+ """For URLs, try native captions before any download."""
159
+ if not run.url_source:
160
+ return
161
+ print("[watch] checking metadata/captions via yt-dlp…", file=sys.stderr)
162
+ run.dl = fetch_captions(run.args.source, run.work / "download")
163
+ if run.dl.get("subtitle_path"):
164
+ try:
165
+ run.segments = parse_vtt(run.dl["subtitle_path"])
166
+ run.transcript_text = format_transcript(run.segments)
167
+ run.transcript_source = "captions"
168
+ except Exception as exc:
169
+ print(f"[watch] subtitle parse failed: {exc}", file=sys.stderr)
170
+ run.segments = []
171
+
172
+
173
+ def obtain_video_stage(run: Run) -> None:
174
+ """Download (or point at) the media the run needs, then probe metadata.
175
+
176
+ --timestamps needs the video for frame grabs, so it overrides the
177
+ transcript-mode download skip (and forces a full, not audio-only, fetch).
178
+ """
179
+ audio_only = run.detail == "transcript" and not run.cue_timestamps
180
+ if run.detail == "transcript" and run.segments and not run.cue_timestamps:
181
+ run.video_path = None
182
+ else:
183
+ if run.url_source:
184
+ print(
185
+ "[watch] downloading audio via yt-dlp…" if audio_only
186
+ else "[watch] downloading video via yt-dlp…",
187
+ file=sys.stderr,
188
+ )
189
+ run.dl = download(run.args.source, run.work / "download", audio_only=audio_only)
190
+ else:
191
+ print("[watch] using local file…", file=sys.stderr)
192
+ run.dl = download(run.args.source, run.work / "download")
193
+ run.video_path = run.dl["video_path"]
194
+
195
+ run.meta = get_metadata(run.video_path) if run.video_path else {
196
+ "duration_seconds": float((run.dl.get("info") or {}).get("duration") or 0),
197
+ "width": None,
198
+ "height": None,
199
+ "codec": None,
200
+ "has_audio": False,
201
+ }
202
+
203
+
204
+ def range_stage(run: Run) -> None:
205
+ """Validate --start/--end and derive the effective extraction window."""
206
+ args = run.args
207
+ run.start_sec = parse_time(args.start)
208
+ run.end_sec = parse_time(args.end)
209
+ full_duration = run.full_duration
210
+
211
+ if run.start_sec is not None and run.start_sec < 0:
212
+ raise SystemExit("--start must be non-negative")
213
+ if run.end_sec is not None and run.start_sec is not None and run.end_sec <= run.start_sec:
214
+ raise SystemExit("--end must be greater than --start")
215
+ if full_duration > 0 and run.start_sec is not None and run.start_sec >= full_duration:
216
+ raise SystemExit(
217
+ f"--start {run.start_sec:.1f}s is past end of video ({full_duration:.1f}s)"
218
+ )
219
+
220
+ run.effective_start = run.start_sec if run.start_sec is not None else 0.0
221
+ run.effective_end = run.end_sec if run.end_sec is not None else full_duration
222
+ run.effective_duration = max(0.0, run.effective_end - run.effective_start)
223
+ run.focused = run.start_sec is not None or run.end_sec is not None
224
+
225
+ if run.focused:
226
+ run.fps, run.target = auto_fps_focus(run.effective_duration, max_frames=run.budget_cap)
227
+ else:
228
+ run.fps, run.target = auto_fps(run.effective_duration, max_frames=run.budget_cap)
229
+ if args.fps is not None:
230
+ run.fps = min(args.fps, MAX_FPS)
231
+ run.target = max(1, round(run.fps * run.effective_duration))
232
+
233
+ if run.segments and run.focused:
234
+ run.segments = filter_range(run.segments, run.start_sec, run.end_sec)
235
+ run.transcript_text = format_transcript(run.segments)
236
+
237
+
238
+ def frames_stage(run: Run) -> None:
239
+ """Extract cue frames first (pinned against the cap), then detail frames."""
240
+ args = run.args
241
+ scope = (
242
+ f"{format_time(run.effective_start)}-{format_time(run.effective_end)} "
243
+ f"({run.effective_duration:.1f}s)"
244
+ if run.focused else f"full {run.effective_duration:.1f}s"
245
+ )
246
+
247
+ # Transcript cues are pinned: extracted first and counted against the cap so
248
+ # the detail engine never evicts the moments the user explicitly asked for.
249
+ if run.cue_timestamps and run.video_path:
250
+ run.cue_frames, run.cue_meta = extract_at_timestamps(
251
+ run.video_path,
252
+ run.work / "frames",
253
+ run.cue_timestamps,
254
+ resolution=args.resolution,
255
+ max_frames=run.max_frames,
256
+ start_seconds=run.start_sec,
257
+ end_seconds=run.end_sec,
258
+ )
259
+ if run.cue_meta.get("dropped_out_of_window"):
260
+ print(
261
+ f"[watch] {run.cue_meta['dropped_out_of_window']} cue timestamp(s) outside the "
262
+ "focus range — dropped",
263
+ file=sys.stderr,
264
+ )
265
+
266
+ run.detail_budget = (
267
+ run.max_frames if run.max_frames is None else max(0, run.max_frames - len(run.cue_frames))
268
+ )
269
+ if run.detail != "transcript" and run.video_path and run.detail_budget != 0:
270
+ cap_label = "unlimited" if run.detail_budget is None else str(run.detail_budget)
271
+ engine_label = "keyframes" if run.detail == "efficient" else "scene-aware frames"
272
+ print(
273
+ f"[watch] extracting {engine_label} over {scope} "
274
+ f"(target {run.target}, cap {cap_label})…",
275
+ file=sys.stderr,
276
+ )
277
+ if run.detail == "efficient":
278
+ run.frames, run.frame_meta = extract_keyframes(
279
+ run.video_path,
280
+ run.work / "frames",
281
+ resolution=args.resolution,
282
+ max_frames=run.detail_budget,
283
+ start_seconds=run.start_sec,
284
+ end_seconds=run.end_sec,
285
+ dedup=not args.no_dedup,
286
+ )
287
+ else: # balanced, token-burner
288
+ run.frames, run.frame_meta = extract_scene_or_uniform(
289
+ run.video_path,
290
+ run.work / "frames",
291
+ fps=run.fps,
292
+ target_frames=run.target,
293
+ resolution=args.resolution,
294
+ max_frames=run.detail_budget,
295
+ start_seconds=run.start_sec,
296
+ end_seconds=run.end_sec,
297
+ dedup=not args.no_dedup,
298
+ )
299
+
300
+ if run.cue_frames:
301
+ run.frames = merge_frames(run.frames, run.cue_frames)
302
+
303
+
304
+ def transcript_stage(run: Run) -> None:
305
+ """Late captions parse, then the Whisper fallback when audio exists."""
306
+ args = run.args
307
+ if not run.segments and run.dl.get("subtitle_path"):
308
+ try:
309
+ all_segments = parse_vtt(run.dl["subtitle_path"])
310
+ run.segments = (
311
+ filter_range(all_segments, run.start_sec, run.end_sec)
312
+ if run.focused else all_segments
313
+ )
314
+ run.transcript_text = format_transcript(run.segments)
315
+ run.transcript_source = "captions"
316
+ except Exception as exc:
317
+ print(f"[watch] subtitle parse failed: {exc}", file=sys.stderr)
318
+
319
+ if not run.segments and not args.no_whisper and run.video_path and run.meta.get("has_audio"):
320
+ backend, api_key = load_api_key(args.whisper)
321
+ if backend and api_key:
322
+ try:
323
+ all_segments, used_backend = transcribe_video(
324
+ run.video_path,
325
+ run.work / "audio.mp3",
326
+ backend=backend,
327
+ api_key=api_key,
328
+ )
329
+ run.segments = (
330
+ filter_range(all_segments, run.start_sec, run.end_sec)
331
+ if run.focused else all_segments
332
+ )
333
+ run.transcript_text = format_transcript(run.segments)
334
+ run.transcript_source = f"whisper ({used_backend})"
335
+ except SystemExit as exc:
336
+ print(f"[watch] whisper fallback failed: {exc}", file=sys.stderr)
337
+ else:
338
+ hint = (
339
+ f"--whisper {args.whisper} was set but the matching API key is missing"
340
+ if args.whisper else
341
+ "no subtitles and no Whisper API key found"
342
+ )
343
+ print(
344
+ f"[watch] {hint} — add one with `/arka keys` (or run "
345
+ f"`python3 {SCRIPT_DIR / 'setup.py'}`) to enable the Whisper fallback",
346
+ file=sys.stderr,
347
+ )
348
+ elif not run.segments and run.video_path and not run.meta.get("has_audio"):
349
+ print("[watch] no audio stream found — proceeding without transcription", file=sys.stderr)
350
+
351
+
352
+ def print_header(run: Run) -> None:
353
+ args = run.args
354
+ info = run.dl.get("info") or {}
355
+ print()
356
+ print("# watch: video report")
357
+ print()
358
+ print(f"- **Source:** {args.source}")
359
+ if info.get("title"):
360
+ print(f"- **Title:** {info['title']}")
361
+ if info.get("uploader"):
362
+ print(f"- **Uploader:** {info['uploader']}")
363
+ print(f"- **Duration:** {format_time(run.full_duration)} ({run.full_duration:.1f}s)")
364
+ if run.focused:
365
+ print(
366
+ f"- **Focus range:** {format_time(run.effective_start)} → "
367
+ f"{format_time(run.effective_end)} ({run.effective_duration:.1f}s)"
368
+ )
369
+ if run.meta.get("width") and run.meta.get("height"):
370
+ print(
371
+ f"- **Resolution:** {run.meta['width']}x{run.meta['height']} "
372
+ f"({run.meta.get('codec') or 'unknown codec'})"
373
+ )
374
+ range_mode = "focused" if run.focused else "full"
375
+ print(f"- **Detail:** {run.detail}")
376
+ detail_count = run.frame_meta.get("selected_count", 0)
377
+ if run.detail != "transcript":
378
+ cap_label = "unlimited" if run.detail_budget is None else str(run.detail_budget)
379
+ engine = run.frame_meta.get("engine", "scene")
380
+ fallback = " with uniform fallback" if run.frame_meta.get("fallback") else ""
381
+ deduped = run.frame_meta.get("deduped_count", 0)
382
+ plural = "s" if deduped != 1 else ""
383
+ dedup_note = f", {deduped} near-duplicate{plural} dropped" if deduped else ""
384
+ candidates = run.frame_meta.get("candidate_count", detail_count)
385
+ print(
386
+ f"- **Frames:** {detail_count} selected from {candidates} candidates "
387
+ f"({engine}{fallback}{dedup_note}, {range_mode} range, "
388
+ f"budget {run.target}, cap {cap_label})"
389
+ )
390
+ elif not run.cue_frames:
391
+ print("- **Frames:** skipped (transcript detail)")
392
+ if run.cue_frames:
393
+ dropped = run.cue_meta.get("dropped_out_of_window", 0)
394
+ drop_note = f", {dropped} dropped outside range" if dropped else ""
395
+ print(
396
+ f"- **Cue frames:** {len(run.cue_frames)} at transcript-flagged timestamps "
397
+ f"(transcript-cue{drop_note})"
398
+ )
399
+ if run.frames:
400
+ print(f"- **Frame size:** max {args.resolution}px wide, max 1998px tall")
401
+ if run.segments:
402
+ in_range = " in range" if run.focused else ""
403
+ print(
404
+ f"- **Transcript:** {len(run.segments)} segments{in_range} "
405
+ f"(via {run.transcript_source or 'captions'})"
406
+ )
407
+ else:
408
+ print("- **Transcript:** none available")
409
+ print_warnings(run)
410
+
411
+
412
+ def print_warnings(run: Run) -> None:
413
+ if run.detail == "token-burner" and len(run.frames) > 250:
414
+ print()
415
+ print(
416
+ f"> **Warning:** token-burner detail selected {len(run.frames)} frames. "
417
+ "This may use a large number of image tokens."
418
+ )
419
+
420
+ long_uncapped = run.detail not in ("transcript", "token-burner")
421
+ if not run.focused and run.full_duration > 600 and long_uncapped:
422
+ mins = int(run.full_duration // 60)
423
+ print()
424
+ print(
425
+ f"> **Warning:** This is a {mins}-minute video. Frame coverage is sparse "
426
+ f"at this length under `{run.detail}` detail — its cap spreads thin across "
427
+ "the full clip. For better results, re-run with "
428
+ "`--start HH:MM:SS --end HH:MM:SS` to zoom into a section, or use "
429
+ "`--detail token-burner` to keep every scene-change frame across the whole video."
430
+ )
431
+
432
+
433
+ def print_frames_section(run: Run) -> None:
434
+ print()
435
+ print("## Frames")
436
+ print()
437
+ if run.frames:
438
+ print(f"Frames live at: `{run.work / 'frames'}`")
439
+ print()
440
+ print(
441
+ "**Read each frame path below with the Read tool to view the image.** "
442
+ "Frames are in chronological order; `t=MM:SS` is the absolute timestamp "
443
+ "in the source video."
444
+ )
445
+ print()
446
+ for frame in run.frames:
447
+ reason = frame.get("reason", "selected")
448
+ print(
449
+ f"- `{frame['path']}` "
450
+ f"(t={format_time(frame['timestamp_seconds'])}, reason={reason})"
451
+ )
452
+ else:
453
+ print("_No frames extracted._")
454
+
455
+
456
+ def print_transcript_section(run: Run) -> None:
457
+ print()
458
+ print("## Transcript")
459
+ print()
460
+ if run.transcript_text:
461
+ label = run.transcript_source or "captions"
462
+ if run.focused:
463
+ print(
464
+ f"_Source: {label}. Filtered to {format_time(run.effective_start)} → "
465
+ f"{format_time(run.effective_end)}:_"
466
+ )
467
+ else:
468
+ print(f"_Source: {label}._")
469
+ print()
470
+ print("```")
471
+ print(run.transcript_text)
472
+ print("```")
473
+ elif run.detail == "transcript":
474
+ print(
475
+ "_No transcript available at transcript detail. Captions were missing and Whisper was "
476
+ "unavailable or failed, so there is no visual fallback here. Re-run with "
477
+ "`--detail balanced` for frames._"
478
+ )
479
+ elif run.focused and run.dl.get("subtitle_path"):
480
+ print(
481
+ f"_No transcript lines fell inside {format_time(run.effective_start)} → "
482
+ f"{format_time(run.effective_end)}._"
483
+ )
484
+ else:
485
+ print(
486
+ "_No transcript available — proceed with frames only. "
487
+ "Captions were missing and the Whisper fallback was unavailable "
488
+ "(no API key set, or `--no-whisper` was used). "
489
+ "Add a key with `/arka keys` to enable Whisper, then re-run._"
490
+ )
491
+
492
+ print()
493
+ print("---")
494
+ print(f"_Work dir: `{run.work}` — delete when done._")
495
+
496
+
497
+ def estimate_image_tokens(frame_count: int, width: int) -> int:
498
+ """Anthropic's (width x height) / 750 with an assumed 16:9 aspect."""
499
+ return frame_count * round(width * width * 9 / 16 / 750)
500
+
501
+
502
+ def telemetry_stage(run: Run) -> None:
503
+ record_telemetry({
504
+ "source_kind": "url" if run.url_source else "file",
505
+ "detail": run.detail,
506
+ "focused": run.focused,
507
+ "duration_seconds": round(run.full_duration, 1),
508
+ "frames": len(run.frames),
509
+ "est_image_tokens": estimate_image_tokens(len(run.frames), run.args.resolution),
510
+ "transcript_source": run.transcript_source,
511
+ "transcript_segments": len(run.segments),
512
+ })
513
+
514
+
515
+ def main() -> int:
516
+ args = build_parser().parse_args()
517
+ run = prepare_run(args)
518
+ captions_stage(run)
519
+ obtain_video_stage(run)
520
+ range_stage(run)
521
+ frames_stage(run)
522
+ transcript_stage(run)
523
+ print_header(run)
524
+ print_frames_section(run)
525
+ print_transcript_section(run)
526
+ telemetry_stage(run)
527
+ return 0
528
+
529
+
530
+ if __name__ == "__main__":
531
+ raise SystemExit(main())