openvibers/openvibe.sdk Commits
0.62.0: pronouns in no-looks; a slot's WHIP address (10-23 batch) (#74)
Changes
@@@ -3,6 +3,22 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.62.0 (2026-10-23)++++- **`openvibe-sdk/no-looks`: pronouns.** A pronoun is a guessed gender too, so the 27 Community summaries repaired on++ 10-22 that still said "he looks at the camera" or "on his device" are covered. Outside quotes:++ - he and she become "the person" (the verb stays as written: "he looks" → "the person looks"; he'll → the person++ will, he'd → the person would);++ - his and her before a word become "their"; him, and her after a verb, a preposition or at the end, become "them";++ - himself and herself become "themself", hers and a final his "theirs".++ Quoted speech stays word for word. `RULES_VERSION` changes, so services that repair stored rows run their repair++ once more.++- **`openvibe-sdk/live`: where a slot publishes.** `live.slots.profile(slotId)` reads a slot's settings and ingest++ (`GET /api/streams/managed/:id/profile`, owner): its stream key, Live's WHIP origin and RTMP URL, or, for a slot++ OpenRestream ingests, OpenRestream's own WHIP and RTMP URLs and no key. `live.whip.forSlot(slotId)` turns that into++ `{ url, key, authority }` to pass to `live.whip.publish()`, so going live from code no longer builds the WHIP URL by++ hand (OpenVibe.Examples' go-live example, plan G2).++## 0.61.0 (2026-10-22)- **`openvibe-sdk/no-looks`**: the owner's rule that public AI text never describes a person, as one shared filter.@@@ -2,14 +2,14 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.61.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.62.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.62.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.61.0",++ "version": "0.62.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.61.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.62.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.61.0';++const SDK_VERSION = '0.62.0';module.exports = {createClient,@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.61.0",++ "version": "0.62.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.61.0",++ "version": "0.62.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.61.0",++ "version": "0.62.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.61.0';++const SDK_VERSION = '0.62.0';module.exports = {createClient,@@@ -9,6 +9,7 @@* const now = await live.streams.list(); // everyone live right now* const channel = await live.channels.get('goosely'); // the channel page's data* const { managed_stream: slot } = await live.slots.create({ title: 'Robot cam', streaming_method: 'whip' });++ * const target = await live.whip.forSlot(slot.id); // → { url, key, authority } from the slot's profile* const { stream, endpoint } = await live.goLive(slot.id, { title: 'Live now' });* const session = await live.whip.publish(live.whip.url(slot.stream_key), offerSdp); // → { answer, location }* await live.whip.end(session.location); // the last session ends the stream@@@ -69,6 +70,11 @@ function createLiveClient(client, { baseUrl } = {}) {remove: (slotId, { signal } = {}) => call({ method: 'DELETE', path: `/api/streams/managed/${id(slotId, 'a slot id')}`, signal }),/** A new stream key for the slot (the old one stops working). */regenerateKey: (slotId, { signal } = {}) => call({ method: 'POST', path: `/api/streams/managed/${id(slotId, 'a slot id')}/regenerate-key`, signal }),++ /**++ * A slot's settings and where to publish to it (owner) → { managed_stream, stream_key, whip_url_base, rtmp_url,++ * restream_destinations, … }. A slot OpenRestream ingests answers OpenRestream's own whip_url and rtmp_url and no key.++ */++ profile: (slotId, { signal } = {}) => call({ path: `/api/streams/managed/${id(slotId, 'a slot id')}/profile`, signal }),};/** Go live from a slot → { stream, endpoint }. opts: title, category, tags, is_nsfw. */@@@ -95,6 +101,17 @@ function createLiveClient(client, { baseUrl } = {}) {};const whip = {++ /**++ * Where a slot publishes, from its profile → { url, key, authority }: Live's WHIP origin with the slot's key, or++ * OpenRestream's own WHIP URL when it ingests the slot (key null: its URL carries it). Pass the result to publish().++ */++ forSlot: async (slotId, { signal } = {}) => {++ const p = await slots.profile(slotId, { signal });++ if (p && typeof p.whip_url === 'string' && p.whip_url) return { url: p.whip_url, key: null, authority: p.ingest_authority || 'openre' };++ const key = p && p.stream_key;++ if (!key || !p.whip_url_base) throw new TypeError('live.whip.forSlot: this slot has no WHIP address (OpenRestream unreachable, or WHIP is off)');++ return { url: `${String(p.whip_url_base).replace(/\/+$/, '')}/whip/${enc(key)}`, key, authority: 'live' };++ },/** The WHIP URL for a stream key (or a slot id, with the key sent as the bearer). */url: async (keyOrSlot) => {const k = String(keyOrSlot || '');@@@ -80,6 +80,26 @@ const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:const _GENDERED = 'man|woman|guy|gal|lady|gentleman';const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');++// A pronoun is a guessed gender too: he and she are "the person", his and her (before a word) "their", him and her++// (after a verb or a preposition, or at the end) "them", himself and herself "themself", hers "theirs". Outside++// quotes only: what someone said stays word for word. "The person" keeps the verb as written ("he looks" → "the person++// looks"), which "they" would not.++const _HE_SHE = /\b(?:he|she)\b(?:'(s|d|ll)\b)?/gi;++const _HER_OBJECT = /\bher\b(?=\s*(?:[.,;:!?)\]"”]|$)|\s+(?:and|or|but|to|from|with|in|on|at|for|as|while|when|because|so|if|the|a|an|this|that|is|was|again|back|up|down|out|off|away|over|now|too|here|there|more|about|into|onto)\b)/gi;++const _PRONOUNS = [++ [/\b(?:himself|herself)\b/gi, 'themself'],++ [/\bhers\b/gi, 'theirs'],++ [/\bhim\b/gi, 'them'],++ [_HER_OBJECT, 'them'],++ [/\b(?:his|her)\b(?=\s+[\w'-])/gi, 'their'],++ [/\bhis\b/gi, 'theirs'],++];++const _CONTRACTED = { s: "'s", d: ' would', ll: ' will' };++function _noPronouns(part) {++ let out = part.replace(_HE_SHE, (m, c) => _cap(m, `the person${c ? _CONTRACTED[c.toLowerCase()] : ''}`));++ for (const [re, word] of _PRONOUNS) out = out.replace(re, (m) => _cap(m, word));++ return out;++}const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the// counter") cannot be repaired word by word: the sentence goes.@@@ -95,7 +115,7 @@ function _noGender(text) {.replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`)).replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`)).replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))-- .replace(/ {2,}/g, ' '))).join('');++ .replace(/ {2,}/g, ' '))).map((part, i) => (i % 2 ? part : _noPronouns(part))).join('');}function _noGrooming(text) {const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];@@@ -129,6 +149,6 @@ function _hash(text) {return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');}const RULES_VERSION = _hash([_LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,-- _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing].map(String).join('|'));++ _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION };@@@ -22,6 +22,10 @@ const { createLiveClient } = require('../src/live');return res.end('v=0\r\no=answer\r\n');}if (req.method === 'POST' && req.url === '/api/streams') return json(201, { stream: { id: 77 }, endpoint: { whipUrlBase: 'x' } });++ // A slot's profile: Live's own ingest (5), OpenRestream's (6), and one with no WHIP address (7).++ if (req.url === '/api/streams/managed/5/profile') return json(200, { managed_stream: { id: 5 }, stream_key: 'sk_live_5', whip_url_base: 'https://whip.example.test/', rtmp_url: 'rtmp://x/live' });++ if (req.url === '/api/streams/managed/6/profile') return json(200, { managed_stream: { id: 6 }, stream_key: null, whip_url_base: null, ingest_authority: 'openre', whip_url: 'https://openre.example.test/whip/k6' });++ if (req.url === '/api/streams/managed/7/profile') return json(200, { managed_stream: { id: 7 }, stream_key: 'sk_live_7', whip_url_base: null });return json(200, { ok: true });});});@@@ -84,6 +88,16 @@ const { createLiveClient } = require('../src/live');assert.strictEqual(seen.filter((s) => s.url === '/whip/bad-key').length, 1, 'a refused offer is not retried');await assert.rejects(live.whip.url('../x'), /stream key/);++ // A slot's profile and where it publishes.++ seen.length = 0;++ const prof = await live.slots.profile(5);++ assert.strictEqual(prof.stream_key, 'sk_live_5');++ assert.strictEqual(last().url, '/api/streams/managed/5/profile');++ assert.deepStrictEqual(await live.whip.forSlot(5), { url: 'https://whip.example.test/whip/sk_live_5', key: 'sk_live_5', authority: 'live' });++ assert.deepStrictEqual(await live.whip.forSlot(6), { url: 'https://openre.example.test/whip/k6', key: null, authority: 'openre' });++ await assert.rejects(live.whip.forSlot(7), /no WHIP address/);++ assert.throws(() => live.slots.profile('5/../x'), /slot id/);++assert.throws(() => createLiveClient({ json() {} }), /core client/);console.log('live: all checks passed');} finally {@@@ -28,7 +28,7 @@ const cases = [['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],['A man wearing sunglasses on his head looks down at the camera.', 'A person looks down at the camera.'],['A collage of a man in a white shirt and tie wearing dark sunglasses in the left panel.', 'A collage of a person in the left panel.'],-- ['Behind him a green mat; he wears a blue jacket and is leaning to the left.', 'Behind him a green mat; he is leaning to the left.'],++ ['Behind him a green mat; he wears a blue jacket and is leaning to the left.', 'Behind them a green mat; the person is leaning to the left.'],['Two men lift weights, one shirtless and sweating.', 'Two people lift weights, one sweating.'],['Two men stand in an alley, one and drinking from a bottle while the other wears a dusty t-shirt; both look tired.', 'Two people stand in an alley, one drinking from a bottle; both look tired.'],['A man wearing a green bucket hat with frog-like eyes sits indoors.', 'A person sits indoors.'],@@@ -62,7 +62,20 @@ const cases = [cases.push(['A man lounging on a sofa in a living room, holding a remote and facing camera.', 'A person lounging on a sofa in a living room, holding a remote and facing camera.'],['A man is shown outdoors, smoking a cigarette against a clear blue sky.', 'A person is shown outdoors, smoking a cigarette against a clear blue sky.'],-- ['A man sitting on a bed reading a book while homework papers and stacked boxes surround him.', 'A person sitting on a bed reading a book while homework papers and stacked boxes surround him.'],++ ['A man sitting on a bed reading a book while homework papers and stacked boxes surround him.', 'A person sitting on a bed reading a book while homework papers and stacked boxes surround them.'],++);++++// Pronouns are a guessed gender too (27 repaired Community summaries kept "he looks", "on his device"): he and she are++// "the person", his and her "their", him and her "them"; quoted speech stays.++cases.push(++ ['He looks at the camera and adjusts the volume on his device.', 'The person looks at the camera and adjusts the volume on their device.'],++ ['The streamer checks his phone and laughs at her joke.', 'The streamer checks their phone and laughs at their joke.'],++ ['Chat cheers for her.', 'Chat cheers for them.'],++ ['The streamer gives her a wave and thanks him for the raid.', 'The streamer gives them a wave and thanks them for the raid.'],++ ["She's playing Minecraft with him; he'll join later.", "The person's playing Minecraft with them; the person will join later."],++ ['He hurt himself, but the win is his.', 'The person hurt themself, but the win is theirs.'],++ ['A person reads, says "he is lying to her", and waves at her chat.', 'A person reads, says "he is lying to her", and waves at their chat.'],++ ['Shell, Heather and the theme park stay as written.', 'Shell, Heather and the theme park stay as written.'],);let failed = 0;@@@ -27,6 +27,8 @@ export interface LiveClient {update(slotId: number | string, patch: SlotRequest, opts?: Opts): Promise<LiveJson>;remove(slotId: number | string, opts?: Opts): Promise<LiveJson>;regenerateKey(slotId: number | string, opts?: Opts): Promise<LiveJson>;++ /** A slot's settings and where to publish to it (owner): stream_key, whip_url_base, rtmp_url, or OpenRestream's whip_url. */++ profile(slotId: number | string, opts?: Opts): Promise<LiveJson>;};/** Go live from a slot → { stream, endpoint }. */goLive(slotId: number | string, opts?: GoLiveOptions, extra?: Opts): Promise<{ stream: LiveJson; endpoint: LiveJson }>;@@@ -42,6 +44,8 @@ export interface LiveClient {moments(opts?: FeedOptions): Promise<{ items: LiveJson[]; next: string | null; [k: string]: unknown }>;};whip: {++ /** Where a slot publishes: Live's WHIP origin with the slot's key, or OpenRestream's WHIP URL (key null). */++ forSlot(slotId: number | string, opts?: Opts): Promise<{ url: string; key: string | null; authority: string }>;url(keyOrSlot: string | number): Promise<string>;publish(url: string | Promise<string>, offer: string, opts?: Opts & { key?: string }): Promise<WhipSession>;end(location: string, opts?: Opts): Promise<void>;
@@ -3,6 +3,22 @@
@@ -3,6 +3,22 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.62.0 (2026-10-23)
+
+- **`openvibe-sdk/no-looks`: pronouns.** A pronoun is a guessed gender too, so the 27 Community summaries repaired on
+ 10-22 that still said "he looks at the camera" or "on his device" are covered. Outside quotes:
+ - he and she become "the person" (the verb stays as written: "he looks" → "the person looks"; he'll → the person
+ will, he'd → the person would);
+ - his and her before a word become "their"; him, and her after a verb, a preposition or at the end, become "them";
+ - himself and herself become "themself", hers and a final his "theirs".
+ Quoted speech stays word for word. `RULES_VERSION` changes, so services that repair stored rows run their repair
+ once more.
+- **`openvibe-sdk/live`: where a slot publishes.** `live.slots.profile(slotId)` reads a slot's settings and ingest
+ (`GET /api/streams/managed/:id/profile`, owner): its stream key, Live's WHIP origin and RTMP URL, or, for a slot
+ OpenRestream ingests, OpenRestream's own WHIP and RTMP URLs and no key. `live.whip.forSlot(slotId)` turns that into
+ `{ url, key, authority }` to pass to `live.whip.publish()`, so going live from code no longer builds the WHIP URL by
+ hand (OpenVibe.Examples' go-live example, plan G2).
+
## 0.61.0 (2026-10-22)
## 0.61.0 (2026-10-22)
- **`openvibe-sdk/no-looks`**: the owner's rule that public AI text never describes a person, as one shared filter.
- **`openvibe-sdk/no-looks`**: the owner's rule that public AI text never describes a person, as one shared filter.
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.61.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.62.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.61.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.62.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.61.0",
+ "version": "0.62.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.61.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.62.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.61.0';
+const SDK_VERSION = '0.62.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.61.0",
+ "version": "0.62.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.61.0",
+ "version": "0.62.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.61.0",
+ "version": "0.62.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.61.0';
+const SDK_VERSION = '0.62.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* const now = await live.streams.list(); // everyone live right now
* const now = await live.streams.list(); // everyone live right now
* const channel = await live.channels.get('goosely'); // the channel page's data
* const channel = await live.channels.get('goosely'); // the channel page's data
* const { managed_stream: slot } = await live.slots.create({ title: 'Robot cam', streaming_method: 'whip' });
* const { managed_stream: slot } = await live.slots.create({ title: 'Robot cam', streaming_method: 'whip' });
+ * const target = await live.whip.forSlot(slot.id); // → { url, key, authority } from the slot's profile
* const { stream, endpoint } = await live.goLive(slot.id, { title: 'Live now' });
* const { stream, endpoint } = await live.goLive(slot.id, { title: 'Live now' });
* const session = await live.whip.publish(live.whip.url(slot.stream_key), offerSdp); // → { answer, location }
* const session = await live.whip.publish(live.whip.url(slot.stream_key), offerSdp); // → { answer, location }
* await live.whip.end(session.location); // the last session ends the stream
* await live.whip.end(session.location); // the last session ends the stream
@@ -69,6 +70,11 @@ function createLiveClient(client, { baseUrl } = {}) {
@@ -69,6 +70,11 @@ function createLiveClient(client, { baseUrl } = {}) {
remove: (slotId, { signal } = {}) => call({ method: 'DELETE', path: `/api/streams/managed/${id(slotId, 'a slot id')}`, signal }),
remove: (slotId, { signal } = {}) => call({ method: 'DELETE', path: `/api/streams/managed/${id(slotId, 'a slot id')}`, signal }),
/** A new stream key for the slot (the old one stops working). */
/** A new stream key for the slot (the old one stops working). */
regenerateKey: (slotId, { signal } = {}) => call({ method: 'POST', path: `/api/streams/managed/${id(slotId, 'a slot id')}/regenerate-key`, signal }),
regenerateKey: (slotId, { signal } = {}) => call({ method: 'POST', path: `/api/streams/managed/${id(slotId, 'a slot id')}/regenerate-key`, signal }),
+ /**
+ * A slot's settings and where to publish to it (owner) → { managed_stream, stream_key, whip_url_base, rtmp_url,
+ * restream_destinations, … }. A slot OpenRestream ingests answers OpenRestream's own whip_url and rtmp_url and no key.
+ */
+ profile: (slotId, { signal } = {}) => call({ path: `/api/streams/managed/${id(slotId, 'a slot id')}/profile`, signal }),
};
};
/** Go live from a slot → { stream, endpoint }. opts: title, category, tags, is_nsfw. */
/** Go live from a slot → { stream, endpoint }. opts: title, category, tags, is_nsfw. */
@@ -95,6 +101,17 @@ function createLiveClient(client, { baseUrl } = {}) {
@@ -95,6 +101,17 @@ function createLiveClient(client, { baseUrl } = {}) {
};
};
const whip = {
const whip = {
+ /**
+ * Where a slot publishes, from its profile → { url, key, authority }: Live's WHIP origin with the slot's key, or
+ * OpenRestream's own WHIP URL when it ingests the slot (key null: its URL carries it). Pass the result to publish().
+ */
+ forSlot: async (slotId, { signal } = {}) => {
+ const p = await slots.profile(slotId, { signal });
+ if (p && typeof p.whip_url === 'string' && p.whip_url) return { url: p.whip_url, key: null, authority: p.ingest_authority || 'openre' };
+ const key = p && p.stream_key;
+ if (!key || !p.whip_url_base) throw new TypeError('live.whip.forSlot: this slot has no WHIP address (OpenRestream unreachable, or WHIP is off)');
+ return { url: `${String(p.whip_url_base).replace(/\/+$/, '')}/whip/${enc(key)}`, key, authority: 'live' };
+ },
/** The WHIP URL for a stream key (or a slot id, with the key sent as the bearer). */
/** The WHIP URL for a stream key (or a slot id, with the key sent as the bearer). */
url: async (keyOrSlot) => {
url: async (keyOrSlot) => {
const k = String(keyOrSlot || '');
const k = String(keyOrSlot || '');
@@ -80,6 +80,26 @@ const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:
@@ -80,6 +80,26 @@ const _POSE = /,?\s*(?:(?:appearing|seeming|seemingly)\s+(?:to\s+be\s+)?)?(?:(?:
const _GENDERED = 'man|woman|guy|gal|lady|gentleman';
const _GENDERED = 'man|woman|guy|gal|lady|gentleman';
const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');
const _GENDERED_ONE = new RegExp(`\\b(a|an|the|one|another|this|that|each|every)\\s+(?:${_GENDERED})\\b(?!-)`, 'gi');
const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');
const _GENDERED_MANY = new RegExp(`\\b(two|three|four|five|several|some|the|both|many|other|few|these|those|\\d+)\\s+(?:men|women|guys|gals|ladies|gentlemen)\\b`, 'gi');
+// A pronoun is a guessed gender too: he and she are "the person", his and her (before a word) "their", him and her
+// (after a verb or a preposition, or at the end) "them", himself and herself "themself", hers "theirs". Outside
+// quotes only: what someone said stays word for word. "The person" keeps the verb as written ("he looks" → "the person
+// looks"), which "they" would not.
+const _HE_SHE = /\b(?:he|she)\b(?:'(s|d|ll)\b)?/gi;
+const _HER_OBJECT = /\bher\b(?=\s*(?:[.,;:!?)\]"”]|$)|\s+(?:and|or|but|to|from|with|in|on|at|for|as|while|when|because|so|if|the|a|an|this|that|is|was|again|back|up|down|out|off|away|over|now|too|here|there|more|about|into|onto)\b)/gi;
+const _PRONOUNS = [
+ [/\b(?:himself|herself)\b/gi, 'themself'],
+ [/\bhers\b/gi, 'theirs'],
+ [/\bhim\b/gi, 'them'],
+ [_HER_OBJECT, 'them'],
+ [/\b(?:his|her)\b(?=\s+[\w'-])/gi, 'their'],
+ [/\bhis\b/gi, 'theirs'],
+];
+const _CONTRACTED = { s: "'s", d: ' would', ll: ' will' };
+function _noPronouns(part) {
+ let out = part.replace(_HE_SHE, (m, c) => _cap(m, `the person${c ? _CONTRACTED[c.toLowerCase()] : ''}`));
+ for (const [re, word] of _PRONOUNS) out = out.replace(re, (m) => _cap(m, word));
+ return out;
+}
const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;
const _GENDER_ADJ = /\b(?:(?:a|an)\s+)?(?:male|female)\s+(?:or|and|\/)\s+(?:male|female)\s+|\b(?:male|female)\s+(?=(?:hosts?|streamers?|players?|speakers?|presenters?|gamers?|voices?|narrators?|commentators?|figures?|characters?|broadcasters?|viewers?|individuals?|persons?|people)\b)/gi;
// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the
// What is left of a sentence about someone's hair, clothes or body ("a person adjusts their hair or clothing near the
// counter") cannot be repaired word by word: the sentence goes.
// counter") cannot be repaired word by word: the sentence goes.
@@ -95,7 +115,7 @@ function _noGender(text) {
@@ -95,7 +115,7 @@ function _noGender(text) {
.replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))
.replace(_GENDERED_ONE, (m, art) => _cap(m, `${/^an$/i.test(art) ? 'a' : art.toLowerCase()} person`))
.replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))
.replace(_GENDERED_MANY, (m, n) => _cap(m, `${n.toLowerCase()} people`))
.replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))
.replace(/\ba\s+(?=[aeiou]\w*\s+(?:host|streamer|player|speaker|presenter|gamer|figure|character|broadcaster|person)\b)/gi, (m) => _cap(m, 'an '))
- .replace(/ {2,}/g, ' '))).join('');
+ .replace(/ {2,}/g, ' '))).map((part, i) => (i % 2 ? part : _noPronouns(part))).join('');
}
}
function _noGrooming(text) {
function _noGrooming(text) {
const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];
const sentences = text.match(/[^.!?]+(?:[.!?]+(?:["”])?|$)\s*/g) || [text];
@@ -129,6 +149,6 @@ function _hash(text) {
@@ -129,6 +149,6 @@ function _hash(text) {
return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
}
}
const RULES_VERSION = _hash([_LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
const RULES_VERSION = _hash([_LOOKS, _PEOPLE, _WORN, _WORN_ANY, _FACE, _WITH_CLOTHES, _GENDER_ADJ, _GROOMING, _LOOKS_TAG, _LOOKS_WORD, _POSE,
- _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing].map(String).join('|'));
+ _noLooks, _noLooksWords, _noGender, _noGrooming, _noClothing, _HE_SHE, _HER_OBJECT, _PRONOUNS.map(([re, w]) => `${re}${w}`).join(','), _noPronouns].map(String).join('|'));
module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION };
module.exports = { noLooks: _noLooks, noLooksTags: _noLooksTags, hasLooks: _hasLooks, RULES_VERSION };
@@ -22,6 +22,10 @@ const { createLiveClient } = require('../src/live');
@@ -22,6 +22,10 @@ const { createLiveClient } = require('../src/live');
return res.end('v=0\r\no=answer\r\n');
return res.end('v=0\r\no=answer\r\n');
}
}
if (req.method === 'POST' && req.url === '/api/streams') return json(201, { stream: { id: 77 }, endpoint: { whipUrlBase: 'x' } });
if (req.method === 'POST' && req.url === '/api/streams') return json(201, { stream: { id: 77 }, endpoint: { whipUrlBase: 'x' } });
+ // A slot's profile: Live's own ingest (5), OpenRestream's (6), and one with no WHIP address (7).
+ if (req.url === '/api/streams/managed/5/profile') return json(200, { managed_stream: { id: 5 }, stream_key: 'sk_live_5', whip_url_base: 'https://whip.example.test/', rtmp_url: 'rtmp://x/live' });
+ if (req.url === '/api/streams/managed/6/profile') return json(200, { managed_stream: { id: 6 }, stream_key: null, whip_url_base: null, ingest_authority: 'openre', whip_url: 'https://openre.example.test/whip/k6' });
+ if (req.url === '/api/streams/managed/7/profile') return json(200, { managed_stream: { id: 7 }, stream_key: 'sk_live_7', whip_url_base: null });
return json(200, { ok: true });
return json(200, { ok: true });
});
});
});
});
@@ -84,6 +88,16 @@ const { createLiveClient } = require('../src/live');
@@ -84,6 +88,16 @@ const { createLiveClient } = require('../src/live');
assert.strictEqual(seen.filter((s) => s.url === '/whip/bad-key').length, 1, 'a refused offer is not retried');
assert.strictEqual(seen.filter((s) => s.url === '/whip/bad-key').length, 1, 'a refused offer is not retried');
await assert.rejects(live.whip.url('../x'), /stream key/);
await assert.rejects(live.whip.url('../x'), /stream key/);
+ // A slot's profile and where it publishes.
+ seen.length = 0;
+ const prof = await live.slots.profile(5);
+ assert.strictEqual(prof.stream_key, 'sk_live_5');
+ assert.strictEqual(last().url, '/api/streams/managed/5/profile');
+ assert.deepStrictEqual(await live.whip.forSlot(5), { url: 'https://whip.example.test/whip/sk_live_5', key: 'sk_live_5', authority: 'live' });
+ assert.deepStrictEqual(await live.whip.forSlot(6), { url: 'https://openre.example.test/whip/k6', key: null, authority: 'openre' });
+ await assert.rejects(live.whip.forSlot(7), /no WHIP address/);
+ assert.throws(() => live.slots.profile('5/../x'), /slot id/);
+
assert.throws(() => createLiveClient({ json() {} }), /core client/);
assert.throws(() => createLiveClient({ json() {} }), /core client/);
console.log('live: all checks passed');
console.log('live: all checks passed');
} finally {
} finally {
@@ -28,7 +28,7 @@ const cases = [
@@ -28,7 +28,7 @@ const cases = [
['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],
['Panelists discuss while wearing headsets and microphones, with a trophy nearby.', 'Panelists discuss, with a trophy nearby.'],
['A man wearing sunglasses on his head looks down at the camera.', 'A person looks down at the camera.'],
['A man wearing sunglasses on his head looks down at the camera.', 'A person looks down at the camera.'],
['A collage of a man in a white shirt and tie wearing dark sunglasses in the left panel.', 'A collage of a person in the left panel.'],
['A collage of a man in a white shirt and tie wearing dark sunglasses in the left panel.', 'A collage of a person in the left panel.'],
- ['Behind him a green mat; he wears a blue jacket and is leaning to the left.', 'Behind him a green mat; he is leaning to the left.'],
+ ['Behind him a green mat; he wears a blue jacket and is leaning to the left.', 'Behind them a green mat; the person is leaning to the left.'],
['Two men lift weights, one shirtless and sweating.', 'Two people lift weights, one sweating.'],
['Two men lift weights, one shirtless and sweating.', 'Two people lift weights, one sweating.'],
['Two men stand in an alley, one and drinking from a bottle while the other wears a dusty t-shirt; both look tired.', 'Two people stand in an alley, one drinking from a bottle; both look tired.'],
['Two men stand in an alley, one and drinking from a bottle while the other wears a dusty t-shirt; both look tired.', 'Two people stand in an alley, one drinking from a bottle; both look tired.'],
['A man wearing a green bucket hat with frog-like eyes sits indoors.', 'A person sits indoors.'],
['A man wearing a green bucket hat with frog-like eyes sits indoors.', 'A person sits indoors.'],
@@ -62,7 +62,20 @@ const cases = [
@@ -62,7 +62,20 @@ const cases = [
cases.push(
cases.push(
['A man lounging on a sofa in a living room, holding a remote and facing camera.', 'A person lounging on a sofa in a living room, holding a remote and facing camera.'],
['A man lounging on a sofa in a living room, holding a remote and facing camera.', 'A person lounging on a sofa in a living room, holding a remote and facing camera.'],
['A man is shown outdoors, smoking a cigarette against a clear blue sky.', 'A person is shown outdoors, smoking a cigarette against a clear blue sky.'],
['A man is shown outdoors, smoking a cigarette against a clear blue sky.', 'A person is shown outdoors, smoking a cigarette against a clear blue sky.'],
- ['A man sitting on a bed reading a book while homework papers and stacked boxes surround him.', 'A person sitting on a bed reading a book while homework papers and stacked boxes surround him.'],
+ ['A man sitting on a bed reading a book while homework papers and stacked boxes surround him.', 'A person sitting on a bed reading a book while homework papers and stacked boxes surround them.'],
+);
+
+// Pronouns are a guessed gender too (27 repaired Community summaries kept "he looks", "on his device"): he and she are
+// "the person", his and her "their", him and her "them"; quoted speech stays.
+cases.push(
+ ['He looks at the camera and adjusts the volume on his device.', 'The person looks at the camera and adjusts the volume on their device.'],
+ ['The streamer checks his phone and laughs at her joke.', 'The streamer checks their phone and laughs at their joke.'],
+ ['Chat cheers for her.', 'Chat cheers for them.'],
+ ['The streamer gives her a wave and thanks him for the raid.', 'The streamer gives them a wave and thanks them for the raid.'],
+ ["She's playing Minecraft with him; he'll join later.", "The person's playing Minecraft with them; the person will join later."],
+ ['He hurt himself, but the win is his.', 'The person hurt themself, but the win is theirs.'],
+ ['A person reads, says "he is lying to her", and waves at her chat.', 'A person reads, says "he is lying to her", and waves at their chat.'],
+ ['Shell, Heather and the theme park stay as written.', 'Shell, Heather and the theme park stay as written.'],
);
);
let failed = 0;
let failed = 0;
@@ -27,6 +27,8 @@ export interface LiveClient {
@@ -27,6 +27,8 @@ export interface LiveClient {
update(slotId: number | string, patch: SlotRequest, opts?: Opts): Promise<LiveJson>;
update(slotId: number | string, patch: SlotRequest, opts?: Opts): Promise<LiveJson>;
remove(slotId: number | string, opts?: Opts): Promise<LiveJson>;
remove(slotId: number | string, opts?: Opts): Promise<LiveJson>;
regenerateKey(slotId: number | string, opts?: Opts): Promise<LiveJson>;
regenerateKey(slotId: number | string, opts?: Opts): Promise<LiveJson>;
+ /** A slot's settings and where to publish to it (owner): stream_key, whip_url_base, rtmp_url, or OpenRestream's whip_url. */
+ profile(slotId: number | string, opts?: Opts): Promise<LiveJson>;
};
};
/** Go live from a slot → { stream, endpoint }. */
/** Go live from a slot → { stream, endpoint }. */
goLive(slotId: number | string, opts?: GoLiveOptions, extra?: Opts): Promise<{ stream: LiveJson; endpoint: LiveJson }>;
goLive(slotId: number | string, opts?: GoLiveOptions, extra?: Opts): Promise<{ stream: LiveJson; endpoint: LiveJson }>;
@@ -42,6 +44,8 @@ export interface LiveClient {
@@ -42,6 +44,8 @@ export interface LiveClient {
moments(opts?: FeedOptions): Promise<{ items: LiveJson[]; next: string | null; [k: string]: unknown }>;
moments(opts?: FeedOptions): Promise<{ items: LiveJson[]; next: string | null; [k: string]: unknown }>;
};
};
whip: {
whip: {
+ /** Where a slot publishes: Live's WHIP origin with the slot's key, or OpenRestream's WHIP URL (key null). */
+ forSlot(slotId: number | string, opts?: Opts): Promise<{ url: string; key: string | null; authority: string }>;
url(keyOrSlot: string | number): Promise<string>;
url(keyOrSlot: string | number): Promise<string>;
publish(url: string | Promise<string>, offer: string, opts?: Opts & { key?: string }): Promise<WhipSession>;
publish(url: string | Promise<string>, offer: string, opts?: Opts & { key?: string }): Promise<WhipSession>;
end(location: string, opts?: Opts): Promise<void>;
end(location: string, opts?: Opts): Promise<void>;