T-HUB/docs/design/2026-10-11-video-ai-architecture.md
glass beadian 777c67f7a3 Expand video-AI architecture doc to the full research report
281-line version with the person-identity DDL sketch, per-model license
audit table, VRAM/cost budget, GDPR Art. 9 analysis, and all primary-source
citations — replaces the 53-line executive summary.
2026-10-11 08:29:30 +00:00

281 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# A/V Intelligence Pipeline — Research Report (transcription · diarization · faces · identity · GDPR)
**Date:** 2026-10-11 · **Scope:** Tactical Media Broadcast Hub (PeerTube occ-tube + R2 media plane, NestJS api, pgvector, RTX 3090 fleet on Tailscale, GDPR/Spain, EN+ES, no third-party cloud APIs).
**Method:** every claim below was checked against a primary source fetched live in this session (GitHub source/release API, HuggingFace API/model cards, PyPI, EUR-Lex, nvlpubs) unless explicitly marked **UNVERIFIED-MEMORY** or **ESTIMATE**. Versions quoted are current as of today.
---
## 0. Recommended architecture (TL;DR)
| Concern | Choice | License | Why |
|---|---|---|---|
| Transcription | **PeerTube built-in (v8.3.1), engine `whisper-ctranslate2`, model `large-v3-turbo` (int8), executed by a remote runner on the 3090** | Whisper weights MIT/Apache-2.0 (see §2) | It ships today, subtitles land in PeerTube's caption system (VTT), API retrievable, zero glue code |
| Word timestamps | faster-whisper `word_timestamps` / WhisperX alignment (phase 2) | BSD-2/MIT | VTT + speaker fusion |
| Diarization | **pyannote `speaker-diarization-community-1`** via WhisperX (or standalone) | **CC-BY-4.0** weights, MIT code, gated `auto` | Best open DER; the gate is access-control, not a license restriction |
| Face detection | **SCRFD (ONNX, via insightface pip for runtime only)** or MediaPipe BlazeFace if you want zero-contamination pipeline | weights **non-commercial** (SCRFD/buffalo) / Apache-2.0 (MediaPipe) | See §4 — this is the one genuinely dirty licensing corner; options given |
| Face recognition | **Deferred to phase 3, human-review-gated.** If run: AdaFace/ArcFace-class 512-d embeddings; store centroids, not crops | — | GDPR Art. 9 — see §6; weight licenses are non-commercial across the board for the accurate models |
| Identity model | Postgres tables in §5: `persons`, `person_face_embeddings`, `video_face_detections`, `transcript_segments` + review queue + audit | — | Label once → propagate; undo/merge; erasure |
| Orchestration | Reuse the transcode-pool pattern: PeerTube runner for transcription jobs; a small GPU worker container (like TEI) + Postgres-backed job queue for the rest | — | Few moving parts, no k8s |
Phase 1 = transcription+subtitles (PeerTube-native, low risk). Phase 2 = diarization + speaker naming. Phase 3 = faces/clustering with human-review gating (only after DPIA).
---
## 1. PeerTube transcription — verified from v8.3.1 source
Current release: **v8.3.1 (2026-09-28)** via GitHub releases API.
- **Built into core since 6.2** — CHANGELOG: *"Add automatic transcription of videos to generate subtitles" (PR #6303)*, *"Transcription can also be performed by PeerTube runners, as it can consume a lot of CPU"*, and *"PeerTube instance requires python >= 3.8 for transcription"*; pip3 is a listed dependency. Config (source: `config/default.yaml`, `video_transcription:`):
- `enabled: false` (default off), `engine: 'whisper-ctranslate2'` (alternative `openai-whisper`), `model: 'small'` (choices `tiny|base|small|medium|large-v2|large-v3` — **no `turbo` entry**; a CTranslate2 turbo dir can be used via `model_path`), `model_path: null`, `timeout: '6 hours'`, `remote_runners.enabled: false`.
- **Engines verified** (`packages/transcription/src/whisper/engines.ts`): `openai-whisper` (v20250625, MIT) and `whisper-ctranslate2` (>=0.4.3, MIT, CTranslate2 format). `packages/transcription` is an internal package (`@peertube/peertube-transcription`) wrapping these CLIs; transcription runs in the Node job queue locally or as a **runner job** (`server/core/lib/runners/job-handlers/transcription-job-handler.ts`; runner code: `apps/peertube-runner/.../process-transcription.ts`).
- **Where subtitles land:** caption rows in PeerTube DB + **VTT files** under `storage/captions/` (or object-storage prefix `captions/` — migrations `0875-caption-object-storage`, `0890-hls-caption`; a recent changelog line *"Fix transcription CORS issue if the caption file is stored externally"* confirms captions on R2 are a supported posture).
- **API (verified routes in `server/core/controllers/api/videos/captions.ts`):**
- `GET /api/v1/videos/{id}/captions` (public listing) → caption file URLs (`.vtt`)
- `PUT /api/v1/videos/{id}/captions/{lang}` (upload VTT, multipart)
- `DELETE /api/v1/videos/{id}/captions/{lang}`
- `POST /api/v1/videos/{id}/captions/generate` (request transcription manually; `generateTranscription` also honored on upload/import per changelog)
- **No webhooks exist in PeerTube** (repo-tree grep: zero webhook files). Completion signals available: (a) user notification `video-transcription-generated-for-owner` (not programmatic), (b) REST polling, (c) a **custom plugin** — the caption controller imports the plugin `Hooks` subsystem (specific transcription hook names NOT verified — flagged).
- Transcription widget in the web player exists (v6.3+ changelog) and re-runs happen after file replacement (v7.x changelog).
**Implication for us:** enable `video_transcription` with `remote_runners.enabled: true` and run the official `peertube-runner` on a Linux GPU node with the transcription capability — same runner contract as the transcode pool (companion doc). The api then watches the captions endpoint on video-publish and pulls the VTT. Whether the runner path picks up CUDA for `whisper-ctranslate2` needs one smoke test (flagged, §8).
---
## 2. Whisper family — models, licenses, Spanish, VRAM
| Model | License (HF API) | Spanish | VRAM | Notes |
|---|---|---|---|---|
| `openai/whisper-large-v3` | **Apache-2.0** | strong (paper-level; card no longer publishes the per-language table) | fp16 ~10 GB, int8 ~4.5 GB (README-measured class) | Best accuracy ceiling |
| `openai/whisper-large-v3-turbo` | **MIT** | multilingual; card states *"the exact same model, except that the number of decoding layers have reduced from 32 to 4"*, 809 M params | fp16 ~6 GB, int8 ~3 GB (ESTIMATE) | **Recommended default**: ~95% of v3 quality at a fraction of the cost |
| `distil-whisper/distil-large-v3` | MIT | **English-only** (UNVERIFIED-MEMORY: card language restriction; not re-fetched today) | ~4 GB | **Exclude** for ES |
| `Systran/faster-whisper-large-v3` (+turbo conversions) | **MIT** | = v3 | — | The CTranslate2 conversions PeerTube's engine consumes |
- **Spanish WER reality:** the current HF cards no longer carry the FLEURS/Common-Voice per-language tables (checked both cards — absent). From memory (UNVERIFIED-MEMORY): large-v3 FLEURS-es ≈ 5–7% WER, Common Voice-es ≈ 8–15%; expect **15–35%+ on street footage** (wind, PA, chants, overlapping speech, masks). Build a 20–30-clip internal ES/EN eval set (same as the search-architecture doc's eval plan) before choosing large-v3 vs turbo.
- **Whisper failure modes** (from the v3 card, verified text): *"prone to generating repetitive texts"*; hallucination risk on music/chants/silence — directly relevant to protest footage. Mitigation: VAD pre-filter (WhisperX/faster-whisper do this), temperature-fallback, human check of chant-heavy segments.
- **Runtime (faster-whisper 1.2.1, PyPI):** README-verified benchmark: large-v3 **int8, batch_size=8 → 4500 MB peak, 5× realtime on an RTX 3070 Ti 8GB**; `BatchedInferencePipeline` documented; word timestamps supported by the API (`word_timestamps=True` — standard; word output also verified downstream in whisper-asr-webservice "Word-level timestamps support").
- **whisper.cpp v1.9.5 (2026-10-06):** CPU/Windows fallback verified — `-ovtt/--output-vtt` in `examples/cli/cli.cpp`, CUDA + Vulkan backends, Q5_0 quantization. Multilingual ggml models only for ES. Use for the Windows node without WSL2 or as CPU emergency path.
- **3090 throughput (ESTIMATE, extrapolated):** turbo int8 batched ≈ 20–40× realtime (1.5–3 min per video-hour); large-v3 int8 ≈ 8–12× realtime (5–8 min per video-hour).
**Serving options (if we outgrow PeerTube's runner):**
- **Speaches** (MIT, v0.9.0-rc.3, 2025-12-27): OpenAI-compatible server over faster-whisper + TTS; modern but pre-1.0. Diarization endpoints: UNVERIFIED-MEMORY (not in README excerpt fetched).
- **whisper-asr-webservice** (v1.10.0, 2026-08-09): engines `openai_whisper | faster_whisper | whisperx`, outputs **text/JSON/VTT/SRT/TSV, word-level timestamps, diarization via WhisperX engine** (all README-verified). The most "boring and works" option; good as a standalone microservice feeding `transcript_segments` for media-plane blobs that never enter PeerTube.
---
## 3. Speaker diarization
- **pyannote.audio 4.0.7** (PyPI). **Code is MIT** (repo LICENSE, Copyright CNRS).
- **Model gating — the important nuance (verified):**
- `pyannote/speaker-diarization-community-1` (lastModified 2025-09-29, ~5.5 M downloads): **license CC-BY-4.0, gated: `auto`**.
- `pyannote/speaker-diarization-3.1` and `segmentation-3.0`: **MIT, gated: `auto`**.
- Empirically: fetching the model card **without auth returns HTTP 401** ("Access to model … is restricted. You must have access to it and be authenticated").
- `gated: auto` = you need an HF account, accept the model's user conditions once (name/email are recorded by HF), and pass a read token. It is **access control + attribution, not a use restriction** — no non-commercial clause found anywhere in the cards or the repo license. Verdict: **OK for this use** (CC-BY-4.0 needs attribution — add a models/credits page).
- The current pyannote README instructs exactly: install `pyannote.audio`, *"Accept pyannote/speaker-diarization-community-1 user conditions"*, create an HF access token, then `Pipeline.from_pretrained(..., token=...)`, runs locally, `.to("cuda")`.
- **Quality (official README benchmark table, DER %, lower better):** community-1 beats 3.1 across the board — AMI IHM 17.0 vs 18.8, DIHARD 3 20.2 vs 21.4, MSDWild 22.8 vs 25.4, Ego4D 46.8 vs 51.2. **No Spanish-specific benchmark exists in that table** — flag; expect degradation on street/overlapping speech and validate on our footage.
- **WhisperX** (BSD-2-Clause, PyPI 3.8.6): *"fast ASR (70× realtime with large-v2) with word-level timestamps and speaker diarization"*; current README: *"include your Hugging Face access token … after the `--hf_token` argument and accept the user agreement for the **speaker-diarization-community-1** model"*; `--min_speakers/--max_speakers`; Spanish word alignment via **`VOXPOPULI_ASR_BASE_10K_ES`** (verified line 36 of `whisperx/alignment.py`; license: UNVERIFIED — check before phase 2).
- **NVIDIA NeMo:** toolkit Apache-2.0, but **`nvidia/diar_sortformer_4spk-v1` is CC-BY-NC-4.0 → excluded** (non-commercial). (titanet speaker embedding is CC-BY-4.0, not gated, if ever needed.)
- **diart** (MIT, Univ. Paris-Saclay): streaming diarization over pyannote; maintenance status unclear (no fresh release evidence) — not needed for offline batch processing anyway.
- **Fusion pattern (recommended):** phase 2 = WhisperX (whisper large-v3-turbo CT2 + alignment + diarization) → `transcript_segments(video, start, end, speaker_key, text, lang, words jsonb)`. `speaker_key` = ephemeral per-video label (`SPEAKER_00…`). Speaker→person mapping lives in `video_speaker_person(video_id, speaker_key, person_id, confidence, reviewed)` so **one assignment per speaker per video, and a confirmed mapping can be suggested across videos** by pairing diarization with face-track co-occurrence (same timestamps as face detections) — cheap and surprisingly reliable.
- VRAM: pyannote community-1 on GPU ≈ 2–4 GB (ESTIMATE, no official figure published — flagged).
---
## 4. Faces — license audit (the deliverable table) + quality limits
| Model / repo | Code license | **Weights terms** | Weights provenance | Verdict for us |
|---|---|---|---|---|
| InsightFace repo (`deepinsight/insightface`) | MIT — *"no limitation for both academic and commercial usage"* (README) | **NON-COMMERCIAL** — *"The training data containing the annotation (and the models trained with these data) are available for non-commercial research purposes only"*, explicitly covering *"both manual-downloading models … and auto-downloading models with our python-library"*; **2025-11-24 update: for open-sourced FR models (e.g. buffalo_l) "contact recognition-oss-pack@insightface.ai for licensing"** | MS1M/WebFace-family research datasets | **Cannot use buffalo_l / ArcFace-w600k weights** for the collective's production index |
| `public-data/insightface` HF mirror | — | no separate license field; links back to insightface GitHub release (`buffalo_l.zip`) → same non-commercial terms | same | same |
| SCRFD detector weights | (inside insightface, MIT code) | same non-commercial policy (OneDrive-hosted) | — | detector-only contamination; replaceable |
| AdaFace (`mk-minchul/AdaFace`) | MIT | **silent** — weights on Google Drive, no license attached; trained on CASIA-WebFace/VGGFace2/WebFace4M/MS1MV2-3 (README table) | research datasets with own terms | Gray zone; not "clean" |
| deepface (`serengil/deepface`) | MIT | **does not launder weights** — README: *"License types will be inherited when you intend to utilize those models"*; wraps VGG-Face/Facenet/ArcFace/**Buffalo_L** | mixed | Convenience wrapper only; license risk unchanged |
| facenet-pytorch (`timesler`) | MIT | weights ported from davidsandberg/facenet (VGGFace2/CASIA-WebFace); no explicit weight license (README) | research datasets | Older/weaker anyway; not recommended |
| MediaPipe (`google-ai-edge/mediapipe`) | **Apache-2.0** (LICENSE verified); solution docs pages show Apache 2.0 links | model files: docs do not state a per-file license clearly (flagged) | Google self-collected | **Detection** OK-ish; **no recognition/identity embedding model exists in MediaPipe** (solutions index has detector+landmarker only) → cannot do identity with MediaPipe alone |
| yolov8-face / yolov5-face | **GPL-3.0** (both LICENSE files verified) | — | — | Copyleft hazard for a distributed self-hosted stack; avoid |
| WAFiA / PaddlePaddle FR | not audited this session | — | — | **Flagged: unexamined** |
**Honest reading:** every *accurate, realistic-footage* FR embedding model in the open ecosystem (InsightFace buffalo_l, ArcFace-webface variants, AdaFace-best) is trained on MS-Celeb-1M-lineage or WebFace research datasets whose terms are **non-commercial or undefined**. There is no verified fully-clean MIT/Apache recognition model with competitive accuracy today. Options:
1. **(Recommended) Do not run automated identity matching by default.** Run **detection + tracking + blurring/on-demand reveal**, and restrict recognition to an opt-in, DPIA-approved, human-review workflow (phase 3), under legal advice. Detection models can be MediaPipe/SCRFD (SCRFD weights non-commercial → if strict, use MediaPipe BlazeFace or YuNet (libfacedetection, MIT-code — weights terms unverified, flagged)).
2. If recognition is approved: accept the non-commercial-weights risk **in writing as a collective decision** (non-commercial terms restrict *commercialization*, and a self-hosted activist tool arguably isn't a "commercial use" — but that's a legal judgment call, not mine), and prefer AdaFace R50 (MIT code, documented weights) or InsightFace-runtime-with-explicitly-acknowledged-terms.
3. Watch for genuinely clean re-licenses (InsightFace's 2025-11-24 "contact for licensing" note suggests a relicensing channel exists).
**Quality on activist footage (limits to state publicly):** masks and occlusions massively degrade FR — NIST IR 8331 (masks) exists at `https://nvlpubs.nist.gov/nistpubs/IR/2020/NIST.IR.8331.pdf` (fetched, PDF confirmed); the often-quoted *"error rates 5×–50× higher with digitally applied masks"* figure is **UNVERIFIED-MEMORY** (PDF text extraction failed in-session — re-verify before quoting). Motion blur, low light, side profiles, helmets, and distant faces push embeddings into the low-confidence band where clustering silently merges the wrong people — which in our threat model is the *most dangerous* failure (see §6).
**Sampling strategy:** PySceneDetect (0.7.1, BSD-3) per shot → 1–2 keyframes/shot plus a hard cap (e.g. ≤2 fps) → detect → track (IoU/OSNet) → 1 embedding per track per scene. This cuts GPU cost ~10× vs naive fps and improves cluster purity.
**Clustering:** HDBSCAN (0.8.44, BSD) or Chinese Whispers on cosine similarity of 512-d embeddings; ArcFace-cosine same-person band ~0.4–0.5 (UNVERIFIED-MEMORY, calibrate on our data); then **incremental assignment**: new track → match against reviewed centroids (`person_face_embeddings`); ≥τ_accept auto-assign, τ_band → review queue, else new cluster candidate. Scale note: collection-scale kNN in pgvector works (512-d `vector` < 2000-d limit, HNSW) — reuse the existing pgvector pattern.
---
## 5. Person identity model — DDL sketch
```sql
-- Canonical identities. A person starts as an unverified cluster and is
-- only promoted to a reviewed label by a human.
CREATE TABLE persons (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
display_name text, -- null until a member labels it
status text NOT NULL DEFAULT 'auto' -- auto | member_confirmed | hidden | erased
CHECK (status IN ('auto','member_confirmed','hidden','erased')),
visibility text NOT NULL DEFAULT 'members_only'
CHECK (visibility IN ('members_only','staff_only','erased')),
centroid vector(512), -- running mean of reviewed embeddings
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Reference embeddings (reviewed only; see minimization note)
CREATE TABLE person_face_embeddings (
id bigserial PRIMARY KEY,
person_id uuid NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
embedding vector(512) NOT NULL,
source text, -- 'review_ui' | 'backfill'
model text NOT NULL, -- e.g. 'adaface-r50' (pin it)
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON person_face_embeddings USING hnsw (embedding vector_cosine_ops);
-- Every face box ever detected. Coordinates, never crops.
CREATE TABLE video_face_detections (
id bigserial PRIMARY KEY,
video_id uuid NOT NULL, -- PeerTube uuid or media-plane blob hash
source text NOT NULL DEFAULT 'peertube' CHECK (source IN ('peertube','media_plane')),
t_start_ms int NOT NULL, t_end_ms int, -- track span
bbox numeric[4] NOT NULL, -- x,y,w,h normalized
track_id int NOT NULL, -- within-video track
person_id uuid REFERENCES persons(id), -- null = unassigned
confidence real NOT NULL, -- detector conf
match_sim real, -- cosine to assigned centroid
assignment text NOT NULL DEFAULT 'auto' -- auto | human_confirmed | rejected | pending_review
CHECK (assignment IN ('auto','human_confirmed','rejected','pending_review')),
reviewed_by uuid, reviewed_at timestamptz,
embedding vector(512), -- TRANSIENT: nulled by retention job after review/SLA
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON video_face_detections (video_id, t_start_ms);
CREATE INDEX ON video_face_detections (person_id);
CREATE INDEX ON video_face_detections (assignment) WHERE assignment = 'pending_review';
-- Phase 1/2: transcripts (feeds chat_media_archive + pgvector via the search pipeline)
CREATE TABLE transcript_segments (
id bigserial PRIMARY KEY,
video_id uuid NOT NULL,
source text NOT NULL DEFAULT 'peertube',
lang text NOT NULL, -- 'en' | 'es' (also ca/eu occur in Spain!)
start_ms int NOT NULL, end_ms int NOT NULL,
speaker_key text, -- 'SPEAKER_00' from diarization
person_id uuid REFERENCES persons(id), -- resolved speaker (phase 2)
text text NOT NULL,
words jsonb, -- word-level timestamps
confidence real,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (video_id, start_ms, lang)
);
-- Speaker-name bridge (label once per speaker per video; suggestions across videos)
CREATE TABLE video_speaker_person (
video_id uuid NOT NULL, speaker_key text NOT NULL,
person_id uuid REFERENCES persons(id),
confidence real, assignment text NOT NULL DEFAULT 'auto',
PRIMARY KEY (video_id, speaker_key)
);
-- Audit trail: every label action is reversible
CREATE TABLE identity_audit (
id bigserial PRIMARY KEY,
actor uuid NOT NULL, action text NOT NULL, -- assign|merge|unmerge|erase|relabel
person_id uuid, other_person_id uuid, payload jsonb,
at timestamptz NOT NULL DEFAULT now()
);
```
**Propagation rules**
- Label a face once (human confirms track → `persons.status='member_confirmed'`, centroid updated): backfill = `UPDATE video_face_detections SET person_id=$p WHERE match_sim >= τ_accept AND person_id IS NULL` (τ_accept ~0.6 calibrated); everything in the τ band goes to `pending_review`.
- **Merge personas:** union → recompute centroid → single `identity_audit` row; undo by replaying audit (append-only, never destructive).
- **Erasure (Art. 17):** `persons.status='erased'` → delete person embeddings + null all `video_face_detections.person_id` + remove speaker mappings + purge embeddings column for that person's detections; detections revert to anonymous boxes. Name/label never appears again.
- **Minimization:** `embedding` on detections is a *transient* column, wiped by a retention job (e.g. 72h after processing) — persist only reviewed centroids. No face crops on disk at rest (processing temp dir, tmpfs, TTL).
---
## 6. GDPR / threat model (Spain)
**Verified legal text (EUR-Lex CELEX:32016R0679, fetched today):**
- **Art. 4(14):** biometric data = *"personal data resulting from specific technical processing relating to the physical, physiological or behavioural characteristics of a natural person, which allow or confirm the unique identification of that natural person, such as facial images…"*
- **Art. 9(1) prohibition:** processing of *"biometric data for the purpose of uniquely identifying a natural person"* is prohibited unless a 9(2) condition applies.
- 9(2) candidates for third parties in footage: **(a) explicit consent** (bystanders/police never gave it), **(e) manifestly made public by the data subject** (appearing in a protest video is not "manifestly making public" one's biometric template — analysis, not legal advice), **(f) vital interests** (narrow emergencies), **(g) substantial public interest — requires a Union/Member-State law providing the basis** (no obvious one for us). **Analysis: none applies cleanly to automated identity indexing of non-consenting third parties.** Note also that face *detection/blur* that does not uniquely identify arguably isn't Art. 9 data at all; the moment we build a gallery to identify people, it is.
- **Art. 35(3)(b):** DPIA is mandatory for *"processing on a large scale of special categories of personal data"* → **DPIA before phase 3**.
- Art. 5 (minimization/purpose limitation), Art. 17 (erasure), Art. 25 (protection by design), Art. 32 (security), Art. 85 (freedom of expression exemption — relevant for *publishing* footage, and LOPDGDD (LO 3/2018) develops it; article-level mapping **flagged: not verified against BOE this session**).
**EDPB / AEPD sources — fetch status this session (flagged):**
- EDPB *Guidelines 05/2022 on processing personal data through facial recognition technology in law enforcement* — known document; **all attempted URLs 404'd in this session → URL unverified** (search "EDPB Guidelines 05/2022 facial recognition" before citing).
- **AEPD (aepd.es) returned 500/503 all session** — the AEPD+CAT *guía de tratamiento de datos biométricos* and enforcement cases could not be retrieved. **Flagged as unverified.** From memory (unverified): AEPD sanctioned Ayuntamiento de Zaragoza over facial-recognition cameras (2019, small fine) — do not cite without checking.
- NIST IR 8331 masks study: PDF confirmed live (URL above); headline figure unverified (see §4).
**The specific danger: mislabeling.** A wrong person_id in activist footage (a) wrongly associates a bystander with a collective, (b) creates a target list for state/paramilitary actors if the index leaks, (c) is effectively irreversible reputationally. Therefore:
**Design mitigations (concrete):**
1. **Default = detect, don't identify.** Phase 3 ships with face *detection* + blurring/redaction tooling and a **kill switch: identity matching is an opt-in feature flag per instance**, default off, behind DPIA sign-off.
2. **Human-review gating:** nothing from `auto` assignment is ever exposed in any UI or API; only `human_confirmed` labels render, and only to roles with `identity:read`.
3. **Visibility tiers:** `persons.visibility` (members_only / staff_only) enforced at the API/query layer, not the client; face-label search endpoints excluded from `chat_media_archive` public surfaces.
4. **Minimization:** coordinates not crops (§5); transient embeddings wiped; centroids only for confirmed persons; model + threshold pinned and logged (reproducibility).
5. **Erasure workflow:** one click → §5 erasure; plus per-person "scrub from index" independent of video deletion; document that PeerTube/R2 originals stay (erasure applies to the *index*, per minimization the index is what we must justify).
6. **Audit trail** (§5) — who labeled what, when; undo/merge reversible.
7. **Blast-radius controls:** index DB on Tailscale-only interface; no face data in backups of public shares; consider per-person "high-risk" flag (police-adjacent identifications) that hard-restricts visibility to staff and logs every read.
8. **Co-official languages:** Spain footage will contain Catalan/Basque/Galician — Whisper supports them (`ca`, `eu` verified in the turbo model card language list) but with worse WER than ES; transcribe+flag language per segment rather than assuming EN/ES.
---
## 7. Pipeline integration + VRAM/cost budget
**Data flow**
```
Phase 1: PeerTube upload → transcode (existing pool) → video published
→ video_transcription (remote runner on 3090, whisper-ctranslate2 + large-v3-turbo CT2 int8)
→ captions (VTT) on R2 (prefix captions/)
→ api poller (no webhooks in PeerTube — verified): GET /api/v1/videos/{id}/captions
→ fetch VTT → parse → transcript_segments → TEI/bge-m3 → pgvector (existing search doc)
Phase 1b (media-plane blobs): upload-finalized api event → job queue → whisper-asr-webservice
(faster_whisper engine, VTT out) → same transcript_segments path
Phase 2: api event → diarization job (WhisperX: turbo CT2 + align + community-1)
→ speaker_key per segment → review UI "who is this speaker?" → video_speaker_person
Phase 3: api event → face job: PySceneDetect → keyframes → detect (BlazeFace/SCRFD) → track
→ embed (only if flag on) → assign vs reviewed centroids → pending_review queue
→ human review → persons / centroid update → backfill propagation (§5)
```
Orchestration reuses the transcode-pool pattern: PeerTube runner protocol for transcription; everything else is a small GPU worker container + Postgres-backed queue (like the api's existing transcode queue), jobs idempotent, resumable per video.
**VRAM budget (24 GB 3090, Linux node — per the transcode-pool doc, production NVIDIA work stays off WSL2; the Windows box is feasible for ASR-only via WSL2 *if* Windows-side VRAM tenants are cleared — measured 23.8/24 GB occupied there on 2026-10-09)**
| Component | fp16 | int8 | Duty |
|---|---|---|---|
| TEI + bge-m3 (already deployed) | ~2–3 GB | — | persistent |
| whisper turbo (CT2) | ~6 GB | ~3 GB | burst |
| whisper large-v3 (CT2) | ~10 GB | ~4.5 GB (README-measured on 8 GB card) | burst, quality tier |
| WhisperX + pyannote community-1 | +2–4 GB (EST.) | — | burst, phase 2 |
| SCRFD/BlazeFace + embed ONNX | ~1–2 GB (EST.) | — | burst, phase 3 |
Concurrent worst case (large-v3 + diarization + faces) ≈ 16 GB → fits; but **serialize per job class** anyway (queue). Turbo + diarization + faces ≈ 11–13 GB. Always keep the NVENC transcode pool's VRAM floor in mind on shared nodes — schedule A/V-intelligence jobs on the same nodes only when transcode queue depth is low (reuse the pool manager's pressure signal).
**Cost per video-hour (3090, ESTIMATES):** transcription turbo int8 1.5–3 min (large-v3 5–12 min) · diarization 1–3 min · keyframe faces 3–10 min (sampler-capped) → **~5–25 GPU-min per video-hour** full pipeline; backfilling 500 h ≈ 2–4 GPU-days for transcription alone.
---
## 8. Honest gaps & unverified items (all flagged in-text; consolidated)
1. **Spanish ASR on street audio** — no published benchmark matches our domain (noise/PA/chants/code-switching); FLEURS numbers absent from current HF cards. Internal eval set is mandatory.
2. **Hallucination on chants/music** — documented Whisper weakness; needs VAD + segment-level confidence and human spot-checks before any published subtitle.
3. **Diarization has no Spanish benchmark** in pyannote's table; overlapping-protest-speech DER will be far worse than AMI numbers.
4. **Face weights licensing is dirty everywhere** (§4) — the accurate models are non-commercial/undefined; no clean equal exists today. Decision needed at collective level + legal advice; SCRFD/YuNet/MediaPipe weight-file license status partially unverified.
5. **NIST mask figure**, **EDPB 05/2022 URL**, **AEPD guidance/enforcement** — could not be fully retrieved this session; re-verify before citing (aepd.es was 5xx all day).
6. **PeerTube runner + CUDA pickup** for `whisper-ctranslate2`, and whether `model_path` accepts a turbo CT2 dir in the runner path — one smoke test needed.
7. **PeerTube plugin transcription hook names** — plugin system exists (verified import), specific hook unverified.
8. **Speaches diarization endpoints**, **diart maintenance status**, **chinese-whispers package**, **pyannote community-1 VRAM**, **3090 RTF figures** — ESTIMATE/UNVERIFIED-MEMORY as marked.
9. **WhisperX Spanish alignment model license** (VoxPopuli weights) — check before phase 2.
## Key primary sources (fetched 2026-10-11)
- PeerTube tree/CHANGELOG/source: https://github.com/Chocobozzz/PeerTube (v8.3.1; `config/default.yaml`, `packages/transcription/*`, `server/core/controllers/api/videos/captions.ts`, runner `process-transcription.ts`)
- HF API/cards: openai/whisper-large-v3 (Apache-2.0), openai/whisper-large-v3-turbo (MIT, 32→4 layers, 809M), distil-whisper/distil-large-v3 (MIT), Systran/faster-whisper-large-v3 (MIT), pyannote/speaker-diarization-community-1 (CC-BY-4.0, gated auto), pyannote/speaker-diarization-3.1 + segmentation-3.0 (MIT, gated auto), nvidia/diar_sortformer_4spk-v1 (**CC-BY-NC-4.0**)
- faster-whisper README (VRAM/RTF benchmarks), whisper.cpp v1.9.5 (VTT/CUDA/Vulkan/Q5_0), whisperX README + alignment.py (community-1, `VOXPOPULI_ASR_BASE_10K_ES`), Speaches v0.9.0-rc.3 (MIT), whisper-asr-webservice v1.10.0
- insightface README (MIT code / non-commercial models + 2025-11-24 relicensing note), AdaFace (MIT code, Drive weights), deepface (MIT + inherited-weights disclaimer), MediaPipe (Apache-2.0), yolov8/yolov5-face (GPL-3.0), facenet-pytorch (MIT), PySceneDetect 0.7.1 (BSD-3), hdbscan 0.8.44 (BSD)
- EUR-Lex GDPR (Art. 4(14), 9, 35(3)(b) quoted), NIST IR 8331 PDF (exists; figure unverified), EDPB/AEPD (unreachable this session — flagged)