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,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Setup / preflight for /watch.
|
|
3
|
+
|
|
4
|
+
Modes:
|
|
5
|
+
setup.py --check Silent preflight. Exit 0 if ready, 2/3/4 on failure.
|
|
6
|
+
setup.py --json Machine-readable status for Claude to parse.
|
|
7
|
+
setup.py Installer. Auto-installs deps, scaffolds .env, marks SETUP_COMPLETE.
|
|
8
|
+
|
|
9
|
+
Design:
|
|
10
|
+
- Silent on success: --check exits 0 with no output when everything's ready so
|
|
11
|
+
that /watch doesn't spam "setup is complete" on every turn.
|
|
12
|
+
- Idempotent: re-running the installer is safe — it never clobbers existing
|
|
13
|
+
keys and only appends missing ones.
|
|
14
|
+
- SETUP_COMPLETE=true in ~/.arkaos/watch.env tells us the user has been
|
|
15
|
+
through a successful installer run at least once.
|
|
16
|
+
- Never sudo. On macOS, auto-install via brew. Elsewhere, print exact commands.
|
|
17
|
+
- Never write an API key to disk automatically — keys belong in `/arka keys`
|
|
18
|
+
(~/.arkaos/keys.json); watch.env only carries placeholders as a fallback.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import platform
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
31
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
32
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
33
|
+
from config import ( # noqa: E402
|
|
34
|
+
config_file,
|
|
35
|
+
get_config,
|
|
36
|
+
read_env_file,
|
|
37
|
+
resolve_api_key,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
REQUIRED_BINARIES = ["ffmpeg", "ffprobe", "yt-dlp"]
|
|
41
|
+
ENV_TEMPLATE = """# ArkaOS /watch configuration
|
|
42
|
+
#
|
|
43
|
+
# Whisper transcription fallback — used only when yt-dlp cannot get captions
|
|
44
|
+
# (or when you point /watch at a local file with no subtitles).
|
|
45
|
+
#
|
|
46
|
+
# PREFERRED: store keys with `/arka keys` (~/.arkaos/keys.json) — this file
|
|
47
|
+
# is only a fallback. Groq runs whisper-large-v3 cheaper and faster; OpenAI
|
|
48
|
+
# whisper-1 is the compatible alternative.
|
|
49
|
+
#
|
|
50
|
+
# Get a Groq key: https://console.groq.com/keys
|
|
51
|
+
# Get an OpenAI key: https://platform.openai.com/api-keys
|
|
52
|
+
#
|
|
53
|
+
# With no key anywhere, /watch still works, but videos without native
|
|
54
|
+
# captions come back frames-only.
|
|
55
|
+
|
|
56
|
+
GROQ_API_KEY=
|
|
57
|
+
OPENAI_API_KEY=
|
|
58
|
+
|
|
59
|
+
# Default watch behavior (the /watch first-run wizard sets this for you).
|
|
60
|
+
# Allowed values: transcript | efficient | balanced | token-burner
|
|
61
|
+
# Keep the value on its own line with no trailing comment.
|
|
62
|
+
# WATCH_DETAIL=balanced
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _which(name: str) -> str | None:
|
|
67
|
+
return shutil.which(name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _binary_runs(name: str) -> bool:
|
|
71
|
+
"""A binary on PATH can still be a corpse — e.g. a dangling homebrew
|
|
72
|
+
dylib makes ffmpeg abort at load (dyld error, exit 134). Probe
|
|
73
|
+
`-version` so a broken install is reported as missing instead of
|
|
74
|
+
crashing mid-run with no setup hint."""
|
|
75
|
+
try:
|
|
76
|
+
result = subprocess.run([name, "-version"], capture_output=True, timeout=10)
|
|
77
|
+
except (OSError, subprocess.SubprocessError):
|
|
78
|
+
return False
|
|
79
|
+
return result.returncode == 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_binaries() -> list[str]:
|
|
83
|
+
missing: list[str] = []
|
|
84
|
+
for b in REQUIRED_BINARIES:
|
|
85
|
+
if not _which(b):
|
|
86
|
+
missing.append(b)
|
|
87
|
+
elif b in ("ffmpeg", "ffprobe") and not _binary_runs(b):
|
|
88
|
+
# Only probe the native binaries: they are the dyld-breakage
|
|
89
|
+
# candidates and probe in ~20ms. yt-dlp is a Python zipapp —
|
|
90
|
+
# probing it costs interpreter startup and breaks the <100ms
|
|
91
|
+
# --check budget.
|
|
92
|
+
missing.append(b)
|
|
93
|
+
return missing
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_PERM_WARNED: set[str] = set()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _check_file_permissions(path: Path) -> None:
|
|
100
|
+
"""Warn to stderr (once per path per process) if a secrets file is
|
|
101
|
+
world/group readable."""
|
|
102
|
+
key = str(path)
|
|
103
|
+
if key in _PERM_WARNED:
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
mode = path.stat().st_mode
|
|
107
|
+
if mode & 0o044:
|
|
108
|
+
_PERM_WARNED.add(key)
|
|
109
|
+
sys.stderr.write(
|
|
110
|
+
f"[watch] WARNING: {path} is readable by other users. "
|
|
111
|
+
f"Run: chmod 600 {path}\n"
|
|
112
|
+
)
|
|
113
|
+
sys.stderr.flush()
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _have_api_key() -> tuple[bool, str | None]:
|
|
119
|
+
cfg = config_file()
|
|
120
|
+
if cfg.exists():
|
|
121
|
+
_check_file_permissions(cfg)
|
|
122
|
+
if resolve_api_key("GROQ_API_KEY"):
|
|
123
|
+
return True, "groq"
|
|
124
|
+
if resolve_api_key("OPENAI_API_KEY"):
|
|
125
|
+
return True, "openai"
|
|
126
|
+
return False, None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_first_run() -> bool:
|
|
130
|
+
"""True if the installer hasn't completed successfully yet."""
|
|
131
|
+
return read_env_file().get("SETUP_COMPLETE") != "true"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _scaffold_env() -> bool:
|
|
135
|
+
"""Create ~/.arkaos/watch.env with placeholders if missing."""
|
|
136
|
+
cfg = config_file()
|
|
137
|
+
if cfg.exists():
|
|
138
|
+
return False
|
|
139
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
cfg.write_text(ENV_TEMPLATE, encoding="utf-8")
|
|
141
|
+
with contextlib.suppress(OSError):
|
|
142
|
+
cfg.chmod(0o600)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_setup_complete() -> None:
|
|
147
|
+
"""Idempotently append SETUP_COMPLETE=true to watch.env.
|
|
148
|
+
|
|
149
|
+
Used only after a fully successful install (deps + key). Future sessions
|
|
150
|
+
detect this marker to skip wizard-style UI and stay silent.
|
|
151
|
+
"""
|
|
152
|
+
cfg = config_file()
|
|
153
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
if cfg.exists():
|
|
155
|
+
existing = cfg.read_text(encoding="utf-8")
|
|
156
|
+
for line in existing.splitlines():
|
|
157
|
+
if line.strip().startswith("SETUP_COMPLETE="):
|
|
158
|
+
return
|
|
159
|
+
if existing and not existing.endswith("\n"):
|
|
160
|
+
existing += "\n"
|
|
161
|
+
cfg.write_text(existing + "SETUP_COMPLETE=true\n", encoding="utf-8")
|
|
162
|
+
else:
|
|
163
|
+
cfg.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n", encoding="utf-8")
|
|
164
|
+
with contextlib.suppress(OSError):
|
|
165
|
+
cfg.chmod(0o600)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _brew_pkg(missing: list[str]) -> list[str]:
|
|
169
|
+
pkgs: list[str] = []
|
|
170
|
+
for bin_name in missing:
|
|
171
|
+
if bin_name in ("ffmpeg", "ffprobe"):
|
|
172
|
+
if "ffmpeg" not in pkgs:
|
|
173
|
+
pkgs.append("ffmpeg")
|
|
174
|
+
elif bin_name == "yt-dlp":
|
|
175
|
+
if "yt-dlp" not in pkgs:
|
|
176
|
+
pkgs.append("yt-dlp")
|
|
177
|
+
else:
|
|
178
|
+
pkgs.append(bin_name)
|
|
179
|
+
return pkgs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _install_macos(missing: list[str]) -> tuple[bool, str]:
|
|
183
|
+
if _which("brew") is None:
|
|
184
|
+
return False, (
|
|
185
|
+
"Homebrew is not installed. Install it from https://brew.sh, then re-run setup. "
|
|
186
|
+
"Or install manually: `brew install " + " ".join(_brew_pkg(missing)) + "`"
|
|
187
|
+
)
|
|
188
|
+
pkgs = _brew_pkg(missing)
|
|
189
|
+
if not pkgs:
|
|
190
|
+
return True, "nothing to install"
|
|
191
|
+
cmd = ["brew", "install", *pkgs]
|
|
192
|
+
print(f"[setup] running: {' '.join(cmd)}", file=sys.stderr)
|
|
193
|
+
result = subprocess.run(cmd)
|
|
194
|
+
if result.returncode != 0:
|
|
195
|
+
return False, f"brew install failed with exit code {result.returncode}"
|
|
196
|
+
return True, f"installed via brew: {', '.join(pkgs)}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _install_hint_linux(missing: list[str]) -> str:
|
|
200
|
+
pkgs = _brew_pkg(missing)
|
|
201
|
+
hints = []
|
|
202
|
+
if "ffmpeg" in pkgs:
|
|
203
|
+
hints.append("apt: `sudo apt install ffmpeg` or dnf: `sudo dnf install ffmpeg`")
|
|
204
|
+
if "yt-dlp" in pkgs:
|
|
205
|
+
hints.append("`pipx install yt-dlp` (recommended) or `pip install --user yt-dlp`")
|
|
206
|
+
return "\n ".join(hints) if hints else "nothing to install"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _install_hint_windows(missing: list[str]) -> str:
|
|
210
|
+
pkgs = _brew_pkg(missing)
|
|
211
|
+
hints = []
|
|
212
|
+
if "ffmpeg" in pkgs:
|
|
213
|
+
hints.append("winget: `winget install Gyan.FFmpeg`")
|
|
214
|
+
if "yt-dlp" in pkgs:
|
|
215
|
+
hints.append("winget: `winget install yt-dlp.yt-dlp` or pip: `pip install --user yt-dlp`")
|
|
216
|
+
return "\n ".join(hints) if hints else "nothing to install"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _status() -> dict:
|
|
220
|
+
"""Structured preflight snapshot.
|
|
221
|
+
|
|
222
|
+
`status` describes the *ideal* state (a Whisper key is encouraged), so a
|
|
223
|
+
keyless install still reports `needs_key` on the very first run — that's
|
|
224
|
+
the agent's cue to encourage adding one.
|
|
225
|
+
|
|
226
|
+
`can_proceed` is the operational gate: /watch can run as long as the
|
|
227
|
+
binaries are present AND the user has either set a key or already finished
|
|
228
|
+
setup (consciously opting out of Whisper). A keyless user who completed
|
|
229
|
+
setup is NOT nagged on every call.
|
|
230
|
+
"""
|
|
231
|
+
missing = _check_binaries()
|
|
232
|
+
has_key, backend = _have_api_key()
|
|
233
|
+
setup_complete = not is_first_run()
|
|
234
|
+
|
|
235
|
+
if not missing and has_key:
|
|
236
|
+
status = "ready"
|
|
237
|
+
elif missing and not has_key:
|
|
238
|
+
status = "needs_install_and_key"
|
|
239
|
+
elif missing:
|
|
240
|
+
status = "needs_install"
|
|
241
|
+
else:
|
|
242
|
+
status = "needs_key"
|
|
243
|
+
|
|
244
|
+
can_proceed = (not missing) and (has_key or setup_complete)
|
|
245
|
+
|
|
246
|
+
cfg = get_config()
|
|
247
|
+
return {
|
|
248
|
+
"status": status,
|
|
249
|
+
"can_proceed": can_proceed,
|
|
250
|
+
"first_run": not setup_complete,
|
|
251
|
+
"setup_complete": setup_complete,
|
|
252
|
+
"missing_binaries": missing,
|
|
253
|
+
"whisper_backend": backend,
|
|
254
|
+
"has_api_key": has_key,
|
|
255
|
+
"config_file": str(config_file()),
|
|
256
|
+
"watch_detail": cfg["detail"],
|
|
257
|
+
"platform": platform.system(),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cmd_check() -> int:
|
|
262
|
+
"""Silent-on-success preflight.
|
|
263
|
+
|
|
264
|
+
Exit 0 with no output when /watch can run. A keyless user who already
|
|
265
|
+
finished setup (SETUP_COMPLETE=true) counts as ready — Whisper is
|
|
266
|
+
encouraged, not required — so they are never nagged on follow-up calls.
|
|
267
|
+
|
|
268
|
+
On a state that blocks /watch, print one actionable line to stderr:
|
|
269
|
+
2 → binaries missing
|
|
270
|
+
3 → genuine first run with no API key (encourage one)
|
|
271
|
+
4 → both missing
|
|
272
|
+
"""
|
|
273
|
+
s = _status()
|
|
274
|
+
if s["can_proceed"]:
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
parts = []
|
|
278
|
+
if s["missing_binaries"]:
|
|
279
|
+
parts.append(f"missing binaries: {', '.join(s['missing_binaries'])}")
|
|
280
|
+
if not s["has_api_key"] and not s["setup_complete"]:
|
|
281
|
+
parts.append("no Whisper API key (GROQ_API_KEY or OPENAI_API_KEY)")
|
|
282
|
+
installer = Path(__file__).resolve()
|
|
283
|
+
sys.stderr.write(
|
|
284
|
+
f"[watch] setup incomplete ({'; '.join(parts)}). "
|
|
285
|
+
f"Run: python3 {installer}\n"
|
|
286
|
+
)
|
|
287
|
+
sys.stderr.flush()
|
|
288
|
+
|
|
289
|
+
if s["missing_binaries"] and not s["has_api_key"]:
|
|
290
|
+
return 4
|
|
291
|
+
if s["missing_binaries"]:
|
|
292
|
+
return 2
|
|
293
|
+
return 3
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def cmd_json() -> int:
|
|
297
|
+
json.dump(_status(), sys.stdout, indent=2)
|
|
298
|
+
sys.stdout.write("\n")
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_install() -> int:
|
|
303
|
+
missing = _check_binaries()
|
|
304
|
+
installed_deps = False
|
|
305
|
+
if missing:
|
|
306
|
+
system = platform.system()
|
|
307
|
+
if system == "Darwin":
|
|
308
|
+
ok, msg = _install_macos(missing)
|
|
309
|
+
print(f"[setup] {msg}", file=sys.stderr)
|
|
310
|
+
if not ok:
|
|
311
|
+
return 2
|
|
312
|
+
still_missing = _check_binaries()
|
|
313
|
+
if still_missing:
|
|
314
|
+
print(
|
|
315
|
+
f"[setup] still missing after install: {', '.join(still_missing)}",
|
|
316
|
+
file=sys.stderr,
|
|
317
|
+
)
|
|
318
|
+
return 2
|
|
319
|
+
installed_deps = True
|
|
320
|
+
elif system == "Linux":
|
|
321
|
+
print("[setup] dependencies missing on Linux — please install:", file=sys.stderr)
|
|
322
|
+
print(" " + _install_hint_linux(missing), file=sys.stderr)
|
|
323
|
+
return 2
|
|
324
|
+
elif system == "Windows":
|
|
325
|
+
print("[setup] dependencies missing on Windows — please install:", file=sys.stderr)
|
|
326
|
+
print(" " + _install_hint_windows(missing), file=sys.stderr)
|
|
327
|
+
return 2
|
|
328
|
+
else:
|
|
329
|
+
print(
|
|
330
|
+
f"[setup] unsupported platform ({system}) for auto-install. Install manually:",
|
|
331
|
+
file=sys.stderr,
|
|
332
|
+
)
|
|
333
|
+
print(f" missing: {', '.join(missing)}", file=sys.stderr)
|
|
334
|
+
return 2
|
|
335
|
+
|
|
336
|
+
created = _scaffold_env()
|
|
337
|
+
if created:
|
|
338
|
+
print(f"[setup] created config: {config_file()}")
|
|
339
|
+
else:
|
|
340
|
+
print(f"[setup] config exists: {config_file()}")
|
|
341
|
+
|
|
342
|
+
has_key, backend = _have_api_key()
|
|
343
|
+
if has_key:
|
|
344
|
+
_write_setup_complete()
|
|
345
|
+
print(f"[setup] ready. whisper backend: {backend}")
|
|
346
|
+
if installed_deps:
|
|
347
|
+
print("[setup] installed dependencies; /watch is fully set up.")
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
print("")
|
|
351
|
+
print("[setup] one step left: add a Whisper API key.")
|
|
352
|
+
print("")
|
|
353
|
+
print(" Preferred: store it with `/arka keys` — either:")
|
|
354
|
+
print(" OPENAI_API_KEY (whisper-1; get one at platform.openai.com/api-keys)")
|
|
355
|
+
print(" GROQ_API_KEY (whisper-large-v3 — cheaper, faster; console.groq.com/keys)")
|
|
356
|
+
print(f" Fallback: set the same variable in {config_file()}")
|
|
357
|
+
print("")
|
|
358
|
+
print(" Without a key, /watch still works but videos without captions come back frames-only.")
|
|
359
|
+
return 3
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main() -> int:
|
|
363
|
+
if len(sys.argv) > 1:
|
|
364
|
+
arg = sys.argv[1]
|
|
365
|
+
if arg == "--check":
|
|
366
|
+
return cmd_check()
|
|
367
|
+
if arg == "--json":
|
|
368
|
+
return cmd_json()
|
|
369
|
+
return cmd_install()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parse a WebVTT subtitle file into a clean, timestamped transcript.
|
|
3
|
+
|
|
4
|
+
YouTube auto-subs emit rolling-duplicate cues (each line appears 2-3 times as it
|
|
5
|
+
scrolls). We dedupe consecutive identical cues and merge their time ranges.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
TS_RE = re.compile(
|
|
14
|
+
r"(\d{2}):(\d{2}):(\d{2})[.,](\d{3})\s+-->\s+(\d{2}):(\d{2}):(\d{2})[.,](\d{3})"
|
|
15
|
+
)
|
|
16
|
+
TAG_RE = re.compile(r"<[^>]+>")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_seconds(h: str, m: str, s: str, ms: str) -> float:
|
|
20
|
+
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_vtt(path: str) -> list[dict]:
|
|
24
|
+
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
25
|
+
lines = text.splitlines()
|
|
26
|
+
|
|
27
|
+
segments: list[dict] = []
|
|
28
|
+
i = 0
|
|
29
|
+
while i < len(lines):
|
|
30
|
+
match = TS_RE.match(lines[i])
|
|
31
|
+
if not match:
|
|
32
|
+
i += 1
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
start = _to_seconds(*match.groups()[:4])
|
|
36
|
+
end = _to_seconds(*match.groups()[4:])
|
|
37
|
+
i += 1
|
|
38
|
+
|
|
39
|
+
cue_lines: list[str] = []
|
|
40
|
+
while i < len(lines) and lines[i].strip():
|
|
41
|
+
cleaned = TAG_RE.sub("", lines[i]).strip()
|
|
42
|
+
if cleaned:
|
|
43
|
+
cue_lines.append(cleaned)
|
|
44
|
+
i += 1
|
|
45
|
+
|
|
46
|
+
cue_text = " ".join(cue_lines).strip()
|
|
47
|
+
if cue_text:
|
|
48
|
+
segments.append({"start": round(start, 2), "end": round(end, 2), "text": cue_text})
|
|
49
|
+
i += 1
|
|
50
|
+
|
|
51
|
+
return _dedupe(segments)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _dedupe(segments: list[dict]) -> list[dict]:
|
|
55
|
+
"""Collapse rolling duplicates common in YouTube auto-subs."""
|
|
56
|
+
out: list[dict] = []
|
|
57
|
+
for seg in segments:
|
|
58
|
+
if out and seg["text"] == out[-1]["text"]:
|
|
59
|
+
out[-1]["end"] = seg["end"]
|
|
60
|
+
continue
|
|
61
|
+
if out and seg["text"].startswith(out[-1]["text"] + " "):
|
|
62
|
+
out[-1]["text"] = seg["text"]
|
|
63
|
+
out[-1]["end"] = seg["end"]
|
|
64
|
+
continue
|
|
65
|
+
out.append(seg)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def filter_range(
|
|
70
|
+
segments: list[dict],
|
|
71
|
+
start_seconds: float | None,
|
|
72
|
+
end_seconds: float | None,
|
|
73
|
+
) -> list[dict]:
|
|
74
|
+
"""Return segments whose time range overlaps [start, end]."""
|
|
75
|
+
if start_seconds is None and end_seconds is None:
|
|
76
|
+
return segments
|
|
77
|
+
lo = start_seconds if start_seconds is not None else float("-inf")
|
|
78
|
+
hi = end_seconds if end_seconds is not None else float("inf")
|
|
79
|
+
return [seg for seg in segments if seg["end"] >= lo and seg["start"] <= hi]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_transcript(segments: list[dict]) -> str:
|
|
83
|
+
lines = []
|
|
84
|
+
for seg in segments:
|
|
85
|
+
start = int(seg["start"])
|
|
86
|
+
stamp = f"[{start // 60:02d}:{start % 60:02d}]"
|
|
87
|
+
lines.append(f"{stamp} {seg['text']}")
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
if len(sys.argv) < 2:
|
|
93
|
+
print("usage: transcribe.py <vtt-path>", file=sys.stderr)
|
|
94
|
+
raise SystemExit(2)
|
|
95
|
+
print(format_transcript(parse_vtt(sys.argv[1])))
|