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/selector.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""scribecast.selector — recommend a render engine from note context, but the USER always wins.
|
|
2
|
+
|
|
3
|
+
The recommendation is a transparent heuristic (no hard LLM dependency): it scores the note text
|
|
4
|
+
for math/formula/code density (-> manim), web/React/interaction language (-> remotion), and
|
|
5
|
+
defaults to hyperframes (fast, clean HTML/CSS) otherwise. An optional LLM recommender can be
|
|
6
|
+
plugged in later, but the heuristic is always available.
|
|
7
|
+
|
|
8
|
+
USER-WINS CONTRACT (AC7): if the user supplies an explicit engine choice, choose() returns it
|
|
9
|
+
unchanged — the recommendation is advisory only and never overrides an explicit choice.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .logging_setup import get_logger
|
|
17
|
+
_log = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
ENGINES = ("hyperframes", "manim", "remotion")
|
|
20
|
+
DEFAULT_ENGINE = "hyperframes"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Recommendation:
|
|
25
|
+
engine: str
|
|
26
|
+
reason: str
|
|
27
|
+
scores: dict[str, int]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Signal patterns (case-insensitive, counted as weighted hits).
|
|
31
|
+
# STRONG math signals only — bare prose words like "proof" or "graph" are too ambiguous and
|
|
32
|
+
# caused false manim recommendations on plain notes. Require LaTeX/symbols/explicit math terms.
|
|
33
|
+
_MATH = re.compile(
|
|
34
|
+
r"(\\frac|\\sum|\\int|\\sqrt|\\alpha|\\beta|\\theta|\$\$|"
|
|
35
|
+
r"\b(eigenvalue|eigenvector|derivative|integral|theorem|matrix multiplication|"
|
|
36
|
+
r"big-?o notation|time complexity|gradient descent|linear algebra|calculus)\b)", re.I)
|
|
37
|
+
# code fences / clear code constructs (not bare punctuation that prose also uses)
|
|
38
|
+
_CODE = re.compile(r"```|\bdef \w+\(|\bclass \w+[:(]|\bfunction \w+\(|=>\s|\bimport \w+", re.M)
|
|
39
|
+
_WEB = re.compile(
|
|
40
|
+
r"\b(react|component|usestate|useeffect|tailwind|css animation|transition|"
|
|
41
|
+
r"parallax|landing page|web app|frontend|scroll-?triggered)\b", re.I)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def recommend_engine(text: str) -> Recommendation:
|
|
45
|
+
"""Heuristic engine recommendation from note text. Always returns one of ENGINES."""
|
|
46
|
+
t = text or ""
|
|
47
|
+
math_hits = len(_MATH.findall(t)) + len(_CODE.findall(t))
|
|
48
|
+
web_hits = len(_WEB.findall(t))
|
|
49
|
+
|
|
50
|
+
scores = {
|
|
51
|
+
"manim": math_hits,
|
|
52
|
+
"remotion": web_hits,
|
|
53
|
+
"hyperframes": 1, # baseline bias toward the fast clean default
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Decide: strongest signal wins; ties / no-signal -> default.
|
|
57
|
+
if math_hits >= 2 and math_hits >= web_hits:
|
|
58
|
+
return Recommendation(
|
|
59
|
+
"manim", f"math/algorithm/formula density is high ({math_hits} signals) — "
|
|
60
|
+
f"Manim renders equations & step-by-step motion best.", scores)
|
|
61
|
+
if web_hits >= 2 and web_hits > math_hits:
|
|
62
|
+
return Recommendation(
|
|
63
|
+
"remotion", f"web/React/interaction language detected ({web_hits} signals) — "
|
|
64
|
+
f"Remotion suits component-driven motion.", scores)
|
|
65
|
+
return Recommendation(
|
|
66
|
+
DEFAULT_ENGINE, "no strong math or web signal — HyperFrames (fast, clean HTML/CSS) "
|
|
67
|
+
"is the sensible default for general notes.", scores)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def choose(text: str, user_choice: str | None = None) -> tuple[str, Recommendation]:
|
|
71
|
+
"""Return (engine, recommendation). USER-WINS: an explicit user_choice is returned as the
|
|
72
|
+
engine even if it differs from the recommendation; the recommendation is still computed and
|
|
73
|
+
returned (advisory/transparency)."""
|
|
74
|
+
rec = recommend_engine(text)
|
|
75
|
+
if user_choice:
|
|
76
|
+
uc = user_choice.strip().lower()
|
|
77
|
+
if uc not in ENGINES:
|
|
78
|
+
raise ValueError(f"unknown engine '{user_choice}'. Choose one of: {', '.join(ENGINES)}")
|
|
79
|
+
return uc, rec # user choice wins, recommendation kept for transparency
|
|
80
|
+
return rec.engine, rec
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_engine(text: str, user_choice: str | None = None,
|
|
84
|
+
config_engine: str | None = None) -> tuple[str, Recommendation]:
|
|
85
|
+
"""THE single engine-selection contract. Precedence (first that applies):
|
|
86
|
+
|
|
87
|
+
1. explicit user_choice (CLI --engine / lib arg) — user always wins
|
|
88
|
+
2. config_engine (config file / env) IF the user set it to a non-default value
|
|
89
|
+
3. heuristic recommendation from the note text
|
|
90
|
+
|
|
91
|
+
Returns (engine, recommendation). The recommendation is ALWAYS computed and returned for
|
|
92
|
+
transparency, regardless of which layer won. This is the ONLY place precedence lives —
|
|
93
|
+
callers (pipeline, cli, mcp) must not re-implement it.
|
|
94
|
+
|
|
95
|
+
`config_engine` is treated as "set by the user" only when it differs from the built-in default
|
|
96
|
+
("hyperframes"); a config still on the default defers to the recommendation so a math note
|
|
97
|
+
auto-routes to manim unless the user explicitly pinned an engine.
|
|
98
|
+
"""
|
|
99
|
+
rec = recommend_engine(text)
|
|
100
|
+
# 1. explicit user choice wins
|
|
101
|
+
if user_choice:
|
|
102
|
+
uc = user_choice.strip().lower()
|
|
103
|
+
if uc not in ENGINES:
|
|
104
|
+
raise ValueError(f"unknown engine '{user_choice}'. Choose one of: {', '.join(ENGINES)}")
|
|
105
|
+
return uc, rec
|
|
106
|
+
# 2. config engine, only if the user moved it off the default
|
|
107
|
+
if config_engine and config_engine.strip().lower() != DEFAULT_ENGINE:
|
|
108
|
+
ce = config_engine.strip().lower()
|
|
109
|
+
if ce not in ENGINES:
|
|
110
|
+
raise ValueError(f"unknown engine '{config_engine}'. Choose one of: {', '.join(ENGINES)}")
|
|
111
|
+
return ce, rec
|
|
112
|
+
# 3. heuristic recommendation
|
|
113
|
+
return rec.engine, rec
|
|
@@ -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,35 @@
|
|
|
1
|
+
scribecast/__init__.py,sha256=NBmab9p5cl5TOVqToTvrP2gQPu9LSUX2zR6V5PWbWw0,1340
|
|
2
|
+
scribecast/cli.py,sha256=dFCv_uNXqfVbe-sqsL5OGN4TPUAGiYGmtmSha16vf_Y,5723
|
|
3
|
+
scribecast/config.py,sha256=jVxTJgE209zPm5iUGQhW29HJPi5-8Pp8RaL_5h2sT1g,5358
|
|
4
|
+
scribecast/logging_setup.py,sha256=_fNxXdocoohe3HbC8w3Y4B447z9ua_nc11gseOthA7g,2066
|
|
5
|
+
scribecast/mcp.py,sha256=mAE-ZvCaENQG_WOZpRcB0bQ6XrxzPP8r0yaLptYEn-o,8223
|
|
6
|
+
scribecast/pipeline.py,sha256=bQiil6x4ie35sWNk9VUqa_uonoCF-63zP4PF87eVYnw,12341
|
|
7
|
+
scribecast/resolver.py,sha256=543PNgPW-mCa-ruJwRoboE8NfFJ2XmGQuqWGwUEGlTU,6318
|
|
8
|
+
scribecast/selector.py,sha256=T9HhTQbjzMfHf0MGJl3C55AMHFkNxj84vY0X43lJpLk,5314
|
|
9
|
+
scribecast/engines/__init__.py,sha256=vyg9-ZIHcYY0im-5kPRieR3hGshTzqL5Lx3gS8y45Dk,889
|
|
10
|
+
scribecast/engines/manim_adapter.py,sha256=IzJCCIu7c1aAceWShfMc-d6_QRYSFINvAYT8lVsQYhY,7534
|
|
11
|
+
scribecast/engines/remotion_adapter.py,sha256=-ymCUADbdeZDnIay1bUBwZ4B4Hs2PsyU7tPZvtazRzQ,5172
|
|
12
|
+
vidkit_core/__init__.py,sha256=XIROT81G1REXWdVEz-4Syc-GlvPVlqdh2bnxBZ3O1RY,1129
|
|
13
|
+
vidkit_core/audio.py,sha256=pEd8Hb8w_TfDCalI1T1bKIgh-PAD2_kENQ9ql5pz9k4,6122
|
|
14
|
+
vidkit_core/cli.py,sha256=IxLXj7E4BWunOBkfimxfW6J3sB2xz6O1T-JBEo1yT5g,4364
|
|
15
|
+
vidkit_core/export.py,sha256=0xzVa5I85bjY2CSsMU07VzZ6J0PvDxSP42teR3Iubs4,5215
|
|
16
|
+
vidkit_core/layout.py,sha256=SY21SkCyppejcm3gjQOCDDp9dUVvY6FQAh-r9j6lf_M,4431
|
|
17
|
+
vidkit_core/phash.py,sha256=GHWf8kkMoK15A0W4YYVcwlu8d3Oh-o5dnMH_q7J1t-g,3322
|
|
18
|
+
vidkit_core/publish.py,sha256=ThHs5QDY-iXCy-0eeXdyWwCHXGNDYBgp5mH9Z7z05Y0,8238
|
|
19
|
+
vidkit_core/render.py,sha256=ZrAiZvQLzOdksD8AZJJ7gexLysT6qdsYnwkWvcFxRW8,2765
|
|
20
|
+
vidkit_core/theme.py,sha256=lipZa8HANFqHYdNLtFxjFYxqsBr3qLWhQpdqE4wjBg0,2567
|
|
21
|
+
vqkit/__init__.py,sha256=Is_I4bwmTTv6EmngzEMxzOEYASMFqmtLaKyhCz0OrCs,1566
|
|
22
|
+
vqkit/audio.py,sha256=uO1A0bh6jXG_XgZmvw0a6aD5H7Qev6TYy_hXjxFTkow,5478
|
|
23
|
+
vqkit/export.py,sha256=0xzVa5I85bjY2CSsMU07VzZ6J0PvDxSP42teR3Iubs4,5215
|
|
24
|
+
vqkit/layout.py,sha256=oNNdu10sCzloP6y02HYGbKt9MxRKsbhK7BXEh_n39Wk,5639
|
|
25
|
+
vqkit/phash.py,sha256=SfGEHmxwUW5HScO49CT-2zqwZJY9u-GTkzR4nqMhGlg,3045
|
|
26
|
+
vqkit/publish.py,sha256=Hy6u2Rsz0T0AY3It-vytY5WEmdFpAyeTKzXCumi5eVA,4432
|
|
27
|
+
vqkit/render.py,sha256=Z5onsHMrGawWCaFXZnDNANmqBHjfOlDkISxdptfUQJc,4088
|
|
28
|
+
vqkit/scene.py,sha256=JGSPrRwkN8typoSoBY4xMHFPkesW55mAEpUTNLdqL9A,9160
|
|
29
|
+
vqkit/scene3d.py,sha256=XbrsqRyYuz9s6qLuJ__2rZS97F9_kzpVaMCEjUWOwtk,4590
|
|
30
|
+
vqkit/theme.py,sha256=lipZa8HANFqHYdNLtFxjFYxqsBr3qLWhQpdqE4wjBg0,2567
|
|
31
|
+
scribecast-0.1.0.dist-info/METADATA,sha256=5WIWhvg79ZvCQy3YsY-PVPyUAvekvhIhHg0UtWbY-xM,8360
|
|
32
|
+
scribecast-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
33
|
+
scribecast-0.1.0.dist-info/entry_points.txt,sha256=glpXY-W-pv0AqB2NkMc3Ke4CFkhLT84IqtjRQQ5egHM,81
|
|
34
|
+
scribecast-0.1.0.dist-info/top_level.txt,sha256=wKAj-1TNYmw2Th4WdOedt9Fz0NecF7mXXbTLqwS6yoY,29
|
|
35
|
+
scribecast-0.1.0.dist-info/RECORD,,
|
vidkit_core/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""vidkit_core — renderer-agnostic video toolkit core.
|
|
2
|
+
|
|
3
|
+
Shared across Manim (vqkit), Remotion (remotion_kit), and HyperFrames (hyperframes_kit).
|
|
4
|
+
Everything here operates on an OUTPUT MP4 + ffprobe + ffmpeg — zero renderer dependency.
|
|
5
|
+
Council-locked 2026-06-15 (extract-by-addition; the seam was empirically proven).
|
|
6
|
+
|
|
7
|
+
Modules:
|
|
8
|
+
render — Probe + ffprobe (mp4 introspection) + render_cli (generic CLI driver)
|
|
9
|
+
audio — synth_voiceover, mux (VO+music-duck+LUFS), srt_from_cues, measure_lufs
|
|
10
|
+
export — reframe (16:9/9:16/1:1), gif, thumbnail, export_bundle->manifest(sha256)
|
|
11
|
+
phash — dHash keyframe perceptual-hash regression (L4)
|
|
12
|
+
layout — BBox geometry + bbox_from_dom_rect + diff_snapshot (L2 math, renderer feeds boxes)
|
|
13
|
+
publish — armed-but-inert social lever (never posts autonomously)
|
|
14
|
+
theme — design tokens (palette/type/timing)
|
|
15
|
+
"""
|
|
16
|
+
from . import render, audio, export, phash, layout, publish, theme
|
|
17
|
+
from .render import Probe, ffprobe, render_cli
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
__all__ = ["render", "audio", "export", "phash", "layout", "publish", "theme",
|
|
21
|
+
"Probe", "ffprobe", "render_cli"]
|
vidkit_core/audio.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""vqkit.audio — voiceover mux, music ducking, LUFS mastering, SRT generation.
|
|
2
|
+
|
|
3
|
+
Council EARS#3: WHEN a scene declares a voiceover, system SHALL mux audio + generate
|
|
4
|
+
SRT + ducked music, LUFS in [-16,-14]. All via ffmpeg (no external API).
|
|
5
|
+
|
|
6
|
+
Pipeline:
|
|
7
|
+
voiceover.mp3 + [music.mp3] + video.mp4
|
|
8
|
+
-> sidechaincompress (duck music under VO) -> amix -> loudnorm (-14 LUFS) -> mux
|
|
9
|
+
SRT: built from a list of (start, end, text) cues OR auto-timed from VO segment durations.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Cue:
|
|
20
|
+
start: float
|
|
21
|
+
end: float
|
|
22
|
+
text: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def measure_lufs(audio_or_video: str) -> float | None:
|
|
26
|
+
"""Integrated LUFS via ffmpeg loudnorm print_format=json. None if no audio/parse fails.
|
|
27
|
+
|
|
28
|
+
Robust parse (review P2-A): ffmpeg logs more lines after the loudnorm JSON, so the naive
|
|
29
|
+
'last {...}' slice breaks on any trailing brace. We anchor on the loudnorm block by matching
|
|
30
|
+
a flat JSON object that contains "input_i". Distinguishes parse-fail from genuine no-audio
|
|
31
|
+
is best-effort; both return None but we log which."""
|
|
32
|
+
try:
|
|
33
|
+
proc = subprocess.run(
|
|
34
|
+
["ffmpeg", "-i", str(audio_or_video), "-af",
|
|
35
|
+
"loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"],
|
|
36
|
+
capture_output=True, text=True, timeout=300)
|
|
37
|
+
except subprocess.TimeoutExpired:
|
|
38
|
+
return None
|
|
39
|
+
err = proc.stderr
|
|
40
|
+
import re
|
|
41
|
+
m = re.search(r'\{[^{}]*"input_i"[^{}]*\}', err)
|
|
42
|
+
if not m:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(m.group(0))
|
|
46
|
+
return float(data.get("input_i", 0.0))
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def srt_from_cues(cues: list[Cue], out_path: str) -> str:
|
|
52
|
+
"""Write a .srt subtitle file from cues. Returns the path."""
|
|
53
|
+
def ts(s: float) -> str:
|
|
54
|
+
h = int(s // 3600); m = int((s % 3600) // 60)
|
|
55
|
+
sec = s % 60
|
|
56
|
+
return f"{h:02d}:{m:02d}:{sec:06.3f}".replace(".", ",")
|
|
57
|
+
lines = []
|
|
58
|
+
for i, c in enumerate(cues, 1):
|
|
59
|
+
lines += [str(i), f"{ts(c.start)} --> {ts(c.end)}", c.text, ""]
|
|
60
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
Path(out_path).write_text("\n".join(lines), encoding="utf-8")
|
|
62
|
+
return out_path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def auto_cues_from_segments(segments: list[tuple[str, float]], gap: float = 0.15) -> list[Cue]:
|
|
66
|
+
"""Build cues from [(text, duration)] segments laid end-to-end with a small gap."""
|
|
67
|
+
cues, t = [], 0.0
|
|
68
|
+
for text, dur in segments:
|
|
69
|
+
cues.append(Cue(t, t + dur, text))
|
|
70
|
+
t += dur + gap
|
|
71
|
+
return cues
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def mux(video: str, voiceover: str | None = None, music: str | None = None,
|
|
75
|
+
out_path: str = "out_audio.mp4", target_lufs: float = -14.0,
|
|
76
|
+
music_gain_db: float = -18.0, timeout: int = 300) -> str:
|
|
77
|
+
"""Mux audio onto video. If both VO + music: duck music under VO via sidechaincompress,
|
|
78
|
+
mix, loudnorm to target_lufs. If only one source: loudnorm + mux. Returns out_path.
|
|
79
|
+
|
|
80
|
+
Deterministic: no model, pure ffmpeg. Never silently drops audio — raises on ffmpeg error.
|
|
81
|
+
"""
|
|
82
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
if not voiceover and not music:
|
|
84
|
+
raise ValueError("mux() needs at least a voiceover or music track")
|
|
85
|
+
|
|
86
|
+
if voiceover and music:
|
|
87
|
+
# [1]=VO [2]=music ; duck music by VO envelope, mix, normalize.
|
|
88
|
+
# apad pads the mixed audio with silence so it is never SHORTER than the video;
|
|
89
|
+
# -shortest then trims the (now longer-or-equal) audio back to the video length.
|
|
90
|
+
# Without apad, a short VO + bare -shortest would truncate the VIDEO to the audio
|
|
91
|
+
# length (the 78.7s->23s narration-truncation bug). Video duration is authoritative.
|
|
92
|
+
fc = (
|
|
93
|
+
"[2:a]volume=" + str(music_gain_db) + "dB[mbed];"
|
|
94
|
+
"[mbed][1:a]sidechaincompress=threshold=0.05:ratio=8:attack=20:release=300[ducked];"
|
|
95
|
+
"[1:a][ducked]amix=inputs=2:duration=longest:dropout_transition=0[mix];"
|
|
96
|
+
f"[mix]loudnorm=I={target_lufs}:TP=-1.5:LRA=11,apad[aout]"
|
|
97
|
+
)
|
|
98
|
+
cmd = ["ffmpeg", "-y", "-i", video, "-i", voiceover, "-i", music,
|
|
99
|
+
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
|
100
|
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", out_path]
|
|
101
|
+
else:
|
|
102
|
+
track = voiceover or music
|
|
103
|
+
# apad pads audio with trailing silence to >= video length; -shortest trims it back
|
|
104
|
+
# to exactly the video length. This guarantees the OUTPUT keeps the full video and is
|
|
105
|
+
# never truncated to a shorter narration track (the -shortest truncation bug).
|
|
106
|
+
fc = f"[1:a]loudnorm=I={target_lufs}:TP=-1.5:LRA=11,apad[aout]"
|
|
107
|
+
cmd = ["ffmpeg", "-y", "-i", video, "-i", track,
|
|
108
|
+
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
|
109
|
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", out_path]
|
|
110
|
+
|
|
111
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
112
|
+
if proc.returncode != 0:
|
|
113
|
+
raise RuntimeError(f"audio mux failed (exit {proc.returncode}):\n{proc.stderr[-1200:]}")
|
|
114
|
+
if not Path(out_path).exists() or Path(out_path).stat().st_size == 0:
|
|
115
|
+
raise RuntimeError("audio mux produced no output")
|
|
116
|
+
return out_path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def synth_voiceover(text: str, out_path: str, voice: str = "en-US-AriaNeural",
|
|
120
|
+
timeout: int = 120) -> str:
|
|
121
|
+
"""Synthesize narration with edge-TTS (free, no key, good quality). Returns path.
|
|
122
|
+
Falls back through edge -> gTTS if edge unavailable."""
|
|
123
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
try:
|
|
125
|
+
import edge_tts, asyncio
|
|
126
|
+
async def _go():
|
|
127
|
+
await edge_tts.Communicate(text, voice).save(out_path)
|
|
128
|
+
asyncio.run(_go())
|
|
129
|
+
if Path(out_path).exists() and Path(out_path).stat().st_size > 0:
|
|
130
|
+
return out_path
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
# fallback: gTTS
|
|
134
|
+
try:
|
|
135
|
+
from gtts import gTTS
|
|
136
|
+
gTTS(text=text, lang="en").save(out_path)
|
|
137
|
+
return out_path
|
|
138
|
+
except Exception as e:
|
|
139
|
+
raise RuntimeError(f"no TTS backend available: {e}")
|
vidkit_core/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""vidkit CLI — one installed binary for the renderer-agnostic video toolkit.
|
|
2
|
+
|
|
3
|
+
Exposes the vidkit_core operations so consumers call ONE installed `vidkit` command
|
|
4
|
+
instead of vendoring/shelling a copy. Subcommands:
|
|
5
|
+
probe ffprobe an mp4 (duration, dims, fps, has_audio)
|
|
6
|
+
narrate synth_voiceover via edge-tts (needs `pip install vidkit[narrate]`)
|
|
7
|
+
mux mux voiceover/music onto a video (the apad/-shortest-fixed path)
|
|
8
|
+
reframe reframe to 16:9 / 9:16 / 1:1
|
|
9
|
+
gif master mp4 -> gif
|
|
10
|
+
thumbnail grab a thumbnail frame
|
|
11
|
+
version print vidkit version
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _probe(args: argparse.Namespace) -> int:
|
|
21
|
+
from vidkit_core.render import ffprobe
|
|
22
|
+
p = ffprobe(args.path)
|
|
23
|
+
out = {
|
|
24
|
+
"path": args.path,
|
|
25
|
+
"duration": getattr(p, "duration", None),
|
|
26
|
+
"width": getattr(p, "width", None),
|
|
27
|
+
"height": getattr(p, "height", None),
|
|
28
|
+
"fps": getattr(p, "fps", None),
|
|
29
|
+
"has_audio": getattr(p, "has_audio", None),
|
|
30
|
+
}
|
|
31
|
+
print(json.dumps(out, indent=2))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _narrate(args: argparse.Namespace) -> int:
|
|
36
|
+
from vidkit_core.audio import synth_voiceover
|
|
37
|
+
out = synth_voiceover(args.text, args.out, voice=args.voice)
|
|
38
|
+
print(out)
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _mux(args: argparse.Namespace) -> int:
|
|
43
|
+
from vidkit_core.audio import mux
|
|
44
|
+
out = mux(args.video, voiceover=args.voiceover, music=args.music, out_path=args.out)
|
|
45
|
+
print(out)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reframe(args: argparse.Namespace) -> int:
|
|
50
|
+
from vidkit_core.export import reframe
|
|
51
|
+
out = reframe(args.master, args.aspect, args.out, mode=args.mode)
|
|
52
|
+
print(out)
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _gif(args: argparse.Namespace) -> int:
|
|
57
|
+
from vidkit_core.export import gif
|
|
58
|
+
out = gif(args.master, args.out, fps=args.fps, width=args.width)
|
|
59
|
+
print(out)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _thumbnail(args: argparse.Namespace) -> int:
|
|
64
|
+
from vidkit_core.export import thumbnail
|
|
65
|
+
out = thumbnail(args.master, args.out, at=args.at)
|
|
66
|
+
print(out)
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _version(_args: argparse.Namespace) -> int:
|
|
71
|
+
from vidkit_core import __version__
|
|
72
|
+
print(__version__)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
77
|
+
p = argparse.ArgumentParser(prog="vidkit", description="Renderer-agnostic video toolkit.")
|
|
78
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
79
|
+
|
|
80
|
+
sp = sub.add_parser("probe", help="ffprobe an mp4")
|
|
81
|
+
sp.add_argument("path")
|
|
82
|
+
sp.set_defaults(func=_probe)
|
|
83
|
+
|
|
84
|
+
sn = sub.add_parser("narrate", help="synth voiceover (edge-tts; needs vidkit[narrate])")
|
|
85
|
+
sn.add_argument("text")
|
|
86
|
+
sn.add_argument("-o", "--out", default="voiceover.mp3")
|
|
87
|
+
sn.add_argument("--voice", default="en-US-AriaNeural")
|
|
88
|
+
sn.set_defaults(func=_narrate)
|
|
89
|
+
|
|
90
|
+
sm = sub.add_parser("mux", help="mux voiceover/music onto a video (video duration authoritative)")
|
|
91
|
+
sm.add_argument("video")
|
|
92
|
+
sm.add_argument("--voiceover")
|
|
93
|
+
sm.add_argument("--music")
|
|
94
|
+
sm.add_argument("-o", "--out", default="out_audio.mp4")
|
|
95
|
+
sm.set_defaults(func=_mux)
|
|
96
|
+
|
|
97
|
+
sr = sub.add_parser("reframe", help="reframe to 16:9 / 9:16 / 1:1")
|
|
98
|
+
sr.add_argument("master")
|
|
99
|
+
sr.add_argument("aspect", choices=["16:9", "9:16", "1:1"])
|
|
100
|
+
sr.add_argument("-o", "--out", default="reframed.mp4")
|
|
101
|
+
sr.add_argument("--mode", default="pad", choices=["pad", "crop"])
|
|
102
|
+
sr.set_defaults(func=_reframe)
|
|
103
|
+
|
|
104
|
+
sg = sub.add_parser("gif", help="master mp4 -> gif")
|
|
105
|
+
sg.add_argument("master")
|
|
106
|
+
sg.add_argument("-o", "--out", default="out.gif")
|
|
107
|
+
sg.add_argument("--fps", type=int, default=12)
|
|
108
|
+
sg.add_argument("--width", type=int, default=640)
|
|
109
|
+
sg.set_defaults(func=_gif)
|
|
110
|
+
|
|
111
|
+
st = sub.add_parser("thumbnail", help="grab a thumbnail frame")
|
|
112
|
+
st.add_argument("master")
|
|
113
|
+
st.add_argument("-o", "--out", default="thumb.png")
|
|
114
|
+
st.add_argument("--at", type=float, default=1.0)
|
|
115
|
+
st.set_defaults(func=_thumbnail)
|
|
116
|
+
|
|
117
|
+
sv = sub.add_parser("version", help="print vidkit version")
|
|
118
|
+
sv.set_defaults(func=_version)
|
|
119
|
+
|
|
120
|
+
return p
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
124
|
+
args = build_parser().parse_args(argv)
|
|
125
|
+
try:
|
|
126
|
+
return args.func(args)
|
|
127
|
+
except Exception as exc: # surface real errors, non-zero exit
|
|
128
|
+
print(f"vidkit: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
raise SystemExit(main())
|