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.
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()