Media & opname
Deze pagina beschrijft de greenfield-doelarchitectuur voor media-afhandeling: hoe een Vraag wordt vastgelegd via de live camera, tijdelijk gebufferd, gepromoveerd naar definitieve opslag, gestreamd en uiteindelijk verwijderd. Backend en frontend worden vanaf 0 opgebouwd; het oude prototype dient hooguit als referentie, niet als basis. Centraal staat de anonimiteit: media bestaat slechts tijdelijk en wordt altijd verwijderd door het Retentiebeleid — na afronding blijft de opname nog een begrensd resultaat-retentievenster bestaan zodat de maker het resultaat + de opname kan terugzien, waarna zij automatisch (of eerder, door de maker zelf) verdwijnt.
Een vraag is óf een video van maximaal 10 seconden (mag korter) óf een foto — bepaald door MediaType { Video, Foto }. Beide volgen dezelfde opinie-flow en dezelfde media-pipeline; alleen de capture-stap verschilt.
De bredere backend-keuzes (modulaire monoliet, .NET/C#, EF Core/PostgreSQL, SignalR) staan op Backend & API.
Uitgangspunten
Section titled “Uitgangspunten”| # | Uitgangspunt | Reden (eis van I See You) |
|---|---|---|
| 1 | Uitsluitend live-camera-capture, geen upload van bestaande bestanden. | Borgt echtheid en anonimiteit: er kan geen bestaand of bewerkt bestand worden ingestuurd; de opname ontstaat camera-centrisch in de browser. |
| 2 | Video én foto in één pipeline. | MediaType generaliseert beide; alleen de capture-stap (opname van maximaal 10 seconden versus één momentopname) wijkt af. Dit voorkomt twee aparte verwerkingspaden. |
| 3 | Twee-traps opslag: tijdelijke buffer dan promote naar definitieve opslag. | De gebruiker kan terugkijken/opnieuw opnemen vóór bevestiging; pas bij het voltooien van de vraag wordt het bestand definitief en krijgt het een willekeurige key. |
| 4 | Bytes in object-/blob-storage, niet in PostgreSQL. | Lage kosten en schaalbaarheid: de database bewaart alleen de key en metadata; binaire data hoort thuis in goedkope, horizontaal schaalbare object-storage. |
| 5 | Opslag achter de IMediaStore-poort. | Cloud-agnostisch: lokaal filesystem/MinIO, in productie S3-compatibel; wisselen is een kwestie van DI/config, geen herontwerp. |
| 6 | Automatische verwijdering als altijd-actieve regel — na een begrensd retentievenster. | Anonimiteit is een kernbelofte: elke opname verdwijnt gegarandeerd. Na afronding krijgt de maker eerst een instelbaar resultaat-retentievenster (resultaatRetentieUren) om resultaat + opname terug te zien; daarna ruimt de retentie (BackgroundService, zónder uitschakelbare vlag) de opname op — of eerder, wanneer de maker zelf verwijdert. |
| 7 | Range-streaming voor video, statisch serveren voor foto. | Opiniegevers bekijken video direct in de browser en kunnen seeken zonder de volledige opname te downloaden; foto’s zijn klein en worden statisch met cache-headers geleverd. |
De IMediaStore-poort
Section titled “De IMediaStore-poort”Media-opslag is in de Application-laag een poort (IMediaStore); de Infrastructure-laag levert de adapters. De domein- en use-case-laag weten niets van filesystem of cloud — alleen van keys en streams.
public interface IMediaStore{ Task<string> SchrijfTempAsync(Stream bytes, string extensie, CancellationToken ct); Task<string> PromoveerAsync(string tempKey, MediaType type, CancellationToken ct); Task<Stream> OpenAsync(string key, CancellationToken ct); Task VerwijderAsync(string key, CancellationToken ct); Task<IReadOnlyList<string>> VerlopenTempKeysAsync(TimeSpan ouderDan, CancellationToken ct);}| Adapter | Inzet | Toelichting |
|---|---|---|
| Filesystem | Lokale ontwikkeling | Schrijft naar mappen op schijf; geen externe dienst nodig. |
| MinIO | Lokale dev / on-prem | S3-compatibel; benaderd via dezelfde S3-implementatie. |
| S3-compatibel (AWS SDK for .NET) | Productie | Één implementatie die tegen elke S3-compatibele opslag werkt — AWS S3, Google Cloud Storage, MinIO en meer. |
| Azure Blob | Optioneel | Kan via de S3-laag, of als dunne AzureBlobMediaStore met dezelfde interface. |
Wisselen = DI/config, geen herontwerp. Welke adapter wordt gebruikt, is een keuze in de compositieroot (
ISeeYou.Api) op basis van configuratie. De use-cases en endpoints blijven ongewijzigd. Dit houdt de start goedkoop (filesystem/MinIO) en de productie schaalbaar (S3-compatibel) zonder cloud-lock-in.
Capture in de browser
Section titled “Capture in de browser”De opname gebeurt client-side via de MediaDevices/getUserMedia-API. De gebruiker krijgt een live camerabeeld; er is geen bestandskiezer en geen upload-pad. De frontend (React + TypeScript) gebruikt een getypeerde camera-hook rond getUserMedia/MediaRecorder.
| Aspect | Video | Foto |
|---|---|---|
| Capture | MediaRecorder neemt de stream maximaal 10 seconden op (mag korter) | Eén frame uit de live stream als momentopname |
| Resultaat client-side | Blob (bv. video/webm) | Blob (bv. image/jpeg of image/png) |
MediaType | Video | Foto |
| Verschil in de rest van de pipeline | Geen — vanaf de Blob identiek | Geen — vanaf de Blob identiek |
Foto past in dezelfde pipeline. De enige afwijking zit in de capture: bij een foto wordt eenmalig één frame vastgelegd in plaats van een opname van maximaal 10 seconden. De resulterende
Blobdoorloopt vervolgens exact dezelfde stappen — tijdelijke upload, promotie naar definitieve opslag, serveren en cleanup. Zie het scenario Vraag stellen met foto.
Opslagpipeline
Section titled “Opslagpipeline”De pipeline kent twee schrijf-stappen (temp dan promote), een lees-stap (serveren) en een opruim-stap (cleanup). De Blob uit de browser gaat eerst naar een tijdelijke buffer; pas bij het bevestigen van de vraag wordt hij gepromoveerd naar definitieve opslag onder een willekeurige key. De Vraag in PostgreSQL bewaart uitsluitend die key plus metadata, nooit de bytes zelf.
flowchart LR
Cam["Live camera (getUserMedia)"] -->|"max. 10s opname of 1 foto"| Blob["Media-Blob (browser)"]
Blob -->|"POST /api/media/temp"| Temp["Tijdelijke buffer (IMediaStore)"]
Temp -->|"tempKey + magic-bytes-validatie"| TempOk["Temp-key teruggegeven"]
TempOk -->|"POST /api/vragen (promote)"| Def["Definitieve opslag (willekeurige key)"]
Def -->|"key + metadata"| DB["PostgreSQL: Vraag (alleen key)"]
Def -->|"GET stream of statisch"| Viewer["Opiniegever (browser)"]
Def -->|"GET /api/vragen/mijn + /media (na afronding)"| Maker["Maker: resultaat + opname terugzien"]
Retentie["RetentieHostedService (altijd actief)"] -.->|"temp verlopen"| Temp
Retentie -.->|"resultaat-retentie verstreken, maker verwijdert, of veiligheids-timeout"| Def
1. Tijdelijke upload — POST /api/media/temp
Section titled “1. Tijdelijke upload — POST /api/media/temp”De client stuurt de opgenomen Blob naar het tijdelijke-upload-endpoint. De API:
- begrenst de grootte via een body-/multipart-limiet en past rate limiting toe (media-policy);
- valideert het werkelijke bestandstype op de magische bytes (de eerste bytes van de stream), niet op de opgegeven MIME-type — alleen toegestane media-formaten passeren;
- schrijft de bytes via
IMediaStore.SchrijfTempAsync(...)naar de tijdelijke buffer en geeft een temp-key terug.
Op dit punt bestaat er nog geen Vraag: de bytes staan los van maker- en selectiegegevens. Wordt de upload niet voltooid, dan ruimt de retentie de temp-key op zodra deze een via Beheer instelbaar aantal minuten oud is.
2. Promote naar definitieve opslag — POST /api/vragen
Section titled “2. Promote naar definitieve opslag — POST /api/vragen”Bij het bevestigen van de vraag stuurt de client de maker- en selectiegegevens samen met de temp-key. De use-case-handler (Application):
- controleert eerst de optionele
toelichtingvia de tekstmoderatie; bij een afkeuring wordt de vraag niet aangemaakt (de temp-media blijft staan en wordt via retentie opgeruimd); - roept
IMediaStore.PromoveerAsync(tempKey, mediaType, ct)aan, dat de bytes naar de definitieve opslag verplaatst onder een willekeurige, niet-raadbare key; - bepaalt server-side of de maker een geregistreerde gebruiker is, of legt anders een
AnoniemeGebruikervast; - persisteert de
Vraagin PostgreSQL met de definitieve key, hetMediaType,makerIsGeregistreerd, de optioneletoelichting, hetProfielSnapshotvan de maker, deSelectieCriteria, deBeoordelingsselectie(de te beoordelen secties), het uit de Systeeminstellingen overgenomen en bevrorenaantalBeoordelingen(met de daaruit bevrorencreditkosten) en detimestamp; er ontstaan net zoveelOpdracht-slots alsaantalBeoordelingen; - geeft de temp-key impliciet vrij (de buffer-entry wordt na promotie of via retentie opgeruimd).
De timestamp op de definitieve Vraag is het ankerpunt voor de veiligheids-timeout van de retentie; na afronding stuurt beoordeeldOp het resultaat-retentievenster waarin de maker resultaat + opname kan terugzien. De database bewaart alleen de key en metadata — de bytes leven uitsluitend in de object-/blob-storage.
Eén voltooi-endpoint voor video én foto. De promote-stap is media-neutraal:
MediaType(en de bijbehorende extensie) is de enige media-specifieke variabele. Er is geen apart endpoint per media-type.
Toegestane bestandstypen
Section titled “Toegestane bestandstypen”Validatie gebeurt bij de tijdelijke upload op magische bytes. Alleen onderstaande formaten passeren; alle andere worden geweigerd.
MediaType | Toegestane types (magic-bytes-validatie) | Serveren |
|---|---|---|
Video | mp4, webm, mov (video/mp4, video/webm, video/quicktime) | GET /api/vragen/{id}/stream met range-requests (206 Partial Content) |
Foto | jpeg/jpg, png (image/jpeg, image/png) | GET /api/vragen/{id}/media statisch met cache-headers |
Het onderscheid tussen video en foto wordt vastgelegd in MediaType op de Vraag; de opslag-key en de serveerwijze volgen daaruit. Alle overige stappen (temp-buffer, magic-bytes-validatie, promote, retentie) zijn voor beide media identiek.
Streaming met range-requests
Section titled “Streaming met range-requests”Video wordt geserveerd via een stream-endpoint dat HTTP-range-requests ondersteunt, zodat de browser kan seeken en alleen de gevraagde byte-range ophaalt. De bytes komen via IMediaStore.OpenAsync(key, ct):
- bij een
Range-header antwoordt de API met206 Partial Content, eenContent-Rangeen de juisteContent-Lengthvoor de chunk, en levert hij alleen het gevraagde stuk van de stream; - zonder
Range-header wordt het volledige bestand metContent-Lengthgeserveerd; - het
Content-Typevolgt uit hetMediaType/de extensie (video/webm,video/quicktimeofvideo/mp4); - CORS is beperkt tot de allowlist zodat de gescheiden frontends de media mogen laden.
Foto’s vereisen geen range-streaming en worden statisch geserveerd, met cache-headers. Omdat álle opnames uiterlijk aan het einde van het resultaat-retentievenster worden verwijderd (en nooit-afgeronde vragen bij de veiligheids-timeout), blijft dit hoe dan ook binnen het retentiebeleid.
Frame-extractie voor de AI-persona (FFmpeg)
Section titled “Frame-extractie voor de AI-persona (FFmpeg)”De AI-persona beoordeelt sinds 2026-07-04f vision-gebaseerd (OpenAI gpt-4o-mini): het model moet de opname kunnen “zien”. Omdat een vision-model beelden verwacht en geen video-stream, verschilt de aanlevering per MediaType:
MediaType | Aanlevering aan het vision-model |
|---|---|
Foto | Het beeld gaat rechtstreeks mee (één afbeelding). |
Video (max. 10s) | De backend haalt met FFmpeg ~4 frames gelijkmatig verdeeld uit de opname en stuurt die als beelden mee. |
- De frame-extractie draait server-side in de Infrastructure-laag, achter de
IAiPersonaService-poort; de bytes komen viaIMediaStore.OpenAsync(key, ct). FFmpeg is meegebakken in het API-Docker-image (zie Deployment), zodat er geen externe dienst nodig is. - Het is een vluchtige bewerking: de geëxtraheerde frames worden alleen in het geheugen voor de AI-aanroep gebruikt en niet apart opgeslagen; ze vallen dus buiten de opslagpipeline en het retentievenster (de video zelf blijft de enige bron en volgt het gewone retentiebeleid).
- Fail-graceful. Ontbreekt de OpenAI-sleutel/chat-permissie, mislukt de frame-extractie of faalt de AI-aanroep, dan valt de dienst terug op de deterministische score (geen frames nodig). Kosten blijven ordegrootte < $0,01 per AI-opinie (foto 1 beeld, video ~4 frames).
Cleanup en retentie
Section titled “Cleanup en retentie”Zodra de beoordeling klaar is (alle opinies binnen) wordt op de Vraag beoordeeldOp gezet. De opname wordt op dat moment niet verwijderd: het resultaat + de opname blijven een resultaat-retentievenster bestaan, zodat de maker ze kan terugzien (GET /api/vragen/mijn + de media-endpoints). De opname is in dit venster uitsluitend voor de maker zichtbaar — opiniegevers zagen die alleen tijdens hun eigen beoordeling.
De opname (bytes via IMediaStore.VerwijderAsync én de records in PostgreSQL) verdwijnt vervolgens langs drie wegen:
- Handmatig door de maker — een
DELETE /api/vragen/{id}(met eigenaarscontrole opmakerId) verwijdert vraag + opname direct. - Automatisch na de resultaat-retentie — de
RetentieHostedServiceverwijdert de vraag + opname zodranow − beoordeeldOp ≥ resultaatRetentieUren(uit de Systeeminstellingen, standaard 168 = 7 dagen). - Moderatie / veiligheids-timeout — zie de tabel.
Het RetentieHostedService : BackgroundService draait altijd (geen uitschakelbare vlag), tikt periodiek en handhaaft het Retentiebeleid.
| Doelwit | Grens | Actie |
|---|---|---|
Beoordeelde opnames — door de maker verwijderd (Vraag, video én foto) | maker klikt “verwijder opname” (DELETE /api/vragen/{id}) | Bytes uit object-storage + Vraag-record + opinies/slots direct verwijderd |
Beoordeelde opnames — automatisch (Vraag) | beoordeeldOp ouder dan de resultaat-retentie (resultaatRetentieUren, instelbaar) | Bytes + records verwijderd door de retentie-sweep |
Niet-afgeronde opnames (Vraag) | ouder dan de veiligheids-timeout (veiligheidsTimeoutMin, instelbaar) | Bytes + Vraag-record verwijderd door het vangnet |
Gemelde opnames (Vraag) | meldingen-drempel bereikt (standaard 3) | Bytes + Vraag-record direct verwijderd (moderatie) |
| Tijdelijke uploads (temp-buffer) | ouder dan een via Beheer instelbaar aantal minuten | Temp-key uit de buffer verwijderd (niet-voltooide upload) |
Wees-AnoniemeGebruiker | niet meer in gebruik | Record verwijderd zodra de bijbehorende vraag weg is |
Verlopen Sessie | langer inactief dan toegestaan | Sessie-record verwijderd |
De retentie geldt voor álle gebruikerstypen: ongeacht of de maker geregistreerd of anoniem is, wordt de opname altijd verwijderd — uiterlijk aan het einde van het resultaat-retentievenster. Een anonieme maker kan alleen terugzien zolang zijn sessie leeft; de automatische verwijdering na het venster geldt onverkort. Zie Melden en verwijderen en Anonimiteit en retentie.
Altijd actief, niet uitschakelbaar. Omdat gegarandeerde verwijdering een kernbelofte is, kent de doelarchitectuur géén flag om de retentie uit te zetten — alleen de lengte van het resultaat-retentievenster is instelbaar. De
RetentieHostedServicestart mee met de API en blijft draaien zolang de instance leeft. Bij horizontale schaal-out draait de opruimlogica idempotent: een al-verwijderde key of record levert geen fout op.
API-endpoints (media)
Section titled “API-endpoints (media)”| Methode | Endpoint | Doel |
|---|---|---|
POST | /api/media/temp | Opgenomen video-/foto-Blob naar de tijdelijke buffer; magic-bytes-validatie + groottelimiet; geeft een temp-key terug |
POST | /api/vragen | Voltooit de vraag: promote naar definitieve opslag onder willekeurige key + persisteert de Vraag |
GET | /api/vragen/mijn?actor=… | Mijn vragen: de eigen vragen van de maker met status (in behandeling/beoordeeld) en, indien beoordeeld, het resultaat + de media-key |
GET | /api/vragen/{id}/uitslag | Resultaat van één vraag: opinies + afgeleide SectieGemiddelden + de reacties en de door de maker gegeven waardering |
GET | /api/vragen/{id} | Metadata van één vraag (key wordt niet rechtstreeks blootgesteld) |
GET | /api/vragen/{id}/stream | Video streamen met range-requests (206 Partial Content) |
GET | /api/vragen/{id}/media | Foto statisch ophalen (cache-headers) |
DELETE | /api/vragen/{id}?actor=… | Maker verwijdert zijn eigen vraag + opname (eigenaarscontrole op makerId) |
Alle state-mutaties verlopen via REST; realtime push (bv. OpdrachtToegewezen, VraagVerlopen) loopt via SignalR — zie Backend & API.