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/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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ scribecast = scribecast.cli:main
3
+ vidkit = vidkit_core.cli:main
@@ -0,0 +1,3 @@
1
+ scribecast
2
+ vidkit_core
3
+ vqkit
@@ -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())