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.
- package/README.md +1 -1
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/config/claude-agents/brand-director.md +1 -1
- package/config/claude-agents/content-strategist.md +1 -1
- package/config/claude-agents/frontend-dev.md +1 -1
- package/config/claude-agents/marketing-director.md +1 -1
- package/config/claude-agents/scriptwriter.md +1 -1
- package/config/claude-agents/trends-analyst.md +1 -1
- package/config/claude-agents/video-producer.md +1 -1
- package/config/skills-curated.yaml +2 -1
- package/config/skills-provenance.yaml +6 -0
- package/core/keys.py +17 -5
- package/departments/brand/agents/brand-director.yaml +1 -0
- package/departments/content/agents/content-strategist.yaml +1 -0
- package/departments/content/agents/production/trends-analyst.yaml +1 -0
- package/departments/content/agents/production/video-producer.yaml +1 -0
- package/departments/content/agents/scriptwriter.yaml +1 -0
- package/departments/dev/agents/frontend-dev.yaml +1 -0
- package/departments/dev/skills/animated-website/SKILL.md +14 -1
- package/departments/dev/skills/watch/SKILL.md +162 -0
- package/departments/dev/skills/watch/references/claude-video.LICENSE +21 -0
- package/departments/dev/skills/watch/scripts/config.py +145 -0
- package/departments/dev/skills/watch/scripts/download.py +179 -0
- package/departments/dev/skills/watch/scripts/frames.py +762 -0
- package/departments/dev/skills/watch/scripts/setup.py +373 -0
- package/departments/dev/skills/watch/scripts/transcribe.py +95 -0
- package/departments/dev/skills/watch/scripts/watch.py +531 -0
- package/departments/dev/skills/watch/scripts/whisper.py +466 -0
- package/departments/marketing/agents/marketing-director.yaml +1 -0
- package/departments/marketing/skills/ad-creative/SKILL.md +5 -0
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/doctor.js +38 -4
- package/knowledge/agents-registry-v2.json +8 -1
- package/knowledge/skills-manifest.json +14 -1
- package/package.json +1 -1
- 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())
|