openvibers/openvibe.cam README.md

README.md

main 18.3 KB · 265 lines · markdown

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
12OpenVibe.Cam is the camera app, the private wall for cameras you own, and the API Live's cam button calls. A photo
13or a still is saved to OpenVibe.Pics; Cam keeps the link, the filter and the caption, never the bytes. A camera is
14private 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
51There is no image, blob or byte column in any Cam table. The `pics_slug` and links are the picture's address in
52OpenVibe.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
68Environment 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
90Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `cam_at`
91session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network JWKS. A write made
92with the session cookie must come from `https://openvibe.cam` (403 `cam.cross_site`). A signed-in account also gets
93per-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
120A 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
122An 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
124One 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
126Validation 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
135Live 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
1381. The watch page sends `{ slot, stream_id, caption }` to Live's server.
1392. Live calls `POST https://openvibe.cam/api/v1/stills` with `Authorization: Bearer <viewer's Network token>`.
1403. 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`.
1424. 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.
1445. 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
147Cam 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
149by 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
168The 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
172The camera app is also one framework-free custom element, at `https://openvibe.cam/embed/ov-camera.js` and documented
173live 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
194Events: `ov-camera:capture` with `{ blob, width, height, filter }` and `ov-camera:error` with `{ code, message }`.
195Methods: `start()`, `stop()`, `capture()`, `flip()`. Style with `--ov-camera-accent` and `--ov-camera-radius`. The
196element 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
201All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with
202semantic HTML and stable `cm-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn
203for 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
217it. `public/js/cam.js` adds the live preview, the timer, the burst, the grid and the on-device filter. The two
218pictures 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
231Re-running reuses the camera and makes a fresh grant. The photo and the still need a signed-in person's Network
232token and are done through `/app` or the API; both go to OpenVibe.Pics.
233
234## Run and test
235
236```sh
237npm install
238npm test # PGlite; every test file in its own process, no network needed
239npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
240OV_ALLOW_SUITE=1 npm test
241npm start # PORT 5160, loopback; DATABASE_URL or an embedded PGlite in development
242CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js
243```
244
245The tests boot the app with a mock Network, a Media/Live/Pics fixture server, and a temporary database: the
246migration and its constraints, pairing and stream ownership, grants (username resolution, extension, revocation,
247expiry, staff have no bypass), the still's Media → Pics path and its refusals, the photo upload and its limits, the
248pages 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`
254and `/metrics` 404, a 16 MB body cap for a photo). Not deployed by this change.
255
256## License
257
258AGPL-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