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.
Files changed (60) hide show
  1. openpod/__init__.py +48 -0
  2. openpod/align.py +249 -0
  3. openpod/asr.py +164 -0
  4. openpod/brand/banner.txt +4 -0
  5. openpod/brand/brand-tokens.json +69 -0
  6. openpod/brand/card-template.html +34 -0
  7. openpod/bridge.py +486 -0
  8. openpod/briefing.py +232 -0
  9. openpod/captions.py +360 -0
  10. openpod/card.py +99 -0
  11. openpod/catch.py +165 -0
  12. openpod/cli.py +878 -0
  13. openpod/clip.py +502 -0
  14. openpod/config.py +240 -0
  15. openpod/crosswalk.py +205 -0
  16. openpod/deeplink.py +169 -0
  17. openpod/doctor.py +121 -0
  18. openpod/errors.py +153 -0
  19. openpod/exports.py +59 -0
  20. openpod/follows.py +174 -0
  21. openpod/identity.py +132 -0
  22. openpod/imports.py +172 -0
  23. openpod/ingest/__init__.py +8 -0
  24. openpod/ingest/apple.py +183 -0
  25. openpod/ingest/resolve.py +250 -0
  26. openpod/ingest/rss.py +206 -0
  27. openpod/ingest/spotify.py +154 -0
  28. openpod/ingest/validate.py +134 -0
  29. openpod/ingest/youtube.py +188 -0
  30. openpod/library.py +284 -0
  31. openpod/mcp_server.py +569 -0
  32. openpod/media.py +119 -0
  33. openpod/models.py +400 -0
  34. openpod/persona.py +358 -0
  35. openpod/player_manifest.json +374 -0
  36. openpod/scan.py +179 -0
  37. openpod/search/__init__.py +31 -0
  38. openpod/search/embed.py +68 -0
  39. openpod/search/index.py +212 -0
  40. openpod/segments.py +314 -0
  41. openpod/skills/__init__.py +89 -0
  42. openpod/skills/bring-in-my-world/SKILL.md +41 -0
  43. openpod/skills/catch-me-up/SKILL.md +52 -0
  44. openpod/skills/chapter-it/SKILL.md +25 -0
  45. openpod/skills/cut-the-clip/SKILL.md +65 -0
  46. openpod/skills/find-the-moment/SKILL.md +40 -0
  47. openpod/skills/follow-this/SKILL.md +24 -0
  48. openpod/skills/make-it-shareable/SKILL.md +63 -0
  49. openpod/skills/remember-this/SKILL.md +61 -0
  50. openpod/skills/set-up-my-persona/SKILL.md +85 -0
  51. openpod/skills/sharpen-my-persona/SKILL.md +33 -0
  52. openpod/skills/whats-new/SKILL.md +40 -0
  53. openpod/sync.py +875 -0
  54. openpod/theme.py +189 -0
  55. openpod/transcript.py +197 -0
  56. openpod-0.1.0.dist-info/METADATA +241 -0
  57. openpod-0.1.0.dist-info/RECORD +60 -0
  58. openpod-0.1.0.dist-info/WHEEL +4 -0
  59. openpod-0.1.0.dist-info/entry_points.txt +3 -0
  60. 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
@@ -0,0 +1,4 @@
1
+ ┌─────────────────────────────────────────────────────────┐
2
+ │ ▸ OpenPod pull the ten minutes that matter — to you │
3
+ └─────────────────────────────────────────────────────────┘
4
+ v0.1 · local & private · run openpod skills to begin
@@ -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}};">&ldquo;{{QUOTE}}&rdquo;</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>