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
vidkit_core/export.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""vqkit.export — multi-aspect / gif / thumbnail / manifest export (distribution axis).
|
|
2
|
+
|
|
3
|
+
Council EARS#2 (reflowed aspect MP4s) + EARS#4 (MP4 + GIF + thumbnail + subtitle + JSON
|
|
4
|
+
manifest). Two aspect paths:
|
|
5
|
+
- reframe_ffmpeg(): fast crop/pad a 16:9 master into 9:16 / 1:1 (works on ANY video)
|
|
6
|
+
- true reflow (re-render at target frame dims) is exposed via render.render_scene with
|
|
7
|
+
--resolution; aspect-aware scenes use config.frame_width/height. ffmpeg path is the
|
|
8
|
+
default (deterministic, no re-render cost); reflow is opt-in for hero pieces.
|
|
9
|
+
All deterministic via ffmpeg. Manifest carries duration/fps/res/sha256 for regression.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass, asdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
ASPECTS = {
|
|
19
|
+
"16:9": (1920, 1080),
|
|
20
|
+
"9:16": (1080, 1920),
|
|
21
|
+
"1:1": (1080, 1080),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run(cmd: list[str], timeout: int = 300):
|
|
26
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
27
|
+
if p.returncode != 0:
|
|
28
|
+
raise RuntimeError(f"ffmpeg failed (exit {p.returncode}):\n{p.stderr[-1000:]}")
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sha256(path: str) -> str:
|
|
33
|
+
h = hashlib.sha256()
|
|
34
|
+
with open(path, "rb") as f:
|
|
35
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
36
|
+
h.update(chunk)
|
|
37
|
+
return h.hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reframe(master: str, aspect: str, out_path: str, mode: str = "pad") -> str:
|
|
41
|
+
"""Reframe a master video to a target aspect. mode='pad' (letterbox, no content loss)
|
|
42
|
+
or 'crop' (fill, edge loss). Default pad — safest, never drops content."""
|
|
43
|
+
if aspect not in ASPECTS:
|
|
44
|
+
raise ValueError(f"unknown aspect {aspect}; valid: {list(ASPECTS)}")
|
|
45
|
+
w, h = ASPECTS[aspect]
|
|
46
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
if mode == "pad":
|
|
48
|
+
vf = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
49
|
+
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=0x0E1116")
|
|
50
|
+
else: # crop-to-fill
|
|
51
|
+
vf = (f"scale={w}:{h}:force_original_aspect_ratio=increase,"
|
|
52
|
+
f"crop={w}:{h}")
|
|
53
|
+
cmd = ["ffmpeg", "-y", "-i", master, "-vf", vf]
|
|
54
|
+
# preserve audio if present
|
|
55
|
+
cmd += ["-c:a", "copy", out_path]
|
|
56
|
+
_run(cmd)
|
|
57
|
+
return out_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def gif(master: str, out_path: str, fps: int = 12, width: int = 640,
|
|
61
|
+
start: float = 0.0, duration: float | None = None) -> str:
|
|
62
|
+
"""High-quality GIF via palettegen/paletteuse (2-pass). For embeds/previews."""
|
|
63
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
palette = str(Path(out_path).with_suffix(".palette.png"))
|
|
65
|
+
seek = ["-ss", str(start)] + (["-t", str(duration)] if duration else [])
|
|
66
|
+
vf = f"fps={fps},scale={width}:-1:flags=lanczos"
|
|
67
|
+
try:
|
|
68
|
+
_run(["ffmpeg", "-y", *seek, "-i", master, "-vf", f"{vf},palettegen=stats_mode=diff", palette])
|
|
69
|
+
_run(["ffmpeg", "-y", *seek, "-i", master, "-i", palette,
|
|
70
|
+
"-lavfi", f"{vf}[x];[x][1:v]paletteuse=dither=bayer", out_path])
|
|
71
|
+
finally:
|
|
72
|
+
Path(palette).unlink(missing_ok=True) # review P2-C: clean up even if pass 2 fails
|
|
73
|
+
return out_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def thumbnail(master: str, out_path: str, at: float = 1.0) -> str:
|
|
77
|
+
"""Extract a hero frame as PNG at timestamp `at` seconds."""
|
|
78
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
_run(["ffmpeg", "-y", "-ss", str(at), "-i", master, "-frames:v", "1", out_path])
|
|
80
|
+
return out_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Manifest:
|
|
85
|
+
name: str
|
|
86
|
+
master: str
|
|
87
|
+
duration: float
|
|
88
|
+
fps: float
|
|
89
|
+
width: int
|
|
90
|
+
height: int
|
|
91
|
+
sha256: str
|
|
92
|
+
aspects: dict
|
|
93
|
+
gif: str | None
|
|
94
|
+
thumbnail: str | None
|
|
95
|
+
subtitle: str | None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def export_bundle(master: str, name: str, out_dir: str,
|
|
99
|
+
aspects: list[str] | None = None, subtitle: str | None = None,
|
|
100
|
+
make_gif: bool = True, make_thumb: bool = True) -> dict:
|
|
101
|
+
"""Produce the full distribution bundle: aspect variants + gif + thumbnail + manifest.
|
|
102
|
+
Returns the manifest dict (also written to <out_dir>/<name>.manifest.json)."""
|
|
103
|
+
from .render import ffprobe
|
|
104
|
+
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
probe = ffprobe(master)
|
|
106
|
+
|
|
107
|
+
aspect_paths = {}
|
|
108
|
+
for a in (aspects or ["16:9", "9:16", "1:1"]):
|
|
109
|
+
safe = a.replace(":", "x")
|
|
110
|
+
aspect_paths[a] = reframe(master, a, str(out / f"{name}_{safe}.mp4"))
|
|
111
|
+
|
|
112
|
+
gif_path = gif(master, str(out / f"{name}.gif")) if make_gif else None
|
|
113
|
+
# midpoint hero frame (review P2-B): clamp to the video length, NOT to 1.0s — the old
|
|
114
|
+
# min(1.0, ...) pinned every >2s video's thumbnail to second 1.
|
|
115
|
+
thumb_at = max(0.1, min(probe.duration - 0.1, probe.duration / 2)) if probe.duration > 0.2 else 0.1
|
|
116
|
+
thumb_path = thumbnail(master, str(out / f"{name}_thumb.png"), at=thumb_at) if make_thumb else None
|
|
117
|
+
|
|
118
|
+
man = Manifest(
|
|
119
|
+
name=name, master=master, duration=probe.duration, fps=probe.fps,
|
|
120
|
+
width=probe.width, height=probe.height, sha256=sha256(master),
|
|
121
|
+
aspects=aspect_paths, gif=gif_path, thumbnail=thumb_path, subtitle=subtitle,
|
|
122
|
+
)
|
|
123
|
+
man_path = out / f"{name}.manifest.json"
|
|
124
|
+
with open(man_path, "w") as f: # review P2-D: context manager, no truncate-on-error
|
|
125
|
+
json.dump(asdict(man), f, indent=2)
|
|
126
|
+
return asdict(man)
|
vidkit_core/layout.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""vidkit_core.layout — renderer-AGNOSTIC layout geometry + regression (L2 math).
|
|
2
|
+
|
|
3
|
+
Council-locked: the BBox math, safe-frame check, overlap, snapshot-diff, and golden I/O are
|
|
4
|
+
pure and shared across Manim / Remotion / HyperFrames. The renderer-SPECIFIC part — how you
|
|
5
|
+
OBTAIN a bbox for an element — lives in each kit:
|
|
6
|
+
- Manim: mobject.get_left()/get_right()/... (vqkit.layout.bbox_of)
|
|
7
|
+
- Remotion: Playwright getBoundingClientRect -> bbox_from_dom_rect()
|
|
8
|
+
- HyperFrames: native lint/inspect/validate (its own L2, no bbox needed)
|
|
9
|
+
|
|
10
|
+
This module provides the shared geometry + a DOM-rect adapter so browser renderers feed the
|
|
11
|
+
same diff/regression pipeline as Manim.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, asdict
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Manim default frame; browser renderers pass their own frame dims to exceeds_safe_frame().
|
|
19
|
+
FRAME_W = 14.222222
|
|
20
|
+
FRAME_H = 8.0
|
|
21
|
+
SAFE_FRAC = 0.96
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BBox:
|
|
26
|
+
x0: float; y0: float; x1: float; y1: float
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def w(self): return self.x1 - self.x0
|
|
30
|
+
@property
|
|
31
|
+
def h(self): return self.y1 - self.y0
|
|
32
|
+
@property
|
|
33
|
+
def cx(self): return (self.x0 + self.x1) / 2
|
|
34
|
+
@property
|
|
35
|
+
def cy(self): return (self.y0 + self.y1) / 2
|
|
36
|
+
|
|
37
|
+
def overlaps(self, other: "BBox", pad: float = 0.0) -> bool:
|
|
38
|
+
return not (self.x1 + pad <= other.x0 or other.x1 + pad <= self.x0 or
|
|
39
|
+
self.y1 + pad <= other.y0 or other.y1 + pad <= self.y0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bbox_from_dom_rect(rect: dict) -> BBox:
|
|
43
|
+
"""Adapt a Playwright/DOM getBoundingClientRect {x,y,width,height} to a BBox in PIXEL space.
|
|
44
|
+
For browser renderers (Remotion). Origin top-left, y grows downward — that's fine, the
|
|
45
|
+
diff/overlap math is origin-agnostic as long as golden + current use the same convention."""
|
|
46
|
+
x, y, w, h = rect["x"], rect["y"], rect["width"], rect["height"]
|
|
47
|
+
return BBox(round(float(x), 2), round(float(y), 2),
|
|
48
|
+
round(float(x + w), 2), round(float(y + h), 2))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def exceeds_frame(bb: BBox, frame_w: float, frame_h: float, frac: float = SAFE_FRAC,
|
|
52
|
+
origin: str = "center") -> bool:
|
|
53
|
+
"""True if bbox pokes outside the safe area. origin='center' (Manim scene coords) or
|
|
54
|
+
'topleft' (browser pixel coords)."""
|
|
55
|
+
if origin == "center":
|
|
56
|
+
hw, hh = frame_w * frac / 2, frame_h * frac / 2
|
|
57
|
+
return bb.x0 < -hw or bb.x1 > hw or bb.y0 < -hh or bb.y1 > hh
|
|
58
|
+
# top-left pixel space: safe area is a centered frac-box of the frame
|
|
59
|
+
mx, my = frame_w * (1 - frac) / 2, frame_h * (1 - frac) / 2
|
|
60
|
+
return bb.x0 < mx or bb.x1 > frame_w - mx or bb.y0 < my or bb.y1 > frame_h - my
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def assert_no_overlap(boxes: dict, pad: float = 0.0, ignore_pairs: set | None = None) -> list[str]:
|
|
64
|
+
"""Return overlapping pairs that should be disjoint. boxes: {key: BBox}. Empty = pass."""
|
|
65
|
+
ignore_pairs = ignore_pairs or set()
|
|
66
|
+
keys = list(boxes.keys())
|
|
67
|
+
out = []
|
|
68
|
+
for i in range(len(keys)):
|
|
69
|
+
for j in range(i + 1, len(keys)):
|
|
70
|
+
ka, kb = keys[i], keys[j]
|
|
71
|
+
if (ka, kb) in ignore_pairs or (kb, ka) in ignore_pairs:
|
|
72
|
+
continue
|
|
73
|
+
if boxes[ka].overlaps(boxes[kb], pad):
|
|
74
|
+
out.append(f"{ka} overlaps {kb}")
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def snapshot_boxes(boxes: dict) -> dict:
|
|
79
|
+
"""{key: {bbox}} snapshot from a {key: BBox} dict (renderer feeds the boxes)."""
|
|
80
|
+
return {k: {"bbox": asdict(v)} for k, v in boxes.items()}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def diff_snapshot(golden: dict, current: dict, pos_tol: float = 0.15) -> list[str]:
|
|
84
|
+
"""Compare two snapshots; return drift lines. pos_tol in the snapshot's unit (scene or px)."""
|
|
85
|
+
drift = []
|
|
86
|
+
gk, ck = set(golden), set(current)
|
|
87
|
+
for k in gk - ck:
|
|
88
|
+
drift.append(f"MISSING: {k} present in golden, absent now")
|
|
89
|
+
for k in ck - gk:
|
|
90
|
+
drift.append(f"ADDED: {k} present now, absent in golden")
|
|
91
|
+
for k in gk & ck:
|
|
92
|
+
gb, cb = golden[k]["bbox"], current[k]["bbox"]
|
|
93
|
+
for f in ("x0", "y0", "x1", "y1"):
|
|
94
|
+
if abs(gb[f] - cb[f]) > pos_tol:
|
|
95
|
+
drift.append(f"DRIFT: {k}.{f} {gb[f]} -> {cb[f]} (>{pos_tol})")
|
|
96
|
+
return drift
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def save_golden(snap: dict, path: str):
|
|
100
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with open(path, "w") as f:
|
|
102
|
+
json.dump(snap, f, indent=2)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_golden(path: str) -> dict | None:
|
|
106
|
+
p = Path(path)
|
|
107
|
+
if not p.exists():
|
|
108
|
+
return None
|
|
109
|
+
with open(p) as f:
|
|
110
|
+
return json.load(f)
|
vidkit_core/phash.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""vqkit.phash — L4 keyframe perceptual-hash regression (the NARROW pixel check).
|
|
2
|
+
|
|
3
|
+
Council L4: pHash on 3-5 KEYFRAMES ONLY, generous Hamming tolerance (>=6) — catches gross
|
|
4
|
+
visual regressions (black frame, totally wrong layout) without the antialiasing/font-drift
|
|
5
|
+
false-positives that killed golden-frame-as-primary. L2 bbox is the primary mechanism; this
|
|
6
|
+
is the backstop. Pure Pillow, deterministic.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# PIL (Pillow) is lazy-imported so `import vidkit_core` works without it. Pillow is only
|
|
14
|
+
# needed when you actually compute perceptual hashes — install via `pip install vidkit[manim]`
|
|
15
|
+
# (the extra that bundles pillow) or `pip install pillow`.
|
|
16
|
+
def _require_pil():
|
|
17
|
+
try:
|
|
18
|
+
from PIL import Image
|
|
19
|
+
return Image
|
|
20
|
+
except ImportError as exc: # pragma: no cover
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"phash needs Pillow. Install it: pip install pillow (or pip install vidkit[manim])"
|
|
23
|
+
) from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _dhash(img, hash_size: int = 8) -> int:
|
|
27
|
+
"""Difference hash (dHash) — robust to scale/brightness, sensitive to structure.
|
|
28
|
+
`img` is a PIL.Image.Image."""
|
|
29
|
+
Image = _require_pil()
|
|
30
|
+
img = img.convert("L").resize((hash_size + 1, hash_size), Image.LANCZOS)
|
|
31
|
+
bits = 0
|
|
32
|
+
idx = 0
|
|
33
|
+
px = img.load()
|
|
34
|
+
for y in range(hash_size):
|
|
35
|
+
for x in range(hash_size):
|
|
36
|
+
bits |= (1 if px[x, y] < px[x + 1, y] else 0) << idx
|
|
37
|
+
idx += 1
|
|
38
|
+
return bits
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hamming(a: int, b: int) -> int:
|
|
42
|
+
return bin(a ^ b).count("1")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_frame(video: str, at: float, out_png: str) -> str:
|
|
46
|
+
Path(out_png).parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
subprocess.run(["ffmpeg", "-y", "-ss", str(at), "-i", video, "-frames:v", "1", out_png],
|
|
48
|
+
capture_output=True, check=True, timeout=60) # review P3: timeout
|
|
49
|
+
return out_png
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def keyframe_hashes(video: str, timestamps: list[float], work_dir: str) -> dict:
|
|
53
|
+
"""Return {timestamp: dhash} for the given keyframes. Keys normalized to 3dp so int/float
|
|
54
|
+
callers don't desync golden vs current (review P3)."""
|
|
55
|
+
Image = _require_pil()
|
|
56
|
+
out = {}
|
|
57
|
+
for i, t in enumerate(timestamps):
|
|
58
|
+
png = extract_frame(video, t, str(Path(work_dir) / f"kf_{i}.png"))
|
|
59
|
+
with Image.open(png) as im: # review P3: close the PIL handle
|
|
60
|
+
out[f"{float(t):.3f}"] = _dhash(im)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_golden(hashes: dict, path: str):
|
|
65
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
with open(path, "w") as f:
|
|
67
|
+
json.dump(hashes, f, indent=2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_golden(path: str) -> dict | None:
|
|
71
|
+
p = Path(path)
|
|
72
|
+
if not p.exists():
|
|
73
|
+
return None
|
|
74
|
+
with open(p) as f:
|
|
75
|
+
return json.load(f)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compare(golden: dict, current: dict, tolerance: int = 6) -> list[str]:
|
|
79
|
+
"""Return drift lines for keyframes whose Hamming distance exceeds tolerance.
|
|
80
|
+
tolerance>=6 per council (avoids AA/font-hint false positives)."""
|
|
81
|
+
drift = []
|
|
82
|
+
for ts, gh in golden.items():
|
|
83
|
+
if ts not in current:
|
|
84
|
+
drift.append(f"MISSING keyframe {ts}")
|
|
85
|
+
continue
|
|
86
|
+
d = hamming(int(gh), int(current[ts]))
|
|
87
|
+
if d > tolerance:
|
|
88
|
+
drift.append(f"keyframe {ts}: Hamming {d} > {tolerance} (gross visual change)")
|
|
89
|
+
return drift
|
vidkit_core/publish.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""vidkit_core.publish — post a rendered video to X/Twitter.
|
|
2
|
+
|
|
3
|
+
SAFETY BOUNDARY (operator constraint, SOUL §5): publish() defaults to dry_run=True and never
|
|
4
|
+
sends on its own. The REAL send path (send_to_x) is fully implemented but requires (a) an
|
|
5
|
+
explicit confirm token AND (b) four OAuth 1.0a user-context credentials in the environment.
|
|
6
|
+
With both present it WILL post — that's the wired state the user authorized 2026-06-15. The
|
|
7
|
+
agent still drafts the tweet text and gets human approval on the exact text before calling it.
|
|
8
|
+
|
|
9
|
+
X video posting needs OAuth 1.0a user context (bearer tokens can't post media). The flow is:
|
|
10
|
+
chunked media upload (INIT/APPEND/FINALIZE on upload.twitter.com) -> poll processing ->
|
|
11
|
+
create tweet with the media_id (api.twitter.com/2/tweets).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, asdict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# X (Twitter) media limits as of 2026 (video): <=512MB, <=140s for most accounts.
|
|
21
|
+
MAX_VIDEO_BYTES = 512 * 1024 * 1024
|
|
22
|
+
MAX_VIDEO_SECONDS = 140
|
|
23
|
+
REQUIRED_TOKEN = "I-EXPLICITLY-AUTHORIZE-THIS-PUBLIC-POST"
|
|
24
|
+
|
|
25
|
+
# OAuth 1.0a user-context credential env vars (standard tweepy/twurl names).
|
|
26
|
+
X_CRED_VARS = ("X_CONSUMER_KEY", "X_CONSUMER_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_TOKEN_SECRET")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def x_credentials() -> dict | None:
|
|
30
|
+
"""Return the 4 OAuth1 creds from env, or None if any is missing."""
|
|
31
|
+
vals = {k: os.environ.get(k) for k in X_CRED_VARS}
|
|
32
|
+
return vals if all(vals.values()) else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class PostPayload:
|
|
37
|
+
text: str
|
|
38
|
+
media_path: str
|
|
39
|
+
alt_text: str
|
|
40
|
+
media_bytes: int
|
|
41
|
+
media_seconds: float
|
|
42
|
+
within_limits: bool
|
|
43
|
+
limit_notes: list
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compose(video_path: str, text: str, alt_text: str = "") -> PostPayload:
|
|
47
|
+
"""Build + validate a post payload from a rendered video. No network."""
|
|
48
|
+
from .render import ffprobe
|
|
49
|
+
p = Path(video_path)
|
|
50
|
+
notes = []
|
|
51
|
+
if not p.exists():
|
|
52
|
+
return PostPayload(text, video_path, alt_text, 0, 0.0, False, ["media file not found"])
|
|
53
|
+
size = p.stat().st_size
|
|
54
|
+
probe = ffprobe(video_path)
|
|
55
|
+
if size > MAX_VIDEO_BYTES:
|
|
56
|
+
notes.append(f"video {size/1e6:.1f}MB > 512MB limit")
|
|
57
|
+
if probe.duration > MAX_VIDEO_SECONDS:
|
|
58
|
+
notes.append(f"video {probe.duration:.0f}s > {MAX_VIDEO_SECONDS}s limit")
|
|
59
|
+
if len(text) > 280:
|
|
60
|
+
notes.append(f"caption {len(text)} chars > 280")
|
|
61
|
+
if not alt_text:
|
|
62
|
+
notes.append("no alt-text (accessibility)")
|
|
63
|
+
within = not [n for n in notes if "limit" in n or ">" in n]
|
|
64
|
+
return PostPayload(text, video_path, alt_text, size, probe.duration, within, notes)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def send_to_x(payload: PostPayload, timeout: int = 300) -> dict:
|
|
68
|
+
"""REAL send: chunked video upload (OAuth1) -> tweet with media. Requires the 4 X_* creds
|
|
69
|
+
AND the `requests` + `requests_oauthlib` packages. Returns {ok, tweet_id, url} or {ok:False,
|
|
70
|
+
error}. Caller (publish) gates this behind dry_run=False + confirm token. No autonomous use."""
|
|
71
|
+
creds = x_credentials()
|
|
72
|
+
if not creds:
|
|
73
|
+
return {"ok": False, "error": f"missing X creds: {[k for k in X_CRED_VARS if not os.environ.get(k)]}"}
|
|
74
|
+
try:
|
|
75
|
+
import requests
|
|
76
|
+
from requests_oauthlib import OAuth1
|
|
77
|
+
except ImportError as e:
|
|
78
|
+
return {"ok": False, "error": f"pip install requests requests_oauthlib ({e})"}
|
|
79
|
+
|
|
80
|
+
auth = OAuth1(creds["X_CONSUMER_KEY"], creds["X_CONSUMER_SECRET"],
|
|
81
|
+
creds["X_ACCESS_TOKEN"], creds["X_ACCESS_TOKEN_SECRET"])
|
|
82
|
+
UPLOAD = "https://upload.twitter.com/1.1/media/upload.json"
|
|
83
|
+
total = os.path.getsize(payload.media_path)
|
|
84
|
+
|
|
85
|
+
# 1. INIT
|
|
86
|
+
r = requests.post(UPLOAD, auth=auth, timeout=timeout, data={
|
|
87
|
+
"command": "INIT", "media_type": "video/mp4", "total_bytes": total,
|
|
88
|
+
"media_category": "tweet_video"})
|
|
89
|
+
if r.status_code >= 300:
|
|
90
|
+
return {"ok": False, "error": f"INIT failed {r.status_code}: {r.text[:200]}"}
|
|
91
|
+
media_id = r.json()["media_id_string"]
|
|
92
|
+
|
|
93
|
+
# 2. APPEND (chunked, 4MB segments)
|
|
94
|
+
seg, CHUNK = 0, 4 * 1024 * 1024
|
|
95
|
+
with open(payload.media_path, "rb") as f:
|
|
96
|
+
while True:
|
|
97
|
+
chunk = f.read(CHUNK)
|
|
98
|
+
if not chunk:
|
|
99
|
+
break
|
|
100
|
+
ra = requests.post(UPLOAD, auth=auth, timeout=timeout,
|
|
101
|
+
data={"command": "APPEND", "media_id": media_id, "segment_index": seg},
|
|
102
|
+
files={"media": chunk})
|
|
103
|
+
if ra.status_code >= 300:
|
|
104
|
+
return {"ok": False, "error": f"APPEND seg {seg} failed {ra.status_code}: {ra.text[:200]}"}
|
|
105
|
+
seg += 1
|
|
106
|
+
|
|
107
|
+
# 3. FINALIZE + poll processing
|
|
108
|
+
rf = requests.post(UPLOAD, auth=auth, timeout=timeout,
|
|
109
|
+
data={"command": "FINALIZE", "media_id": media_id})
|
|
110
|
+
if rf.status_code >= 300:
|
|
111
|
+
return {"ok": False, "error": f"FINALIZE failed {rf.status_code}: {rf.text[:200]}"}
|
|
112
|
+
info = rf.json().get("processing_info", {})
|
|
113
|
+
waited = 0
|
|
114
|
+
while info.get("state") in ("pending", "in_progress"):
|
|
115
|
+
wait = min(info.get("check_after_secs", 5), 15)
|
|
116
|
+
time.sleep(wait); waited += wait
|
|
117
|
+
if waited > timeout:
|
|
118
|
+
return {"ok": False, "error": "media processing timed out"}
|
|
119
|
+
rs = requests.get(UPLOAD, auth=auth, timeout=timeout,
|
|
120
|
+
params={"command": "STATUS", "media_id": media_id})
|
|
121
|
+
info = rs.json().get("processing_info", {})
|
|
122
|
+
if info.get("state") == "failed":
|
|
123
|
+
return {"ok": False, "error": f"media processing failed: {info}"}
|
|
124
|
+
|
|
125
|
+
# 3b. alt-text (accessibility) — best-effort
|
|
126
|
+
if payload.alt_text:
|
|
127
|
+
try:
|
|
128
|
+
requests.post("https://upload.twitter.com/1.1/media/metadata/create.json", auth=auth,
|
|
129
|
+
timeout=timeout, json={"media_id": media_id,
|
|
130
|
+
"alt_text": {"text": payload.alt_text[:1000]}})
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
# 4. create tweet
|
|
135
|
+
rt = requests.post("https://api.twitter.com/2/tweets", auth=auth, timeout=timeout,
|
|
136
|
+
json={"text": payload.text, "media": {"media_ids": [media_id]}})
|
|
137
|
+
if rt.status_code >= 300:
|
|
138
|
+
return {"ok": False, "error": f"tweet create failed {rt.status_code}: {rt.text[:200]}"}
|
|
139
|
+
tid = rt.json().get("data", {}).get("id")
|
|
140
|
+
return {"ok": True, "tweet_id": tid, "url": f"https://x.com/i/status/{tid}" if tid else None}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def publish(payload: PostPayload, dry_run: bool = True, confirm: str = "") -> dict:
|
|
144
|
+
"""Publish to X. DEFAULT dry_run=True -> never sends, returns the would-post plan.
|
|
145
|
+
|
|
146
|
+
To actually send, ALL must hold: dry_run=False AND confirm==REQUIRED_TOKEN AND the 4 X_*
|
|
147
|
+
OAuth1 creds in env. When all three are satisfied this CALLS send_to_x and really posts
|
|
148
|
+
(the user-authorized wired state, 2026-06-15). Without the token or creds it blocks safely.
|
|
149
|
+
"""
|
|
150
|
+
plan = {
|
|
151
|
+
"would_post": {
|
|
152
|
+
"text": payload.text,
|
|
153
|
+
"media": payload.media_path,
|
|
154
|
+
"alt_text": payload.alt_text,
|
|
155
|
+
"media_bytes": payload.media_bytes,
|
|
156
|
+
"media_seconds": round(payload.media_seconds, 1),
|
|
157
|
+
},
|
|
158
|
+
"within_limits": payload.within_limits,
|
|
159
|
+
"limit_notes": payload.limit_notes,
|
|
160
|
+
}
|
|
161
|
+
if dry_run:
|
|
162
|
+
plan["status"] = "DRY_RUN — nothing sent"
|
|
163
|
+
return plan
|
|
164
|
+
if confirm != REQUIRED_TOKEN:
|
|
165
|
+
plan["status"] = "BLOCKED — missing explicit confirm token (no post sent)"
|
|
166
|
+
return plan
|
|
167
|
+
if not payload.within_limits:
|
|
168
|
+
plan["status"] = "BLOCKED — media exceeds X limits (no post sent)"
|
|
169
|
+
return plan
|
|
170
|
+
if not x_credentials():
|
|
171
|
+
plan["status"] = (f"BLOCKED — X credentials not in env ({', '.join(X_CRED_VARS)}). "
|
|
172
|
+
"No post sent.")
|
|
173
|
+
return plan
|
|
174
|
+
# All gates passed: token + within-limits + creds present -> REAL post (user-authorized).
|
|
175
|
+
result = send_to_x(payload)
|
|
176
|
+
plan["status"] = "POSTED" if result.get("ok") else f"SEND_FAILED — {result.get('error')}"
|
|
177
|
+
plan["result"] = result
|
|
178
|
+
return plan
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def dry_run_report(video_path: str, text: str, alt_text: str = "") -> str:
|
|
182
|
+
"""Convenience: compose + dry-run publish, return a readable report."""
|
|
183
|
+
payload = compose(video_path, text, alt_text)
|
|
184
|
+
plan = publish(payload, dry_run=True)
|
|
185
|
+
return json.dumps(plan, indent=2)
|
vidkit_core/render.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""vidkit_core.render — renderer-AGNOSTIC mp4 introspection + generic CLI render driver.
|
|
2
|
+
|
|
3
|
+
Probe + ffprobe are pure (any mp4). render_cli() is a generic subprocess driver that each kit
|
|
4
|
+
calls with its own command (manim / npx remotion render / npx hyperframes render), then locates
|
|
5
|
+
the produced mp4 and returns a Probe. No renderer assumptions in core.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Probe:
|
|
16
|
+
path: str
|
|
17
|
+
exists: bool
|
|
18
|
+
size: int
|
|
19
|
+
duration: float
|
|
20
|
+
fps: float
|
|
21
|
+
width: int
|
|
22
|
+
height: int
|
|
23
|
+
has_audio: bool
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ok(self) -> bool:
|
|
27
|
+
return self.exists and self.size > 0 and self.duration > 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ffprobe(path: str) -> Probe:
|
|
31
|
+
"""Introspect an mp4 via ffprobe. Deterministic, no vision model. Timeout-guarded."""
|
|
32
|
+
p = Path(path)
|
|
33
|
+
if not p.exists():
|
|
34
|
+
return Probe(path, False, 0, 0.0, 0.0, 0, 0, False)
|
|
35
|
+
try:
|
|
36
|
+
proc = subprocess.run(
|
|
37
|
+
["ffprobe", "-v", "error", "-print_format", "json",
|
|
38
|
+
"-show_format", "-show_streams", str(p)],
|
|
39
|
+
capture_output=True, text=True, timeout=30)
|
|
40
|
+
out = proc.stdout
|
|
41
|
+
except subprocess.TimeoutExpired:
|
|
42
|
+
return Probe(path, True, p.stat().st_size, 0.0, 0.0, 0, 0, False)
|
|
43
|
+
meta = json.loads(out or "{}")
|
|
44
|
+
dur = float(meta.get("format", {}).get("duration", 0) or 0)
|
|
45
|
+
vid = next((s for s in meta.get("streams", []) if s.get("codec_type") == "video"), {})
|
|
46
|
+
aud = any(s.get("codec_type") == "audio" for s in meta.get("streams", []))
|
|
47
|
+
rfr = vid.get("r_frame_rate", "0/1")
|
|
48
|
+
try:
|
|
49
|
+
num, den = rfr.split("/"); fps = float(num) / float(den) if float(den) else 0.0
|
|
50
|
+
except Exception:
|
|
51
|
+
fps = 0.0
|
|
52
|
+
return Probe(path, True, p.stat().st_size, dur, fps,
|
|
53
|
+
int(vid.get("width", 0)), int(vid.get("height", 0)), aud)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_cli(cmd: list[str], cwd: str, out_glob: str, search_dir: str,
|
|
57
|
+
timeout: int = 600) -> Probe:
|
|
58
|
+
"""Generic CLI render driver: run `cmd` in cwd, then locate the newest mp4 matching out_glob
|
|
59
|
+
under search_dir, return its Probe. Each kit supplies its own command + glob.
|
|
60
|
+
Raises RuntimeError on non-zero exit or no output (no silent failure)."""
|
|
61
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd)
|
|
62
|
+
if proc.returncode != 0:
|
|
63
|
+
raise RuntimeError(f"render failed (exit {proc.returncode}):\n{proc.stderr[-1500:]}")
|
|
64
|
+
candidates = list(Path(search_dir).rglob(out_glob))
|
|
65
|
+
if not candidates:
|
|
66
|
+
raise RuntimeError(f"render exit 0 but no {out_glob} found under {search_dir}")
|
|
67
|
+
newest = max(candidates, key=lambda p: (p.stat().st_mtime, str(p)))
|
|
68
|
+
return ffprobe(str(newest))
|
vidkit_core/theme.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""vqkit.theme — design tokens for cinematic Manim videos.
|
|
2
|
+
|
|
3
|
+
Single source of truth for color/typography/timing/layout, extracted from the
|
|
4
|
+
anti-ppt-cinematic-playbook. Every scene imports from here so videos share a
|
|
5
|
+
visual language (playbook principle: "cohesive visual language across scenes").
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Palette (Neon-Tech base from the playbook, dark bg + high-contrast accents) ──
|
|
12
|
+
class Palette:
|
|
13
|
+
BG = "#0E1116" # near-black, maximizes contrast (playbook principle 11)
|
|
14
|
+
BG_GRADIENT_TOP = "#141925"
|
|
15
|
+
BG_GRADIENT_BOTTOM = "#0A0C10"
|
|
16
|
+
PRIMARY = "#58A6FF" # blue — primary concept
|
|
17
|
+
SECONDARY = "#4ECDC4" # teal — secondary
|
|
18
|
+
ACCENT = "#FFD93D" # yellow — attention/highlight
|
|
19
|
+
WIN = "#6BCB77" # green — success/winner
|
|
20
|
+
DANGER = "#FF6B6B" # red — error/emphasis
|
|
21
|
+
FUSE = "#A78BFA" # violet — synthesis
|
|
22
|
+
INK = "#E6E9EF" # default text
|
|
23
|
+
MUTED = "#9AA3B2" # context text
|
|
24
|
+
DIM = 0.18 # opacity for de-emphasized (spotlight losers)
|
|
25
|
+
CONTEXT = 0.4 # opacity for context layer
|
|
26
|
+
GRID = 0.15 # opacity for structural (axes/grid)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Typography (Menlo mono — Pango kerning-safe per playbook) ──
|
|
30
|
+
class Type:
|
|
31
|
+
FONT = "Menlo" # mono: zero kerning bugs (playbook font rule)
|
|
32
|
+
TITLE = 48
|
|
33
|
+
H1 = 36
|
|
34
|
+
H2 = 28
|
|
35
|
+
BODY = 22
|
|
36
|
+
LABEL = 19
|
|
37
|
+
SMALL = 16
|
|
38
|
+
MIN = 18 # min readable size at -ql (playbook)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Timing (breathing room — playbook principle 8) ──
|
|
42
|
+
class Timing:
|
|
43
|
+
BEAT = 0.6 # pause after a minor reveal
|
|
44
|
+
HOLD = 1.5 # pause after a key reveal (playbook minimum)
|
|
45
|
+
LONG_HOLD = 2.0 # pause after the payoff
|
|
46
|
+
WRITE = 1.2 # text write run_time
|
|
47
|
+
MOVE = 1.4 # camera move run_time
|
|
48
|
+
FAST = 0.6
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── Camera framing (frame default width = 14.222) ──
|
|
52
|
+
class Cam:
|
|
53
|
+
FULL_W = 14.222
|
|
54
|
+
WIDE_W = 16.0 # pan-out to show more
|
|
55
|
+
ZOOM_W = 7.0 # framed zoom (incl. labels)
|
|
56
|
+
TIGHT_W = 4.8 # ~3x magnification
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── RRF / fusion constant (used by the example scenes) ──
|
|
60
|
+
RRF_K = 60
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Theme:
|
|
65
|
+
"""Bundled theme passed to CinematicScene; override per-video for brand variants."""
|
|
66
|
+
palette: type = field(default=Palette)
|
|
67
|
+
type: type = field(default=Type)
|
|
68
|
+
timing: type = field(default=Timing)
|
|
69
|
+
cam: type = field(default=Cam)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DEFAULT_THEME = Theme()
|