Files
copilot-handleidingen/_gereedschap/maak_video.py
T
Guido van Dijk 62544b10bf feat(video): beeldvullende modus en eigen bronlabel voor presentatiedia's
Met '# vullend: ja' vult een beeld het hele frame, zonder kader, label en
voettekst. Dat is nodig voor hele dia's uit een presentatie, die zelf al
een kop en een bronregel hebben. Met '# bronlabel: ...' past de slotkaart
bij iets anders dan een handleiding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:13:09 +02:00

289 lines
14 KiB
Python

#!/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) via de Edge-leesfunctie.
Voor publicatie dezelfde stem via Azure Speech: zet AZURE_SPEECH_KEY (en eventueel
AZURE_SPEECH_REGION, standaard westeurope) in de omgeving; de sleutel wordt nergens
opgeslagen. Zonder sleutel gebruikt het script edge-tts.
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)
# vullend: ja beelden vullen het hele frame (hele dia's uit een presentatie)
[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, os, re, subprocess, sys, tempfile, urllib.request
from xml.sax.saxutils import escape
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/copilot-handleidingen"
REEKS = "Reeks Copilot-agents voor school"
VULLEND = False # bij "# vullend: ja" vullen de beelden het hele frame (presentatiedia's)
BRONLABEL = "Handleiding, instructies en beelden" # aan te passen met "# bronlabel: ..."
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 [BRONLABEL + ": " + 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):
if VULLEND: # hele dia's uit een presentatie: beeldvullend, zonder kader en voettekst
shot = Image.open(pad).convert("RGB")
img = Image.new("RGB", (W, Hh), DARK)
k = min(W / shot.width, Hh / shot.height)
shot = shot.resize((round(shot.width * k), round(shot.height * k)), Image.LANCZOS)
img.paste(shot, ((W - shot.width) // 2, (Hh - shot.height) // 2))
img.save(dest); return
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 spreek_azure(tekst, stem, tempo, mp3):
"""Dezelfde stem via Azure Speech (REST). Geeft geen woordgrenzen; de ondertitels worden
dan naar tekstlengte verdeeld."""
key = os.environ["AZURE_SPEECH_KEY"]; regio = os.environ.get("AZURE_SPEECH_REGION", "westeurope")
taal = "-".join(stem.split("-")[:2])
alineas = "".join(f"<p>{escape(a.strip())}</p>" for a in tekst.split("\n\n") if a.strip())
ssml = (f'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{taal}">'
f'<voice name="{stem}"><prosody rate="{tempo}">{alineas}</prosody></voice></speak>')
req = urllib.request.Request(f"https://{regio}.tts.speech.microsoft.com/cognitiveservices/v1",
data=ssml.encode("utf-8"), method="POST",
headers={"Ocp-Apim-Subscription-Key": key, "Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "audio-24khz-96kbitrate-mono-mp3",
"User-Agent": "maak_video"})
with urllib.request.urlopen(req, timeout=120) as r:
Path(mp3).write_bytes(r.read())
return []
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, VULLEND, BRONLABEL
BRON = meta.get("bron", BRON); REEKS = meta.get("reeks", REEKS)
VULLEND = meta.get("vullend", "nee").lower() in ("ja", "yes", "true")
BRONLABEL = meta.get("bronlabel", BRONLABEL)
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
print("stem via", "Azure Speech" if os.environ.get("AZURE_SPEECH_KEY") else "edge-tts", stem, tempo)
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)
if os.environ.get("AZURE_SPEECH_KEY"):
woorden = spreek_azure(s["spraak"], stem, tempo, mp3)
else:
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()