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.
- scribecast-0.1.0/PKG-INFO +155 -0
- scribecast-0.1.0/README-scribecast.md +121 -0
- scribecast-0.1.0/pyproject.toml +52 -0
- scribecast-0.1.0/scribecast/__init__.py +32 -0
- scribecast-0.1.0/scribecast/cli.py +145 -0
- scribecast-0.1.0/scribecast/config.py +140 -0
- scribecast-0.1.0/scribecast/engines/__init__.py +19 -0
- scribecast-0.1.0/scribecast/engines/manim_adapter.py +156 -0
- scribecast-0.1.0/scribecast/engines/remotion_adapter.py +114 -0
- scribecast-0.1.0/scribecast/logging_setup.py +45 -0
- scribecast-0.1.0/scribecast/mcp.py +190 -0
- scribecast-0.1.0/scribecast/pipeline.py +264 -0
- scribecast-0.1.0/scribecast/resolver.py +171 -0
- scribecast-0.1.0/scribecast/selector.py +113 -0
- scribecast-0.1.0/scribecast.egg-info/PKG-INFO +155 -0
- scribecast-0.1.0/scribecast.egg-info/SOURCES.txt +58 -0
- scribecast-0.1.0/scribecast.egg-info/dependency_links.txt +1 -0
- scribecast-0.1.0/scribecast.egg-info/entry_points.txt +3 -0
- scribecast-0.1.0/scribecast.egg-info/requires.txt +19 -0
- scribecast-0.1.0/scribecast.egg-info/top_level.txt +3 -0
- scribecast-0.1.0/setup.cfg +4 -0
- scribecast-0.1.0/tests/test_edge_cases.py +90 -0
- scribecast-0.1.0/tests/test_l1_l2_smoke.py +136 -0
- scribecast-0.1.0/tests/test_l3_audio.py +58 -0
- scribecast-0.1.0/tests/test_l4_phash.py +68 -0
- scribecast-0.1.0/tests/test_mux_no_truncation.py +84 -0
- scribecast-0.1.0/tests/test_publish_safety.py +53 -0
- scribecast-0.1.0/tests/test_scene3d_fit.py +98 -0
- scribecast-0.1.0/tests/test_scribecast.py +223 -0
- scribecast-0.1.0/tests/test_scribecast_coverage.py +218 -0
- scribecast-0.1.0/tests/test_scribecast_coverage2.py +108 -0
- scribecast-0.1.0/tests/test_scribecast_coverage3.py +79 -0
- scribecast-0.1.0/tests/test_scribecast_coverage4.py +79 -0
- scribecast-0.1.0/tests/test_scribecast_coverage5.py +71 -0
- scribecast-0.1.0/tests/test_scribecast_coverage6.py +45 -0
- scribecast-0.1.0/tests/test_scribecast_coverage7.py +61 -0
- scribecast-0.1.0/tests/test_scribecast_e2e.py +77 -0
- scribecast-0.1.0/tests/test_scribecast_hardening.py +94 -0
- scribecast-0.1.0/tests/test_scribecast_integration.py +140 -0
- scribecast-0.1.0/tests/test_scribecast_manim.py +42 -0
- scribecast-0.1.0/tests/test_scribecast_remotion.py +44 -0
- scribecast-0.1.0/vidkit_core/__init__.py +21 -0
- scribecast-0.1.0/vidkit_core/audio.py +139 -0
- scribecast-0.1.0/vidkit_core/cli.py +133 -0
- scribecast-0.1.0/vidkit_core/export.py +126 -0
- scribecast-0.1.0/vidkit_core/layout.py +110 -0
- scribecast-0.1.0/vidkit_core/phash.py +89 -0
- scribecast-0.1.0/vidkit_core/publish.py +185 -0
- scribecast-0.1.0/vidkit_core/render.py +68 -0
- scribecast-0.1.0/vidkit_core/theme.py +72 -0
- scribecast-0.1.0/vqkit/__init__.py +36 -0
- scribecast-0.1.0/vqkit/audio.py +132 -0
- scribecast-0.1.0/vqkit/export.py +126 -0
- scribecast-0.1.0/vqkit/layout.py +140 -0
- scribecast-0.1.0/vqkit/phash.py +84 -0
- scribecast-0.1.0/vqkit/publish.py +102 -0
- scribecast-0.1.0/vqkit/render.py +92 -0
- scribecast-0.1.0/vqkit/scene.py +190 -0
- scribecast-0.1.0/vqkit/scene3d.py +100 -0
- 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: ...
|