"""videos.py — séances vidéo (enregistrements Zoom) : transcription synchronisée, chapitres, storyboard des diapositives. Sources : _Site_web/videos/// video.mp4 enregistrement (remuxé faststart), gitignoré audio.m4a piste audio seule (option « audio seulement »), gitignorée transcript.json sortie whisper (segments + mots) meta.json rédigé à la main : titre, date, chapitres, résumé, à retenir, questions chat.txt clavardage Zoom (optionnel) scenes.txt horodatages des changements d'image (ffmpeg select=scene), optionnel Sortie : dist//videos// (page + médias + captions.vtt + chapters.vtt + transcript.{json,txt,srt} + sb/*.webp) """ from __future__ import annotations import glob import json import os import re import shutil import subprocess HALLUCINATIONS = re.compile(r"^(sous-titr(age|es)|merci d'avoir regardé|abonnez-vous|amara\.org|❤️|…)", re.I) def hms(t: float, always_h: bool = False) -> str: t = max(0, int(round(t))) h, m, s = t // 3600, (t % 3600) // 60, t % 60 return f"{h}:{m:02d}:{s:02d}" if h or always_h else f"{m}:{s:02d}" def ts_vtt(t: float) -> str: ms = int(round(t * 1000)) h, m, s, ms = ms // 3600000, (ms % 3600000) // 60000, (ms % 60000) // 1000, ms % 1000 return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" def ts_srt(t: float) -> str: return ts_vtt(t).replace(".", ",") def human_dur(sec: float) -> str: m = int(round(sec / 60)) return f"{m // 60} h {m % 60:02d}" if m >= 60 else f"{m} min" def clean_text(s: str) -> str: s = s.strip() s = re.sub(r"\s+", " ", s) s = re.sub(r"\s+([,.;:!?])", r"\1", s) s = re.sub(r"([«])\s*", r"\1 ", s) s = re.sub(r"\s*([»])", r" \1", s) s = re.sub(r"\s*([;:!?])", " \\1", s) # espace fine française return s def load_segments(transcript: dict): """Segments whisper nettoyés : [{start, end, text, words}]""" out = [] prev = "" for s in transcript.get("segments", []): text = s.get("text", "").strip() if not text or HALLUCINATIONS.search(text): continue if s.get("no_speech_prob", 0) > 0.85 and s.get("avg_logprob", 0) < -1.0: continue if text == prev and len(text) < 40: # boucles de répétition continue prev = text words = [{"w": w["word"], "s": float(w["start"]), "e": float(w["end"])} for w in s.get("words", []) if w.get("start") is not None] out.append({"start": float(s["start"]), "end": float(s["end"]), "text": text, "words": words}) return out def make_cues(segments, max_chars: int = 84, max_dur: float = 6.0, max_gap: float = 1.6): """Sous-titres : flux de mots (tous segments confondus) regroupé en cues d'une à deux lignes, coupées à la ponctuation.""" words = [] for seg in segments: if seg["words"]: words.extend(seg["words"]) else: words.append({"w": " " + seg["text"], "s": seg["start"], "e": seg["end"]}) cues, cur, t0 = [], [], None for i, w in enumerate(words): if not cur: t0 = w["s"] cur.append(w) text = "".join(x["w"] for x in cur).strip() last = i == len(words) - 1 nxt = None if last else words[i + 1] tok = w["w"].strip() ends_sent = bool(re.search(r"[.!?…]$", tok)) ends_clause = bool(re.search(r"[,;:]$", tok)) gap = (nxt["s"] - w["e"]) if nxt else 0 nxt_len = len(text) + (len(nxt["w"]) if nxt else 0) dur = w["e"] - t0 cut = last or gap > max_gap or nxt_len > max_chars or dur >= max_dur or (ends_sent and len(text) > 28) or (ends_clause and len(text) > max_chars * .65) if cut: cues.append((t0, w["e"], clean_text(text))) cur = [] fixed = [] for i, (a, b, t) in enumerate(cues): if i + 1 < len(cues): b = min(b, cues[i + 1][0] - 0.02) if b - a < 0.8: b = a + 0.8 fixed.append((a, b, t)) return fixed def make_blocks(segments, target_chars: int = 260, max_gap: float = 2.5): """Paragraphes de lecture : fusion des segments jusqu'à ~260 caractères et fin de phrase.""" blocks = [] cur = None for seg in segments: t = clean_text(seg["text"]) if cur and (seg["start"] - cur["end"] > max_gap and len(cur["text"]) > 60): blocks.append(cur) cur = None if cur is None: cur = {"start": seg["start"], "end": seg["end"], "text": t} continue cur["text"] = (cur["text"] + " " + t).strip() cur["end"] = seg["end"] if len(cur["text"]) >= target_chars and re.search(r"[.!?…]$", cur["text"]): blocks.append(cur) cur = None if cur: blocks.append(cur) for b in blocks: b["text"] = b["text"][:1].upper() + b["text"][1:] return blocks def parse_chat(path: str): rows = [] if not os.path.exists(path): return rows for line in open(path, encoding="utf-8", errors="replace"): m = re.match(r"\s*(\d{1,2}:\d{2}:\d{2})\s+From\s+(.*?)\s*(?:to\s+.*?)?\s*:\s*(.*)$", line.strip()) if m: rows.append({"time": m.group(1), "who": m.group(2).strip(), "text": m.group(3).strip()}) return rows def parse_scenes(path: str, min_gap: float = 2.5): if not os.path.exists(path): return [] ts = [] for line in open(path): line = line.strip() if not line: continue try: t = float(line) except ValueError: continue if not ts or t - ts[-1] >= min_gap: ts.append(t) return ts def frame(video: str, t: float, out_path: str, width: int, quality: int = 72): """Extrait une image (ffmpeg → PNG) puis l'encode en WebP avec Pillow (ffmpeg Homebrew sans libwebp).""" if os.path.exists(out_path): return True tmp = out_path + ".png" p = subprocess.run(["ffmpeg", "-v", "error", "-ss", f"{t:.2f}", "-i", video, "-frames:v", "1", "-vf", f"scale={width}:-2", "-y", tmp], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) if p.returncode != 0 or not os.path.exists(tmp): return False try: from PIL import Image Image.open(tmp).convert("RGB").save(out_path, "WEBP", quality=quality, method=4) except Exception: # noqa: BLE001 shutil.move(tmp, out_path) # repli : PNG sous extension webp (le serveur sert selon l'extension, éviter) return False finally: if os.path.exists(tmp): os.remove(tmp) return os.path.exists(out_path) def duration_of(video: str) -> float: try: out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", video], stdout=subprocess.PIPE, text=True, timeout=30).stdout.strip() return float(out) except Exception: # noqa: BLE001 return 0.0 def build_videos(code: str, site_dir: str, out: str, cache_dir: str, chapters, log, warn): """Construit dist//videos//… ; retourne la liste des séances vidéo (dicts pour les gabarits).""" src_root = os.path.join(site_dir, "videos", code) if not os.path.isdir(src_root): return [] videos = [] for d in sorted(glob.glob(os.path.join(src_root, "[0-9][0-9]"))): nn = os.path.basename(d) n = int(nn) meta_p, tr_p, vid_p = os.path.join(d, "meta.json"), os.path.join(d, "transcript.json"), os.path.join(d, "video.mp4") if not os.path.exists(vid_p): warn(f"vidéo {nn} : video.mp4 manquant") continue meta = json.load(open(meta_p, encoding="utf-8")) if os.path.exists(meta_p) else {} transcript = json.load(open(tr_p, encoding="utf-8")) if os.path.exists(tr_p) else {"segments": []} segments = load_segments(transcript) cues = make_cues(segments) blocks = make_blocks(segments) dur = duration_of(vid_p) or (segments[-1]["end"] if segments else 0) ch_title = chapters[n - 1].title_text if 0 < n <= len(chapters) else meta.get("title", f"Séance {n}") ch_title_html = chapters[n - 1].title_html if 0 < n <= len(chapters) else ch_title vdir = os.path.join(out, "videos", nn) os.makedirs(os.path.join(vdir, "sb"), exist_ok=True) # médias (copie ou lien dur pour éviter de dupliquer 130 Mo) for name in ("video.mp4", "audio.m4a", "chat.txt"): sp = os.path.join(d, name) if os.path.exists(sp): dp = os.path.join(vdir, name) try: os.link(sp, dp) except OSError: shutil.copy2(sp, dp) # sous-titres + chapitres WebVTT + SRT + TXT vtt = ["WEBVTT", "Kind: captions", "Language: fr", ""] for i, (a, b, t) in enumerate(cues, 1): vtt += [str(i), f"{ts_vtt(a)} --> {ts_vtt(b)}", t, ""] open(os.path.join(vdir, "captions.vtt"), "w", encoding="utf-8").write("\n".join(vtt)) srt = [] for i, (a, b, t) in enumerate(cues, 1): srt += [str(i), f"{ts_srt(a)} --> {ts_srt(b)}", t, ""] open(os.path.join(vdir, "transcript.srt"), "w", encoding="utf-8").write("\n".join(srt)) chaps = meta.get("chapters", []) for i, c in enumerate(chaps): c["end"] = chaps[i + 1]["t"] if i + 1 < len(chaps) else dur c["id"] = f"c{i + 1}" cvtt = ["WEBVTT", "Kind: chapters", ""] for c in chaps: cvtt += [f"{ts_vtt(c['t'])} --> {ts_vtt(c['end'])}", c["title"], ""] open(os.path.join(vdir, "chapters.vtt"), "w", encoding="utf-8").write("\n".join(cvtt)) txt = [f"{code.upper()} — Séance {n} — {ch_title}", f"Enregistrement Zoom du {meta.get('date_text', meta.get('date', ''))} · durée {human_dur(dur)}", "Transcription automatique (whisper large-v3-turbo), non corrigée.", ""] for c in chaps: txt.append(f"[{hms(c['t'], True)}] {c['title']}") txt.append("") for b in blocks: txt.append(f"[{hms(b['start'], True)}] {b['text']}") txt.append("") open(os.path.join(vdir, "transcript.txt"), "w", encoding="utf-8").write("\n".join(txt)) # storyboard (changements d'image) scenes = parse_scenes(os.path.join(d, "scenes.txt")) cdir = os.path.join(cache_dir, "video-frames", f"{code}-{nn}") os.makedirs(cdir, exist_ok=True) sb = [] for i, t in enumerate(scenes): name = f"{i:03d}.webp" cp = os.path.join(cdir, name) if frame(vid_p, t + 0.6, cp, 360): shutil.copy2(cp, os.path.join(vdir, "sb", name)) sb.append({"t": round(t, 2), "src": f"/videos/{nn}/sb/{name}"}) # affiche poster_t = meta.get("poster_t", chaps[0]["t"] + 2 if chaps else 60) poster_c = os.path.join(cdir, f"poster-{int(poster_t)}.webp") if frame(vid_p, poster_t, poster_c, 1280, 78): shutil.copy2(poster_c, os.path.join(vdir, "poster.webp")) # données client client = {"n": nn, "title": ch_title, "duration": dur, "chapters": [{"t": c["t"], "end": c["end"], "title": c["title"], "id": c["id"]} for c in chaps], "blocks": [[round(b["start"], 2), round(b["end"], 2), b["text"]] for b in blocks], "cues": [[round(a, 2), round(b, 2), t] for a, b, t in cues], "storyboard": sb} json.dump(client, open(os.path.join(vdir, "transcript.json"), "w", encoding="utf-8"), ensure_ascii=False) words = sum(len(b["text"].split()) for b in blocks) sizes = {name: os.path.getsize(os.path.join(vdir, name)) for name in ("video.mp4", "audio.m4a") if os.path.exists(os.path.join(vdir, name))} videos.append({"n": n, "nn": nn, "title": ch_title, "title_html": ch_title_html, "meta": meta, "duration": dur, "duration_text": human_dur(dur), "chapters": chaps, "blocks": blocks, "n_cues": len(cues), "words": words, "chat": parse_chat(os.path.join(d, "chat.txt")), "storyboard": sb, "sizes": sizes, "has_audio": "audio.m4a" in sizes, "url": f"/videos/{nn}/"}) log(f" vidéo {nn} : {human_dur(dur)}, {len(segments)} segments → {len(cues)} sous-titres, {len(blocks)} paragraphes, {len(chaps)} chapitres, {len(sb)} vignettes") return videos