#!/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: .mp4 (1920x1080, H.264, AAC, met ingesloten ondertitels) en .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/copilot-handleidingen" REEKS = "Reeks Copilot-agents 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()