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/mcp.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""scribecast.mcp — a minimal stdio JSON-RPC 2.0 MCP server exposing scribecast to any agent.
|
|
2
|
+
|
|
3
|
+
Zero third-party dependency (no mcp-sdk required) so it installs with the base wheel. Implements
|
|
4
|
+
the MCP subset that matters: `initialize`, `tools/list`, `tools/call`. Speaks newline-delimited
|
|
5
|
+
JSON-RPC over stdin/stdout (the common stdio transport). Any MCP client (Claude, Hermes, Cursor)
|
|
6
|
+
or a plain worker can drive it.
|
|
7
|
+
|
|
8
|
+
Tools:
|
|
9
|
+
render_note_video(ref, source?, engine?, voice?, narrate?) -> {output, engine, duration, ...}
|
|
10
|
+
recommend_engine(ref, source?) -> {recommended, reason, scores}
|
|
11
|
+
list_sources() -> {source: {tool, available}}
|
|
12
|
+
list_voices() -> {voices: [...]}
|
|
13
|
+
|
|
14
|
+
Run: python -m scribecast.mcp (stdio)
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .logging_setup import get_logger
|
|
25
|
+
|
|
26
|
+
_log = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
29
|
+
|
|
30
|
+
TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
"name": "render_note_video",
|
|
33
|
+
"description": "Resolve a note from a source and render it into a narrated video. "
|
|
34
|
+
"Returns the output mp4 path. Engine: user choice wins over recommendation.",
|
|
35
|
+
"inputSchema": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"ref": {"type": "string", "description": "source:locator or locator, e.g. file:notes.md, gbrain:my-slug"},
|
|
39
|
+
"source": {"type": "string", "description": "default source if ref has no prefix (file/gbrain/vault/ksum/stdin)"},
|
|
40
|
+
"engine": {"type": "string", "enum": ["hyperframes", "manim", "remotion"], "description": "force engine (wins over recommendation)"},
|
|
41
|
+
"voice": {"type": "string", "description": "edge-tts voice id"},
|
|
42
|
+
"out": {"type": "string", "description": "output mp4 path"},
|
|
43
|
+
"narrate": {"type": "boolean", "description": "narrate with edge-tts (default true)"},
|
|
44
|
+
},
|
|
45
|
+
"required": ["ref"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "recommend_engine",
|
|
50
|
+
"description": "Recommend a render engine from a note's content (advisory; user always decides).",
|
|
51
|
+
"inputSchema": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"ref": {"type": "string"},
|
|
55
|
+
"source": {"type": "string"},
|
|
56
|
+
},
|
|
57
|
+
"required": ["ref"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "list_sources",
|
|
62
|
+
"description": "List note sources and whether each one's tool is available on this machine.",
|
|
63
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "list_voices",
|
|
67
|
+
"description": "List common edge-tts voice ids for narration.",
|
|
68
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _ok(result: Any) -> dict:
|
|
74
|
+
# MCP tools/call returns content blocks
|
|
75
|
+
return {"content": [{"type": "text", "text": json.dumps(result, indent=2)}], "isError": False}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _err(msg: str) -> dict:
|
|
79
|
+
return {"content": [{"type": "text", "text": msg}], "isError": True}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _call_tool(name: str, args: dict) -> dict:
|
|
83
|
+
"""Dispatch a tool call, converting ANY exception (missing args, bad enum, engine error)
|
|
84
|
+
into a JSON-RPC tool error. Nothing here may propagate — an uncaught exception would kill
|
|
85
|
+
the server's stdin loop (P0). args is validated defensively (ref required for render/recommend)."""
|
|
86
|
+
_log.info("mcp: tools/call name=%s", name)
|
|
87
|
+
try:
|
|
88
|
+
return _dispatch_tool(name, args)
|
|
89
|
+
except Exception as exc: # noqa: BLE001 — deliberate catch-all; server must never crash
|
|
90
|
+
_log.error("mcp: tool %s raised %s: %s", name, type(exc).__name__, exc)
|
|
91
|
+
return _err(f"{type(exc).__name__}: {exc}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _dispatch_tool(name: str, args: dict) -> dict:
|
|
95
|
+
if name == "render_note_video":
|
|
96
|
+
from .pipeline import render_note, PipelineError
|
|
97
|
+
from .resolver import ResolverError
|
|
98
|
+
if not args.get("ref"):
|
|
99
|
+
return _err("render_note_video requires 'ref'")
|
|
100
|
+
try:
|
|
101
|
+
res = render_note(
|
|
102
|
+
args["ref"], source=args.get("source"), engine=args.get("engine"),
|
|
103
|
+
voice=args.get("voice"), out=args.get("out"),
|
|
104
|
+
narrate=args.get("narrate", True),
|
|
105
|
+
)
|
|
106
|
+
except (PipelineError, ResolverError) as exc:
|
|
107
|
+
return _err(f"{type(exc).__name__}: {exc}")
|
|
108
|
+
return _ok({"output": res.output, "engine": res.engine,
|
|
109
|
+
"recommended": res.recommendation.engine, "cards": res.cards,
|
|
110
|
+
"narrated": res.narrated, "duration": res.duration, "notes": res.notes})
|
|
111
|
+
if name == "recommend_engine":
|
|
112
|
+
from .resolver import resolve, ResolverError
|
|
113
|
+
from .selector import recommend_engine
|
|
114
|
+
if not args.get("ref"):
|
|
115
|
+
return _err("recommend_engine requires 'ref'")
|
|
116
|
+
try:
|
|
117
|
+
text = resolve(args["ref"], source=args.get("source", "file"))
|
|
118
|
+
except ResolverError as exc:
|
|
119
|
+
return _err(str(exc))
|
|
120
|
+
rec = recommend_engine(text)
|
|
121
|
+
return _ok({"recommended": rec.engine, "reason": rec.reason, "scores": rec.scores})
|
|
122
|
+
if name == "list_sources":
|
|
123
|
+
from .resolver import list_sources
|
|
124
|
+
return _ok(list_sources())
|
|
125
|
+
if name == "list_voices":
|
|
126
|
+
return _ok({"voices": ["en-US-AriaNeural", "en-US-GuyNeural", "en-GB-RyanNeural",
|
|
127
|
+
"en-IN-NeerjaNeural", "en-AU-NatashaNeural"]})
|
|
128
|
+
return _err(f"unknown tool: {name}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def handle(req: dict) -> dict | None:
|
|
132
|
+
"""Handle one JSON-RPC request. Returns a response dict, or None for notifications."""
|
|
133
|
+
method = req.get("method")
|
|
134
|
+
rid = req.get("id")
|
|
135
|
+
if method == "initialize":
|
|
136
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {
|
|
137
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
138
|
+
"capabilities": {"tools": {}},
|
|
139
|
+
"serverInfo": {"name": "scribecast", "version": __version__},
|
|
140
|
+
}}
|
|
141
|
+
if method == "notifications/initialized":
|
|
142
|
+
return None
|
|
143
|
+
if method == "tools/list":
|
|
144
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
|
|
145
|
+
if method == "tools/call":
|
|
146
|
+
params = req.get("params", {})
|
|
147
|
+
result = _call_tool(params.get("name", ""), params.get("arguments", {}) or {})
|
|
148
|
+
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
|
149
|
+
if method == "ping":
|
|
150
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
|
151
|
+
# unknown method: notifications (no id) get no response per JSON-RPC; requests get an error
|
|
152
|
+
if rid is None:
|
|
153
|
+
return None
|
|
154
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
155
|
+
"error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def serve(stdin=None, stdout=None) -> None:
|
|
159
|
+
"""Run the stdio JSON-RPC loop (newline-delimited). A handler exception is converted to a
|
|
160
|
+
JSON-RPC internal error (or swallowed for notifications) — the loop NEVER dies on bad input."""
|
|
161
|
+
stdin = stdin or sys.stdin
|
|
162
|
+
stdout = stdout or sys.stdout
|
|
163
|
+
os.environ["SCRIBECAST_MCP_ACTIVE"] = "1" # tells the resolver that stdin is the transport
|
|
164
|
+
# logs MUST go to stderr — stdout is the JSON-RPC transport
|
|
165
|
+
from .logging_setup import configure_logging
|
|
166
|
+
configure_logging(stream=sys.stderr)
|
|
167
|
+
_log.info("mcp: scribecast server started (v%s)", __version__)
|
|
168
|
+
for line in stdin:
|
|
169
|
+
line = line.strip()
|
|
170
|
+
if not line:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
req = json.loads(line)
|
|
174
|
+
except json.JSONDecodeError: # pragma: no cover - malformed line skip
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
resp = handle(req)
|
|
178
|
+
except Exception as exc: # noqa: BLE001 — server must never crash on a single bad request
|
|
179
|
+
rid = req.get("id") if isinstance(req, dict) else None
|
|
180
|
+
resp = None if rid is None else {
|
|
181
|
+
"jsonrpc": "2.0", "id": rid,
|
|
182
|
+
"error": {"code": -32603, "message": f"internal error: {type(exc).__name__}: {exc}"},
|
|
183
|
+
}
|
|
184
|
+
if resp is not None:
|
|
185
|
+
stdout.write(json.dumps(resp) + "\n")
|
|
186
|
+
stdout.flush()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__": # pragma: no cover
|
|
190
|
+
serve()
|
scribecast/pipeline.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""scribecast.pipeline — the render_note core: resolve -> script -> render -> narrate -> mux -> mp4.
|
|
2
|
+
|
|
3
|
+
Imports vidkit (vidkit_core) for narration + mux + probe; never shells a vendored copy.
|
|
4
|
+
|
|
5
|
+
Default renderer ("cards"): split the script into titled text cards, render each as a PNG with
|
|
6
|
+
Pillow, assemble an image-sequence mp4 with ffmpeg, then narrate (edge-tts) and mux (the
|
|
7
|
+
bug-fixed vidkit mux — video duration authoritative). This default has only Pillow + ffmpeg as
|
|
8
|
+
needs and works without a browser or Manim. The heavy engines (manim/remotion) are opt-in and
|
|
9
|
+
delegate to the vidkit engine kits when their extras are installed.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import textwrap
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .config import Config, load_config
|
|
20
|
+
from .resolver import resolve, ResolverError
|
|
21
|
+
from .selector import choose, Recommendation
|
|
22
|
+
from .logging_setup import get_logger
|
|
23
|
+
|
|
24
|
+
_log = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PipelineError(RuntimeError):
|
|
28
|
+
"""Raised when the render pipeline cannot complete (missing dep, render failure)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RenderResult:
|
|
33
|
+
output: str # path to the final mp4
|
|
34
|
+
engine: str
|
|
35
|
+
recommendation: Recommendation
|
|
36
|
+
cards: int
|
|
37
|
+
narrated: bool
|
|
38
|
+
duration: float | None = None
|
|
39
|
+
notes: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---- script segmentation --------------------------------------------------
|
|
43
|
+
def script_from_text(text: str, max_cards: int = 12) -> list[tuple[str, str]]:
|
|
44
|
+
"""Turn note text into [(title, body)] cards. Splits on markdown headings; falls back to
|
|
45
|
+
paragraph chunks. Keeps it bounded so a long note doesn't make a 200-card video."""
|
|
46
|
+
text = (text or "").strip()
|
|
47
|
+
if not text:
|
|
48
|
+
raise PipelineError("empty script: nothing to render")
|
|
49
|
+
cards: list[tuple[str, str]] = []
|
|
50
|
+
cur_title, cur_body = "", []
|
|
51
|
+
for line in text.splitlines():
|
|
52
|
+
s = line.strip()
|
|
53
|
+
if s.startswith("#"):
|
|
54
|
+
if cur_title or cur_body:
|
|
55
|
+
cards.append((cur_title, "\n".join(cur_body).strip()))
|
|
56
|
+
cur_title = s.lstrip("#").strip()
|
|
57
|
+
cur_body = []
|
|
58
|
+
else:
|
|
59
|
+
cur_body.append(line)
|
|
60
|
+
if cur_title or cur_body:
|
|
61
|
+
cards.append((cur_title, "\n".join(cur_body).strip()))
|
|
62
|
+
|
|
63
|
+
# fallback: no headings (single untitled card) -> chunk into paragraphs
|
|
64
|
+
if not cards or (len(cards) == 1 and not cards[0][0]):
|
|
65
|
+
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
|
|
66
|
+
if len(paras) > 1:
|
|
67
|
+
cards = [("", p) for p in paras]
|
|
68
|
+
# bound
|
|
69
|
+
if len(cards) > max_cards:
|
|
70
|
+
cards = cards[:max_cards]
|
|
71
|
+
# ensure at least one card with content
|
|
72
|
+
cards = [(t, b) for (t, b) in cards if (t or b)]
|
|
73
|
+
if not cards:
|
|
74
|
+
cards = [("", text[:500])]
|
|
75
|
+
return cards
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---- default "cards" renderer (Pillow -> ffmpeg image sequence) -----------
|
|
79
|
+
def _render_cards(cards: list[tuple[str, str]], out_path: str, *,
|
|
80
|
+
width: int = 1280, height: int = 720, seconds_per_card: float = 3.0,
|
|
81
|
+
fps: int = 24) -> str:
|
|
82
|
+
try:
|
|
83
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
84
|
+
except ImportError as exc: # pragma: no cover - Pillow present in this env
|
|
85
|
+
raise PipelineError(
|
|
86
|
+
"the default 'cards' renderer needs Pillow. Install: pip install pillow "
|
|
87
|
+
"(or pip install vidkit[manim])."
|
|
88
|
+
) from exc
|
|
89
|
+
if shutil.which("ffmpeg") is None: # pragma: no cover - ffmpeg present in this env
|
|
90
|
+
raise PipelineError("ffmpeg is required to assemble the video but is not on PATH.")
|
|
91
|
+
|
|
92
|
+
import tempfile
|
|
93
|
+
bg = (16, 18, 28)
|
|
94
|
+
fg = (236, 238, 245)
|
|
95
|
+
accent = (110, 123, 255)
|
|
96
|
+
|
|
97
|
+
def _font(size: int):
|
|
98
|
+
for cand in ("/System/Library/Fonts/Helvetica.ttc",
|
|
99
|
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
100
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"):
|
|
101
|
+
if Path(cand).exists():
|
|
102
|
+
try:
|
|
103
|
+
return ImageFont.truetype(cand, size)
|
|
104
|
+
except Exception: # pragma: no cover - system font present
|
|
105
|
+
pass
|
|
106
|
+
return ImageFont.load_default() # pragma: no cover - only if no system font
|
|
107
|
+
|
|
108
|
+
title_f = _font(56)
|
|
109
|
+
body_f = _font(34)
|
|
110
|
+
seconds_per_card = max(0.3, float(seconds_per_card)) # clamp: negative/zero -> cryptic ffmpeg fail
|
|
111
|
+
frames_dir = Path(tempfile.mkdtemp(prefix="scribecast-frames-"))
|
|
112
|
+
try:
|
|
113
|
+
# One PNG per card (not N identical frames) + a concat demuxer with per-image durations:
|
|
114
|
+
# avoids the frame-explosion (was int(spc*fps) identical files per card).
|
|
115
|
+
concat_lines = []
|
|
116
|
+
for idx, (title, body) in enumerate(cards):
|
|
117
|
+
img = Image.new("RGB", (width, height), bg)
|
|
118
|
+
d = ImageDraw.Draw(img)
|
|
119
|
+
y = 90
|
|
120
|
+
if title:
|
|
121
|
+
d.rectangle([(80, y - 20), (88, y + 60)], fill=accent)
|
|
122
|
+
d.text((110, y), title[:80], font=title_f, fill=fg)
|
|
123
|
+
y += 110
|
|
124
|
+
for para in (body or "").split("\n"):
|
|
125
|
+
for wrapped in textwrap.wrap(para, width=58) or [""]:
|
|
126
|
+
if y > height - 80:
|
|
127
|
+
break
|
|
128
|
+
d.text((110, y), wrapped, font=body_f, fill=fg)
|
|
129
|
+
y += 46
|
|
130
|
+
y += 10
|
|
131
|
+
png = frames_dir / f"card_{idx:04d}.png"
|
|
132
|
+
img.save(png)
|
|
133
|
+
concat_lines.append(f"file '{png.name}'")
|
|
134
|
+
concat_lines.append(f"duration {seconds_per_card}")
|
|
135
|
+
# concat demuxer needs the last file repeated (its duration is otherwise dropped)
|
|
136
|
+
if concat_lines:
|
|
137
|
+
last_png = f"card_{len(cards) - 1:04d}.png"
|
|
138
|
+
concat_lines.append(f"file '{last_png}'")
|
|
139
|
+
(frames_dir / "concat.txt").write_text("\n".join(concat_lines))
|
|
140
|
+
|
|
141
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(frames_dir / "concat.txt"),
|
|
143
|
+
"-vf", f"fps={fps},format=yuv420p", "-c:v", "libx264", out_path]
|
|
144
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
145
|
+
finally:
|
|
146
|
+
shutil.rmtree(frames_dir, ignore_errors=True)
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
raise PipelineError(f"ffmpeg card assembly failed: {proc.stderr[-800:]}") # pragma: no cover
|
|
149
|
+
if not Path(out_path).exists() or Path(out_path).stat().st_size == 0:
|
|
150
|
+
raise PipelineError("card render produced no output") # pragma: no cover
|
|
151
|
+
return out_path
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_engine(engine: str, cards, out_path, **kw) -> str:
|
|
155
|
+
"""Dispatch to the chosen engine. 'hyperframes'/'cards' use the built-in Pillow renderer
|
|
156
|
+
(always available). 'manim'/'remotion' require their extras + kits; raise a clear error if
|
|
157
|
+
not installed (never silently degrade to a different engine)."""
|
|
158
|
+
if engine in ("hyperframes", "cards"):
|
|
159
|
+
return _render_cards(cards, out_path, **kw)
|
|
160
|
+
if engine == "manim":
|
|
161
|
+
try:
|
|
162
|
+
import manim # noqa: F401
|
|
163
|
+
except ImportError as exc: # pragma: no cover - manim installed in this env
|
|
164
|
+
raise PipelineError(
|
|
165
|
+
"engine 'manim' needs the manim extra: pip install vidkit[manim]. "
|
|
166
|
+
"(Or choose --engine hyperframes for the dependency-light default.)"
|
|
167
|
+
) from exc
|
|
168
|
+
# Real cinematic adapter: renders the cards through vqkit.CinematicScene (moving camera,
|
|
169
|
+
# animated reveal, MathTex). NOT a silent fallback to hyperframes.
|
|
170
|
+
from .engines.manim_adapter import render_cards_manim, ManimAdapterError
|
|
171
|
+
quality = kw.pop("quality", "m")
|
|
172
|
+
try:
|
|
173
|
+
return render_cards_manim(cards, out_path, quality=quality)
|
|
174
|
+
except ManimAdapterError as exc:
|
|
175
|
+
raise PipelineError(str(exc)) from exc
|
|
176
|
+
if engine == "remotion":
|
|
177
|
+
# Real adapter: drives the remotion_kit ScribecastCards composition. Raises a clear
|
|
178
|
+
# error if Node/kit/node_modules are missing — NEVER a silent fallback to another engine.
|
|
179
|
+
from .engines.remotion_adapter import render_cards_remotion, RemotionAdapterError
|
|
180
|
+
spc = kw.pop("seconds_per_card", 3.0)
|
|
181
|
+
try:
|
|
182
|
+
return render_cards_remotion(cards, out_path, per_card_seconds=spc)
|
|
183
|
+
except RemotionAdapterError as exc:
|
|
184
|
+
raise PipelineError(str(exc)) from exc
|
|
185
|
+
raise PipelineError(f"unknown engine '{engine}'")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render_note(ref, *, source: str | None = None, engine: str | None = None,
|
|
189
|
+
voice: str | None = None, out: str | None = None,
|
|
190
|
+
narrate: bool = True, config: Config | None = None,
|
|
191
|
+
seconds_per_card: float = 3.0) -> RenderResult:
|
|
192
|
+
"""Resolve a note reference, build a card script, render a video, narrate it, and mux.
|
|
193
|
+
|
|
194
|
+
ref: a `source:locator` / locator, or a list (merge).
|
|
195
|
+
Returns RenderResult with the output mp4 path. USER-WINS on engine (explicit > recommended).
|
|
196
|
+
"""
|
|
197
|
+
cfg = config or load_config(overrides={"engine": engine, "voice": voice, "source": source})
|
|
198
|
+
src = source or cfg.source
|
|
199
|
+
notes: list[str] = []
|
|
200
|
+
_log.info("render_note: ref=%r source=%s narrate=%s", ref, src, narrate)
|
|
201
|
+
|
|
202
|
+
# 1) resolve
|
|
203
|
+
text = resolve(ref, source=src)
|
|
204
|
+
_log.debug("render_note: resolved %d chars", len(text))
|
|
205
|
+
|
|
206
|
+
# 2) engine selection — ALL precedence lives in selector.resolve_engine (single contract:
|
|
207
|
+
# explicit user engine > non-default config engine > heuristic recommendation). Pipeline
|
|
208
|
+
# does NO reconciliation of its own.
|
|
209
|
+
from .selector import resolve_engine
|
|
210
|
+
eng, rec = resolve_engine(text, user_choice=engine, config_engine=cfg.engine)
|
|
211
|
+
notes.append(f"engine={eng} (recommended={rec.engine}: {rec.reason})")
|
|
212
|
+
_log.info("render_note: engine=%s (recommended=%s, user=%s, config=%s)",
|
|
213
|
+
eng, rec.engine, engine, cfg.engine)
|
|
214
|
+
|
|
215
|
+
# 3) script
|
|
216
|
+
cards = script_from_text(text)
|
|
217
|
+
_log.info("render_note: %d card(s) from script", len(cards))
|
|
218
|
+
|
|
219
|
+
# 4) render — intermediates go in a UNIQUE temp dir (not fixed names in the shared out_dir),
|
|
220
|
+
# so concurrent render_note calls can't clobber each other's _silent.mp4 / _vo.mp3.
|
|
221
|
+
out_dir = Path(cfg.out_dir); out_dir.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
out_path = out or str(out_dir / "scribecast.mp4")
|
|
223
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True) # honor a user -o into a new dir
|
|
224
|
+
import tempfile as _tf
|
|
225
|
+
work = Path(_tf.mkdtemp(prefix="scribecast-render-"))
|
|
226
|
+
try:
|
|
227
|
+
silent = str(work / "_silent.mp4")
|
|
228
|
+
_log.info("render_note: rendering %d card(s) via %s", len(cards), eng)
|
|
229
|
+
_render_engine(eng, cards, silent, seconds_per_card=seconds_per_card)
|
|
230
|
+
_log.debug("render_note: engine render complete -> %s", silent)
|
|
231
|
+
|
|
232
|
+
# 5) narrate + mux (imports vidkit; video duration authoritative via the apad fix)
|
|
233
|
+
narrated = False
|
|
234
|
+
if narrate:
|
|
235
|
+
try:
|
|
236
|
+
from vidkit_core.audio import synth_voiceover, mux
|
|
237
|
+
script_text = ". ".join(f"{t}. {b}" if t else b for t, b in cards)[:4000]
|
|
238
|
+
vo = str(work / "_vo.mp3")
|
|
239
|
+
_log.info("render_note: narrating (voice=%s)", voice or cfg.voice)
|
|
240
|
+
synth_voiceover(script_text, vo, voice=voice or cfg.voice)
|
|
241
|
+
mux(silent, voiceover=vo, out_path=out_path)
|
|
242
|
+
narrated = True
|
|
243
|
+
_log.info("render_note: narrated + muxed -> %s", out_path)
|
|
244
|
+
except Exception as exc: # narration is best-effort; never silently fake it
|
|
245
|
+
notes.append(f"narration skipped: {type(exc).__name__}: {exc}")
|
|
246
|
+
_log.warning("render_note: narration skipped (%s: %s); using silent video",
|
|
247
|
+
type(exc).__name__, exc)
|
|
248
|
+
shutil.copy(silent, out_path)
|
|
249
|
+
else:
|
|
250
|
+
shutil.copy(silent, out_path)
|
|
251
|
+
finally:
|
|
252
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
253
|
+
|
|
254
|
+
# 6) probe
|
|
255
|
+
duration = None
|
|
256
|
+
try:
|
|
257
|
+
from vidkit_core.render import ffprobe
|
|
258
|
+
duration = ffprobe(out_path).duration
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
_log.info("render_note: done -> %s (%.2fs, narrated=%s)", out_path, duration or 0.0, narrated)
|
|
262
|
+
|
|
263
|
+
return RenderResult(output=out_path, engine=eng, recommendation=rec,
|
|
264
|
+
cards=len(cards), narrated=narrated, duration=duration, notes=notes)
|
scribecast/resolver.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""scribecast.resolver — turn a note reference into clean text from any configured source.
|
|
2
|
+
|
|
3
|
+
A reference is `source:locator` (e.g. `file:notes.md`, `gbrain:two-brain-architecture`,
|
|
4
|
+
`vault:what did I decide about X`, `ksum:https://...`, `stdin:`) OR a bare locator with an
|
|
5
|
+
explicit `source=` argument. `merge` takes multiple refs and concatenates them with headers.
|
|
6
|
+
|
|
7
|
+
Source CLIs are called as subprocess for READING text only (note resolution), never as the
|
|
8
|
+
render engine. A missing CLI raises ResolverError naming the tool — never returns silent empty.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
from .logging_setup import get_logger
|
|
20
|
+
|
|
21
|
+
_log = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
# Known sources and the CLI each needs (None = no external tool).
|
|
24
|
+
SOURCES: dict[str, str | None] = {
|
|
25
|
+
"file": None,
|
|
26
|
+
"stdin": None,
|
|
27
|
+
"gbrain": "gbrain",
|
|
28
|
+
"vault": "praxvault-ask",
|
|
29
|
+
"ksum": "ksum",
|
|
30
|
+
"openmemory": "openmemory", # optional; config-gated, may not exist
|
|
31
|
+
"merge": None,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResolverError(RuntimeError):
|
|
36
|
+
"""Raised when a note cannot be resolved (missing tool, missing file, empty result)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run(cmd: list[str], tool: str, timeout: int = 120) -> str:
|
|
40
|
+
if shutil.which(cmd[0]) is None and not Path(cmd[0]).exists():
|
|
41
|
+
_log.error("resolver: tool %r not on PATH", tool)
|
|
42
|
+
raise ResolverError(
|
|
43
|
+
f"source needs '{tool}' but it is not on PATH. Install/expose it, or pick another source."
|
|
44
|
+
)
|
|
45
|
+
_log.debug("resolver: running %s", " ".join(cmd))
|
|
46
|
+
try:
|
|
47
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
48
|
+
except subprocess.TimeoutExpired as exc:
|
|
49
|
+
_log.error("resolver: %s timed out after %ds", tool, timeout)
|
|
50
|
+
raise ResolverError(f"{tool} timed out after {timeout}s") from exc
|
|
51
|
+
if proc.returncode != 0:
|
|
52
|
+
_log.error("resolver: %s failed (exit %d)", tool, proc.returncode)
|
|
53
|
+
raise ResolverError(f"{tool} failed (exit {proc.returncode}): {proc.stderr.strip()[:500]}")
|
|
54
|
+
text = proc.stdout.strip()
|
|
55
|
+
if not text:
|
|
56
|
+
_log.error("resolver: %s returned empty output", tool)
|
|
57
|
+
raise ResolverError(f"{tool} returned empty output for the given reference")
|
|
58
|
+
_log.info("resolver: %s resolved %d chars", tool, len(text))
|
|
59
|
+
return text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_file(locator: str) -> str:
|
|
63
|
+
p = Path(locator).expanduser()
|
|
64
|
+
if not p.is_file():
|
|
65
|
+
raise ResolverError(f"file not found: {p}")
|
|
66
|
+
text = p.read_text(encoding="utf-8", errors="replace").strip()
|
|
67
|
+
if not text:
|
|
68
|
+
raise ResolverError(f"file is empty: {p}")
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_stdin(_locator: str) -> str:
|
|
73
|
+
# Guard: if stdin is a TTY (no pipe) it would block forever waiting for EOF; and under the
|
|
74
|
+
# MCP server stdin IS the JSON-RPC transport (reading it would hang the server). Refuse both.
|
|
75
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
76
|
+
raise ResolverError("stdin source selected but nothing is piped (or stdin is a terminal)")
|
|
77
|
+
if os.environ.get("SCRIBECAST_MCP_ACTIVE") == "1":
|
|
78
|
+
raise ResolverError("stdin source is not available inside the MCP server "
|
|
79
|
+
"(stdin is the JSON-RPC transport); use file/gbrain/vault/ksum")
|
|
80
|
+
data = sys.stdin.read().strip()
|
|
81
|
+
if not data:
|
|
82
|
+
raise ResolverError("stdin source selected but no piped input was received")
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_locator(locator: str, tool: str) -> None:
|
|
87
|
+
"""Reject locators that would be mis-parsed as CLI options (argv option injection)."""
|
|
88
|
+
if locator.startswith("-"):
|
|
89
|
+
raise ResolverError(
|
|
90
|
+
f"{tool}: locator may not start with '-' (would be parsed as an option): {locator!r}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_gbrain(locator: str) -> str:
|
|
95
|
+
_check_locator(locator, "gbrain")
|
|
96
|
+
return _run(["gbrain", "get", locator], "gbrain")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_vault(locator: str) -> str:
|
|
100
|
+
# praxvault-ask <query> — plain-text answer used as the note body.
|
|
101
|
+
_check_locator(locator, "praxvault-ask")
|
|
102
|
+
return _run(["praxvault-ask", locator], "praxvault-ask")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_ksum(locator: str) -> str:
|
|
106
|
+
_check_locator(locator, "ksum")
|
|
107
|
+
return _run(["ksum", locator, "--no-save"], "ksum")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_openmemory(locator: str) -> str:
|
|
111
|
+
_check_locator(locator, "openmemory")
|
|
112
|
+
return _run(["openmemory", "get", locator], "openmemory")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_DISPATCH = {
|
|
116
|
+
"file": _resolve_file,
|
|
117
|
+
"stdin": _resolve_stdin,
|
|
118
|
+
"gbrain": _resolve_gbrain,
|
|
119
|
+
"vault": _resolve_vault,
|
|
120
|
+
"ksum": _resolve_ksum,
|
|
121
|
+
"openmemory": _resolve_openmemory,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _split_ref(ref: str, default_source: str) -> tuple[str, str]:
|
|
126
|
+
"""Split `source:locator`. If no known `source:` prefix, use default_source and whole ref."""
|
|
127
|
+
if ":" in ref:
|
|
128
|
+
head, _, tail = ref.partition(":")
|
|
129
|
+
if head in SOURCES:
|
|
130
|
+
return head, tail
|
|
131
|
+
return default_source, ref
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve(ref: str | Iterable[str], source: str = "file") -> str:
|
|
135
|
+
"""Resolve a reference (or several, for merge) to text.
|
|
136
|
+
|
|
137
|
+
ref: a single `source:locator` / locator, OR an iterable of them (merge).
|
|
138
|
+
source: default source when a ref carries no `source:` prefix.
|
|
139
|
+
"""
|
|
140
|
+
# merge: iterable of refs
|
|
141
|
+
if not isinstance(ref, str):
|
|
142
|
+
refs = list(ref)
|
|
143
|
+
if not refs:
|
|
144
|
+
raise ResolverError("merge: no references given")
|
|
145
|
+
parts = []
|
|
146
|
+
for r in refs:
|
|
147
|
+
src, loc = _split_ref(r, source)
|
|
148
|
+
text = _resolve_one(src, loc)
|
|
149
|
+
parts.append(f"## Source: {src}:{loc}\n\n{text}")
|
|
150
|
+
return "\n\n---\n\n".join(parts)
|
|
151
|
+
|
|
152
|
+
src, loc = _split_ref(ref, source)
|
|
153
|
+
if src == "merge":
|
|
154
|
+
raise ResolverError("merge needs multiple references; pass a list to resolve()")
|
|
155
|
+
return _resolve_one(src, loc)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _resolve_one(src: str, locator: str) -> str:
|
|
159
|
+
fn = _DISPATCH.get(src)
|
|
160
|
+
if fn is None:
|
|
161
|
+
raise ResolverError(f"unknown source '{src}'. Known: {', '.join(sorted(_DISPATCH))}")
|
|
162
|
+
return fn(locator)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_sources() -> dict[str, dict]:
|
|
166
|
+
"""Describe each source + whether its tool is currently available."""
|
|
167
|
+
out = {}
|
|
168
|
+
for name, tool in SOURCES.items():
|
|
169
|
+
available = True if tool is None else (shutil.which(tool) is not None)
|
|
170
|
+
out[name] = {"tool": tool, "available": available}
|
|
171
|
+
return out
|