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 +32 -0
- scribecast/cli.py +145 -0
- scribecast/config.py +140 -0
- scribecast/engines/__init__.py +19 -0
- scribecast/engines/manim_adapter.py +156 -0
- scribecast/engines/remotion_adapter.py +114 -0
- scribecast/logging_setup.py +45 -0
- scribecast/mcp.py +190 -0
- scribecast/pipeline.py +264 -0
- scribecast/resolver.py +171 -0
- scribecast/selector.py +113 -0
- scribecast-0.1.0.dist-info/METADATA +155 -0
- scribecast-0.1.0.dist-info/RECORD +35 -0
- scribecast-0.1.0.dist-info/WHEEL +5 -0
- scribecast-0.1.0.dist-info/entry_points.txt +3 -0
- scribecast-0.1.0.dist-info/top_level.txt +3 -0
- vidkit_core/__init__.py +21 -0
- vidkit_core/audio.py +139 -0
- vidkit_core/cli.py +133 -0
- vidkit_core/export.py +126 -0
- vidkit_core/layout.py +110 -0
- vidkit_core/phash.py +89 -0
- vidkit_core/publish.py +185 -0
- vidkit_core/render.py +68 -0
- vidkit_core/theme.py +72 -0
- vqkit/__init__.py +36 -0
- vqkit/audio.py +132 -0
- vqkit/export.py +126 -0
- vqkit/layout.py +140 -0
- vqkit/phash.py +84 -0
- vqkit/publish.py +102 -0
- vqkit/render.py +92 -0
- vqkit/scene.py +190 -0
- vqkit/scene3d.py +100 -0
- vqkit/theme.py +72 -0
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
|