scribecast 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.
scribecast/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """scribecast — turn any note (from gbrain, PraxVault, ksum, a file, stdin, or several merged)
2
+ into a narrated video, using vidkit as the render+narration engine.
3
+
4
+ Three interfaces over ONE importable core:
5
+ - library: from scribecast import render_note, resolve, recommend_engine
6
+ - CLI: scribecast render <ref> [--engine ...] [--voice ...] [-o out.mp4]
7
+ - MCP: python -m scribecast.mcp (stdio JSON-RPC: render_note_video, list_voices, ...)
8
+
9
+ scribecast IMPORTS vidkit (vidkit_core) — it never shells out to a vendored copy. Note SOURCES
10
+ are resolved by calling the existing local CLIs (gbrain/ksum/praxvault-ask) as subprocess for
11
+ READING note text only; that is resolution, not the shell-glue-engine anti-pattern.
12
+ """
13
+ __version__ = "0.1.0"
14
+
15
+ from .config import Config, load_config
16
+ from .resolver import resolve, ResolverError
17
+ from .selector import recommend_engine, ENGINES
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "Config", "load_config",
22
+ "resolve", "ResolverError",
23
+ "recommend_engine", "ENGINES",
24
+ "render_note",
25
+ ]
26
+
27
+
28
+ def render_note(*args, **kwargs):
29
+ """Lazy proxy to scribecast.pipeline.render_note (keeps `import scribecast` light —
30
+ the pipeline imports vidkit which pulls ffmpeg-dependent modules)."""
31
+ from .pipeline import render_note as _render_note
32
+ return _render_note(*args, **kwargs)
scribecast/cli.py ADDED
@@ -0,0 +1,145 @@
1
+ """scribecast CLI — one binary for humans, Raycast, scripts.
2
+
3
+ scribecast render <ref> [--source S] [--engine E] [--voice V] [-o out.mp4] [--no-narrate]
4
+ scribecast resolve <ref> [--source S] # print resolved note text
5
+ scribecast recommend <ref> [--source S] # show engine recommendation (advisory)
6
+ scribecast sources # list note sources + availability
7
+ scribecast voices # list a few common edge-tts voices
8
+ scribecast config # show effective config + provenance
9
+ scribecast version
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+ from . import __version__
18
+ from .config import load_config
19
+ from .resolver import resolve, list_sources, ResolverError
20
+ from .selector import choose, ENGINES
21
+
22
+
23
+ def _render(a: argparse.Namespace) -> int:
24
+ from .pipeline import render_note, PipelineError
25
+ ref = a.ref if not a.merge else [a.ref, *a.merge]
26
+ try:
27
+ res = render_note(
28
+ ref, source=a.source, engine=a.engine, voice=a.voice, out=a.out,
29
+ narrate=not a.no_narrate, seconds_per_card=a.seconds_per_card,
30
+ )
31
+ except (PipelineError, ResolverError) as exc:
32
+ print(f"scribecast: {type(exc).__name__}: {exc}", file=sys.stderr)
33
+ return 1
34
+ print(json.dumps({
35
+ "output": res.output, "engine": res.engine, "recommended": res.recommendation.engine,
36
+ "cards": res.cards, "narrated": res.narrated, "duration": res.duration,
37
+ "notes": res.notes,
38
+ }, indent=2))
39
+ return 0
40
+
41
+
42
+ def _resolve_cmd(a: argparse.Namespace) -> int:
43
+ try:
44
+ ref = a.ref if not a.merge else [a.ref, *a.merge]
45
+ print(resolve(ref, source=a.source or "file"))
46
+ except ResolverError as exc:
47
+ print(f"scribecast: {exc}", file=sys.stderr)
48
+ return 1
49
+ return 0
50
+
51
+
52
+ def _recommend(a: argparse.Namespace) -> int:
53
+ try:
54
+ text = resolve(a.ref, source=a.source or "file")
55
+ except ResolverError as exc:
56
+ print(f"scribecast: {exc}", file=sys.stderr)
57
+ return 1
58
+ eng, rec = choose(text, user_choice=a.engine)
59
+ print(json.dumps({
60
+ "chosen": eng, "recommended": rec.engine, "reason": rec.reason,
61
+ "scores": rec.scores, "user_override": bool(a.engine),
62
+ }, indent=2))
63
+ return 0
64
+
65
+
66
+ def _sources(_a: argparse.Namespace) -> int:
67
+ print(json.dumps(list_sources(), indent=2))
68
+ return 0
69
+
70
+
71
+ def _voices(_a: argparse.Namespace) -> int:
72
+ common = [
73
+ "en-US-AriaNeural", "en-US-GuyNeural", "en-GB-RyanNeural", "en-GB-SoniaNeural",
74
+ "en-IN-NeerjaNeural", "en-IN-PrabhatNeural", "en-AU-NatashaNeural",
75
+ ]
76
+ print(json.dumps({"voices": common, "note": "any edge-tts voice id works; "
77
+ "run `edge-tts --list-voices` for the full list"}, indent=2))
78
+ return 0
79
+
80
+
81
+ def _config_cmd(_a: argparse.Namespace) -> int:
82
+ cfg = load_config()
83
+ print(json.dumps({"config": {k: getattr(cfg, k) for k in
84
+ ("engine", "voice", "source", "out_dir", "aspect", "music")},
85
+ "origin": cfg.explain()}, indent=2))
86
+ return 0
87
+
88
+
89
+ def _version(_a: argparse.Namespace) -> int:
90
+ print(__version__)
91
+ return 0
92
+
93
+
94
+ def build_parser() -> argparse.ArgumentParser:
95
+ p = argparse.ArgumentParser(prog="scribecast",
96
+ description="Turn any note into a narrated video.")
97
+ sub = p.add_subparsers(dest="cmd", required=True)
98
+
99
+ r = sub.add_parser("render", help="render a note to a narrated video")
100
+ r.add_argument("ref", help="source:locator or locator (e.g. file:notes.md, gbrain:my-slug)")
101
+ r.add_argument("--merge", nargs="*", help="additional refs to merge into one video")
102
+ r.add_argument("--source", help="default source when ref has no prefix")
103
+ r.add_argument("--engine", choices=ENGINES, help="force engine (user-wins over recommendation)")
104
+ r.add_argument("--voice", help="edge-tts voice id")
105
+ r.add_argument("-o", "--out", help="output mp4 path")
106
+ r.add_argument("--no-narrate", action="store_true", help="skip narration (silent video)")
107
+ r.add_argument("--seconds-per-card", type=float, default=3.0)
108
+ r.set_defaults(func=_render)
109
+
110
+ rs = sub.add_parser("resolve", help="print resolved note text")
111
+ rs.add_argument("ref")
112
+ rs.add_argument("--merge", nargs="*")
113
+ rs.add_argument("--source")
114
+ rs.set_defaults(func=_resolve_cmd)
115
+
116
+ rc = sub.add_parser("recommend", help="show engine recommendation (advisory)")
117
+ rc.add_argument("ref")
118
+ rc.add_argument("--source")
119
+ rc.add_argument("--engine", choices=ENGINES, help="simulate a user override")
120
+ rc.set_defaults(func=_recommend)
121
+
122
+ sub.add_parser("sources", help="list note sources + availability").set_defaults(func=_sources)
123
+ sub.add_parser("voices", help="list common edge-tts voices").set_defaults(func=_voices)
124
+ sub.add_parser("config", help="show effective config + provenance").set_defaults(func=_config_cmd)
125
+ sub.add_parser("version", help="print version").set_defaults(func=_version)
126
+ p.add_argument("-v", "--verbose", action="count", default=0,
127
+ help="-v for INFO logs, -vv for DEBUG (logs go to stderr)")
128
+ return p
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ args = build_parser().parse_args(argv)
133
+ from .logging_setup import configure_logging
134
+ level = {0: None, 1: "INFO"}.get(args.verbose, "DEBUG")
135
+ if level is not None:
136
+ configure_logging(level)
137
+ try:
138
+ return args.func(args)
139
+ except Exception as exc:
140
+ print(f"scribecast: {type(exc).__name__}: {exc}", file=sys.stderr)
141
+ return 1
142
+
143
+
144
+ if __name__ == "__main__": # pragma: no cover
145
+ raise SystemExit(main())
scribecast/config.py ADDED
@@ -0,0 +1,140 @@
1
+ """scribecast.config — layered configuration.
2
+
3
+ Precedence (highest wins): explicit flag/kwarg > $SCRIBECAST_* env > config file > default.
4
+
5
+ Config file: $SCRIBECAST_CONFIG, else ~/.scribecast/config.toml. Parsed read-only (tomllib,
6
+ stdlib on 3.11+; falls back to a tiny parser on 3.10). Missing file = all defaults.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field, fields
12
+ from pathlib import Path
13
+ from typing import Any, Mapping
14
+
15
+ # ---- defaults -------------------------------------------------------------
16
+ _DEFAULTS: dict[str, Any] = {
17
+ "engine": "hyperframes", # default render engine
18
+ "voice": "en-US-AriaNeural", # default edge-tts voice
19
+ "source": "file", # default resolver source
20
+ "out_dir": "scribecast-out", # default output directory
21
+ "aspect": "16:9", # default aspect ratio
22
+ "music": "", # optional background music path
23
+ }
24
+
25
+ _ENV_PREFIX = "SCRIBECAST_"
26
+
27
+
28
+ def _config_path() -> Path:
29
+ override = os.environ.get("SCRIBECAST_CONFIG")
30
+ if override:
31
+ return Path(override).expanduser()
32
+ return Path.home() / ".scribecast" / "config.toml"
33
+
34
+
35
+ def _read_toml(path: Path) -> dict[str, Any]:
36
+ if not path.is_file():
37
+ return {}
38
+ data = path.read_bytes()
39
+ try:
40
+ try:
41
+ import tomllib # py3.11+
42
+ return tomllib.loads(data.decode("utf-8"))
43
+ except ModuleNotFoundError:
44
+ try: # pragma: no cover - py3.10 only (3.11+ has tomllib)
45
+ import tomli # type: ignore
46
+ return tomli.loads(data.decode("utf-8"))
47
+ except ModuleNotFoundError: # pragma: no cover
48
+ return _mini_toml(data.decode("utf-8"))
49
+ except (UnicodeDecodeError, ValueError) as exc:
50
+ # Malformed config must NOT crash every command — warn to stderr, fall back to defaults.
51
+ # (tomllib.TOMLDecodeError subclasses ValueError, so this catches it without importing it.)
52
+ import sys
53
+ print(f"scribecast: ignoring malformed config at {path}: {exc}", file=sys.stderr)
54
+ return {}
55
+
56
+
57
+ def _mini_toml(text: str) -> dict[str, Any]:
58
+ """Tiny flat key=value TOML reader (py3.10 fallback; only needs flat string/bool values).
59
+ Strips inline `#` comments outside quotes; skips [section] headers (flat namespace only)."""
60
+ out: dict[str, Any] = {}
61
+ for raw in text.splitlines():
62
+ line = raw.strip()
63
+ if not line or line.startswith("#") or line.startswith("["):
64
+ continue
65
+ if "=" not in line:
66
+ continue
67
+ k, _, v = line.partition("=")
68
+ k = k.strip()
69
+ v = v.strip()
70
+ if v[:1] in ("'", '"'):
71
+ # quoted value: take through the matching closing quote, ignore any trailing comment
72
+ q = v[0]
73
+ end = v.find(q, 1)
74
+ v = v[1:end] if end > 0 else v[1:]
75
+ else:
76
+ # bare value: strip an inline comment
77
+ v = v.split("#", 1)[0].strip()
78
+ if v.lower() in ("true", "false"):
79
+ out[k] = v.lower() == "true"
80
+ else:
81
+ out[k] = v
82
+ return out
83
+
84
+
85
+ @dataclass
86
+ class Config:
87
+ engine: str = _DEFAULTS["engine"]
88
+ voice: str = _DEFAULTS["voice"]
89
+ source: str = _DEFAULTS["source"]
90
+ out_dir: str = _DEFAULTS["out_dir"]
91
+ aspect: str = _DEFAULTS["aspect"]
92
+ music: str = _DEFAULTS["music"]
93
+ # provenance: which layer set each field (for --explain / debugging)
94
+ _origin: dict[str, str] = field(default_factory=dict, repr=False)
95
+
96
+ def explain(self) -> dict[str, str]:
97
+ """Return {field: layer} showing where each value came from."""
98
+ return dict(self._origin)
99
+
100
+
101
+ def load_config(overrides: dict[str, Any] | None = None,
102
+ config_path: str | os.PathLike | None = None,
103
+ env: "Mapping[str, str] | None" = None) -> Config:
104
+ """Build a Config honoring precedence flag/kwarg > env > file > default.
105
+
106
+ overrides: explicit flags/kwargs (None values are ignored, so callers can pass argparse
107
+ Namespaces straight through without clobbering config with None).
108
+ """
109
+ env_map: Mapping[str, str] = os.environ if env is None else env
110
+ path = Path(config_path).expanduser() if config_path else _config_path()
111
+
112
+ file_cfg = _read_toml(path)
113
+ overrides = overrides or {}
114
+
115
+ valid = {f.name for f in fields(Config) if not f.name.startswith("_")}
116
+ values: dict[str, Any] = {}
117
+ origin: dict[str, str] = {}
118
+
119
+ for key in valid:
120
+ # default
121
+ values[key] = _DEFAULTS[key]
122
+ origin[key] = "default"
123
+ # file
124
+ if key in file_cfg and file_cfg[key] is not None:
125
+ values[key] = file_cfg[key]
126
+ origin[key] = "file"
127
+ # env (use `is not None` for consistency with file/flag layers; an explicitly-set
128
+ # empty env var overrides rather than being silently dropped)
129
+ env_key = _ENV_PREFIX + key.upper()
130
+ if env_map.get(env_key) is not None:
131
+ values[key] = env_map[env_key]
132
+ origin[key] = "env"
133
+ # explicit override (flag/kwarg) — wins, but only if not None
134
+ if key in overrides and overrides[key] is not None:
135
+ values[key] = overrides[key]
136
+ origin[key] = "flag"
137
+
138
+ cfg = Config(**values)
139
+ cfg._origin = origin
140
+ return cfg
@@ -0,0 +1,19 @@
1
+ """scribecast.engines — opt-in render engine adapters (manim, remotion).
2
+
3
+ The default 'hyperframes' (Pillow cards) renderer lives in pipeline.py and needs no engine adapter.
4
+ These adapters wrap the heavy, opt-in engines and are imported lazily so the base install stays light.
5
+
6
+ All engine renderers share the EngineRenderer Protocol below so the pipeline's _render_engine
7
+ dispatch stays honest as engines are added: each is a callable
8
+ (cards: list[tuple[str, str]], out_path: str, **kw) -> str # returns the output mp4 path
9
+ and raises a clear <Engine>AdapterError (subclass of RuntimeError) on failure — never a silent
10
+ fallback to a different engine.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Protocol, runtime_checkable
15
+
16
+
17
+ @runtime_checkable
18
+ class EngineRenderer(Protocol):
19
+ def __call__(self, cards: "list[tuple[str, str]]", out_path: str, **kw) -> str: ...
@@ -0,0 +1,156 @@
1
+ """scribecast.engines.manim_adapter — render a scribecast card script as a CINEMATIC Manim video.
2
+
3
+ Reuses vqkit.CinematicScene (the council-locked cinematic base: MovingCameraScene + gradient bg +
4
+ theme tokens + title_in/pan_to/spotlight). NOT a static slideshow — each card gets an animated
5
+ reveal, the camera moves between cards, and headings use Write() with a fly-to-edge. Math/LaTeX in
6
+ a card renders via tex_or_text (real MathTex when LaTeX is present, Text fallback otherwise).
7
+
8
+ This is the opt-in `--engine manim` path. It needs the [manim] extra installed. The default
9
+ hyperframes (Pillow cards) path stays dependency-light.
10
+
11
+ The scene reads its card script from a JSON file pointed at by $SCRIBECAST_CARDS so the same
12
+ CinematicScene class can render any note (Manim renders by importing a scene file + class name).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ # The scene module written to a temp file and rendered via `manim`. It imports vqkit's cinematic
25
+ # base at render time (inside the manim subprocess), reads cards from $SCRIBECAST_CARDS, and
26
+ # animates them. Kept as a string so the adapter has no import-time manim dependency.
27
+ _SCENE_SRC = r'''
28
+ import json, os, sys
29
+ sys.path.insert(0, os.environ.get("SCRIBECAST_REPO", "."))
30
+ from manim import (Text, VGroup, FadeIn, FadeOut, Write, UP, DOWN, ORIGIN, LEFT)
31
+ from vqkit import CinematicScene, Palette, Type, Timing
32
+ from vqkit.scene import tex_or_text
33
+
34
+ # Only treat a line as LaTeX when it carries REAL math delimiters ($...$ or a backslash command).
35
+ # Prose like "The integral shrinks" must NOT be compiled as LaTeX (it errors and yields an empty
36
+ # render). When a $...$ span exists we MathTex that span and pass the full line as the plain fallback.
37
+ import re as _re
38
+ _TEX_SPAN = _re.compile(r"\$(.+?)\$|(\\[a-zA-Z]+)")
39
+
40
+
41
+ class ScribecastScene(CinematicScene):
42
+ def story(self):
43
+ cards = json.load(open(os.environ["SCRIBECAST_CARDS"]))
44
+ for idx, card in enumerate(cards):
45
+ title = (card.get("title") or "").strip()
46
+ body = (card.get("body") or "").strip()
47
+ if title:
48
+ self.title_in(title) # cinematic heading: Write + fly to edge
49
+ mobs = []
50
+ y = 1.5
51
+ for line in [l for l in body.split("\n") if l.strip()][:8]:
52
+ line = line.strip()
53
+ m = _TEX_SPAN.search(line)
54
+ if m:
55
+ # real LaTeX present: render the math span via MathTex, prose as plain fallback
56
+ latex = (m.group(1) or m.group(2) or line).strip()
57
+ m_obj = tex_or_text(latex, line[:70], font_size=Type.BODY)
58
+ else:
59
+ # plain prose -> Text (NEVER feed prose to MathTex)
60
+ m_obj = Text(line[:70], font_size=Type.BODY, font=Type.FONT, color=Palette.INK)
61
+ m_obj.move_to([0, y, 0])
62
+ mobs.append(m_obj)
63
+ y -= 0.7
64
+ if mobs:
65
+ grp = VGroup(*mobs)
66
+ # animated reveal (not a static dump): stagger fade-in
67
+ self.play(*[FadeIn(m, shift=UP * 0.2) for m in mobs], run_time=Timing.WRITE)
68
+ self.wait(Timing.BEAT)
69
+ if idx < len(cards) - 1:
70
+ self.play(FadeOut(grp), run_time=Timing.FAST)
71
+ self.wait(Timing.BEAT)
72
+ '''
73
+
74
+
75
+ from ..logging_setup import get_logger
76
+ _log = get_logger(__name__)
77
+
78
+
79
+ class ManimAdapterError(RuntimeError):
80
+ pass
81
+
82
+
83
+ def render_cards_manim(cards: list[tuple[str, str]], out_path: str, *,
84
+ repo_dir: str | None = None, quality: str = "m",
85
+ timeout: int = 900) -> str:
86
+ """Render cards as a cinematic Manim video. Returns out_path.
87
+
88
+ cards: [(title, body)] from scribecast.pipeline.script_from_text.
89
+ quality: manim quality flag: l(480p15) m(720p30) h(1080p60). Default m.
90
+ repo_dir: path to the vidkit/scribecast repo (for `import vqkit`); defaults to the installed
91
+ package location so it works delete-proof.
92
+ """
93
+ try:
94
+ import manim # noqa: F401
95
+ except ImportError as exc:
96
+ raise ManimAdapterError(
97
+ "engine 'manim' needs the manim extra: pip install vidkit[manim]"
98
+ ) from exc
99
+
100
+ # locate the INSTALLED vqkit so the manim subprocess imports it regardless of cwd
101
+ # (delete-proof: this resolves to site-packages when scribecast is wheel-installed, so it
102
+ # works even with the workspace source deleted). Never fall back to a cwd-relative ".".
103
+ if repo_dir is None:
104
+ try:
105
+ import vqkit
106
+ repo_dir = str(Path(vqkit.__file__).resolve().parent.parent)
107
+ except ImportError as exc: # pragma: no cover - defensive: vqkit ships in the wheel
108
+ raise ManimAdapterError(
109
+ "manim engine needs vqkit (ships in the vidkit wheel) but it could not be "
110
+ "imported. Reinstall: pip install vidkit[manim]."
111
+ ) from exc
112
+
113
+ import shutil
114
+ work = Path(tempfile.mkdtemp(prefix="scribecast-manim-"))
115
+ try:
116
+ scene_file = work / "scribecast_scene.py"
117
+ scene_file.write_text(_SCENE_SRC)
118
+ cards_file = work / "cards.json"
119
+ cards_file.write_text(json.dumps([{"title": t, "body": b} for t, b in cards]))
120
+
121
+ fps = {"l": 15, "m": 30, "h": 60, "k": 60}.get(quality, 30)
122
+ env = dict(os.environ, SCRIBECAST_CARDS=str(cards_file), SCRIBECAST_REPO=repo_dir)
123
+ media = work / "media"
124
+ cmd = [sys.executable, "-m", "manim", f"-q{quality}", "--format=mp4", "--fps", str(fps),
125
+ "--media_dir", str(media), str(scene_file), "ScribecastScene"]
126
+ _log.info("manim: rendering %d card(s) q=%s", len(cards), quality)
127
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
128
+ if proc.returncode != 0:
129
+ raise ManimAdapterError(f"manim render failed (exit {proc.returncode}):\n{proc.stderr[-1500:]}")
130
+ # locate the newest mp4 manim produced
131
+ mp4s = list(media.rglob("*.mp4"))
132
+ if not mp4s:
133
+ raise ManimAdapterError("manim exited 0 but produced no mp4")
134
+ newest = max(mp4s, key=lambda p: p.stat().st_mtime)
135
+ # Guard against "exit 0 but empty render" (a scene error manim swallows): a real card
136
+ # video is never ~0s. Probe and fail loudly rather than return a blank video.
137
+ try:
138
+ from vidkit_core.render import ffprobe
139
+ dur = ffprobe(str(newest)).duration
140
+ if dur < 0.5: # pragma: no cover - defensive: only if manim emits a corrupt 0s file
141
+ tail = proc.stderr[-1200:] or proc.stdout[-1200:]
142
+ raise ManimAdapterError(
143
+ f"manim produced a {dur:.2f}s (empty) video — the scene likely errored. "
144
+ f"Last output:\n{tail}"
145
+ )
146
+ except ManimAdapterError: # pragma: no cover - re-raise guard
147
+ raise
148
+ except Exception: # pragma: no cover - ffprobe present in this env
149
+ pass # ffprobe unavailable — fall through to size check
150
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True)
151
+ shutil.copy(str(newest), out_path)
152
+ finally:
153
+ shutil.rmtree(work, ignore_errors=True)
154
+ if not Path(out_path).exists() or Path(out_path).stat().st_size == 0: # pragma: no cover
155
+ raise ManimAdapterError("manim render produced no usable output")
156
+ return out_path
@@ -0,0 +1,114 @@
1
+ """scribecast.engines.remotion_adapter — render a scribecast card script as a Remotion video.
2
+
3
+ Drives the vendored remotion_kit (React + Remotion 4.x) ScribecastCards composition, passing the
4
+ cards as Remotion inputProps. Cinematic primitives (spring entrances, gradient, continuous fade)
5
+ live in the React composition; this adapter just feeds it data and invokes `npx remotion render`.
6
+
7
+ NOTE ON LICENSING: Remotion is free for individuals and companies <= 3 people; larger orgs need a
8
+ paid licence (Remotion License v1). The operator explicitly opted into all three engines, so this
9
+ adapter is wired; it is the operator's responsibility to hold a licence if their org exceeds the
10
+ free tier. The adapter never bypasses or misrepresents licensing.
11
+
12
+ This is the opt-in `--engine remotion` path. It needs Node + the remotion_kit node_modules. The
13
+ default hyperframes (Pillow cards) path stays dependency-light.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+
25
+ from ..logging_setup import get_logger
26
+ _log = get_logger(__name__)
27
+
28
+
29
+ class RemotionAdapterError(RuntimeError):
30
+ pass
31
+
32
+
33
+ def _find_remotion_kit() -> Path | None:
34
+ """Locate the remotion_kit dir. Checks (1) $SCRIBECAST_REMOTION_KIT, (2) alongside the
35
+ installed vidkit package (delete-proof when the kit ships in the wheel data), (3) the dev repo.
36
+ Returns None if not found."""
37
+ cand = []
38
+ env = os.environ.get("SCRIBECAST_REMOTION_KIT")
39
+ if env:
40
+ cand.append(Path(env))
41
+ try:
42
+ import vidkit_core
43
+ repo = Path(vidkit_core.__file__).resolve().parent.parent
44
+ cand.append(repo / "remotion_kit")
45
+ except Exception: # pragma: no cover - vidkit_core importable in this env
46
+ pass
47
+ cand.append(Path.cwd() / "remotion_kit")
48
+ for c in cand:
49
+ if c and (c / "package.json").is_file() and (c / "src" / "Root.tsx").is_file():
50
+ return c
51
+ return None # pragma: no cover - kit present in this env
52
+
53
+
54
+ def render_cards_remotion(cards: list[tuple[str, str]], out_path: str, *,
55
+ per_card_seconds: float = 3.0, fps: int = 60,
56
+ timeout: int = 900) -> str:
57
+ """Render cards as a Remotion video. Returns out_path.
58
+
59
+ fps defaults to 60 to MATCH the remotion_kit composition (theme.ts FPS=60); the per-card frame
60
+ count must be computed at the composition's fps or the wall-clock duration is wrong.
61
+
62
+ Requires Node (`npx`) + the remotion_kit with node_modules installed. Raises a clear
63
+ RemotionAdapterError naming the missing piece otherwise (never a silent fallback).
64
+ """
65
+ if shutil.which("npx") is None:
66
+ raise RemotionAdapterError(
67
+ "engine 'remotion' needs Node.js (npx) on PATH. Install Node, or choose "
68
+ "--engine hyperframes / --engine manim."
69
+ )
70
+ kit = _find_remotion_kit()
71
+ if kit is None:
72
+ raise RemotionAdapterError(
73
+ "remotion_kit not found. Set $SCRIBECAST_REMOTION_KIT to the kit dir, or use another "
74
+ "engine. (The kit ships in the vidkit repo; a bare wheel install may not include it.)"
75
+ )
76
+ if not (kit / "node_modules").is_dir():
77
+ raise RemotionAdapterError(
78
+ f"remotion_kit at {kit} has no node_modules — run `npm install` there first "
79
+ "(Remotion is license-gated for orgs > 3; ensure you hold a licence if required)."
80
+ )
81
+
82
+ per_card_frames = max(1, round(per_card_seconds * fps))
83
+ props = {"cards": [{"title": t, "body": b} for t, b in cards], "perCard": per_card_frames}
84
+ work = Path(tempfile.mkdtemp(prefix="scribecast-remotion-"))
85
+ out_abs = str(Path(out_path).resolve())
86
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True)
87
+ try:
88
+ props_file = work / "props.json"
89
+ props_file.write_text(json.dumps(props))
90
+ # remotion render <entry> <composition-id> <output> --props=<file>
91
+ cmd = ["npx", "remotion", "render", "src/index.ts", "ScribecastCards", out_abs,
92
+ f"--props={props_file}"]
93
+ _log.info("remotion: rendering %d card(s) via kit %s", len(cards), kit)
94
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=str(kit))
95
+ finally:
96
+ shutil.rmtree(work, ignore_errors=True)
97
+ if proc.returncode != 0:
98
+ raise RemotionAdapterError(
99
+ f"remotion render failed (exit {proc.returncode}):\n{proc.stderr[-1500:]}"
100
+ )
101
+ if not Path(out_path).exists() or Path(out_path).stat().st_size == 0: # pragma: no cover
102
+ raise RemotionAdapterError("remotion render produced no output")
103
+ # guard the empty-but-exit-0 case
104
+ try:
105
+ from vidkit_core.render import ffprobe
106
+ if ffprobe(out_path).duration < 0.5: # pragma: no cover - defensive: corrupt 0s output
107
+ raise RemotionAdapterError(
108
+ f"remotion produced a near-empty video — composition likely errored.\n{proc.stderr[-1000:]}"
109
+ )
110
+ except RemotionAdapterError: # pragma: no cover - re-raise guard
111
+ raise
112
+ except Exception: # pragma: no cover - ffprobe present
113
+ pass
114
+ return out_path
@@ -0,0 +1,45 @@
1
+ """scribecast.logging_setup — package logging.
2
+
3
+ Library best-practice: the package logger has a NullHandler so importing scribecast produces NO
4
+ output unless the application opts in. `configure_logging(level)` (called by the CLI and MCP
5
+ server) attaches a stderr handler. Every important path logs through `get_logger(__name__)`:
6
+ resolution, engine selection, each render stage, narration, mux, MCP tool dispatch, and errors.
7
+
8
+ Env: SCRIBECAST_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR) overrides the default when configure_logging
9
+ is called without an explicit level.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+
16
+ _ROOT = "scribecast"
17
+
18
+ # Attach a NullHandler once so library use never emits "No handler" warnings or unwanted output.
19
+ logging.getLogger(_ROOT).addHandler(logging.NullHandler())
20
+
21
+
22
+ def get_logger(name: str) -> logging.Logger:
23
+ """Return a child logger under the scribecast namespace (e.g. scribecast.pipeline)."""
24
+ if name == "__main__" or not name.startswith(_ROOT):
25
+ name = f"{_ROOT}.{name.rsplit('.', 1)[-1]}"
26
+ return logging.getLogger(name)
27
+
28
+
29
+ def configure_logging(level: str | int | None = None, *, stream=None) -> None:
30
+ """Attach a stderr StreamHandler to the scribecast logger (idempotent). Called by the CLI/MCP
31
+ entry points. `level` falls back to $SCRIBECAST_LOG_LEVEL then WARNING."""
32
+ logger = logging.getLogger(_ROOT)
33
+ lvl: str | int = level if level is not None else os.environ.get("SCRIBECAST_LOG_LEVEL", "WARNING")
34
+ if isinstance(lvl, str):
35
+ lvl = getattr(logging, lvl.upper(), logging.WARNING)
36
+ logger.setLevel(lvl)
37
+ # don't double-add a real handler
38
+ if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.NullHandler)
39
+ for h in logger.handlers):
40
+ import sys
41
+ h = logging.StreamHandler(stream or sys.stderr)
42
+ h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s",
43
+ datefmt="%H:%M:%S"))
44
+ logger.addHandler(h)
45
+ logger.propagate = False