Files
2026-09-21 17:14:10 +02:00

252 lines
11 KiB
Python
Vendored

#!/usr/bin/env python3
"""Maakt van een videoscript (video-script.md in de map van een handleiding) een uitlegvideo:
de schermafbeeldingen uit beelden/ met een Nederlandse computerstem en ondertitels.
Gebruik: python3 ../_gereedschap/maak_video.py video-script.md
Uitvoer: <naam>.mp4 (1920x1080, H.264, AAC, met ingesloten ondertitels) en <naam>.srt
Vereist: ffmpeg, Pillow en het pakket edge-tts (pip install edge-tts). De stem komt van de
neurale Microsoft-stemmen (nl-NL-FennaNeural standaard); voor publicatie dezelfde
stem via Azure Speech, zie README.
Opbouw van het script (regels die met [ beginnen zijn koppen, de rest is de gesproken tekst):
# titel: ... titel op de eerste kaart
# ondertitel: ... tweede regel op de eerste kaart
# stem: nl-NL-FennaNeural
# tempo: -5%
# uitvoer: naam-zonder-extensie
# reeks: Reeks Copilot-agents voor school (kopregel op de titelkaart en in de voettekst)
# bron: git.lexconsultancy.nl/guido/copilot-handleidingen (op de slotkaart)
[titel] de titelkaart
[beeld: bestand.jpg] een schermafbeelding uit beelden/, met een label
[beeld: bestand.jpg | label]
[kaart: Kop | regel 1 | regel 2] een tekstkaart zonder afbeelding
[slot] de slotkaart
Lege regels tussen de tekst zijn pauzes van een halve seconde.
"""
import asyncio, json, re, subprocess, sys, tempfile
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
try:
import edge_tts
except ImportError:
sys.exit("pip install edge-tts (of gebruik de venv waarin het staat)")
W, Hh = 1920, 1080
DARK, LIGHT, LIGHT2, ACC, BODY, LINE = "#1E2A3A", "#F7F4EC", "#EFEAE0", "#D9480F", "#3D4756", "#D8D2C4"
FONT_B = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
FONT_R = "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
HERE = Path(__file__).resolve().parent
LOGO_LAI = HERE / "logo-ai-netwerk-limburg.png"
LOGO_LEX = HERE / "logo-lex.png"
BRON = "git.lexconsultancy.nl/guido/chatgpt-handleidingen"
REEKS = "Reeks GPT's voor school"
def font(size, bold=False):
return ImageFont.truetype(FONT_B if bold else FONT_R, size)
def wrap(draw, text, f, maxw):
words, lines, cur = text.split(), [], ""
for w in words:
t = (cur + " " + w).strip()
if draw.textlength(t, font=f) <= maxw:
cur = t
else:
lines.append(cur); cur = w
if cur:
lines.append(cur)
return lines
def logos(img, dark):
lai = Image.open(LOGO_LAI).convert("RGBA")
lai.thumbnail((220, 180))
img.paste(lai, (W - 128 - lai.width, 80), lai)
lex = Image.open(LOGO_LEX).convert("RGBA")
lex.thumbnail((140, 80))
if dark: # wit logo op donker: kanalen omkeren, alpha behouden
r, g, b, a = lex.split()
from PIL import ImageOps
lex = Image.merge("RGBA", (ImageOps.invert(r), ImageOps.invert(g), ImageOps.invert(b), a))
img.paste(lex, (W - 128 - lai.width + (lai.width - lex.width) // 2, 80 + lai.height + 20), lex)
def kaart_titel(titel, ondertitel, dest):
img = Image.new("RGB", (W, Hh), DARK); d = ImageDraw.Draw(img)
logos(img, True)
d.text((128, 300), REEKS.upper(), font=font(28, True), fill="#F4B183")
y = 360
for line in wrap(d, titel, font(88, True), 1300):
d.text((128, y), line, font=font(88, True), fill=LIGHT); y += 104
y += 24
for line in wrap(d, ondertitel, font(38), 1300):
d.text((128, y), line, font=font(38), fill="#D9DEE6"); y += 52
d.text((128, Hh - 120), "Guido van Dijk · LeX Consultancy B.V. · AI Netwerk Limburg · CC BY-SA 4.0", font=font(26), fill="#9AA3B1")
img.save(dest)
def kaart_slot(titel, dest):
img = Image.new("RGB", (W, Hh), DARK); d = ImageDraw.Draw(img)
logos(img, True)
y = 330
for line in wrap(d, titel, font(80, True), 1300):
d.text((128, y), line, font=font(80, True), fill=LIGHT); y += 96
y += 30
for line in ["Handleiding, instructies en beelden: " + BRON,
"Alles mag gekopieerd en bewerkt worden onder CC BY-SA 4.0, met bronvermelding.",
"Namen van producten zijn merken van hun eigenaren."]:
d.text((128, y), line, font=font(32), fill="#D9DEE6"); y += 48
d.text((128, Hh - 120), "Guido van Dijk · LeX Consultancy B.V. · AI Netwerk Limburg", font=font(26), fill="#9AA3B1")
img.save(dest)
def kaart_tekst(kop, regels, dest):
img = Image.new("RGB", (W, Hh), LIGHT); d = ImageDraw.Draw(img)
y = 200
for line in wrap(d, kop, font(72, True), 1600):
d.text((160, y), line, font=font(72, True), fill=DARK); y += 88
y += 40
for r in regels:
for i, line in enumerate(wrap(d, r, font(40), 1520)):
d.text((200, y), ("• " if i == 0 else " ") + line, font=font(40), fill=BODY); y += 58
y += 18
d.text((160, Hh - 90), REEKS + " · LeX Consultancy · CC BY-SA 4.0", font=font(24), fill="#8A8F98")
img.save(dest)
def kaart_beeld(pad, label, dest):
img = Image.new("RGB", (W, Hh), LIGHT2); d = ImageDraw.Draw(img)
shot = Image.open(pad).convert("RGB")
box_w, box_h = W - 2 * 96, Hh - 160 - 80
shot.thumbnail((box_w, box_h), Image.LANCZOS)
x = (W - shot.width) // 2; y = 130 + (box_h - shot.height) // 2
d.rectangle([x - 2, y - 2, x + shot.width + 1, y + shot.height + 1], outline=LINE, width=2)
img.paste(shot, (x, y))
if label:
d.text((96, 44), label, font=font(34, True), fill=DARK)
d.text((96, Hh - 60), REEKS + " · LeX Consultancy · CC BY-SA 4.0", font=font(22), fill="#8A8F98")
img.save(dest)
def lees_script(pad):
meta, segs, cur = {}, [], None
for raw in pad.read_text(encoding="utf-8").splitlines():
line = raw.rstrip()
if line.startswith("# ") and ":" in line and cur is None:
k, v = line[2:].split(":", 1); meta[k.strip()] = v.strip(); continue
m = re.match(r"^\[(titel|slot|beeld|kaart)(?::\s*(.*))?\]$", line)
if m:
cur = {"soort": m.group(1), "arg": (m.group(2) or "").strip(), "tekst": []}
segs.append(cur); continue
if cur is not None:
cur["tekst"].append(line)
for s in segs:
# lege regels worden een pauze in de spraak
s["spraak"] = re.sub(r"\n\s*\n", "\n\n", "\n".join(s["tekst"])).strip()
return meta, [s for s in segs if s["spraak"]]
async def spreek(tekst, stem, tempo, mp3):
"""Spreekt tekst uit en geeft de woordgrenzen (offset, duur in seconden, woord) terug."""
comm = edge_tts.Communicate(tekst, stem, rate=tempo)
woorden = []
with open(mp3, "wb") as f:
async for chunk in comm.stream():
if chunk["type"] == "audio":
f.write(chunk["data"])
elif chunk["type"] == "WordBoundary":
woorden.append((chunk["offset"] / 1e7, chunk["duration"] / 1e7, chunk["text"]))
return woorden
def _norm(w):
return re.sub(r"[^\w]", "", w.lower())
def cues_uit_woorden(tekst, woorden, totaal):
"""Verdeelt de tekst in zinnen en koppelt elke zin aan de tijd van zijn woorden (woordgrenzen
van de spraakdienst; woorden die niet matchen worden overgeslagen, zonder grenzen valt hij
terug op een verdeling naar tekstlengte)."""
zinnen = [z.strip() for z in re.split(r"(?<=[.!?:])\s+", tekst.replace("\n", " ")) if z.strip()]
cues, i, vorige_eind = [], 0, 0.0
for z in zinnen:
start = eind = None
for w in z.split():
nw = _norm(w)
if not nw:
continue
for k in range(i, min(i + 4, len(woorden))):
if _norm(woorden[k][2]) == nw or nw.startswith(_norm(woorden[k][2])) or _norm(woorden[k][2]).startswith(nw):
start = woorden[k][0] if start is None else start
eind = woorden[k][0] + woorden[k][1]; i = k + 1
break
if start is None: # niets gevonden: naar tekstlengte
aandeel = len(z) / max(1, sum(len(x) for x in zinnen))
start, eind = vorige_eind, vorige_eind + aandeel * totaal
cues.append((start, eind, z)); vorige_eind = eind
return cues
def duur(pad):
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(pad)],
capture_output=True, text=True).stdout.strip()
return float(out)
def srt_tijd(t):
h, r = divmod(t, 3600); m, s = divmod(r, 60)
return f"{int(h):02d}:{int(m):02d}:{int(s):02d},{int(round((s - int(s)) * 1000)):03d}"
def main():
script = Path(sys.argv[1]).resolve()
map_ = script.parent
meta, segs = lees_script(script)
global BRON, REEKS
BRON = meta.get("bron", BRON); REEKS = meta.get("reeks", REEKS)
stem = meta.get("stem", "nl-NL-FennaNeural"); tempo = meta.get("tempo", "-5%")
naam = meta.get("uitvoer", script.stem.replace("video-script", "video"))
pauze = 0.8 # seconden stilte na elk segment
tmp = Path(tempfile.mkdtemp(prefix="video-"))
clips, cues, t0 = [], [], 0.0
for n, s in enumerate(segs, 1):
png, mp3, clip = tmp / f"{n:02d}.png", tmp / f"{n:02d}.mp3", tmp / f"{n:02d}.mp4"
if s["soort"] == "titel":
kaart_titel(meta.get("titel", ""), meta.get("ondertitel", ""), png)
elif s["soort"] == "slot":
kaart_slot(s["arg"] or "Kies het eenvoudigste patroon dat werkt.", png)
elif s["soort"] == "kaart":
delen = [d.strip() for d in s["arg"].split("|")]
kaart_tekst(delen[0], delen[1:], png)
else:
delen = [d.strip() for d in s["arg"].split("|")]
kaart_beeld(map_ / "beelden" / delen[0], delen[1] if len(delen) > 1 else "", png)
woorden = asyncio.run(spreek(s["spraak"], stem, tempo, mp3))
d = duur(mp3)
for a, b, z in cues_uit_woorden(s["spraak"], woorden, d):
cues.append((t0 + a, t0 + min(b + 0.15, d), z))
subprocess.run(["ffmpeg", "-v", "error", "-y", "-loop", "1", "-framerate", "25", "-i", str(png), "-i", str(mp3),
"-af", f"apad=pad_dur={pauze}", "-t", f"{d + pauze:.3f}",
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-r", "25",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "1", str(clip)], check=True)
clips.append(clip); t0 += d + pauze
print(f"{n:02d} {s['soort']:6s} {d:5.1f}s {s['spraak'][:60]}")
lijst = tmp / "lijst.txt"
lijst.write_text("".join(f"file '{c}'\n" for c in clips))
# ondertitels mogen elkaar niet overlappen
cues = [(a, min(b, cues[i + 1][0] - 0.05) if i + 1 < len(cues) else b, z) for i, (a, b, z) in enumerate(cues)]
srt = map_ / f"{naam}.srt"
srt.write_text("".join(f"{i}\n{srt_tijd(a)} --> {srt_tijd(b)}\n{z}\n\n" for i, (a, b, z) in enumerate(cues, 1)), encoding="utf-8")
mp4 = map_ / f"{naam}.mp4"
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", str(lijst), "-i", str(srt),
"-c:v", "copy", "-c:a", "copy", "-c:s", "mov_text", "-metadata:s:s:0", "language=nld",
"-metadata", f"title={meta.get('titel', naam)}", str(mp4)], check=True)
print(f"{mp4.name}: {mp4.stat().st_size // 1024} kB, {t0 / 60:.1f} min, {len(cues)} ondertitels")
if __name__ == "__main__":
main()