openvibers/openvibe.cam README.md
README.md
OpenVibe.Cam
Your camera, your cameras, and the moment Live's cam button takes.
Live site: https://openvibe.cam · API docs: https://openvibe.services · For AI: https://openvibe.cam/llms.txt
Run it: npm ci && npm run dev (Node 22 or newer) · Tests: npm test
Status: v1 written and tested (see [STATUS.json](STATUS.json)).
Domain: openvibe.cam · Port: 5160 · Service id: cam · Env prefix: CAM
License: AGPL-3.0-only (same as every OpenVibe service).
OpenVibe.Cam is the camera app, the private wall for cameras you own, and the API Live's cam button calls. A photo
or a still is saved to OpenVibe.Pics; Cam keeps the link, the filter and the caption, never the bytes. A camera is
private to its owner, and a viewer sees it only while the grant the owner gave is active and not revoked.
Purpose
- The camera app. A browser preview with a timer, a 3-shot burst, a grid and on-device colour, light and
softness. The filter runs on the canvas in the person's browser; the server never applies an effect. The picture
is posted to Cam, which forwards it to OpenVibe.Pics with the person's own Network token. There is no server-side
image analysis and no face recognition. - Edit in Studio. A photo you saved opens in Studio with
GET /edit?pics=<Pics slug>&from=camon
openvibe.studio; Studio asks OpenVibe.Pics, with your own token, whether you may read it, then loads it into
Photo withFrom OpenVibe.Cambeside it. The link is on each of your photo cards and on the capture result; a
photo of someone else's answers 404.CAM_STUDIO_URLmoves the link for a drill. - My cameras. Pair an old phone, a browser, a Pi or an IP camera you own. If a Live stream id is given, Cam
reads it from OpenVibe.Live's public API and refuses a stream on somebody else's channel; the slot defaults to the
stream's managed slot. Its wall is private:/w/<id>answers its owner and an active grantee, and 404 to
everyone else, including staff. - Revocable viewer grants. A grant names one canonical subject and an expiry: seven days by default, ninety at
most.@usernameis resolved through OpenVibe.Network (identity.subject.resolve); a subject id works without
it. A re-grant extends the active grant instead of making a second one. Revocation writes a timestamp, and the
wall checks it on every read, so the next request is refused. - The camera element.
<ov-camera>puts the same preview, filters, timer and burst on any site: one script from
https://openvibe.cam/embed/ov-camera.js, a capture event with the blob, and no upload unless the site asks for it. - The Live cam button. Live calls
POST /api/v1/stillswith the viewer's own Network token. Cam fetches
OpenVibe.Media's publicGET /live/:slot/frame.jpg(onlyimage/jpegis accepted), uploads the JPEG to
OpenVibe.Pics as that person, and returns the still and the exact line Live posts in the stream chat.
Owns
| Table | Holds | Migration |
|---|---|---|
| cam_cameras | cam_ + ULID, owner usr_…, owner username, name, source browser\|phone\|pi\|ip, status paired\|live\|offline, Live stream_id (unique per owner), slot, the owner's note, paired/last-seen times | migrations/0001_initial.sql |
| cam_grants | cmg_ + ULID, camera, owner, viewer_subject, viewer username, granted_at, expires_at, revoked_at, revoked_by; one unrevoked grant per camera and viewer | 0001_initial.sql |
| cam_photos | cmp_ + ULID, subject, optional camera, filter label, caption, visibility private\|unlisted, Pics slug, page URL, direct URL | 0001_initial.sql |
| cam_stills | cms_ + ULID, subject, stream id, slot, caption, visibility unlisted\|private, Pics slug, page URL, direct URL | 0001_initial.sql |
| account_data_events | openvibe-sdk/account-data's export/deletion receipts | 0001_initial.sql |
| telemetry_samples | aggregated request telemetry, pruned after 14 days | 0001_initial.sql |
There is no image, blob or byte column in any Cam table. The pics_slug and links are the picture's address in
OpenVibe.Pics; neither Cam nor its database can serve the bytes.
Does not own
- The picture bytes, resizing, metadata stripping and takedowns — OpenVibe.Pics (OpenVibe.MediaHub). Cam reads
its201and stores the slug and links. - The live frame — OpenVibe.Media's
/live/:sel/frame.jpg. Cam never runs ffmpeg or caches a frame. - Streams, chat and the streamer's second camera — OpenVibe.Live and OpenVibe.Chat. The
OVCamPipstreamer
camera is Live's; Live also posts the returned still line to the stream's chat with its own chat authority. - Identity, usernames and accounts — OpenVibe.Network. Cam verifies sessions against its JWKS and resolves a
viewer's@usernamewithidentity.subject.resolve. - Search — OpenVibe.Search. v1 makes nothing public, so Cam queues no Search document.
- Events, the host and the database — OpenVibe.Events, OpenVibe.Host.
Depends on
Environment variable names are in [.env.example](.env.example); no value is committed.
- OpenVibe.Network — sign-in (OAuth client
cam, PKCE S256, cookiescam_at/cam_rt/cam_oauth), JWKS,
token exchange/refresh/revoke, account export/deletion, andidentity.subject.resolvefor@username:
OV_NETWORK_URL,OV_NETWORK_INTERNAL_URL,OV_NETWORK_ISSUER,OV_OAUTH_CLIENT_ID,
OV_OAUTH_CLIENT_SECRET,OV_OAUTH_REDIRECT_URI,OV_SESSION_AUDIENCE,CAM_AUDIENCE. - OpenVibe.Media — the public live frame,
GET /live/:sel/frame.jpg?w=640(media.derivative.readis the
planned capability; the route needs no token today):OV_MEDIA_URL. - OpenVibe.Live —
GET /api/streams/:id, the public read used to check that a paired stream is on the owner's
channel (live.stream.read:OV_LIVE_URL). - OpenVibe.Pics (OpenVibe.MediaHub) —
POST /api/v1/images, called with the signed-in person's own Network
token; Cam never holds a service token for Pics (OV_PICS_URL). The route ispics.image.uploadin the person's
own right; MediaHub refuses an app or service token there. - OpenVibe.Events — account deliveries:
CAM_EVENTS_SECRET,CAM_EVENTS_URL,CAM_EVENTS_ENDPOINT. - PostgreSQL —
DATABASE_URL(through PgBouncer) andDATABASE_DIRECT_URL(migrations); embedded PGlite in
CAM_PGLITE_DIRfor development. - Valkey — optional
VALKEY_URL,VALKEY_PREFIX(readiness lists it; the limits in v1 count in-process). - Packages —
openvibe-contractsv0.234.0,openvibe-sdkv0.161.0,openvibe-sharedv4.100.1, the same tags
OpenVibe.Meme pins.
API
Everything is JSON under /api/v1; errors are application/problem+json with a code. A person is the cam_at
session cookie or Authorization: Bearer <Network user token>, verified offline with the Network JWKS. A write made
with the session cookie must come from https://openvibe.cam (403 cam.cross_site). A signed-in account also gets
per-actor minute/hour limits (CAM_LIMITS_MINUTE, CAM_LIMITS_HOUR).
| Route | What it does |
|---|---|
| POST /photos | a person; raw picture body (image/jpeg, image/png, image/webp), optional X-Filter, X-Caption, X-Camera, X-Filename (URI-encoded); forwarded to Pics |
| GET /photos · GET /photos/:id | your photos |
| GET /cameras | your cameras with active grant counts |
| POST /cameras | { name, source, stream_id?, slot?, notes? }; a stream_id is read from Live and must be yours |
| GET /cameras/:id · PATCH /cameras/:id · DELETE /cameras/:id | the owner: read, change name/source/status/slot/notes, unpair |
| GET /cameras/:id/wall | the owner or an active grantee; a stranger gets 404 |
| GET /cameras/:id/grants | the owner: every grant, including expired and revoked |
| POST /cameras/:id/grants | the owner: { viewer_username } or { viewer_subject }, { days } 1–90 |
| DELETE /cameras/:id/grants/:grantId | the owner: revoke at once |
| POST /stills | a person; { slot, caption?, stream_id?, w?, visibility? }; Media's frame → Pics → a still and the chat line |
| GET /stills | your stills |
| GET /stills/:id | an unlisted still by its link; a private one only for its owner |
| GET/PUT/DELETE /cameras/:id/familiar-faces | the owner: turn Familiar faces on/off, set its region and quiet hours; on creates the owner's Vision library |
| POST /cameras/:id/familiar-faces/enroll · /match · GET /people | the owner, proxied to Vision with the owner's own token; Cam keeps no template or crop |
| PATCH/DELETE /cameras/:id/familiar-faces/people/:personId · POST …/request · POST …/consent-check | the owner: mute/rename/delete a name, or send/re-check its consent request |
| POST /cameras/:id/events | the owner or camera: { kind, at?, still_url?, clip_url?, face_image?, face_image_url?, place?, lat?, lng?, linger_seconds? }; one timeline event |
| GET /cameras/:id/events · GET /events | the owner's timeline, newest first (limit, kind, since) |
| GET/PATCH /events/:id · POST /events/:id/share · POST /events/:id/police · GET /events/:id/export | read, mark "this is X", a private link, or the police download (time, place, still and clip references) |
| GET /events/shared/:token | anyone with the private token; the event, never the owner's account |
| GET/PUT /presence | the owner's own home/away, from the browser, phone or Node |
Familiar faces and the timeline (F1)
A camera's Familiar faces setting is familiar_faces; when it is on, Cam creates an OpenVibe.Vision library for that camera and stores only its vision_library_id, region and region_mode. The owner's own Network token is forwarded to Vision for every library call, so Vision decides ownership, encryption and consent; Cam holds no template and no crop. Deleting the camera deletes its Vision library first.
An event is one detection: person, package, vehicle, animal, loiter or door. A person event may carry a face crop; only then does Cam ask Vision to match inside that camera's library. The event keeps a still and a clip reference plus clip.before_seconds (default 10) and clip.after_seconds (default 20), the owner's away state at that moment, and priority: "high" for an unknown person or a door while away. Detection without identity — package, vehicle, animal, loitering, door — never needs a face.
One Network notification is pushed per event unless the matched name is muted or the camera's quiet_hours cover the event time; the event is still stored. POST /events/:id/share returns a private link once (only its sha256 is stored), and GET /events/:id/export returns an attachment file with the time and place for "Send to police". OV_VISION_URL, CAM_CLIP_BEFORE_SECONDS, CAM_CLIP_AFTER_SECONDS, CAM_EVENT_MAX_BODY_MB, CAM_EVENT_SHARE_DAYS and CAM_NOTIFICATION_SCOPE configure the path.
Validation refusals are 400 { code: "cam.invalid", message }; a blocked caption is 400 { code:
"cam.blocked_words" }. Other codes: cam.sign_in_required, cam.person_required, cam.cross_site,
cam.not_found, cam.camera_exists, cam.camera_limit, cam.grant_limit, cam.grant_not_found,
cam.stream_not_found, cam.not_your_stream, cam.stream_offline, cam.frame_unavailable, cam.frame_busy,
cam.pics.refused, cam.pics.unavailable, cam.too_large, cam.not_an_image, cam.viewer_not_found,
cam.identity_unavailable, cam.rate_limited.
The Live cam button
Live is the caller. It holds the viewer's Network token in its own session; it sends that token to Cam as a Bearer
(server-side), never a Cam service token:
- The watch page sends
{ slot, stream_id, caption }to Live's server. - Live calls
POST https://openvibe.cam/api/v1/stillswithAuthorization: Bearer <viewer's Network token>. - Cam reads
GET https://openvibe.media/live/:slot/frame.jpg?w=640; a 404 iscam.stream_offline, and an SVG
offline card served with 200 is refused ascam.frame_unavailable. - Cam calls
POST https://openvibe.pics/api/v1/imageswith the same token, the JPEG as the body,X-Titleand
X-Filename. Pics re-encodes and strips metadata. - Cam answers
201 { still, frame_url, share: { url, caption, text } }. Live postsshare.textto the stream's
chat and showsshare.url(orstill.pics_direct).
Cam needs no app/service token for this, and Live needs no new Cam grant. A still is unlisted by default; pass
"visibility": "private" to keep it to its owner. Media caches one frame per slot for five seconds and rate-limits
by address, so a burst of button presses can answer 429 cam.frame_busy.
The camera element
Recording kit
https://openvibe.cam/embed/cam-kit.js opens a camera on the calling page. It uses a full-screen stage on phones and a centred stage on computers. The caller owns the result and any upload; Cam does not store video bytes.
<script src="https://openvibe.cam/embed/cam-kit.js" defer></script>
<button id="record">Record</button>
<script>
document.getElementById('record').addEventListener('click', async () => {
const result = await OpenVibeCam.open({ mode: 'video', maxSeconds: 60 });
if (result) console.log(result.file, result.duration);
});
</script>
The script can be pinned with the SHA-384 SRI hash shown on /developers. OpenVibeCam.open({ mode: 'video' | 'photo' | 'screen', maxSeconds, timer, onDone }) resolves to { file, mode, mimeType, duration, width, height, maxSeconds } after Use this, or null when closed. onDone(result) may return a promise. Video and screen modes use the browser's MediaRecorder; screen capture appears when available. The review has two trim handles and exports the selected clip when supported. Pass the returned file to the calling site's uploader, using ov-upload's resumable uploader when available.
Photo element
The camera app is also one framework-free custom element, at https://openvibe.cam/embed/ov-camera.js and documented
live at openvibe.cam/developers. It is one script and one tag; with the default
upload="off" the picture never leaves the browser.
<script src="https://openvibe.cam/embed/ov-camera.js" async></script>
<ov-camera id="cam" facing="user" filters="none,mono,warm,cool,fade,vivid,blur" timer="0" mode="photo" upload="off"></ov-camera>
<script>
document.getElementById('cam').addEventListener('ov-camera:capture', function (e) {
// e.detail = { blob, width, height, filter }
});
</script>
| Attribute | Values | Default |
|---|---|---|
| facing | user (front) or environment (back) | user |
| filters | comma list of none, mono, warm, cool, fade, vivid, blur | none |
| timer | 0, 3 or 10 seconds | 0 |
| mode | photo or burst (three, 450 ms apart) | photo |
| upload | off, or openvibe to post to POST /api/v1/photos when the page is openvibe.cam and signed in | off |
Events: ov-camera:capture with { blob, width, height, filter } and ov-camera:error with { code, message }.
Methods: start(), stop(), capture(), flip(). Style with --ov-camera-accent and --ov-camera-radius. The
element never starts the camera on connect; a click (or an explicit start()) does. OpenVibe.SDK wraps the loader as
openvibe-sdk/cam (defineOvCamera()), with the attributes, events and methods typed.
Pages
All server-rendered through the OpenVibe Frame (openvibe-shared/shell), readable without JavaScript, with
semantic HTML and stable cm- ids/classes. Public pages are public, max-age=60 with Vary: Cookie; a page drawn
for a signed-in person, a wall or a still is private, no-store.
| Page | What it is |
|---|---|
| / | the shutter hero, the three scenes, the privacy rules |
| /app | the camera app: preview, timer, burst, grid, filter, caption, and your recent photos |
| /my | pair a camera, your cameras, your photos and stills (signed in) |
| /my/cameras/:id | one camera: its frame, edit form, viewers, revoke buttons, unpair |
| /w/:id | the private wall an owner or an active grantee sees |
| /s/:id | one still of a live stream, with its Pics link |
| /developers | the API and the Live cam button contract |
| 404 | the not-found page |
public/css/cam.css is a one-comment placeholder: the pages are complete without it, and the main session designs
it. public/js/cam.js adds the live preview, the timer, the burst, the grid and the on-device filter. The two
pictures in public/img/ are data-free SVG placeholders. robots.txt, sitemap.xml, llms.txt and
llms-full.txt list the three public pages and never a wall, a grant or a still.
The first proof
scripts/seed-proof.js performs the part that needs no network:
- pair an owned test camera (
Proof camera, slotproof) forCAM_PROOF_OWNER; - grant
CAM_PROOF_VIEWERfor seven days; - open the wall as that viewer;
- revoke the grant;
- ask for the wall again and assert it is refused.
Re-running reuses the camera and makes a fresh grant. The photo and the still need a signed-in person's Network
token and are done through /app or the API; both go to OpenVibe.Pics.
Run and test
npm install
npm test # PGlite; every test file in its own process, no network needed
npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
OV_ALLOW_SUITE=1 npm test
npm start # PORT 5160, loopback; DATABASE_URL or an embedded PGlite in development
CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js
The tests boot the app with a mock Network, a Media/Live/Pics fixture server, and a temporary database: the
migration and its constraints, pairing and stream ownership, grants (username resolution, extension, revocation,
expiry, staff have no bypass), the still's Media → Pics path and its refusals, the photo upload and its limits, the
pages and cache headers, sign-in, and account export/deletion.
Deploy
deploy/systemd/openvibe-cam.service (127.0.0.1:5160, /etc/openvibe/cam.env) and
deploy/nginx/openvibe.cam.conf (TLS, www → apex, /auth/me on the API zone, /my/ on the API zone, /internal
and /metrics 404, a 16 MB body cap for a photo). Not deployed by this change.
License
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.419.0
- openvibe-sdk: v0.161.0
- openvibe-shared: v4.238.0
<!-- versions:end -->