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