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
vqkit/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""vqkit — cinematic Manim video toolkit. Makes every video cinematic-by-default.
|
|
2
|
+
|
|
3
|
+
Council-locked design (2026-06-15, think-max+deep unanimous): a reusable production
|
|
4
|
+
toolkit (NOT a fixed gallery), with 4-layer deterministic tests (render/bbox/audio/pHash,
|
|
5
|
+
NO vision-model). Built by refactoring FROM the 3 concrete example scenes.
|
|
6
|
+
|
|
7
|
+
The manim-dependent scene classes (CinematicScene, Cinematic3DScene, tex_or_text) are
|
|
8
|
+
LAZY (PEP 562 __getattr__) so the manim-free submodules — audio, export, layout, phash,
|
|
9
|
+
render — import without manim installed. `import vqkit; vqkit.audio` works with zero
|
|
10
|
+
heavy deps; `from vqkit import CinematicScene` triggers the manim import on demand
|
|
11
|
+
(install via `pip install vidkit[manim]`).
|
|
12
|
+
"""
|
|
13
|
+
from .theme import DEFAULT_THEME, Theme, Palette, Type, Timing, Cam, RRF_K
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CinematicScene", "Cinematic3DScene", "tex_or_text",
|
|
18
|
+
"DEFAULT_THEME", "Theme", "Palette", "Type", "Timing", "Cam", "RRF_K",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# Lazy manim-dependent exports — only imported when actually accessed, so the
|
|
22
|
+
# renderer-agnostic submodules stay importable without manim.
|
|
23
|
+
_LAZY = {
|
|
24
|
+
"CinematicScene": ("vqkit.scene", "CinematicScene"),
|
|
25
|
+
"tex_or_text": ("vqkit.scene", "tex_or_text"),
|
|
26
|
+
"Cinematic3DScene": ("vqkit.scene3d", "Cinematic3DScene"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name: str): # PEP 562
|
|
31
|
+
target = _LAZY.get(name)
|
|
32
|
+
if target is None:
|
|
33
|
+
raise AttributeError(f"module 'vqkit' has no attribute {name!r}")
|
|
34
|
+
import importlib
|
|
35
|
+
mod = importlib.import_module(target[0])
|
|
36
|
+
return getattr(mod, target[1])
|
vqkit/audio.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
fc = (
|
|
89
|
+
"[2:a]volume=" + str(music_gain_db) + "dB[mbed];"
|
|
90
|
+
"[mbed][1:a]sidechaincompress=threshold=0.05:ratio=8:attack=20:release=300[ducked];"
|
|
91
|
+
"[1:a][ducked]amix=inputs=2:duration=longest:dropout_transition=0[mix];"
|
|
92
|
+
f"[mix]loudnorm=I={target_lufs}:TP=-1.5:LRA=11[aout]"
|
|
93
|
+
)
|
|
94
|
+
cmd = ["ffmpeg", "-y", "-i", video, "-i", voiceover, "-i", music,
|
|
95
|
+
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
|
96
|
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", out_path]
|
|
97
|
+
else:
|
|
98
|
+
track = voiceover or music
|
|
99
|
+
fc = f"[1:a]loudnorm=I={target_lufs}:TP=-1.5:LRA=11[aout]"
|
|
100
|
+
cmd = ["ffmpeg", "-y", "-i", video, "-i", track,
|
|
101
|
+
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
|
102
|
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", out_path]
|
|
103
|
+
|
|
104
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
105
|
+
if proc.returncode != 0:
|
|
106
|
+
raise RuntimeError(f"audio mux failed (exit {proc.returncode}):\n{proc.stderr[-1200:]}")
|
|
107
|
+
if not Path(out_path).exists() or Path(out_path).stat().st_size == 0:
|
|
108
|
+
raise RuntimeError("audio mux produced no output")
|
|
109
|
+
return out_path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def synth_voiceover(text: str, out_path: str, voice: str = "en-US-AriaNeural",
|
|
113
|
+
timeout: int = 120) -> str:
|
|
114
|
+
"""Synthesize narration with edge-TTS (free, no key, good quality). Returns path.
|
|
115
|
+
Falls back through edge -> gTTS if edge unavailable."""
|
|
116
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
try:
|
|
118
|
+
import edge_tts, asyncio
|
|
119
|
+
async def _go():
|
|
120
|
+
await edge_tts.Communicate(text, voice).save(out_path)
|
|
121
|
+
asyncio.run(_go())
|
|
122
|
+
if Path(out_path).exists() and Path(out_path).stat().st_size > 0:
|
|
123
|
+
return out_path
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
# fallback: gTTS
|
|
127
|
+
try:
|
|
128
|
+
from gtts import gTTS
|
|
129
|
+
gTTS(text=text, lang="en").save(out_path)
|
|
130
|
+
return out_path
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise RuntimeError(f"no TTS backend available: {e}")
|
vqkit/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)
|
vqkit/layout.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""vqkit.layout — deterministic mobject-bbox layout assertions (L2 test layer).
|
|
2
|
+
|
|
3
|
+
Council L2 (primary regression mechanism): at named checkpoints, snapshot
|
|
4
|
+
{mobject: bbox, z, opacity} and assert geometric invariants — NO vision model.
|
|
5
|
+
- no mobject exceeds the safe frame
|
|
6
|
+
- no overlap between sibling mobjects that should be disjoint
|
|
7
|
+
- snapshot -> JSON for golden-diff regression (survives color/AA drift)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Manim default frame (config.frame_width/height); safe area = 92% (title/action safe)
|
|
16
|
+
FRAME_W = 14.222222
|
|
17
|
+
FRAME_H = 8.0
|
|
18
|
+
SAFE_FRAC = 0.96 # mobjects must stay within 96% of frame (small bleed tolerance)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BBox:
|
|
23
|
+
x0: float; y0: float; x1: float; y1: float
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def w(self): return self.x1 - self.x0
|
|
27
|
+
@property
|
|
28
|
+
def h(self): return self.y1 - self.y0
|
|
29
|
+
@property
|
|
30
|
+
def cx(self): return (self.x0 + self.x1) / 2
|
|
31
|
+
@property
|
|
32
|
+
def cy(self): return (self.y0 + self.y1) / 2
|
|
33
|
+
|
|
34
|
+
def overlaps(self, other: "BBox", pad: float = 0.0) -> bool:
|
|
35
|
+
return not (self.x1 + pad <= other.x0 or other.x1 + pad <= self.x0 or
|
|
36
|
+
self.y1 + pad <= other.y0 or other.y1 + pad <= self.y0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bbox_of(mobj) -> BBox:
|
|
40
|
+
"""Bounding box of a manim mobject in scene coordinates (deterministic).
|
|
41
|
+
Raises ValueError on an empty mobject (review P1-2): an empty VGroup/VMobject collapses
|
|
42
|
+
to (0,0,0,0) at the origin, which would silently pass safe-frame/overlap checks. A layout
|
|
43
|
+
test wants that flagged, not waved through."""
|
|
44
|
+
if hasattr(mobj, "family_members_with_points") and not mobj.family_members_with_points():
|
|
45
|
+
raise ValueError("empty mobject has no bounding box (no points)")
|
|
46
|
+
left = mobj.get_left()[0]; right = mobj.get_right()[0]
|
|
47
|
+
bottom = mobj.get_bottom()[1]; top = mobj.get_top()[1]
|
|
48
|
+
return BBox(round(float(left), 3), round(float(bottom), 3),
|
|
49
|
+
round(float(right), 3), round(float(top), 3))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def exceeds_safe_frame(bb: BBox, frac: float = SAFE_FRAC) -> bool:
|
|
53
|
+
"""True if the bbox pokes outside the safe frame area."""
|
|
54
|
+
hw, hh = FRAME_W * frac / 2, FRAME_H * frac / 2
|
|
55
|
+
return bb.x0 < -hw or bb.x1 > hw or bb.y0 < -hh or bb.y1 > hh
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def assert_within_frame(mobjects: dict, frac: float = SAFE_FRAC) -> list[str]:
|
|
59
|
+
"""Return list of mobject-keys that breach the safe frame. Empty = pass."""
|
|
60
|
+
breaches = []
|
|
61
|
+
for key, mobj in mobjects.items():
|
|
62
|
+
try:
|
|
63
|
+
bb = bbox_of(mobj)
|
|
64
|
+
except ValueError as e:
|
|
65
|
+
breaches.append(f"{key}: {e}") # empty mobject is a layout defect, flag it (P1-2)
|
|
66
|
+
continue
|
|
67
|
+
if exceeds_safe_frame(bb, frac):
|
|
68
|
+
breaches.append(f"{key} breaches safe-frame: bbox=({bb.x0},{bb.y0})-({bb.x1},{bb.y1})")
|
|
69
|
+
return breaches
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def assert_no_overlap(mobjects: dict, pad: float = 0.0,
|
|
73
|
+
ignore_pairs: set | None = None) -> list[str]:
|
|
74
|
+
"""Return list of overlapping sibling pairs that should be disjoint. Empty = pass."""
|
|
75
|
+
ignore_pairs = ignore_pairs or set()
|
|
76
|
+
keys = list(mobjects.keys())
|
|
77
|
+
overlaps = []
|
|
78
|
+
for i in range(len(keys)):
|
|
79
|
+
for j in range(i + 1, len(keys)):
|
|
80
|
+
ka, kb = keys[i], keys[j]
|
|
81
|
+
if (ka, kb) in ignore_pairs or (kb, ka) in ignore_pairs:
|
|
82
|
+
continue
|
|
83
|
+
if bbox_of(mobjects[ka]).overlaps(bbox_of(mobjects[kb]), pad):
|
|
84
|
+
overlaps.append(f"{ka} overlaps {kb}")
|
|
85
|
+
return overlaps
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def snapshot(mobjects: dict) -> dict:
|
|
89
|
+
"""{key: {bbox, opacity}} snapshot for golden-diff regression.
|
|
90
|
+
An empty mobject records {bbox: None, error: ...} rather than crashing the snapshot (P1-2)."""
|
|
91
|
+
snap = {}
|
|
92
|
+
for key, mobj in mobjects.items():
|
|
93
|
+
try:
|
|
94
|
+
bb = bbox_of(mobj)
|
|
95
|
+
bbox_d = asdict(bb)
|
|
96
|
+
except ValueError as e:
|
|
97
|
+
snap[key] = {"bbox": None, "opacity": None, "error": str(e)}
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
op = float(getattr(mobj, "fill_opacity", None) or mobj.get_fill_opacity())
|
|
101
|
+
except Exception:
|
|
102
|
+
op = None
|
|
103
|
+
snap[key] = {"bbox": bbox_d, "opacity": op}
|
|
104
|
+
return snap
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def diff_snapshot(golden: dict, current: dict, pos_tol: float = 0.15,
|
|
108
|
+
opacity_tol: float = 0.1) -> list[str]:
|
|
109
|
+
"""Compare two snapshots; return drift lines. pos_tol in scene units, opacity_tol 0..1.
|
|
110
|
+
Compares opacity too (review P3-2) so an opacity regression — e.g. a winner that fails to
|
|
111
|
+
highlight, or losers that fail to dim (P1-1) — is caught, not just position drift."""
|
|
112
|
+
drift = []
|
|
113
|
+
gk, ck = set(golden), set(current)
|
|
114
|
+
for k in gk - ck:
|
|
115
|
+
drift.append(f"MISSING: {k} present in golden, absent now")
|
|
116
|
+
for k in ck - gk:
|
|
117
|
+
drift.append(f"ADDED: {k} present now, absent in golden")
|
|
118
|
+
for k in gk & ck:
|
|
119
|
+
gb, cb = golden[k]["bbox"], current[k]["bbox"]
|
|
120
|
+
for f in ("x0", "y0", "x1", "y1"):
|
|
121
|
+
if abs(gb[f] - cb[f]) > pos_tol:
|
|
122
|
+
drift.append(f"DRIFT: {k}.{f} {gb[f]} -> {cb[f]} (>{pos_tol})")
|
|
123
|
+
go, co = golden[k].get("opacity"), current[k].get("opacity")
|
|
124
|
+
if go is not None and co is not None and abs(go - co) > opacity_tol:
|
|
125
|
+
drift.append(f"DRIFT: {k}.opacity {go} -> {co} (>{opacity_tol})")
|
|
126
|
+
return drift
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_golden(snap: dict, path: str):
|
|
130
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
with open(path, "w") as f:
|
|
132
|
+
json.dump(snap, f, indent=2)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_golden(path: str) -> dict | None:
|
|
136
|
+
p = Path(path)
|
|
137
|
+
if not p.exists():
|
|
138
|
+
return None
|
|
139
|
+
with open(p) as f:
|
|
140
|
+
return json.load(f)
|
vqkit/phash.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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 lazy-imported (see vidkit_core/phash.py) so vqkit's manim-free parts import without it.
|
|
14
|
+
def _require_pil():
|
|
15
|
+
try:
|
|
16
|
+
from PIL import Image
|
|
17
|
+
return Image
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise RuntimeError('phash needs Pillow: pip install pillow (or vidkit[manim])') from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dhash(img, hash_size: int = 8) -> int:
|
|
23
|
+
"""Difference hash (dHash). `img` is a PIL.Image.Image."""
|
|
24
|
+
Image = _require_pil()
|
|
25
|
+
img = img.convert("L").resize((hash_size + 1, hash_size), Image.LANCZOS)
|
|
26
|
+
bits = 0
|
|
27
|
+
idx = 0
|
|
28
|
+
px = img.load()
|
|
29
|
+
for y in range(hash_size):
|
|
30
|
+
for x in range(hash_size):
|
|
31
|
+
bits |= (1 if px[x, y] < px[x + 1, y] else 0) << idx
|
|
32
|
+
idx += 1
|
|
33
|
+
return bits
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hamming(a: int, b: int) -> int:
|
|
37
|
+
return bin(a ^ b).count("1")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_frame(video: str, at: float, out_png: str) -> str:
|
|
41
|
+
Path(out_png).parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
subprocess.run(["ffmpeg", "-y", "-ss", str(at), "-i", video, "-frames:v", "1", out_png],
|
|
43
|
+
capture_output=True, check=True, timeout=60) # review P3: timeout
|
|
44
|
+
return out_png
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def keyframe_hashes(video: str, timestamps: list[float], work_dir: str) -> dict:
|
|
48
|
+
"""Return {timestamp: dhash} for the given keyframes. Keys normalized to 3dp so int/float
|
|
49
|
+
callers don't desync golden vs current (review P3)."""
|
|
50
|
+
Image = _require_pil()
|
|
51
|
+
out = {}
|
|
52
|
+
for i, t in enumerate(timestamps):
|
|
53
|
+
png = extract_frame(video, t, str(Path(work_dir) / f"kf_{i}.png"))
|
|
54
|
+
with Image.open(png) as im: # review P3: close the PIL handle
|
|
55
|
+
out[f"{float(t):.3f}"] = _dhash(im)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_golden(hashes: dict, path: str):
|
|
60
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with open(path, "w") as f:
|
|
62
|
+
json.dump(hashes, f, indent=2)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_golden(path: str) -> dict | None:
|
|
66
|
+
p = Path(path)
|
|
67
|
+
if not p.exists():
|
|
68
|
+
return None
|
|
69
|
+
with open(p) as f:
|
|
70
|
+
return json.load(f)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compare(golden: dict, current: dict, tolerance: int = 6) -> list[str]:
|
|
74
|
+
"""Return drift lines for keyframes whose Hamming distance exceeds tolerance.
|
|
75
|
+
tolerance>=6 per council (avoids AA/font-hint false positives)."""
|
|
76
|
+
drift = []
|
|
77
|
+
for ts, gh in golden.items():
|
|
78
|
+
if ts not in current:
|
|
79
|
+
drift.append(f"MISSING keyframe {ts}")
|
|
80
|
+
continue
|
|
81
|
+
d = hamming(int(gh), int(current[ts]))
|
|
82
|
+
if d > tolerance:
|
|
83
|
+
drift.append(f"keyframe {ts}: Hamming {d} > {tolerance} (gross visual change)")
|
|
84
|
+
return drift
|
vqkit/publish.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""vqkit.publish — auto-publish-to-X lever. ARMED but DRY-RUN ONLY by default.
|
|
2
|
+
|
|
3
|
+
HARD SAFETY BOUNDARY (operator constraint, SOUL §5, SPEC §6.2):
|
|
4
|
+
This module NEVER sends a public post on its own. publish() defaults to dry_run=True and
|
|
5
|
+
REQUIRES an explicit confirm token to actually send. Even with the token, sending is gated
|
|
6
|
+
behind a function the agent will not call without explicit user "go". The build is complete
|
|
7
|
+
and verifiable; firing is a human decision.
|
|
8
|
+
|
|
9
|
+
Composes: a video bundle (from export.export_bundle) + caption + alt-text -> a ready-to-post
|
|
10
|
+
payload. Verifies the media exists and is within X's limits. In dry-run, prints exactly what
|
|
11
|
+
WOULD be posted. Real send path is stubbed with a refusal unless confirm == REQUIRED_TOKEN
|
|
12
|
+
AND a real X credential is wired (neither is, by design).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
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
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PostPayload:
|
|
28
|
+
text: str
|
|
29
|
+
media_path: str
|
|
30
|
+
alt_text: str
|
|
31
|
+
media_bytes: int
|
|
32
|
+
media_seconds: float
|
|
33
|
+
within_limits: bool
|
|
34
|
+
limit_notes: list
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compose(video_path: str, text: str, alt_text: str = "") -> PostPayload:
|
|
38
|
+
"""Build + validate a post payload from a rendered video. No network."""
|
|
39
|
+
from .render import ffprobe
|
|
40
|
+
p = Path(video_path)
|
|
41
|
+
notes = []
|
|
42
|
+
if not p.exists():
|
|
43
|
+
return PostPayload(text, video_path, alt_text, 0, 0.0, False, ["media file not found"])
|
|
44
|
+
size = p.stat().st_size
|
|
45
|
+
probe = ffprobe(video_path)
|
|
46
|
+
if size > MAX_VIDEO_BYTES:
|
|
47
|
+
notes.append(f"video {size/1e6:.1f}MB > 512MB limit")
|
|
48
|
+
if probe.duration > MAX_VIDEO_SECONDS:
|
|
49
|
+
notes.append(f"video {probe.duration:.0f}s > {MAX_VIDEO_SECONDS}s limit")
|
|
50
|
+
if len(text) > 280:
|
|
51
|
+
notes.append(f"caption {len(text)} chars > 280")
|
|
52
|
+
if not alt_text:
|
|
53
|
+
notes.append("no alt-text (accessibility)")
|
|
54
|
+
within = not [n for n in notes if "limit" in n or ">" in n]
|
|
55
|
+
return PostPayload(text, video_path, alt_text, size, probe.duration, within, notes)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def publish(payload: PostPayload, dry_run: bool = True, confirm: str = "") -> dict:
|
|
59
|
+
"""Publish to X. DEFAULT dry_run=True -> never sends, returns the would-post plan.
|
|
60
|
+
|
|
61
|
+
To actually send, ALL must hold: dry_run=False AND confirm==REQUIRED_TOKEN AND a wired
|
|
62
|
+
X credential. The credential is intentionally NOT wired here -> even a fully-authorized
|
|
63
|
+
call returns a structured 'blocked: no credential' rather than posting. This is the
|
|
64
|
+
armed-but-safe state: everything is built and validated; the human fires it elsewhere.
|
|
65
|
+
"""
|
|
66
|
+
plan = {
|
|
67
|
+
"would_post": {
|
|
68
|
+
"text": payload.text,
|
|
69
|
+
"media": payload.media_path,
|
|
70
|
+
"alt_text": payload.alt_text,
|
|
71
|
+
"media_bytes": payload.media_bytes,
|
|
72
|
+
"media_seconds": round(payload.media_seconds, 1),
|
|
73
|
+
},
|
|
74
|
+
"within_limits": payload.within_limits,
|
|
75
|
+
"limit_notes": payload.limit_notes,
|
|
76
|
+
}
|
|
77
|
+
if dry_run:
|
|
78
|
+
plan["status"] = "DRY_RUN — nothing sent"
|
|
79
|
+
return plan
|
|
80
|
+
if confirm != REQUIRED_TOKEN:
|
|
81
|
+
plan["status"] = "BLOCKED — missing explicit confirm token (no post sent)"
|
|
82
|
+
return plan
|
|
83
|
+
if not payload.within_limits:
|
|
84
|
+
plan["status"] = "BLOCKED — media exceeds X limits (no post sent)"
|
|
85
|
+
return plan
|
|
86
|
+
# Real send path — intentionally not wired. Requires X API credential the user must
|
|
87
|
+
# provide + an explicit go. We refuse rather than post autonomously.
|
|
88
|
+
cred = os.environ.get("VQKIT_X_BEARER")
|
|
89
|
+
if not cred:
|
|
90
|
+
plan["status"] = ("ARMED but BLOCKED — no X credential wired (VQKIT_X_BEARER unset). "
|
|
91
|
+
"Posting is a human action; this module will not post autonomously.")
|
|
92
|
+
return plan
|
|
93
|
+
plan["status"] = ("REFUSED — autonomous public posting is disabled by design. "
|
|
94
|
+
"Even with token+credential, a human must invoke the send path explicitly.")
|
|
95
|
+
return plan
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def dry_run_report(video_path: str, text: str, alt_text: str = "") -> str:
|
|
99
|
+
"""Convenience: compose + dry-run publish, return a readable report."""
|
|
100
|
+
payload = compose(video_path, text, alt_text)
|
|
101
|
+
plan = publish(payload, dry_run=True)
|
|
102
|
+
return json.dumps(plan, indent=2)
|