scribecast 0.1.0__tar.gz

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. scribecast-0.1.0/PKG-INFO +155 -0
  2. scribecast-0.1.0/README-scribecast.md +121 -0
  3. scribecast-0.1.0/pyproject.toml +52 -0
  4. scribecast-0.1.0/scribecast/__init__.py +32 -0
  5. scribecast-0.1.0/scribecast/cli.py +145 -0
  6. scribecast-0.1.0/scribecast/config.py +140 -0
  7. scribecast-0.1.0/scribecast/engines/__init__.py +19 -0
  8. scribecast-0.1.0/scribecast/engines/manim_adapter.py +156 -0
  9. scribecast-0.1.0/scribecast/engines/remotion_adapter.py +114 -0
  10. scribecast-0.1.0/scribecast/logging_setup.py +45 -0
  11. scribecast-0.1.0/scribecast/mcp.py +190 -0
  12. scribecast-0.1.0/scribecast/pipeline.py +264 -0
  13. scribecast-0.1.0/scribecast/resolver.py +171 -0
  14. scribecast-0.1.0/scribecast/selector.py +113 -0
  15. scribecast-0.1.0/scribecast.egg-info/PKG-INFO +155 -0
  16. scribecast-0.1.0/scribecast.egg-info/SOURCES.txt +58 -0
  17. scribecast-0.1.0/scribecast.egg-info/dependency_links.txt +1 -0
  18. scribecast-0.1.0/scribecast.egg-info/entry_points.txt +3 -0
  19. scribecast-0.1.0/scribecast.egg-info/requires.txt +19 -0
  20. scribecast-0.1.0/scribecast.egg-info/top_level.txt +3 -0
  21. scribecast-0.1.0/setup.cfg +4 -0
  22. scribecast-0.1.0/tests/test_edge_cases.py +90 -0
  23. scribecast-0.1.0/tests/test_l1_l2_smoke.py +136 -0
  24. scribecast-0.1.0/tests/test_l3_audio.py +58 -0
  25. scribecast-0.1.0/tests/test_l4_phash.py +68 -0
  26. scribecast-0.1.0/tests/test_mux_no_truncation.py +84 -0
  27. scribecast-0.1.0/tests/test_publish_safety.py +53 -0
  28. scribecast-0.1.0/tests/test_scene3d_fit.py +98 -0
  29. scribecast-0.1.0/tests/test_scribecast.py +223 -0
  30. scribecast-0.1.0/tests/test_scribecast_coverage.py +218 -0
  31. scribecast-0.1.0/tests/test_scribecast_coverage2.py +108 -0
  32. scribecast-0.1.0/tests/test_scribecast_coverage3.py +79 -0
  33. scribecast-0.1.0/tests/test_scribecast_coverage4.py +79 -0
  34. scribecast-0.1.0/tests/test_scribecast_coverage5.py +71 -0
  35. scribecast-0.1.0/tests/test_scribecast_coverage6.py +45 -0
  36. scribecast-0.1.0/tests/test_scribecast_coverage7.py +61 -0
  37. scribecast-0.1.0/tests/test_scribecast_e2e.py +77 -0
  38. scribecast-0.1.0/tests/test_scribecast_hardening.py +94 -0
  39. scribecast-0.1.0/tests/test_scribecast_integration.py +140 -0
  40. scribecast-0.1.0/tests/test_scribecast_manim.py +42 -0
  41. scribecast-0.1.0/tests/test_scribecast_remotion.py +44 -0
  42. scribecast-0.1.0/vidkit_core/__init__.py +21 -0
  43. scribecast-0.1.0/vidkit_core/audio.py +139 -0
  44. scribecast-0.1.0/vidkit_core/cli.py +133 -0
  45. scribecast-0.1.0/vidkit_core/export.py +126 -0
  46. scribecast-0.1.0/vidkit_core/layout.py +110 -0
  47. scribecast-0.1.0/vidkit_core/phash.py +89 -0
  48. scribecast-0.1.0/vidkit_core/publish.py +185 -0
  49. scribecast-0.1.0/vidkit_core/render.py +68 -0
  50. scribecast-0.1.0/vidkit_core/theme.py +72 -0
  51. scribecast-0.1.0/vqkit/__init__.py +36 -0
  52. scribecast-0.1.0/vqkit/audio.py +132 -0
  53. scribecast-0.1.0/vqkit/export.py +126 -0
  54. scribecast-0.1.0/vqkit/layout.py +140 -0
  55. scribecast-0.1.0/vqkit/phash.py +84 -0
  56. scribecast-0.1.0/vqkit/publish.py +102 -0
  57. scribecast-0.1.0/vqkit/render.py +92 -0
  58. scribecast-0.1.0/vqkit/scene.py +190 -0
  59. scribecast-0.1.0/vqkit/scene3d.py +100 -0
  60. scribecast-0.1.0/vqkit/theme.py +72 -0
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: scribecast
3
+ Version: 0.1.0
4
+ Summary: Turn any note into a narrated video. scribecast façade + vidkit engine (HyperFrames / Manim / Remotion), edge-tts narration, ffmpeg mux. CLI + library + MCP server.
5
+ Author: Prax
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/praxstack/vidkit
8
+ Project-URL: Repository, https://github.com/praxstack/vidkit
9
+ Project-URL: Issues, https://github.com/praxstack/vidkit/issues
10
+ Keywords: video,notes,narration,edge-tts,manim,remotion,hyperframes,ffmpeg,mcp,obsidian
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Topic :: Multimedia :: Video
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Provides-Extra: narrate
20
+ Requires-Dist: edge-tts>=6.1; extra == "narrate"
21
+ Provides-Extra: manim
22
+ Requires-Dist: manim>=0.18; extra == "manim"
23
+ Requires-Dist: numpy>=1.24; extra == "manim"
24
+ Requires-Dist: pillow>=10.0; extra == "manim"
25
+ Provides-Extra: all
26
+ Requires-Dist: edge-tts>=6.1; extra == "all"
27
+ Requires-Dist: manim>=0.18; extra == "all"
28
+ Requires-Dist: numpy>=1.24; extra == "all"
29
+ Requires-Dist: pillow>=10.0; extra == "all"
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest>=7; extra == "test"
32
+ Requires-Dist: pytest-xdist>=3; extra == "test"
33
+ Requires-Dist: coverage>=7; extra == "test"
34
+
35
+ # scribecast
36
+
37
+ Turn any note — from gbrain, PraxVault, ksum, a file, stdin, or several **merged** — into a
38
+ **narrated video**. scribecast is the façade; **vidkit** is the engine. Both ship in ONE
39
+ pip-installable, delete-proof wheel (this repo).
40
+
41
+ ```
42
+ note ref ──► resolver ──► script (cards) ──► render (engine) ──► narrate (edge-tts) ──► mux ──► mp4
43
+ gbrain/vault/ hyperframes/ (vidkit, video
44
+ ksum/file/stdin/merge manim/remotion duration authoritative)
45
+ ```
46
+
47
+ scribecast **imports vidkit** (`from vidkit_core import ...`) — it never shells out to a vendored
48
+ copy. Note sources are read by calling the existing local CLIs (gbrain/ksum/praxvault-ask) as
49
+ subprocess *for note resolution only*.
50
+
51
+ ## Install (delete-proof wheel — NOT editable)
52
+ ```bash
53
+ pip install . # ships vidkit_core + vqkit + scribecast in one wheel
54
+ pip install .[narrate] # + edge-tts narration
55
+ pip install .[manim] # + manim engine (heavy, opt-in)
56
+ pip install .[all] # narrate + manim
57
+ pip install .[test] # pytest + pytest-xdist + coverage (for the test suite)
58
+ ```
59
+ After install you can delete this source dir — the `scribecast` and `vidkit` CLIs keep working
60
+ (verified by the delete-proof scenario; see `scenarios/run_scenarios.py::s10_delete_proof`).
61
+ The Remotion engine additionally needs Node.js + the `remotion_kit` (with `npm install` run).
62
+
63
+ ## Use — three interfaces, one core
64
+
65
+ **CLI (humans, Raycast, scripts):**
66
+ ```bash
67
+ scribecast render file:notes.md -o out.mp4 # render a file note
68
+ scribecast render gbrain:two-brain-architecture # render a gbrain page
69
+ scribecast render file:a.md --merge file:b.md file:c.md # merge notes into one video
70
+ scribecast render file:n.md --engine manim --voice en-GB-RyanNeural
71
+ scribecast render file:n.md --engine remotion # 1080p React/Remotion engine
72
+ scribecast recommend file:n.md # advisory engine pick (user always decides)
73
+ scribecast sources # list note sources + availability
74
+ scribecast config # effective config + where each value came from
75
+ scribecast -v render file:n.md # -v = INFO logs, -vv = DEBUG (to stderr)
76
+ ```
77
+
78
+ **Library (Python):**
79
+ ```python
80
+ from scribecast import render_note, resolve, recommend_engine
81
+ res = render_note("file:notes.md", out="out.mp4") # -> RenderResult(output, engine, duration, ...)
82
+ text = resolve(["gbrain:slug-a", "file:b.md"]) # merge
83
+ rec = recommend_engine(text) # advisory
84
+ ```
85
+
86
+ **MCP (any agent — Claude/Hermes/Cursor/workers):**
87
+ ```bash
88
+ python -m scribecast.mcp # stdio JSON-RPC: render_note_video, recommend_engine, list_sources, list_voices
89
+ ```
90
+ Zero mcp-sdk dependency (minimal stdio JSON-RPC) so it installs with the base wheel. Logs go to
91
+ stderr (stdout is the JSON-RPC transport).
92
+
93
+ **Obsidian plugin** (`obsidian-plugin/`): render the *current note* from inside Obsidian. It is a
94
+ thin consumer of the installed `scribecast` CLI (no vendoring). Commands: "Render active note to
95
+ video", "Recommend engine for active note"; settings for binary path / engine / voice / output dir.
96
+ Build: `cd obsidian-plugin && npm install && npm run build`, then copy `manifest.json` + `main.js`
97
+ into `<vault>/.obsidian/plugins/scribecast/`.
98
+
99
+ ## Logging
100
+ The package uses the stdlib `logging` framework with library-correct discipline: importing
101
+ scribecast is silent (a `NullHandler` is attached), so it never pollutes a host application's
102
+ output. Logging is enabled by the entry points:
103
+ - CLI: `-v` (INFO) / `-vv` (DEBUG) — records go to stderr.
104
+ - MCP: always configured to stderr on server start.
105
+ - env: `SCRIBECAST_LOG_LEVEL=DEBUG|INFO|WARNING|ERROR`.
106
+ Every important path logs: note resolution (+ subprocess run/timeout/failure), engine selection,
107
+ each render stage (script → render → narrate → mux → probe → done), and MCP tool dispatch + errors.
108
+
109
+ ## Configurable (flag > env > config-file > default)
110
+ - config file: `$SCRIBECAST_CONFIG` or `~/.scribecast/config.toml`
111
+ - env: `SCRIBECAST_ENGINE`, `SCRIBECAST_VOICE`, `SCRIBECAST_SOURCE`, `SCRIBECAST_OUT_DIR`, ...
112
+ - flags: `--engine --voice --source -o ...`
113
+ - `scribecast config` shows the effective value AND which layer set it.
114
+
115
+ ## Engine selection — recommend, never decide
116
+ The selector RECOMMENDS one of `hyperframes | manim | remotion` from note content (math/LaTeX →
117
+ manim, web/React → remotion, else hyperframes). **The user's explicit `--engine` always wins** —
118
+ the recommendation is advisory and is always shown for transparency. (Heuristic today; an LLM
119
+ recommender can be added without changing the user-wins contract.)
120
+
121
+ ## Default engine: `hyperframes` (cards)
122
+ The default renderer builds titled text cards with Pillow → an ffmpeg image-sequence mp4 → narration
123
+ → mux. Needs only Pillow + ffmpeg (no browser, no Manim). All three engines work:
124
+ - **hyperframes** (default, dep-light): Pillow text-cards → ffmpeg, 1280×720.
125
+ - **manim** (opt-in `[manim]`): cinematic via `vqkit.CinematicScene` (MovingCameraScene + MathTex), 1280×720. Math/LaTeX spans render as real equations; prose renders as Text.
126
+ - **remotion** (opt-in): React/Remotion `ScribecastCards` composition (spring entrances, gradient), 1920×1080. Needs Node + `remotion_kit`. License-gated for orgs > 3 (Remotion License v1).
127
+
128
+ A forced `--engine` that lacks its dependency raises a clear error — never a silent fallback.
129
+
130
+ ## Sources
131
+ | source | how | tool |
132
+ |---|---|---|
133
+ | `file` | read a local path | — |
134
+ | `stdin` | piped text | — |
135
+ | `gbrain` | `gbrain get <slug>` | gbrain |
136
+ | `vault` | `praxvault-ask <query>` | praxvault-ask |
137
+ | `ksum` | `ksum <url\|file> --no-save` | ksum |
138
+ | `openmemory` | `openmemory get <id>` | openmemory (optional) |
139
+ | `merge` | multiple refs → one video | — |
140
+
141
+ A missing source tool raises a clear `ResolverError` (never silent-empty).
142
+
143
+ ## Tests
144
+ ```bash
145
+ pytest -q # 126 fast unit tests in ~1s (real-render tests deselected)
146
+ pytest -q --run-slow # full 163 tests incl. real manim/remotion/ffmpeg renders
147
+ pytest -q --run-slow -n auto # full set in parallel (pytest-xdist)
148
+ coverage run --source=scribecast -m pytest --run-slow && coverage report # 100% (692/692)
149
+ python scenarios/run_scenarios.py # 10 realistic end-to-end scenarios (pass/fail + evidence)
150
+ python scenarios/benchmark.py # per-engine timing baseline -> scenarios/baseline.json
151
+ ```
152
+ Real-render tests are auto-marked `slow` (see `tests/conftest.py`) and deselected by default for a
153
+ fast dev loop; `--run-slow` runs them and the coverage gate enforces 100%. The mux-truncation
154
+ regression test (`tests/test_mux_no_truncation.py`) proves a short narration never truncates a
155
+ longer video — the bug this project fixed.
@@ -0,0 +1,121 @@
1
+ # scribecast
2
+
3
+ Turn any note — from gbrain, PraxVault, ksum, a file, stdin, or several **merged** — into a
4
+ **narrated video**. scribecast is the façade; **vidkit** is the engine. Both ship in ONE
5
+ pip-installable, delete-proof wheel (this repo).
6
+
7
+ ```
8
+ note ref ──► resolver ──► script (cards) ──► render (engine) ──► narrate (edge-tts) ──► mux ──► mp4
9
+ gbrain/vault/ hyperframes/ (vidkit, video
10
+ ksum/file/stdin/merge manim/remotion duration authoritative)
11
+ ```
12
+
13
+ scribecast **imports vidkit** (`from vidkit_core import ...`) — it never shells out to a vendored
14
+ copy. Note sources are read by calling the existing local CLIs (gbrain/ksum/praxvault-ask) as
15
+ subprocess *for note resolution only*.
16
+
17
+ ## Install (delete-proof wheel — NOT editable)
18
+ ```bash
19
+ pip install . # ships vidkit_core + vqkit + scribecast in one wheel
20
+ pip install .[narrate] # + edge-tts narration
21
+ pip install .[manim] # + manim engine (heavy, opt-in)
22
+ pip install .[all] # narrate + manim
23
+ pip install .[test] # pytest + pytest-xdist + coverage (for the test suite)
24
+ ```
25
+ After install you can delete this source dir — the `scribecast` and `vidkit` CLIs keep working
26
+ (verified by the delete-proof scenario; see `scenarios/run_scenarios.py::s10_delete_proof`).
27
+ The Remotion engine additionally needs Node.js + the `remotion_kit` (with `npm install` run).
28
+
29
+ ## Use — three interfaces, one core
30
+
31
+ **CLI (humans, Raycast, scripts):**
32
+ ```bash
33
+ scribecast render file:notes.md -o out.mp4 # render a file note
34
+ scribecast render gbrain:two-brain-architecture # render a gbrain page
35
+ scribecast render file:a.md --merge file:b.md file:c.md # merge notes into one video
36
+ scribecast render file:n.md --engine manim --voice en-GB-RyanNeural
37
+ scribecast render file:n.md --engine remotion # 1080p React/Remotion engine
38
+ scribecast recommend file:n.md # advisory engine pick (user always decides)
39
+ scribecast sources # list note sources + availability
40
+ scribecast config # effective config + where each value came from
41
+ scribecast -v render file:n.md # -v = INFO logs, -vv = DEBUG (to stderr)
42
+ ```
43
+
44
+ **Library (Python):**
45
+ ```python
46
+ from scribecast import render_note, resolve, recommend_engine
47
+ res = render_note("file:notes.md", out="out.mp4") # -> RenderResult(output, engine, duration, ...)
48
+ text = resolve(["gbrain:slug-a", "file:b.md"]) # merge
49
+ rec = recommend_engine(text) # advisory
50
+ ```
51
+
52
+ **MCP (any agent — Claude/Hermes/Cursor/workers):**
53
+ ```bash
54
+ python -m scribecast.mcp # stdio JSON-RPC: render_note_video, recommend_engine, list_sources, list_voices
55
+ ```
56
+ Zero mcp-sdk dependency (minimal stdio JSON-RPC) so it installs with the base wheel. Logs go to
57
+ stderr (stdout is the JSON-RPC transport).
58
+
59
+ **Obsidian plugin** (`obsidian-plugin/`): render the *current note* from inside Obsidian. It is a
60
+ thin consumer of the installed `scribecast` CLI (no vendoring). Commands: "Render active note to
61
+ video", "Recommend engine for active note"; settings for binary path / engine / voice / output dir.
62
+ Build: `cd obsidian-plugin && npm install && npm run build`, then copy `manifest.json` + `main.js`
63
+ into `<vault>/.obsidian/plugins/scribecast/`.
64
+
65
+ ## Logging
66
+ The package uses the stdlib `logging` framework with library-correct discipline: importing
67
+ scribecast is silent (a `NullHandler` is attached), so it never pollutes a host application's
68
+ output. Logging is enabled by the entry points:
69
+ - CLI: `-v` (INFO) / `-vv` (DEBUG) — records go to stderr.
70
+ - MCP: always configured to stderr on server start.
71
+ - env: `SCRIBECAST_LOG_LEVEL=DEBUG|INFO|WARNING|ERROR`.
72
+ Every important path logs: note resolution (+ subprocess run/timeout/failure), engine selection,
73
+ each render stage (script → render → narrate → mux → probe → done), and MCP tool dispatch + errors.
74
+
75
+ ## Configurable (flag > env > config-file > default)
76
+ - config file: `$SCRIBECAST_CONFIG` or `~/.scribecast/config.toml`
77
+ - env: `SCRIBECAST_ENGINE`, `SCRIBECAST_VOICE`, `SCRIBECAST_SOURCE`, `SCRIBECAST_OUT_DIR`, ...
78
+ - flags: `--engine --voice --source -o ...`
79
+ - `scribecast config` shows the effective value AND which layer set it.
80
+
81
+ ## Engine selection — recommend, never decide
82
+ The selector RECOMMENDS one of `hyperframes | manim | remotion` from note content (math/LaTeX →
83
+ manim, web/React → remotion, else hyperframes). **The user's explicit `--engine` always wins** —
84
+ the recommendation is advisory and is always shown for transparency. (Heuristic today; an LLM
85
+ recommender can be added without changing the user-wins contract.)
86
+
87
+ ## Default engine: `hyperframes` (cards)
88
+ The default renderer builds titled text cards with Pillow → an ffmpeg image-sequence mp4 → narration
89
+ → mux. Needs only Pillow + ffmpeg (no browser, no Manim). All three engines work:
90
+ - **hyperframes** (default, dep-light): Pillow text-cards → ffmpeg, 1280×720.
91
+ - **manim** (opt-in `[manim]`): cinematic via `vqkit.CinematicScene` (MovingCameraScene + MathTex), 1280×720. Math/LaTeX spans render as real equations; prose renders as Text.
92
+ - **remotion** (opt-in): React/Remotion `ScribecastCards` composition (spring entrances, gradient), 1920×1080. Needs Node + `remotion_kit`. License-gated for orgs > 3 (Remotion License v1).
93
+
94
+ A forced `--engine` that lacks its dependency raises a clear error — never a silent fallback.
95
+
96
+ ## Sources
97
+ | source | how | tool |
98
+ |---|---|---|
99
+ | `file` | read a local path | — |
100
+ | `stdin` | piped text | — |
101
+ | `gbrain` | `gbrain get <slug>` | gbrain |
102
+ | `vault` | `praxvault-ask <query>` | praxvault-ask |
103
+ | `ksum` | `ksum <url\|file> --no-save` | ksum |
104
+ | `openmemory` | `openmemory get <id>` | openmemory (optional) |
105
+ | `merge` | multiple refs → one video | — |
106
+
107
+ A missing source tool raises a clear `ResolverError` (never silent-empty).
108
+
109
+ ## Tests
110
+ ```bash
111
+ pytest -q # 126 fast unit tests in ~1s (real-render tests deselected)
112
+ pytest -q --run-slow # full 163 tests incl. real manim/remotion/ffmpeg renders
113
+ pytest -q --run-slow -n auto # full set in parallel (pytest-xdist)
114
+ coverage run --source=scribecast -m pytest --run-slow && coverage report # 100% (692/692)
115
+ python scenarios/run_scenarios.py # 10 realistic end-to-end scenarios (pass/fail + evidence)
116
+ python scenarios/benchmark.py # per-engine timing baseline -> scenarios/baseline.json
117
+ ```
118
+ Real-render tests are auto-marked `slow` (see `tests/conftest.py`) and deselected by default for a
119
+ fast dev loop; `--run-slow` runs them and the coverage gate enforces 100%. The mux-truncation
120
+ regression test (`tests/test_mux_no_truncation.py`) proves a short narration never truncates a
121
+ longer video — the bug this project fixed.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scribecast"
7
+ version = "0.1.0"
8
+ description = "Turn any note into a narrated video. scribecast façade + vidkit engine (HyperFrames / Manim / Remotion), edge-tts narration, ffmpeg mux. CLI + library + MCP server."
9
+ readme = "README-scribecast.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Prax" }]
13
+ keywords = ["video", "notes", "narration", "edge-tts", "manim", "remotion", "hyperframes", "ffmpeg", "mcp", "obsidian"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Topic :: Multimedia :: Video",
21
+ ]
22
+ # vidkit_core is stdlib-only at import time (ffmpeg/ffprobe are external binaries called via
23
+ # subprocess). edge-tts is lazy-imported inside synth_voiceover, so narration is an opt-in extra.
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/praxstack/vidkit"
28
+ Repository = "https://github.com/praxstack/vidkit"
29
+ Issues = "https://github.com/praxstack/vidkit/issues"
30
+
31
+ [project.optional-dependencies]
32
+ # Narration (edge-tts text-to-speech). Lazy-imported; install when you need voiceover.
33
+ narrate = ["edge-tts>=6.1"]
34
+ # Manim engine (vqkit). Heavy; opt-in per the multi-engine design.
35
+ manim = ["manim>=0.18", "numpy>=1.24", "pillow>=10.0"]
36
+ # Everything.
37
+ all = ["edge-tts>=6.1", "manim>=0.18", "numpy>=1.24", "pillow>=10.0"]
38
+ test = ["pytest>=7", "pytest-xdist>=3", "coverage>=7"]
39
+
40
+ [project.scripts]
41
+ vidkit = "vidkit_core.cli:main"
42
+ scribecast = "scribecast.cli:main"
43
+
44
+ [tool.setuptools]
45
+ # One wheel ships the renderer-agnostic core + the Manim engine package + the scribecast façade.
46
+ # Asset dirs (hyperframes_kit, remotion_kit, theme, scenes) are NOT Python packages.
47
+ packages = ["vidkit_core", "vqkit", "scribecast", "scribecast.engines"]
48
+
49
+ [tool.pytest.ini_options]
50
+ # Let pytest import vidkit_core/vqkit from the repo root without an install.
51
+ pythonpath = ["."]
52
+ testpaths = ["tests", "tests_shared"]
@@ -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)
@@ -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())
@@ -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: ...