Ga naar inhoud

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.

#UitgangspuntReden (eis van I See You)
1Uitsluitend 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.
2Video é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.
3Twee-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.
4Bytes 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.
5Opslag achter de IMediaStore-poort.Cloud-agnostisch: lokaal filesystem/MinIO, in productie S3-compatibel; wisselen is een kwestie van DI/config, geen herontwerp.
6Automatische 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.
7Range-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.

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);
}
AdapterInzetToelichting
FilesystemLokale ontwikkelingSchrijft naar mappen op schijf; geen externe dienst nodig.
MinIOLokale dev / on-premS3-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 BlobOptioneelKan 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.

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.

AspectVideoFoto
CaptureMediaRecorder neemt de stream maximaal 10 seconden op (mag korter)Eén frame uit de live stream als momentopname
Resultaat client-sideBlob (bv. video/webm)Blob (bv. image/jpeg of image/png)
MediaTypeVideoFoto
Verschil in de rest van de pipelineGeen — vanaf de Blob identiekGeen — 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 Blob doorloopt vervolgens exact dezelfde stappen — tijdelijke upload, promotie naar definitieve opslag, serveren en cleanup. Zie het scenario Vraag stellen met foto.

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 toelichting via 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 AnoniemeGebruiker vast;
  • persisteert de Vraag in PostgreSQL met de definitieve key, het MediaType, makerIsGeregistreerd, de optionele toelichting, het ProfielSnapshot van de maker, de SelectieCriteria, de Beoordelingsselectie (de te beoordelen secties), het uit de Systeeminstellingen overgenomen en bevroren aantalBeoordelingen (met de daaruit bevroren creditkosten) en de timestamp; er ontstaan net zoveel Opdracht-slots als aantalBeoordelingen;
  • 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.

Validatie gebeurt bij de tijdelijke upload op magische bytes. Alleen onderstaande formaten passeren; alle andere worden geweigerd.

MediaTypeToegestane types (magic-bytes-validatie)Serveren
Videomp4, webm, mov (video/mp4, video/webm, video/quicktime)GET /api/vragen/{id}/stream met range-requests (206 Partial Content)
Fotojpeg/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.

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 met 206 Partial Content, een Content-Range en de juiste Content-Length voor de chunk, en levert hij alleen het gevraagde stuk van de stream;
  • zonder Range-header wordt het volledige bestand met Content-Length geserveerd;
  • het Content-Type volgt uit het MediaType/de extensie (video/webm, video/quicktime of video/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:

MediaTypeAanlevering aan het vision-model
FotoHet 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 via IMediaStore.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).

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:

  1. Handmatig door de maker — een DELETE /api/vragen/{id} (met eigenaarscontrole op makerId) verwijdert vraag + opname direct.
  2. Automatisch na de resultaat-retentie — de RetentieHostedService verwijdert de vraag + opname zodra now − beoordeeldOp ≥ resultaatRetentieUren (uit de Systeeminstellingen, standaard 168 = 7 dagen).
  3. Moderatie / veiligheids-timeout — zie de tabel.

Het RetentieHostedService : BackgroundService draait altijd (geen uitschakelbare vlag), tikt periodiek en handhaaft het Retentiebeleid.

DoelwitGrensActie
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 minutenTemp-key uit de buffer verwijderd (niet-voltooide upload)
Wees-AnoniemeGebruikerniet meer in gebruikRecord verwijderd zodra de bijbehorende vraag weg is
Verlopen Sessielanger inactief dan toegestaanSessie-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 RetentieHostedService start 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.

MethodeEndpointDoel
POST/api/media/tempOpgenomen video-/foto-Blob naar de tijdelijke buffer; magic-bytes-validatie + groottelimiet; geeft een temp-key terug
POST/api/vragenVoltooit 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}/uitslagResultaat 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}/streamVideo streamen met range-requests (206 Partial Content)
GET/api/vragen/{id}/mediaFoto 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.