openvibers/openvibe.cam README.md
README.md
| 1 | # OpenVibe.Cam |
| 2 | |
| 3 | > Your camera, your cameras, and the moment Live's cam button takes. |
| 4 | |
| 5 | **Live site:** https://openvibe.cam · **API docs:** https://openvibe.services · **For AI:** https://openvibe.cam/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** v1 written and tested (see [STATUS.json](STATUS.json)). |
| 9 | **Domain:** `openvibe.cam` · **Port:** 5160 · **Service id:** `cam` · **Env prefix:** `CAM` |
| 10 | **License:** AGPL-3.0-only (same as every OpenVibe service). |
| 11 | |
| 12 | OpenVibe.Cam is the camera app, the private wall for cameras you own, and the API Live's cam button calls. A photo |
| 13 | or a still is saved to OpenVibe.Pics; Cam keeps the link, the filter and the caption, never the bytes. A camera is |
| 14 | private to its owner, and a viewer sees it only while the grant the owner gave is active and not revoked. |
| 15 | |
| 16 | ## Purpose |
| 17 | |
| 18 | - **The camera app.** A browser preview with a timer, a 3-shot burst, a grid and on-device colour, light and |
| 19 | softness. The filter runs on the canvas in the person's browser; the server never applies an effect. The picture |
| 20 | is posted to Cam, which forwards it to OpenVibe.Pics with the person's own Network token. There is no server-side |
| 21 | image analysis and no face recognition. |
| 22 | - **Edit in Studio.** A photo you saved opens in Studio with `GET /edit?pics=<Pics slug>&from=cam` on |
| 23 | `openvibe.studio`; Studio asks OpenVibe.Pics, with your own token, whether you may read it, then loads it into |
| 24 | Photo with `From OpenVibe.Cam` beside it. The link is on each of your photo cards and on the capture result; a |
| 25 | photo of someone else's answers 404. `CAM_STUDIO_URL` moves the link for a drill. |
| 26 | - **My cameras.** Pair an old phone, a browser, a Pi or an IP camera you own. If a Live stream id is given, Cam |
| 27 | reads it from OpenVibe.Live's public API and refuses a stream on somebody else's channel; the slot defaults to the |
| 28 | stream's managed slot. Its wall is private: `/w/<id>` answers its owner and an active grantee, and 404 to |
| 29 | everyone else, including staff. |
| 30 | - **Revocable viewer grants.** A grant names one canonical subject and an expiry: seven days by default, ninety at |
| 31 | most. `@username` is resolved through OpenVibe.Network (`identity.subject.resolve`); a subject id works without |
| 32 | it. A re-grant extends the active grant instead of making a second one. Revocation writes a timestamp, and the |
| 33 | wall checks it on every read, so the next request is refused. |
| 34 | - **The camera element.** `<ov-camera>` puts the same preview, filters, timer and burst on any site: one script from |
| 35 | `https://openvibe.cam/embed/ov-camera.js`, a capture event with the blob, and no upload unless the site asks for it. |
| 36 | - **The Live cam button.** Live calls `POST /api/v1/stills` with the viewer's own Network token. Cam fetches |
| 37 | OpenVibe.Media's public `GET /live/:slot/frame.jpg` (only `image/jpeg` is accepted), uploads the JPEG to |
| 38 | OpenVibe.Pics as that person, and returns the still and the exact line Live posts in the stream chat. |
| 39 | |
| 40 | ## Owns |
| 41 | |
| 42 | | Table | Holds | Migration | |
| 43 | |---|---|---| |
| 44 | | `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` | |
| 45 | | `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` | |
| 46 | | `cam_photos` | `cmp_` + ULID, subject, optional camera, filter label, caption, visibility `private\|unlisted`, Pics slug, page URL, direct URL | `0001_initial.sql` | |
| 47 | | `cam_stills` | `cms_` + ULID, subject, stream id, slot, caption, visibility `unlisted\|private`, Pics slug, page URL, direct URL | `0001_initial.sql` | |
| 48 | | `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` | |
| 49 | | `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` | |
| 50 | |
| 51 | There is no image, blob or byte column in any Cam table. The `pics_slug` and links are the picture's address in |
| 52 | OpenVibe.Pics; neither Cam nor its database can serve the bytes. |
| 53 | |
| 54 | ## Does not own |
| 55 | |
| 56 | - **The picture bytes, resizing, metadata stripping and takedowns** — OpenVibe.Pics (OpenVibe.MediaHub). Cam reads |
| 57 | its `201` and stores the slug and links. |
| 58 | - **The live frame** — OpenVibe.Media's `/live/:sel/frame.jpg`. Cam never runs ffmpeg or caches a frame. |
| 59 | - **Streams, chat and the streamer's second camera** — OpenVibe.Live and OpenVibe.Chat. The `OVCamPip` streamer |
| 60 | camera is Live's; Live also posts the returned still line to the stream's chat with its own chat authority. |
| 61 | - **Identity, usernames and accounts** — OpenVibe.Network. Cam verifies sessions against its JWKS and resolves a |
| 62 | viewer's `@username` with `identity.subject.resolve`. |
| 63 | - **Search** — OpenVibe.Search. v1 makes nothing public, so Cam queues no Search document. |
| 64 | - **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host. |
| 65 | |
| 66 | ## Depends on |
| 67 | |
| 68 | Environment variable names are in [.env.example](.env.example); no value is committed. |
| 69 | |
| 70 | - **OpenVibe.Network** — sign-in (OAuth client `cam`, PKCE S256, cookies `cam_at`/`cam_rt`/`cam_oauth`), JWKS, |
| 71 | token exchange/refresh/revoke, account export/deletion, and `identity.subject.resolve` for `@username`: |
| 72 | `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`, |
| 73 | `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `CAM_AUDIENCE`. |
| 74 | - **OpenVibe.Media** — the public live frame, `GET /live/:sel/frame.jpg?w=640` (`media.derivative.read` is the |
| 75 | planned capability; the route needs no token today): `OV_MEDIA_URL`. |
| 76 | - **OpenVibe.Live** — `GET /api/streams/:id`, the public read used to check that a paired stream is on the owner's |
| 77 | channel (`live.stream.read`: `OV_LIVE_URL`). |
| 78 | - **OpenVibe.Pics (OpenVibe.MediaHub)** — `POST /api/v1/images`, called with the signed-in person's own Network |
| 79 | token; Cam never holds a service token for Pics (`OV_PICS_URL`). The route is `pics.image.upload` in the person's |
| 80 | own right; MediaHub refuses an app or service token there. |
| 81 | - **OpenVibe.Events** — account deliveries: `CAM_EVENTS_SECRET`, `CAM_EVENTS_URL`, `CAM_EVENTS_ENDPOINT`. |
| 82 | - **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in |
| 83 | `CAM_PGLITE_DIR` for development. |
| 84 | - **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process). |
| 85 | - **Packages** — `openvibe-contracts` v0.234.0, `openvibe-sdk` v0.166.0, `openvibe-shared` v4.100.1, the same tags |
| 86 | OpenVibe.Meme pins. |
| 87 | |
| 88 | ## API |
| 89 | |
| 90 | Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `cam_at` |
| 91 | session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network JWKS. A write made |
| 92 | with the session cookie must come from `https://openvibe.cam` (403 `cam.cross_site`). A signed-in account also gets |
| 93 | per-actor minute/hour limits (`CAM_LIMITS_MINUTE`, `CAM_LIMITS_HOUR`). |
| 94 | |
| 95 | | Route | What it does | |
| 96 | |---|---| |
| 97 | | `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 | |
| 98 | | `GET /photos` · `GET /photos/:id` | your photos | |
| 99 | | `GET /cameras` | your cameras with active grant counts | |
| 100 | | `POST /cameras` | `{ name, source, stream_id?, slot?, notes? }`; a `stream_id` is read from Live and must be yours | |
| 101 | | `GET /cameras/:id` · `PATCH /cameras/:id` · `DELETE /cameras/:id` | the owner: read, change name/source/status/slot/notes, unpair | |
| 102 | | `GET /cameras/:id/wall` | the owner or an active grantee; a stranger gets 404 | |
| 103 | | `GET /cameras/:id/grants` | the owner: every grant, including expired and revoked | |
| 104 | | `POST /cameras/:id/grants` | the owner: `{ viewer_username }` or `{ viewer_subject }`, `{ days }` 1–90 | |
| 105 | | `DELETE /cameras/:id/grants/:grantId` | the owner: revoke at once | |
| 106 | | `POST /stills` | a person; `{ slot, caption?, stream_id?, w?, visibility? }`; Media's frame → Pics → a still and the chat line | |
| 107 | | `GET /stills` | your stills | |
| 108 | | `GET /stills/:id` | an unlisted still by its link; a private one only for its owner | |
| 109 | | `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 | |
| 110 | | `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 | |
| 111 | | `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 | |
| 112 | | `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 | |
| 113 | | `GET /cameras/:id/events` · `GET /events` | the owner's timeline, newest first (`limit`, `kind`, `since`) | |
| 114 | | `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) | |
| 115 | | `GET /events/shared/:token` | anyone with the private token; the event, never the owner's account | |
| 116 | | `GET/PUT /presence` | the owner's own home/away, from the browser, phone or Node | |
| 117 | |
| 118 | ### Familiar faces and the timeline (F1) |
| 119 | |
| 120 | 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. |
| 121 | |
| 122 | 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. |
| 123 | |
| 124 | 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. |
| 125 | |
| 126 | Validation refusals are `400 { code: "cam.invalid", message }`; a blocked caption is `400 { code: |
| 127 | "cam.blocked_words" }`. Other codes: `cam.sign_in_required`, `cam.person_required`, `cam.cross_site`, |
| 128 | `cam.not_found`, `cam.camera_exists`, `cam.camera_limit`, `cam.grant_limit`, `cam.grant_not_found`, |
| 129 | `cam.stream_not_found`, `cam.not_your_stream`, `cam.stream_offline`, `cam.frame_unavailable`, `cam.frame_busy`, |
| 130 | `cam.pics.refused`, `cam.pics.unavailable`, `cam.too_large`, `cam.not_an_image`, `cam.viewer_not_found`, |
| 131 | `cam.identity_unavailable`, `cam.rate_limited`. |
| 132 | |
| 133 | ## The Live cam button |
| 134 | |
| 135 | Live is the caller. It holds the viewer's Network token in its own session; it sends that token to Cam as a Bearer |
| 136 | (server-side), never a Cam service token: |
| 137 | |
| 138 | 1. The watch page sends `{ slot, stream_id, caption }` to Live's server. |
| 139 | 2. Live calls `POST https://openvibe.cam/api/v1/stills` with `Authorization: Bearer <viewer's Network token>`. |
| 140 | 3. Cam reads `GET https://openvibe.media/live/:slot/frame.jpg?w=640`; a 404 is `cam.stream_offline`, and an SVG |
| 141 | offline card served with 200 is refused as `cam.frame_unavailable`. |
| 142 | 4. Cam calls `POST https://openvibe.pics/api/v1/images` with the same token, the JPEG as the body, `X-Title` and |
| 143 | `X-Filename`. Pics re-encodes and strips metadata. |
| 144 | 5. Cam answers `201 { still, frame_url, share: { url, caption, text } }`. Live posts `share.text` to the stream's |
| 145 | chat and shows `share.url` (or `still.pics_direct`). |
| 146 | |
| 147 | Cam needs no app/service token for this, and Live needs no new Cam grant. A still is `unlisted` by default; pass |
| 148 | `"visibility": "private"` to keep it to its owner. Media caches one frame per slot for five seconds and rate-limits |
| 149 | by address, so a burst of button presses can answer `429 cam.frame_busy`. |
| 150 | |
| 151 | ## The camera element |
| 152 | |
| 153 | ### Recording kit |
| 154 | |
| 155 | `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. |
| 156 | |
| 157 | ```html |
| 158 | <script src="https://openvibe.cam/embed/cam-kit.js" defer></script> |
| 159 | <button id="record">Record</button> |
| 160 | <script> |
| 161 | document.getElementById('record').addEventListener('click', async () => { |
| 162 | const result = await OpenVibeCam.open({ mode: 'video', maxSeconds: 60 }); |
| 163 | if (result) console.log(result.file, result.duration); |
| 164 | }); |
| 165 | </script> |
| 166 | ``` |
| 167 | |
| 168 | The script can be pinned with the SHA-384 SRI hash shown on [/developers](https://openvibe.cam/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. |
| 169 | |
| 170 | ### Photo element |
| 171 | |
| 172 | The camera app is also one framework-free custom element, at `https://openvibe.cam/embed/ov-camera.js` and documented |
| 173 | live at [openvibe.cam/developers](https://openvibe.cam/developers). It is one script and one tag; with the default |
| 174 | `upload="off"` the picture never leaves the browser. |
| 175 | |
| 176 | ```html |
| 177 | <script src="https://openvibe.cam/embed/ov-camera.js" async></script> |
| 178 | <ov-camera id="cam" facing="user" filters="none,mono,warm,cool,fade,vivid,blur" timer="0" mode="photo" upload="off"></ov-camera> |
| 179 | <script> |
| 180 | document.getElementById('cam').addEventListener('ov-camera:capture', function (e) { |
| 181 | // e.detail = { blob, width, height, filter } |
| 182 | }); |
| 183 | </script> |
| 184 | ``` |
| 185 | |
| 186 | | Attribute | Values | Default | |
| 187 | |---|---|---| |
| 188 | | `facing` | `user` (front) or `environment` (back) | `user` | |
| 189 | | `filters` | comma list of `none`, `mono`, `warm`, `cool`, `fade`, `vivid`, `blur` | `none` | |
| 190 | | `timer` | `0`, `3` or `10` seconds | `0` | |
| 191 | | `mode` | `photo` or `burst` (three, 450 ms apart) | `photo` | |
| 192 | | `upload` | `off`, or `openvibe` to post to `POST /api/v1/photos` when the page is openvibe.cam and signed in | `off` | |
| 193 | |
| 194 | Events: `ov-camera:capture` with `{ blob, width, height, filter }` and `ov-camera:error` with `{ code, message }`. |
| 195 | Methods: `start()`, `stop()`, `capture()`, `flip()`. Style with `--ov-camera-accent` and `--ov-camera-radius`. The |
| 196 | element never starts the camera on connect; a click (or an explicit `start()`) does. OpenVibe.SDK wraps the loader as |
| 197 | `openvibe-sdk/cam` (`defineOvCamera()`), with the attributes, events and methods typed. |
| 198 | |
| 199 | ## Pages |
| 200 | |
| 201 | All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with |
| 202 | semantic HTML and stable `cm-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn |
| 203 | for a signed-in person, a wall or a still is `private, no-store`. |
| 204 | |
| 205 | | Page | What it is | |
| 206 | |---|---| |
| 207 | | `/` | the shutter hero, the three scenes, the privacy rules | |
| 208 | | `/app` | the camera app: preview, timer, burst, grid, filter, caption, and your recent photos | |
| 209 | | `/my` | pair a camera, your cameras, your photos and stills (signed in) | |
| 210 | | `/my/cameras/:id` | one camera: its frame, edit form, viewers, revoke buttons, unpair | |
| 211 | | `/w/:id` | the private wall an owner or an active grantee sees | |
| 212 | | `/s/:id` | one still of a live stream, with its Pics link | |
| 213 | | `/developers` | the API and the Live cam button contract | |
| 214 | | 404 | the not-found page | |
| 215 | |
| 216 | `public/css/cam.css` is a one-comment placeholder: the pages are complete without it, and the main session designs |
| 217 | it. `public/js/cam.js` adds the live preview, the timer, the burst, the grid and the on-device filter. The two |
| 218 | pictures in `public/img/` are data-free SVG placeholders. `robots.txt`, `sitemap.xml`, `llms.txt` and |
| 219 | `llms-full.txt` list the three public pages and never a wall, a grant or a still. |
| 220 | |
| 221 | ## The first proof |
| 222 | |
| 223 | `scripts/seed-proof.js` performs the part that needs no network: |
| 224 | |
| 225 | - pair an owned test camera (`Proof camera`, slot `proof`) for `CAM_PROOF_OWNER`; |
| 226 | - grant `CAM_PROOF_VIEWER` for seven days; |
| 227 | - open the wall as that viewer; |
| 228 | - revoke the grant; |
| 229 | - ask for the wall again and assert it is refused. |
| 230 | |
| 231 | Re-running reuses the camera and makes a fresh grant. The photo and the still need a signed-in person's Network |
| 232 | token and are done through `/app` or the API; both go to OpenVibe.Pics. |
| 233 | |
| 234 | ## Run and test |
| 235 | |
| 236 | ```sh |
| 237 | npm install |
| 238 | npm test # PGlite; every test file in its own process, no network needed |
| 239 | npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up) |
| 240 | OV_ALLOW_SUITE=1 npm test |
| 241 | npm start # PORT 5160, loopback; DATABASE_URL or an embedded PGlite in development |
| 242 | CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js |
| 243 | ``` |
| 244 | |
| 245 | The tests boot the app with a mock Network, a Media/Live/Pics fixture server, and a temporary database: the |
| 246 | migration and its constraints, pairing and stream ownership, grants (username resolution, extension, revocation, |
| 247 | expiry, staff have no bypass), the still's Media → Pics path and its refusals, the photo upload and its limits, the |
| 248 | pages and cache headers, sign-in, and account export/deletion. |
| 249 | |
| 250 | ## Deploy |
| 251 | |
| 252 | `deploy/systemd/openvibe-cam.service` (127.0.0.1:5160, `/etc/openvibe/cam.env`) and |
| 253 | `deploy/nginx/openvibe.cam.conf` (TLS, www → apex, `/auth/me` on the API zone, `/my/` on the API zone, `/internal` |
| 254 | and `/metrics` 404, a 16 MB body cap for a photo). Not deployed by this change. |
| 255 | |
| 256 | ## License |
| 257 | |
| 258 | AGPL-3.0-only. See [LICENSE](LICENSE). |
| 259 | |
| 260 | <!-- versions:start --> |
| 261 | - openvibe-contracts: v0.419.0 |
| 262 | - openvibe-sdk: v0.166.0 |
| 263 | - openvibe-shared: v4.241.0 |
| 264 | <!-- versions:end --> |
| 265 |