openpod 0.1.0__py3-none-any.whl
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.
- openpod/__init__.py +48 -0
- openpod/align.py +249 -0
- openpod/asr.py +164 -0
- openpod/brand/banner.txt +4 -0
- openpod/brand/brand-tokens.json +69 -0
- openpod/brand/card-template.html +34 -0
- openpod/bridge.py +486 -0
- openpod/briefing.py +232 -0
- openpod/captions.py +360 -0
- openpod/card.py +99 -0
- openpod/catch.py +165 -0
- openpod/cli.py +878 -0
- openpod/clip.py +502 -0
- openpod/config.py +240 -0
- openpod/crosswalk.py +205 -0
- openpod/deeplink.py +169 -0
- openpod/doctor.py +121 -0
- openpod/errors.py +153 -0
- openpod/exports.py +59 -0
- openpod/follows.py +174 -0
- openpod/identity.py +132 -0
- openpod/imports.py +172 -0
- openpod/ingest/__init__.py +8 -0
- openpod/ingest/apple.py +183 -0
- openpod/ingest/resolve.py +250 -0
- openpod/ingest/rss.py +206 -0
- openpod/ingest/spotify.py +154 -0
- openpod/ingest/validate.py +134 -0
- openpod/ingest/youtube.py +188 -0
- openpod/library.py +284 -0
- openpod/mcp_server.py +569 -0
- openpod/media.py +119 -0
- openpod/models.py +400 -0
- openpod/persona.py +358 -0
- openpod/player_manifest.json +374 -0
- openpod/scan.py +179 -0
- openpod/search/__init__.py +31 -0
- openpod/search/embed.py +68 -0
- openpod/search/index.py +212 -0
- openpod/segments.py +314 -0
- openpod/skills/__init__.py +89 -0
- openpod/skills/bring-in-my-world/SKILL.md +41 -0
- openpod/skills/catch-me-up/SKILL.md +52 -0
- openpod/skills/chapter-it/SKILL.md +25 -0
- openpod/skills/cut-the-clip/SKILL.md +65 -0
- openpod/skills/find-the-moment/SKILL.md +40 -0
- openpod/skills/follow-this/SKILL.md +24 -0
- openpod/skills/make-it-shareable/SKILL.md +63 -0
- openpod/skills/remember-this/SKILL.md +61 -0
- openpod/skills/set-up-my-persona/SKILL.md +85 -0
- openpod/skills/sharpen-my-persona/SKILL.md +33 -0
- openpod/skills/whats-new/SKILL.md +40 -0
- openpod/sync.py +875 -0
- openpod/theme.py +189 -0
- openpod/transcript.py +197 -0
- openpod-0.1.0.dist-info/METADATA +241 -0
- openpod-0.1.0.dist-info/RECORD +60 -0
- openpod-0.1.0.dist-info/WHEEL +4 -0
- openpod-0.1.0.dist-info/entry_points.txt +3 -0
- openpod-0.1.0.dist-info/licenses/LICENSE +21 -0
openpod/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""OpenPod — local-first briefings for long-form audio & video.
|
|
2
|
+
|
|
3
|
+
Everything in this package runs on the user's own machine. Nothing is uploaded
|
|
4
|
+
to an OpenPod server; the only "corpus" is the user's local library on disk.
|
|
5
|
+
|
|
6
|
+
Public API surface (kept small on purpose):
|
|
7
|
+
|
|
8
|
+
from openpod import Transcript, Cue, SourceRef, Workspace, Library
|
|
9
|
+
from openpod import catch, search, clip
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .models import Cue, Idea, SearchHit, SourceRef, Transcript
|
|
15
|
+
from .config import Workspace
|
|
16
|
+
from .library import Library, LibraryEntry
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"Cue",
|
|
23
|
+
"Transcript",
|
|
24
|
+
"SourceRef",
|
|
25
|
+
"Idea",
|
|
26
|
+
"SearchHit",
|
|
27
|
+
"Workspace",
|
|
28
|
+
"Library",
|
|
29
|
+
"LibraryEntry",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str):
|
|
34
|
+
# Lazily expose the high-level verbs so importing the package doesn't drag
|
|
35
|
+
# in ingestion / search machinery until it's actually used.
|
|
36
|
+
if name == "catch":
|
|
37
|
+
from .catch import catch
|
|
38
|
+
|
|
39
|
+
return catch
|
|
40
|
+
if name == "search":
|
|
41
|
+
from .search import search
|
|
42
|
+
|
|
43
|
+
return search
|
|
44
|
+
if name == "clip":
|
|
45
|
+
from .clip import clip
|
|
46
|
+
|
|
47
|
+
return clip
|
|
48
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
openpod/align.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Cross-source timestamp alignment — probe-and-bisect.
|
|
2
|
+
|
|
3
|
+
A transcript fetched from the fastest source (usually YouTube captions)
|
|
4
|
+
doesn't necessarily line up with the same episode's audio on the platform
|
|
5
|
+
the user pasted: intros get trimmed, ads get inserted. The offset between
|
|
6
|
+
two versions is **piecewise-constant** — it jumps at each ad break and is
|
|
7
|
+
flat in between — so the whole map can be recovered with a handful of
|
|
8
|
+
cheap probes:
|
|
9
|
+
|
|
10
|
+
1. Probe ``offset(t)`` near the start and end (a probe = transcribe or
|
|
11
|
+
fingerprint a ~15s window of the target audio and locate its text in the
|
|
12
|
+
reference transcript).
|
|
13
|
+
2. Equal offsets → constant in between; done in two probes.
|
|
14
|
+
3. Different → binary-search the jump point and recurse. O(log n) probes
|
|
15
|
+
per ad break, ~10–20 probes for a typical episode.
|
|
16
|
+
|
|
17
|
+
The map is computed once per (episode, source-pair) and cached on the
|
|
18
|
+
crosswalk record. When alignment can't be trusted end-to-end (Spotify
|
|
19
|
+
re-hosts audio and may inject per-listener ads — we can never fetch the
|
|
20
|
+
exact stream a user hears), callers keep the ``approximate`` precision flag;
|
|
21
|
+
alignment narrows the error, honesty stays.
|
|
22
|
+
|
|
23
|
+
The probe is injected as a callable so the algorithm is testable without
|
|
24
|
+
audio; :func:`asr_probe` builds the real one from ffmpeg + faster-whisper.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import tempfile
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Callable, Optional
|
|
35
|
+
|
|
36
|
+
from .models import Transcript
|
|
37
|
+
|
|
38
|
+
# offset(t) for a probe time t in the *target* audio; None = probe failed
|
|
39
|
+
# (silence, music, ad content not present in the reference transcript).
|
|
40
|
+
ProbeFn = Callable[[float], Optional[float]]
|
|
41
|
+
|
|
42
|
+
# Offsets closer than this are "the same" (caption timing jitter).
|
|
43
|
+
TOLERANCE = 3.0
|
|
44
|
+
# Don't bisect intervals shorter than this — pin the jump to the midpoint.
|
|
45
|
+
MIN_INTERVAL = 30.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class OffsetMap:
|
|
50
|
+
"""Piecewise-constant mapping: target-audio time -> reference offset.
|
|
51
|
+
|
|
52
|
+
``points`` is ``[(start_t, offset), ...]`` sorted by ``start_t``; the
|
|
53
|
+
offset applies from ``start_t`` until the next point. ``map_time(t)``
|
|
54
|
+
answers "reference transcript time t lands where in the target audio?"
|
|
55
|
+
via the inverse shift.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
points: list = field(default_factory=list) # [(t, offset), ...]
|
|
59
|
+
probes_used: int = 0
|
|
60
|
+
complete: bool = True # False when some probes failed and gaps remain
|
|
61
|
+
|
|
62
|
+
def offset_at(self, t: float) -> Optional[float]:
|
|
63
|
+
chosen = None
|
|
64
|
+
for start_t, off in self.points:
|
|
65
|
+
if t >= start_t:
|
|
66
|
+
chosen = off
|
|
67
|
+
else:
|
|
68
|
+
break
|
|
69
|
+
return chosen
|
|
70
|
+
|
|
71
|
+
def to_target(self, reference_seconds: float) -> Optional[float]:
|
|
72
|
+
"""Map a reference-transcript time onto the target audio."""
|
|
73
|
+
# points are in target-time; invert by scanning segments.
|
|
74
|
+
for start_t, off in reversed(self.points):
|
|
75
|
+
if reference_seconds >= start_t + off:
|
|
76
|
+
return reference_seconds - off
|
|
77
|
+
if self.points:
|
|
78
|
+
return reference_seconds - self.points[0][1]
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def to_list(self) -> list:
|
|
82
|
+
return [[round(t, 3), round(o, 3)] for t, o in self.points]
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_list(cls, data: list) -> "OffsetMap":
|
|
86
|
+
return cls(points=[(float(t), float(o)) for t, o in data])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_offset_map(probe: ProbeFn, duration: float, *,
|
|
90
|
+
tolerance: float = TOLERANCE,
|
|
91
|
+
min_interval: float = MIN_INTERVAL,
|
|
92
|
+
max_probes: int = 40) -> OffsetMap:
|
|
93
|
+
"""Recover the piecewise-constant offset map with probe-and-bisect."""
|
|
94
|
+
state = {"probes": 0, "failed": 0}
|
|
95
|
+
|
|
96
|
+
def _probe(t: float) -> Optional[float]:
|
|
97
|
+
if state["probes"] >= max_probes:
|
|
98
|
+
return None
|
|
99
|
+
state["probes"] += 1
|
|
100
|
+
off = probe(t)
|
|
101
|
+
if off is None:
|
|
102
|
+
state["failed"] += 1
|
|
103
|
+
return off
|
|
104
|
+
|
|
105
|
+
lo_t = min(30.0, duration * 0.02)
|
|
106
|
+
hi_t = max(duration - 60.0, duration * 0.9)
|
|
107
|
+
lo = _probe(lo_t)
|
|
108
|
+
hi = _probe(hi_t)
|
|
109
|
+
|
|
110
|
+
points: list[tuple[float, float]] = []
|
|
111
|
+
complete = True
|
|
112
|
+
|
|
113
|
+
if lo is None and hi is None:
|
|
114
|
+
return OffsetMap(points=[], probes_used=state["probes"], complete=False)
|
|
115
|
+
if lo is None:
|
|
116
|
+
lo, complete = hi, False
|
|
117
|
+
if hi is None:
|
|
118
|
+
hi, complete = lo, False
|
|
119
|
+
|
|
120
|
+
points.append((0.0, lo))
|
|
121
|
+
|
|
122
|
+
def _bisect(t1: float, off1: float, t2: float, off2: float) -> None:
|
|
123
|
+
nonlocal complete
|
|
124
|
+
if abs(off1 - off2) <= tolerance:
|
|
125
|
+
return
|
|
126
|
+
if t2 - t1 <= min_interval:
|
|
127
|
+
# Pin the jump at the interval midpoint — good to ±min_interval/2.
|
|
128
|
+
points.append((round((t1 + t2) / 2, 3), off2))
|
|
129
|
+
return
|
|
130
|
+
mid = (t1 + t2) / 2
|
|
131
|
+
off_mid = _probe(mid)
|
|
132
|
+
if off_mid is None:
|
|
133
|
+
# Can't see into this interval; place the jump at mid but flag it.
|
|
134
|
+
complete = False
|
|
135
|
+
points.append((round(mid, 3), off2))
|
|
136
|
+
return
|
|
137
|
+
_bisect(t1, off1, mid, off_mid)
|
|
138
|
+
_bisect(mid, off_mid, t2, off2)
|
|
139
|
+
|
|
140
|
+
_bisect(lo_t, lo, hi_t, hi)
|
|
141
|
+
points.sort(key=lambda p: p[0])
|
|
142
|
+
# Collapse consecutive points with ~equal offsets.
|
|
143
|
+
deduped: list[tuple[float, float]] = []
|
|
144
|
+
for t, off in points:
|
|
145
|
+
if deduped and abs(deduped[-1][1] - off) <= tolerance:
|
|
146
|
+
continue
|
|
147
|
+
deduped.append((t, off))
|
|
148
|
+
return OffsetMap(points=deduped, probes_used=state["probes"],
|
|
149
|
+
complete=complete)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------- #
|
|
153
|
+
# The real probe: ffmpeg window -> ASR -> locate text in reference transcript
|
|
154
|
+
# --------------------------------------------------------------------------- #
|
|
155
|
+
|
|
156
|
+
_word_re = re.compile(r"[a-z0-9']+")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _words(text: str) -> list[str]:
|
|
160
|
+
return _word_re.findall(text.lower())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def locate_text(reference: Transcript, words: list[str], *,
|
|
164
|
+
min_overlap: float = 0.6) -> Optional[float]:
|
|
165
|
+
"""Find where a probe's word sequence occurs in the reference transcript.
|
|
166
|
+
|
|
167
|
+
Slides over cue windows comparing word overlap; returns the reference
|
|
168
|
+
time where the sequence starts, or None when nothing matches well
|
|
169
|
+
(music, an inserted ad, ASR noise).
|
|
170
|
+
"""
|
|
171
|
+
if len(words) < 5:
|
|
172
|
+
return None
|
|
173
|
+
target = set(words)
|
|
174
|
+
best_t, best_score = None, 0.0
|
|
175
|
+
cues = reference.cues
|
|
176
|
+
for i, cue in enumerate(cues):
|
|
177
|
+
# A window of roughly the probe's own length: generous windows make
|
|
178
|
+
# the *preceding* cue score as well as the right one.
|
|
179
|
+
window_words: list[str] = []
|
|
180
|
+
j = i
|
|
181
|
+
while j < len(cues) and len(window_words) < len(words) + 3:
|
|
182
|
+
window_words.extend(_words(cues[j].text))
|
|
183
|
+
j += 1
|
|
184
|
+
if not window_words:
|
|
185
|
+
continue
|
|
186
|
+
overlap = len(target & set(window_words[:len(words) + 3])) / len(target)
|
|
187
|
+
if overlap > best_score:
|
|
188
|
+
best_score, best_t = overlap, cue.start
|
|
189
|
+
if best_score < min_overlap:
|
|
190
|
+
return None
|
|
191
|
+
return best_t
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def asr_probe(target_media: str, reference: Transcript, *,
|
|
195
|
+
window: float = 15.0, model: str = "base") -> ProbeFn:
|
|
196
|
+
"""Build a real probe: cut ``window`` seconds at t from ``target_media``
|
|
197
|
+
(ffmpeg), transcribe it locally, and locate the words in ``reference``.
|
|
198
|
+
|
|
199
|
+
``offset = reference_time - t``: positive offset means the reference
|
|
200
|
+
transcript runs *ahead* of the target audio at that point.
|
|
201
|
+
"""
|
|
202
|
+
from .asr import transcribe
|
|
203
|
+
|
|
204
|
+
def probe(t: float) -> Optional[float]:
|
|
205
|
+
with tempfile.TemporaryDirectory(prefix="openpod-align-") as td:
|
|
206
|
+
snippet = Path(td) / "probe.wav"
|
|
207
|
+
cmd = ["ffmpeg", "-y", "-ss", f"{t:.2f}", "-t", f"{window:.2f}",
|
|
208
|
+
"-i", target_media, "-ac", "1", "-ar", "16000",
|
|
209
|
+
str(snippet)]
|
|
210
|
+
proc = subprocess.run(cmd, capture_output=True)
|
|
211
|
+
if proc.returncode != 0 or not snippet.exists():
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
heard = transcribe(str(snippet), model=model)
|
|
215
|
+
except Exception:
|
|
216
|
+
return None
|
|
217
|
+
words = _words(heard.text())
|
|
218
|
+
ref_t = locate_text(reference, words)
|
|
219
|
+
if ref_t is None:
|
|
220
|
+
return None
|
|
221
|
+
return ref_t - t
|
|
222
|
+
|
|
223
|
+
return probe
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def align_entry(entry, target_media: str, *,
|
|
227
|
+
source_kind: str = "youtube") -> Optional[OffsetMap]:
|
|
228
|
+
"""Align a caught entry's transcript against a target audio file and
|
|
229
|
+
persist the map on the entry's crosswalk record (keyed by the
|
|
230
|
+
transcript-source kind)."""
|
|
231
|
+
transcript = entry.read_transcript()
|
|
232
|
+
if transcript is None or not len(transcript):
|
|
233
|
+
return None
|
|
234
|
+
duration = transcript.duration
|
|
235
|
+
probe = asr_probe(target_media, transcript)
|
|
236
|
+
omap = compute_offset_map(probe, duration)
|
|
237
|
+
|
|
238
|
+
meta = entry.read_meta()
|
|
239
|
+
key = meta.get("episode_key")
|
|
240
|
+
if key:
|
|
241
|
+
from .config import Workspace
|
|
242
|
+
from .crosswalk import Crosswalk
|
|
243
|
+
|
|
244
|
+
cw = Crosswalk(entry.workspace)
|
|
245
|
+
ident = cw.get(key)
|
|
246
|
+
if ident is not None:
|
|
247
|
+
ident.offset_maps[source_kind] = omap.to_list()
|
|
248
|
+
cw.put(ident)
|
|
249
|
+
return omap
|
openpod/asr.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Local ASR fallback and audio download.
|
|
2
|
+
|
|
3
|
+
Transcription runs on the user's own hardware via ``faster-whisper`` (install the
|
|
4
|
+
``asr`` extra). This keeps the free path at $0 company COGS — no audio ever
|
|
5
|
+
leaves the machine. Both helpers raise a clear, actionable error when their
|
|
6
|
+
optional dependency / system tool is missing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .models import Cue, Transcript
|
|
20
|
+
|
|
21
|
+
_UA = {"User-Agent": "OpenPod/0.1 (+https://github.com/openpodhq/openpod)"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DependencyMissing(RuntimeError):
|
|
25
|
+
"""Raised when an optional dependency needed for a path isn't installed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def transcribe(audio_path: str, *, model: str = "base",
|
|
29
|
+
language: Optional[str] = None) -> Transcript:
|
|
30
|
+
"""Transcribe a local audio/video file with faster-whisper.
|
|
31
|
+
|
|
32
|
+
Produces segment-level cues (good for navigation). For frame-accurate clip
|
|
33
|
+
cutting, ``clip`` re-aligns a narrow window at higher precision.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
from faster_whisper import WhisperModel # type: ignore
|
|
37
|
+
except ImportError as e: # pragma: no cover - depends on env
|
|
38
|
+
raise DependencyMissing(
|
|
39
|
+
"Local transcription needs faster-whisper. Install it with:\n"
|
|
40
|
+
" pip install 'openpod[asr]'\n"
|
|
41
|
+
"or supply a transcript directly with --transcript."
|
|
42
|
+
) from e
|
|
43
|
+
|
|
44
|
+
model_name = os.environ.get("OPENPOD_WHISPER_MODEL", model)
|
|
45
|
+
whisper = WhisperModel(model_name, device="auto", compute_type="auto")
|
|
46
|
+
segments, info = whisper.transcribe(audio_path, language=language, vad_filter=True)
|
|
47
|
+
cues = [
|
|
48
|
+
Cue(start=float(s.start), end=float(s.end), text=s.text.strip())
|
|
49
|
+
for s in segments
|
|
50
|
+
if s.text.strip()
|
|
51
|
+
]
|
|
52
|
+
return Transcript(
|
|
53
|
+
cues=cues,
|
|
54
|
+
source=f"asr:whisper-{model_name}",
|
|
55
|
+
language=getattr(info, "language", language),
|
|
56
|
+
word_level=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def download_audio(url: str, *, dest_dir: Optional[str] = None) -> str:
|
|
61
|
+
"""Download audio to a local file, returning its path.
|
|
62
|
+
|
|
63
|
+
Uses ``yt-dlp`` for streaming sites (YouTube etc.) and a plain HTTP GET for
|
|
64
|
+
direct podcast enclosures.
|
|
65
|
+
"""
|
|
66
|
+
dest_dir = dest_dir or tempfile.mkdtemp(prefix="openpod-audio-")
|
|
67
|
+
Path(dest_dir).mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
# Direct enclosure: validate the content type, then fetch the terminal
|
|
70
|
+
# URL (tracking redirectors resolved once, not on every request).
|
|
71
|
+
fetch_url, ext = url, None
|
|
72
|
+
if url.lower().split("?")[0].endswith((".mp3", ".m4a", ".aac", ".ogg", ".wav")):
|
|
73
|
+
ext = url.lower().split("?")[0].rsplit(".", 1)[-1]
|
|
74
|
+
elif url.lower().startswith(("http://", "https://")):
|
|
75
|
+
# No recognizable extension (e.g. a chrt.fm-style redirector): sniff
|
|
76
|
+
# what it actually serves instead of guessing.
|
|
77
|
+
from .ingest.validate import sniff
|
|
78
|
+
|
|
79
|
+
info = sniff(url)
|
|
80
|
+
if info.kind == "audio":
|
|
81
|
+
fetch_url = info.url
|
|
82
|
+
ext = (info.content_type or "audio/mpeg").split("/")[-1].split(";")[0]
|
|
83
|
+
ext = {"mpeg": "mp3", "mp4": "m4a", "x-m4a": "m4a"}.get(ext, ext)
|
|
84
|
+
|
|
85
|
+
if ext:
|
|
86
|
+
out = Path(dest_dir) / f"audio.{ext}"
|
|
87
|
+
req = urllib.request.Request(fetch_url, headers=_UA)
|
|
88
|
+
with urllib.request.urlopen(req, timeout=120) as resp, out.open("wb") as fh: # noqa: S310
|
|
89
|
+
shutil.copyfileobj(resp, fh)
|
|
90
|
+
return str(out)
|
|
91
|
+
|
|
92
|
+
# Otherwise defer to yt-dlp.
|
|
93
|
+
try:
|
|
94
|
+
import yt_dlp # type: ignore
|
|
95
|
+
except ImportError as e:
|
|
96
|
+
raise DependencyMissing(
|
|
97
|
+
"Downloading from this source needs yt-dlp. Install it with:\n"
|
|
98
|
+
" pip install 'openpod[youtube]'"
|
|
99
|
+
) from e
|
|
100
|
+
|
|
101
|
+
out_tmpl = str(Path(dest_dir) / "audio.%(ext)s")
|
|
102
|
+
opts = {
|
|
103
|
+
"format": "bestaudio/best",
|
|
104
|
+
"outtmpl": out_tmpl,
|
|
105
|
+
"quiet": True,
|
|
106
|
+
"no_warnings": True,
|
|
107
|
+
"noplaylist": True,
|
|
108
|
+
}
|
|
109
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
110
|
+
info = ydl.extract_info(url, download=True)
|
|
111
|
+
return ydl.prepare_filename(info)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def estimate_transcription(duration: Optional[float],
|
|
115
|
+
model: str = "base") -> dict:
|
|
116
|
+
"""Rough wall-clock estimate for local Whisper on typical hardware.
|
|
117
|
+
|
|
118
|
+
Deliberately a *range* — CPU/GPU spread is wide. The point is honesty
|
|
119
|
+
at the moment of decision ("~9–25 min for this episode"), not precision.
|
|
120
|
+
"""
|
|
121
|
+
# Realtime factors per model size (fraction of audio duration), low/high.
|
|
122
|
+
factors = {"tiny": (0.03, 0.10), "base": (0.05, 0.20),
|
|
123
|
+
"small": (0.10, 0.35), "medium": (0.25, 0.80),
|
|
124
|
+
"large": (0.5, 1.5)}
|
|
125
|
+
lo_f, hi_f = factors.get(model.split("-")[0], (0.05, 0.20))
|
|
126
|
+
if not duration:
|
|
127
|
+
return {"model": model, "human": "several minutes (unknown duration)"}
|
|
128
|
+
lo, hi = duration * lo_f, duration * hi_f
|
|
129
|
+
|
|
130
|
+
def _m(s: float) -> str:
|
|
131
|
+
return f"{max(1, round(s / 60))} min" if s >= 45 else f"{round(s)}s"
|
|
132
|
+
|
|
133
|
+
return {"model": model, "low_seconds": round(lo), "high_seconds": round(hi),
|
|
134
|
+
"human": f"{_m(lo)}–{_m(hi)}"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def has_ffmpeg() -> bool:
|
|
138
|
+
return shutil.which("ffmpeg") is not None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_ffmpeg_caps: Optional[dict] = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def ffmpeg_capabilities(*, refresh: bool = False) -> dict:
|
|
145
|
+
"""What this ffmpeg build can actually do, probed once.
|
|
146
|
+
|
|
147
|
+
Homebrew builds routinely ship without libass/drawtext; anything that
|
|
148
|
+
promises burned-in text must check here first and degrade honestly
|
|
149
|
+
(sidecar captions) instead of failing mid-encode.
|
|
150
|
+
"""
|
|
151
|
+
global _ffmpeg_caps
|
|
152
|
+
if _ffmpeg_caps is not None and not refresh:
|
|
153
|
+
return _ffmpeg_caps
|
|
154
|
+
caps = {"ffmpeg": has_ffmpeg(), "subtitles": False, "drawtext": False}
|
|
155
|
+
if caps["ffmpeg"]:
|
|
156
|
+
try:
|
|
157
|
+
out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"],
|
|
158
|
+
capture_output=True, text=True, timeout=15).stdout
|
|
159
|
+
caps["subtitles"] = " subtitles " in out
|
|
160
|
+
caps["drawtext"] = " drawtext " in out
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
_ffmpeg_caps = caps
|
|
164
|
+
return caps
|
openpod/brand/banner.txt
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "OpenPod",
|
|
3
|
+
"wordmark": "OpenPod",
|
|
4
|
+
"cli_command": "openpod",
|
|
5
|
+
"tagline": "Pull the ten minutes that matter — to you.",
|
|
6
|
+
"principle": "Black, white, and one blue. The blue only ever marks the moment.",
|
|
7
|
+
"color": {
|
|
8
|
+
"ink": {
|
|
9
|
+
"hex": "#0E0E0E",
|
|
10
|
+
"role": "foreground on light / background on dark"
|
|
11
|
+
},
|
|
12
|
+
"paper": {
|
|
13
|
+
"hex": "#FCFCFB",
|
|
14
|
+
"role": "background on light"
|
|
15
|
+
},
|
|
16
|
+
"blue_light": {
|
|
17
|
+
"hex": "#0969DA",
|
|
18
|
+
"role": "accent on white (GitHub light link token)",
|
|
19
|
+
"ansi_256": 33,
|
|
20
|
+
"ansi_24bit": "38;2;9;105;218"
|
|
21
|
+
},
|
|
22
|
+
"blue_dark": {
|
|
23
|
+
"hex": "#58A6FF",
|
|
24
|
+
"role": "accent on near-black (GitHub dark link token)",
|
|
25
|
+
"ansi_256": 75,
|
|
26
|
+
"ansi_24bit": "38;2;88;166;255"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"accent_rule": "Reserve blue for timestamps, deep-links, the jump affordance, and the CTA. Nothing else.",
|
|
30
|
+
"type": {
|
|
31
|
+
"mono": {
|
|
32
|
+
"family": "IBM Plex Mono",
|
|
33
|
+
"weights": [
|
|
34
|
+
400,
|
|
35
|
+
500,
|
|
36
|
+
600
|
|
37
|
+
],
|
|
38
|
+
"license": "OFL",
|
|
39
|
+
"role": "product's native tongue — CLI, timestamps, paths, UI labels"
|
|
40
|
+
},
|
|
41
|
+
"sans": {
|
|
42
|
+
"family": "IBM Plex Sans",
|
|
43
|
+
"weights": [
|
|
44
|
+
400,
|
|
45
|
+
500,
|
|
46
|
+
600,
|
|
47
|
+
700
|
|
48
|
+
],
|
|
49
|
+
"license": "OFL",
|
|
50
|
+
"role": "headlines & captions"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"signature": {
|
|
54
|
+
"glyph": "▸",
|
|
55
|
+
"unicode": "U+25B8",
|
|
56
|
+
"pattern": "▸ m:ss",
|
|
57
|
+
"examples": [
|
|
58
|
+
"▸ 12:34",
|
|
59
|
+
"▸ 41:07"
|
|
60
|
+
],
|
|
61
|
+
"rule": "Always blue, always a live deep-link. The one visual idea we own."
|
|
62
|
+
},
|
|
63
|
+
"logo": {
|
|
64
|
+
"concept": "playhead landing on a position line (jump-to-moment)",
|
|
65
|
+
"viewBox": "0 0 24 24",
|
|
66
|
+
"clear_space": "the height of the playhead triangle on every side",
|
|
67
|
+
"reproduction": "one-color first (black / white / blue); full-color is the exception"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>{{EPISODE}} — OpenPod</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
html, body { background: {{PAPER}}; }
|
|
9
|
+
.op-mono { font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
10
|
+
.op-sans { font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="op-mono" style="width:1200px; height:630px; background:{{PAPER}}; overflow:hidden;">
|
|
15
|
+
<div style="height:100%; padding:60px 64px; display:flex; flex-direction:column; justify-content:space-between;">
|
|
16
|
+
<div>
|
|
17
|
+
<div style="font-size:16px; letter-spacing:0.24em; text-transform:uppercase; color:{{INK}}; font-weight:600; margin-bottom:10px;">{{SHOW}}</div>
|
|
18
|
+
<div class="op-sans" style="font-size:22px; color:#4a4a48; font-weight:500;">{{EPISODE}}</div>
|
|
19
|
+
<div style="width:52px; height:2px; background:#d8d8d5; margin-top:22px;"></div>
|
|
20
|
+
</div>
|
|
21
|
+
<div style="max-width:940px;">
|
|
22
|
+
<div class="op-sans" style="font-weight:600; font-size:44px; line-height:1.16; letter-spacing:-0.018em; color:{{INK}};">“{{QUOTE}}”</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div style="display:flex; align-items:flex-end; justify-content:space-between;">
|
|
25
|
+
{{MOMENT_BLOCK}}
|
|
26
|
+
<a href="{{BACKLINK}}" style="display:flex; align-items:center; gap:8px; color:#8a8a88; text-decoration:none;">
|
|
27
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"><path d="M6 5 L15 12 L6 19 Z" fill="currentColor"></path><rect x="18" y="4" width="2.6" height="16" fill="currentColor"></rect></svg>
|
|
28
|
+
<span style="font-size:13px;">cited with <span style="color:#5a5a58; font-weight:500;">OpenPod</span></span>
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|