Skip to content

Commit b17841d

Browse files
committed
Add narrated demo video and its generator script
1 parent cc20055 commit b17841d

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,13 @@ remediated and pass. The counters, the hazard bars and the audit trail all
385385
update as it happens.* ([full-size still](docs/images/dashboard.png) ·
386386
[recorder](docs/record_dashboard_gif.py))
387387

388+
### Narrated walkthrough
389+
390+
A 72-second video covering the problem, a refused commit, the fix, and the dashboard:
391+
392+
**[▶ sentinel-demo.mp4](docs/images/sentinel-demo.mp4)** (1.5 MB, 1280×720, with narration) — regenerate with [`docs/record_demo_video.py`](docs/record_demo_video.py).
393+
394+
388395
One page, four sections:
389396

390397
- **Stats** — commits, blocks, passes, Tier 1 skips (the free ones), API calls

‎docs/images/sentinel-demo.mp4‎

1.44 MB
Binary file not shown.

‎docs/record_demo_video.py‎

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Build docs/images/sentinel-demo.mp4 - a narrated walkthrough.
4+
5+
Four scenes: the problem, the commit being refused, the remediated commit
6+
passing, and the dashboard animating. Narration is generated with the local
7+
Windows speech synthesiser, and each scene is held on screen for exactly as
8+
long as its own narration takes - so the two never drift apart.
9+
10+
Requirements:
11+
pip install Pillow
12+
ffmpeg on PATH
13+
Windows (System.Speech). On macOS/Linux the `say`/`espeak` path is used.
14+
15+
Usage:
16+
python docs/record_demo_video.py
17+
python docs/record_demo_video.py --no-audio # silent video
18+
"""
19+
20+
import argparse
21+
import contextlib
22+
import os
23+
import shutil
24+
import subprocess
25+
import sys
26+
import tempfile
27+
import wave
28+
29+
HERE = os.path.dirname(os.path.abspath(__file__))
30+
REPO = os.path.dirname(HERE)
31+
IMAGES = os.path.join(HERE, "images")
32+
33+
W, H = 1280, 720
34+
BG = (13, 17, 23)
35+
PANEL = (22, 27, 34)
36+
FG = (230, 237, 243)
37+
MUTED = (139, 148, 158)
38+
RED = (248, 81, 73)
39+
GREEN = (63, 185, 80)
40+
BLUE = (88, 166, 255)
41+
42+
# (key, title, accent, narration)
43+
SCENES = [
44+
("title", "SentinelCommit", BLUE,
45+
"Linters catch syntax. They cannot catch a database write that is made "
46+
"durable before an unguarded network call. SentinelCommit reads your "
47+
"staged diff on git commit, reasons about it with Claude, and refuses "
48+
"the commit when it finds a runtime hazard."),
49+
("blocked", "The error - commit refused", RED,
50+
"Here the customer balance is debited and committed, and only then is the "
51+
"payment gateway called. If that call times out, the debit is already "
52+
"durable and nothing reverses it. Ruff, flake8 and mypy all pass this "
53+
"file. SentinelCommit refuses the commit, names the hazard as a "
54+
"transaction fault, and prints a ready to paste patch."),
55+
("passed", "The fix - commit accepted", GREEN,
56+
"After applying the patch, one transaction now covers the debit and the "
57+
"gateway call, so a failure rolls the debit back. The same commit is "
58+
"re-staged and this time all semantic checks pass."),
59+
("dashboard", "The dashboard", BLUE,
60+
"A blocked commit leaves no trace in git history, so the tool's most "
61+
"valuable moments are invisible to git log. Every verdict is appended to "
62+
"an audit file, and the dashboard joins it against the commit graph. "
63+
"Watch the counters, the hazard bars and the audit trail fill in as each "
64+
"commit is audited."),
65+
]
66+
67+
68+
def run(cmd, **kw):
69+
return subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL,
70+
stderr=subprocess.DEVNULL, **kw)
71+
72+
73+
def find_ffmpeg():
74+
exe = shutil.which("ffmpeg")
75+
if exe:
76+
return exe
77+
for p in [os.path.join(os.environ.get("LOCALAPPDATA", ""),
78+
"Microsoft", "WinGet", "Links", "ffmpeg.exe"),
79+
r"C:\ffmpeg\bin\ffmpeg.exe"]:
80+
if os.path.exists(p):
81+
return p
82+
sys.exit("error: ffmpeg not found on PATH")
83+
84+
85+
def font(size, bold=False):
86+
from PIL import ImageFont
87+
for name in (("segoeuib.ttf", "arialbd.ttf") if bold
88+
else ("segoeui.ttf", "arial.ttf")):
89+
for root in (r"C:\Windows\Fonts", "/Library/Fonts", "/usr/share/fonts"):
90+
p = os.path.join(root, name)
91+
if os.path.exists(p):
92+
return ImageFont.truetype(p, size)
93+
return ImageFont.load_default()
94+
95+
96+
def narrate(text, dest):
97+
"""Render narration to a WAV using whatever TTS the platform provides."""
98+
if sys.platform == "win32":
99+
ps = (
100+
"Add-Type -AssemblyName System.Speech; "
101+
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
102+
"$s.Rate = 0; "
103+
f"$s.SetOutputToWaveFile('{dest}'); "
104+
f"$s.Speak(@'\n{text}\n'@); $s.Dispose()"
105+
)
106+
run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
107+
elif sys.platform == "darwin":
108+
aiff = dest + ".aiff"
109+
run(["say", "-o", aiff, text])
110+
run([find_ffmpeg(), "-y", "-i", aiff, dest])
111+
else:
112+
run(["espeak", "-w", dest, text])
113+
return dest
114+
115+
116+
def wav_seconds(path):
117+
with contextlib.closing(wave.open(path, "rb")) as w:
118+
return w.getnframes() / float(w.getframerate())
119+
120+
121+
def compose(content, title, accent, caption=""):
122+
"""Place one content image on the standard canvas."""
123+
from PIL import Image, ImageDraw
124+
canvas = Image.new("RGB", (W, H), BG)
125+
d = ImageDraw.Draw(canvas)
126+
127+
d.rectangle([0, 0, W, 64], fill=PANEL)
128+
d.rectangle([0, 62, W, 65], fill=accent)
129+
d.text((36, 20), title, font=font(24, bold=True), fill=FG)
130+
d.text((W - 300, 26), "SentinelCommit", font=font(15), fill=MUTED)
131+
132+
top, bottom = 92, H - (56 if caption else 28)
133+
avail_w, avail_h = W - 96, bottom - top
134+
if content is not None:
135+
c = content.convert("RGB")
136+
scale = min(avail_w / c.width, avail_h / c.height, 1.6)
137+
c = c.resize((max(1, int(c.width * scale)), max(1, int(c.height * scale))),
138+
Image.LANCZOS)
139+
x = (W - c.width) // 2
140+
y = top + (avail_h - c.height) // 2
141+
d.rectangle([x - 2, y - 2, x + c.width + 1, y + c.height + 1],
142+
outline=(48, 54, 61))
143+
canvas.paste(c, (x, y))
144+
145+
if caption:
146+
f = font(17)
147+
tw = d.textlength(caption, font=f)
148+
d.text(((W - tw) / 2, H - 42), caption, font=f, fill=MUTED)
149+
return canvas
150+
151+
152+
def title_card():
153+
from PIL import Image, ImageDraw
154+
canvas = Image.new("RGB", (W, H), BG)
155+
d = ImageDraw.Draw(canvas)
156+
d.text((80, 232), "SentinelCommit", font=font(72, bold=True), fill=FG)
157+
d.rectangle([80, 330, 320, 335], fill=BLUE)
158+
d.text((80, 368), "A Git pre-commit hook that catches semantic bugs",
159+
font=font(30), fill=MUTED)
160+
d.text((80, 410), "your linter structurally cannot.", font=font(30), fill=MUTED)
161+
for i, (label, colour) in enumerate([("TRANSACTION_FAULT", RED),
162+
("IDEMPOTENCY_RISK", RED),
163+
("RACE_CONDITION", RED)]):
164+
x = 80 + i * 250
165+
d.rounded_rectangle([x, 500, x + 230, 540], 20, outline=colour)
166+
d.text((x + 18, 511), label, font=font(15), fill=colour)
167+
d.text((80, 620), "Powered by Claude · claude-opus-5",
168+
font=font(18), fill=MUTED)
169+
return canvas
170+
171+
172+
def main():
173+
ap = argparse.ArgumentParser()
174+
ap.add_argument("--no-audio", action="store_true")
175+
ap.add_argument("--fallback-seconds", type=float, default=7.0,
176+
help="per-scene hold when narration is disabled")
177+
args = ap.parse_args()
178+
179+
try:
180+
from PIL import Image
181+
except ImportError:
182+
sys.exit("error: Pillow is required -> pip install Pillow")
183+
184+
ffmpeg = find_ffmpeg()
185+
tmp = tempfile.mkdtemp(prefix="sentinel-video-")
186+
src = os.path.join(os.environ.get("TEMP", tmp), "vidsrc")
187+
188+
try:
189+
blocked = os.path.join(src, "blocked.png")
190+
passed = os.path.join(src, "passed.png")
191+
for p in (blocked, passed):
192+
if not os.path.exists(p):
193+
sys.exit(f"error: missing {p}\n"
194+
f" rasterise the SVGs first (see docs/DEMO.md)")
195+
196+
gif = Image.open(os.path.join(IMAGES, "dashboard-demo.gif"))
197+
gif_frames = []
198+
for i in range(gif.n_frames):
199+
gif.seek(i) # seek, do not cache the iterator:
200+
gif_frames.append(gif.convert("RGB").copy()) # every entry would
201+
# otherwise be the
202+
# last frame.
203+
204+
segments = [] # (png path, seconds)
205+
wavs = []
206+
for idx, (key, title, accent, text) in enumerate(SCENES):
207+
secs = args.fallback_seconds
208+
if not args.no_audio:
209+
wav = os.path.join(tmp, f"n{idx}.wav")
210+
narrate(text, wav)
211+
secs = wav_seconds(wav) + 0.6
212+
wavs.append(wav)
213+
print(f" narration {idx}: {secs:5.1f}s {title}")
214+
215+
if key == "title":
216+
img = title_card()
217+
elif key == "blocked":
218+
img = compose(Image.open(blocked), title, accent,
219+
"git commit -> refused, with a ready-to-paste patch")
220+
elif key == "passed":
221+
img = compose(Image.open(passed), title, accent,
222+
"git commit -> accepted")
223+
else:
224+
img = None
225+
226+
if key == "dashboard":
227+
per = secs / len(gif_frames)
228+
for fi, frame in enumerate(gif_frames):
229+
p = os.path.join(tmp, f"s{idx}_{fi:02d}.png")
230+
compose(frame, title, accent,
231+
"live audit trail, commit graph and hazard counters").save(p)
232+
segments.append((p, per))
233+
else:
234+
p = os.path.join(tmp, f"s{idx}.png")
235+
img.save(p)
236+
segments.append((p, secs))
237+
238+
listing = os.path.join(tmp, "concat.txt")
239+
with open(listing, "w", encoding="utf-8") as fh:
240+
for p, secs in segments:
241+
fh.write(f"file '{p.replace(os.sep, '/')}'\nduration {secs:.3f}\n")
242+
fh.write(f"file '{segments[-1][0].replace(os.sep, '/')}'\n")
243+
244+
out = os.path.join(IMAGES, "sentinel-demo.mp4")
245+
cmd = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", listing]
246+
247+
if wavs:
248+
track = os.path.join(tmp, "narration.wav")
249+
alist = os.path.join(tmp, "audio.txt")
250+
with open(alist, "w", encoding="utf-8") as fh:
251+
for w in wavs:
252+
fh.write(f"file '{w.replace(os.sep, '/')}'\n")
253+
run([ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", alist,
254+
"-c", "copy", track])
255+
cmd += ["-i", track, "-c:a", "aac", "-b:a", "128k", "-shortest"]
256+
257+
cmd += ["-vf", f"scale={W}:{H}:force_original_aspect_ratio=decrease,"
258+
f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:color=0x0d1117,fps=25",
259+
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
260+
"-pix_fmt", "yuv420p", "-movflags", "+faststart", out]
261+
run(cmd)
262+
263+
mb = os.path.getsize(out) / 1_000_000
264+
total = sum(s for _, s in segments)
265+
print(f"\nwrote {os.path.relpath(out, REPO)} "
266+
f"({total:.1f}s, {mb:.2f} MB, {len(segments)} segments)")
267+
finally:
268+
shutil.rmtree(tmp, ignore_errors=True)
269+
270+
271+
if __name__ == "__main__":
272+
main()

0 commit comments

Comments
 (0)