62544b10bf
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>
289 lines
14 KiB
Python
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()
|