openvibers/openvibe.examples Commits
examples/go-live: go live on OpenVibe.Live from code (a Live API token sets up a WHIP slot, FFmpeg 8+ publishes, the session is named and ended; the key is never printed) (#29)
Changes
@@@ -3,7 +3,7 @@> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials> and public APIs, nothing else.--**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Nine examples, each with a smoke test that CI runs on++**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Eleven examples, each with a smoke test that CI runs onNode 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs threeof them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; itis not part of CI or `npm test`, and it was not run against production as part of this release.@@@ -35,6 +35,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` || [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) || [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |++| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer || [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |Each example folder has a README, a `.env.example`, its own `package.json` and a@@@ -290,7 +291,7 @@ test/public-surface.test.js, test/structure.test.js## Owns- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event-- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint)++ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) and the go-live example (Live over WHIP from code)- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)@@@ -9,7 +9,7 @@"deployed": false,"deployment": "none: a repository with CI, not a runtime and not a published package","features": [-- "ten copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider)",++ "eleven copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended)","smoke tests against openvibe-sdk/testing's mock platform, offline","create-openvibe-app scaffolder","npm run e2e against the real platform (on demand)",nnew file mode 100644@@@ -0,0 +1,11 @@++# A Live API token: openvibe.live → Dashboard → API tokens, scopes "read" and "stream" (the "Stream Controller" preset).++# It can start and end your streams; keep it out of git.++OV_LIVE_TOKEN=++# Optional:++# OV_LIVE_URL=https://openvibe.live++# OV_SLOT_ID=123 # publish to this slot; otherwise one titled OV_TITLE is found or created (streaming method WHIP)++# OV_TITLE=Live from code++# OV_CATEGORY=Science & Technology++# OV_SOURCE=test # test (pattern + tone), a video file path, or camera:/dev/video0 (Linux)++# OV_SECONDS=60 # 0 runs until Ctrl-C++# OV_FFMPEG=ffmpeg # FFmpeg 8.0 or later (it has the WHIP muxer)nnew file mode 100644@@@ -0,0 +1,78 @@++# Go live from code++++Stream to [OpenVibe.Live](https://openvibe.live) from a script: a robot's camera, a test rig, a++video file on a loop. No OBS, no browser and no follower minimum. A Live API token sets up the slot,++and FFmpeg publishes over [WHIP](https://openvibe.live/docs/whip).++++```bash++node --env-file=.env go-live.js++# Created slot 41 "Live from code"++# Live: https://openvibe.live/@you (stream 900) for 60 s++# Ended stream 900; its recording is on openvibe.video once Media finishes it++++OV_SOURCE=talk.mp4 OV_SECONDS=0 node --env-file=.env go-live.js # a file, until it ends or Ctrl-C++```++++## What it proves++++- **A Live API token** (`hbt_…`, scopes `read` and `stream`) is enough to go live. It is the++ bearer on Live's own routes (`openvibe-sdk/live`):++ - `slots.list` and `slots.create` (streaming method `whip`);++ - the slot's profile (`GET /api/streams/managed/:id/profile`) for its WHIP URL;++ - `streams.mine`, `streams.update` and `streams.end`.++++ Money, staff and credential routes refuse API tokens, so this token can't touch your balance.++- **Posting to the WHIP URL starts the live session.** There is no "Go live" call: the slot goes++ live when media flows, and the script finds that session to name it.++- **What Live accepts over WHIP:**++ - H.264 Constrained Baseline without B-frames (or VP8), and Opus at 48 kHz;++ - one encoding each, no simulcast;++ - no STUN or TURN, since Live is an ICE-lite server on a public address.++++ `ffmpegArgs()` sets exactly that.++- **Nothing lingers.** FFmpeg ends its WHIP session when it stops (`OV_SECONDS`, the file's end, or++ Ctrl-C). The script also ends the stream, which covers a crash, and the recording appears on++ openvibe.video once Media finishes it.++- **The stream key stays secret.** It is a password: anyone holding it can broadcast to your++ channel. It goes to FFmpeg as an argument and is never printed.++- A slot whose ingest is OpenRestream has no Live WHIP URL, and the script says so rather than++ publishing somewhere that would refuse it.++++## Files++++| File | What |++|---|---|++| `go-live.js` | `loadConfig()`, `createLive()`, `ensureSlot()`, `whipUrl()`, `ffmpegArgs()`, `waitForSession()`, `goLive()`, CLI |++| `test/smoke.test.js` | the whole run against a stand-in for Live's routes and a fake FFmpeg |++++## Run the smoke test++++```bash++npm test++```++++The test hands the SDK a `fetch` that answers like Live's routes (`server/streaming/routes.js` and++`server/auth/routes.js` on OpenVibe.Live's main) and a fake FFmpeg. It checks:++- the slot is created once and reused after;++- the WHIP URL comes from the profile;++- FFmpeg gets Live's codecs and the URL;++- the session is named once live and ended when FFmpeg stops;++- a missing FFmpeg is a clear error;++- the key never appears in the output.++++Nothing reaches the network.++++## Run it against the real platform++++1. On [openvibe.live](https://openvibe.live), open **Dashboard → API tokens** and create a token++ with the **Stream Controller** preset (`read`, `stream`, `control`) or just `read` and `stream`.++2. Install **FFmpeg 8.0 or later**. 8.0 added the WHIP muxer: `ffmpeg -hide_banner -muxers | grep whip`++ should list it. Your FFmpeg also needs libx264 and libopus, as most builds have.++3. `cp .env.example .env`, set `OV_LIVE_TOKEN`, then `npm install && npm start`.++++This goes live on your own channel: viewers can see it, and it is recorded. Use `OV_SECONDS` for a++short test, and delete the slot from the dashboard afterwards if you don't want it.++Your channel's page is `https://openvibe.live/@<you>`.++++To stream from a browser tab instead, with no code at all, use openvibe.live's **Go live** button++or the [single-file WHIP publisher](https://openvibe.live/whip-publisher.html).nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * Go live on OpenVibe.Live from code: no OBS, no browser, no follower minimum.++ *++ * A Live API token (scopes read + stream) finds or creates a WHIP slot, reads the slot's WHIP URL, and FFmpeg 8+++ * publishes a source over WHIP (a test pattern and a tone by default; a video file or a camera). Posting to the WHIP++ * URL starts the live session, so there is no "Go live" call; the stream is live once media flows. The script then++ * names the session, prints the channel's address, and ends the stream when FFmpeg stops (OV_SECONDS, the file's++ * end, or Ctrl-C), so nothing lingers.++ *++ * node --env-file=.env go-live.js++ * OV_SOURCE=talk.mp4 node --env-file=.env go-live.js++ *++ * The stream key is a password (anyone holding it can broadcast to the channel): it goes to FFmpeg as an argument++ * and is never printed.++ */++const { spawn } = require('node:child_process');++const { createClient } = require('openvibe-sdk/core');++const { createLiveClient } = require('openvibe-sdk/live');++++const TOKEN_RE = /^hbt_[A-Za-z0-9_-]{8,}$/;++++function loadConfig(env = process.env) {++ const missing = ['OV_LIVE_TOKEN'].filter((k) => !env[k]);++ if (missing.length) throw new Error(`missing environment variables: ${missing.join(', ')}`);++ if (!TOKEN_RE.test(env.OV_LIVE_TOKEN)) throw new Error('OV_LIVE_TOKEN is a Live API token (hbt_…) with the read and stream scopes');++ const seconds = env.OV_SECONDS == null || env.OV_SECONDS === '' ? 60 : Number(env.OV_SECONDS);++ if (!Number.isFinite(seconds) || seconds < 0) throw new Error('OV_SECONDS is a number of seconds (0 runs until stopped)');++ const slotId = env.OV_SLOT_ID ? Number(env.OV_SLOT_ID) : null;++ if (env.OV_SLOT_ID && !Number.isInteger(slotId)) throw new Error('OV_SLOT_ID is a slot id (a number)');++ return {++ token: env.OV_LIVE_TOKEN,++ liveUrl: String(env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/+$/, ''),++ slotId,++ title: String(env.OV_TITLE || 'Live from code').slice(0, 140),++ category: env.OV_CATEGORY || null,++ source: env.OV_SOURCE || 'test',++ seconds,++ ffmpeg: env.OV_FFMPEG || 'ffmpeg',++ };++}++++/** The core client and Live's: the API token is the bearer on Live's own routes, never on the WHIP request. */++function createLive(config, { fetch } = {}) {++ const client = createClient({ token: config.token, baseUrls: { live: config.liveUrl }, ...(fetch ? { fetch } : {}) });++ return { client, live: createLiveClient(client) };++}++++/** OV_SLOT_ID, else a WHIP or browser slot with this title, else a new WHIP slot. */++async function ensureSlot({ live }, config) {++ const { managed_streams: slots = [] } = await live.slots.list();++ if (config.slotId) {++ const slot = slots.find((s) => Number(s.id) === config.slotId);++ if (!slot) throw new Error(`slot ${config.slotId} is not one of yours`);++ return { slot, created: false };++ }++ const found = slots.find((s) => s.title === config.title && ['whip', 'browser'].includes(s.streaming_method));++ if (found) return { slot: found, created: false };++ const { managed_stream: slot } = await live.slots.create({++ title: config.title, streaming_method: 'whip', ...(config.category ? { category: config.category } : {}),++ });++ return { slot, created: true };++}++++/** The slot's WHIP URL (docs: https://openvibe.live/docs/whip#getting-your-whip-url). */++async function whipUrl({ client }, slotId) {++ const profile = await client.json({ service: 'live', audience: 'openvibe.live', path: `/api/streams/managed/${Number(slotId)}/profile` });++ if (!profile.stream_key || !profile.whip_url_base) {++ // A slot OpenRestream ingests publishes there, not to Live's WHIP.++ throw new Error(`slot ${slotId} has no Live WHIP URL${profile.ingest_authority ? ` (its ingest is ${profile.ingest_authority})` : ''}`);++ }++ return `${String(profile.whip_url_base).replace(/\/+$/, '')}/whip/${profile.stream_key}`;++}++++/**++ * FFmpeg's arguments: what Live accepts over WHIP (docs/whip#media-requirements): H.264 Constrained Baseline with++ * no B-frames, and Opus at 48 kHz, one encoding each. 'test' is a moving pattern with a clock and a 440 Hz tone; a++ * path is read at its own speed; 'camera:<device>' is a V4L2 camera with the default microphone (Linux).++ */++function ffmpegArgs(url, { source = 'test', seconds = 60 } = {}) {++ const input = source === 'test'++ ? ['-re', '-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000']++ : source.startsWith('camera:')++ ? ['-f', 'v4l2', '-framerate', '30', '-video_size', '1280x720', '-i', source.slice(7), '-f', 'pulse', '-i', 'default']++ : ['-re', '-i', source];++ return [++ '-hide_banner', '-loglevel', 'warning', ...input,++ ...(seconds > 0 ? ['-t', String(seconds)] : []),++ '-c:v', 'libx264', '-profile:v', 'baseline', '-level', '3.1', '-pix_fmt', 'yuv420p', '-bf', '0',++ '-g', '60', '-b:v', '2500k', '-maxrate', '2500k', '-bufsize', '5000k', '-tune', 'zerolatency',++ '-c:a', 'libopus', '-ar', '48000', '-ac', '2', '-b:a', '128k',++ '-f', 'whip', url,++ ];++}++++/** The live session the slot started, once Live shows it (media takes a few seconds to flow). */++async function waitForSession({ live }, slotId, { attempts = 20, delayMs = 1500, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {++ for (let i = 0; i < attempts; i++) {++ const { streams = [] } = await live.streams.mine();++ const s = streams.find((x) => Number(x.managed_stream_id) === Number(slotId) && (x.is_live === true || x.is_live === 1));++ if (s) return s;++ await sleep(delayMs);++ }++ return null;++}++++/**++ * Publish until FFmpeg stops, then make sure the stream ended. Returns { stream, channelUrl, code }.++ * `run` starts FFmpeg (child_process.spawn by default) and `log` prints progress.++ */++async function goLive(sdk, config, { run = (cmd, args) => spawn(cmd, args, { stdio: ['ignore', 'inherit', 'inherit'] }), log = console.log, sleep } = {}) {++ const { slot, created } = await ensureSlot(sdk, config);++ log(`${created ? 'Created' : 'Using'} slot ${slot.id} "${slot.title}"`);++ const url = await whipUrl(sdk, slot.id);++ const ff = run(config.ffmpeg, ffmpegArgs(url, config));++ const exited = new Promise((resolve) => {++ ff.once('error', (err) => resolve({ code: null, error: err }));++ ff.once('exit', (code) => resolve({ code }));++ });++ const stop = () => { try { ff.kill('SIGINT'); } catch { /* already gone */ } }; // FFmpeg DELETEs its WHIP session on SIGINT++ process.once('SIGINT', stop);++ try {++ const stream = await Promise.race([waitForSession(sdk, slot.id, { sleep }), exited.then(() => null)]);++ let channelUrl = null;++ if (stream) {++ await sdk.live.streams.update(stream.id, { title: config.title, ...(config.category ? { category: config.category } : {}) });++ const { user } = await sdk.client.json({ service: 'live', audience: 'openvibe.live', path: '/api/auth/me' });++ channelUrl = await sdk.live.channels.url(user.username);++ log(`Live: ${channelUrl} (stream ${stream.id})${config.seconds > 0 ? ` for ${config.seconds} s` : ' until Ctrl-C'}`);++ } else {++ log('FFmpeg stopped before the stream went live (does `ffmpeg -muxers` list whip? FFmpeg 8.0 added it; it also needs libx264 and libopus)');++ }++ const { code, error } = await exited;++ if (error) throw new Error(`could not start ${config.ffmpeg}: ${error.message}`);++ // FFmpeg ends the WHIP session on a clean exit; ending the stream as well covers a crash, so nothing lingers.++ if (stream) {++ await sdk.live.streams.end(stream.id).catch((err) => { if (err.status !== 404 && err.status !== 409) throw err; });++ log(`Ended stream ${stream.id}; its recording is on openvibe.video once Media finishes it`);++ }++ return { stream, channelUrl, code };++ } finally {++ process.removeListener('SIGINT', stop);++ }++}++++module.exports = { loadConfig, createLive, ensureSlot, whipUrl, ffmpegArgs, waitForSession, goLive };++++if (require.main === module) {++ (async () => {++ const config = loadConfig();++ const { code } = await goLive(createLive(config), config);++ process.exit(code === 0 || code === 255 ? 0 : 1); // 255: FFmpeg stopped by Ctrl-C++ })().catch((err) => { console.error(err.code ? `${err.code}: ${err.message}` : err.message); process.exit(1); });++}nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-go-live",++ "version": "0.1.0",++ "private": true,++ "description": "Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot, and FFmpeg publishes over WHIP.",++ "license": "MIT",++ "main": "go-live.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env go-live.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ }++}nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * Smoke test: go-live.js against a stand-in for Live's routes (the shapes server/streaming/routes.js and++ * server/auth/routes.js answer) and a fake FFmpeg. Slot found or created, the WHIP URL read from the slot's profile,++ * FFmpeg given Live's codecs and the URL, the session named once live, the stream ended when FFmpeg stops, and the++ * stream key never printed. No network, no FFmpeg.++ */++const assert = require('node:assert/strict');++const { EventEmitter } = require('node:events');++const { loadConfig, createLive, ensureSlot, whipUrl, ffmpegArgs, goLive } = require('../go-live');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const KEY = 'a'.repeat(32);++const TOKEN = 'hbt_testtokennotreal';++const calls = [];++let slots = [];++let live = false;++++const json = (status, o) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });++async function fakeFetch(url, init = {}) {++ const u = new URL(String(url));++ const method = init.method || 'GET';++ const auth = new Headers(init.headers).get('authorization');++ const body = init.body ? JSON.parse(init.body) : null;++ calls.push({ method, path: u.pathname, auth, body });++ assert.equal(u.origin, 'https://openvibe.live');++ assert.equal(auth, `Bearer ${TOKEN}`, 'the API token is the bearer on Live\'s routes');++ if (method === 'GET' && u.pathname === '/api/streams/managed') return json(200, { managed_streams: slots, limit: 5 });++ if (method === 'POST' && u.pathname === '/api/streams/managed') {++ const slot = { id: 41, user_id: 7, title: body.title, streaming_method: body.streaming_method, stream_key: KEY };++ slots.push(slot);++ return json(201, { managed_stream: slot });++ }++ if (method === 'GET' && u.pathname === '/api/streams/managed/41/profile') {++ return json(200, { managed_stream: { id: 41, title: 'Robot cam' }, stream_key: KEY, whip_url_base: 'https://whip.openvibe.live', rtmp_url: 'rtmp://openvibe.live:1935/live' });++ }++ if (method === 'GET' && u.pathname === '/api/streams/managed/42/profile') {++ return json(200, { managed_stream: { id: 42 }, stream_key: null, whip_url_base: null, ingest_authority: 'openre' });++ }++ if (method === 'GET' && u.pathname === '/api/streams/mine') {++ return json(200, { streams: live ? [{ id: 900, managed_stream_id: 41, is_live: 1, title: 'Robot cam' }, { id: 899, managed_stream_id: 41, is_live: 0 }] : [] });++ }++ if (method === 'PUT' && u.pathname === '/api/streams/900') return json(200, { stream: { id: 900, ...body } });++ if (method === 'GET' && u.pathname === '/api/auth/me') return json(200, { user: { id: 7, username: 'robotlab' }, capabilities: {} });++ if (method === 'DELETE' && u.pathname === '/api/streams/900') { live = false; return json(200, { message: 'Stream ended' }); }++ throw new Error(`unexpected ${method} ${u.pathname}`);++}++++/** A stand-in for FFmpeg: media "flows" when it starts, and it exits 0 when told to. */++function fakeFfmpeg(record) {++ return (cmd, args) => {++ record.push({ cmd, args });++ const child = new EventEmitter();++ child.kill = () => setImmediate(() => child.emit('exit', 255));++ live = true;++ setTimeout(() => child.emit('exit', 0), 30);++ return child;++ };++}++++(async () => {++ // Configuration: the token is required and must be a Live API token.++ assert.throws(() => loadConfig({}), /missing environment variables: OV_LIVE_TOKEN/);++ assert.throws(() => loadConfig({ OV_LIVE_TOKEN: 'eyJhbGciOi' }), /Live API token \(hbt_…\)/);++ assert.throws(() => loadConfig({ OV_LIVE_TOKEN: TOKEN, OV_SECONDS: '-1' }), /OV_SECONDS/);++ const config = loadConfig({ OV_LIVE_TOKEN: TOKEN, OV_TITLE: 'Robot cam', OV_SECONDS: '30' });++ assert.deepEqual([config.liveUrl, config.source, config.seconds, config.slotId], ['https://openvibe.live', 'test', 30, null]);++++ // FFmpeg gets what Live's WHIP accepts: H.264 baseline without B-frames, Opus 48 kHz, the WHIP muxer, the URL last.++ const args = ffmpegArgs('https://whip.openvibe.live/whip/k', { source: 'test', seconds: 30 });++ for (const [flag, value] of [['-c:v', 'libx264'], ['-profile:v', 'baseline'], ['-bf', '0'], ['-c:a', 'libopus'], ['-ar', '48000'], ['-f', 'whip'], ['-t', '30']]) {++ assert.equal(args[args.indexOf(flag, flag === '-f' ? args.lastIndexOf('-f') : 0) + 1], value, `${flag} ${value}`);++ }++ assert.equal(args.at(-1), 'https://whip.openvibe.live/whip/k');++ assert.ok(args.includes('testsrc2=size=1280x720:rate=30'));++ assert.deepEqual(ffmpegArgs('u', { source: 'talk.mp4', seconds: 0 }).slice(3, 6), ['-re', '-i', 'talk.mp4'], 'a file plays at its own speed');++ assert.ok(!ffmpegArgs('u', { source: 'talk.mp4', seconds: 0 }).includes('-t'), 'OV_SECONDS=0 runs until stopped');++ assert.deepEqual(ffmpegArgs('u', { source: 'camera:/dev/video0' }).slice(3, 11), ['-f', 'v4l2', '-framerate', '30', '-video_size', '1280x720', '-i', '/dev/video0']);++++ const sdk = createLive(config, { fetch: fakeFetch });++++ // A slot from another ingest has no Live WHIP URL: said plainly.++ await assert.rejects(whipUrl(sdk, 42), /slot 42 has no Live WHIP URL \(its ingest is openre\)/);++++ // The whole run: a new WHIP slot, its WHIP URL, FFmpeg, the session named, the stream ended.++ const runs = [];++ const lines = [];++ const out = await goLive(sdk, config, { run: fakeFfmpeg(runs), log: (l) => lines.push(l), sleep: () => new Promise((r) => setTimeout(r, 1)) });++ assert.equal(out.code, 0);++ assert.equal(out.stream.id, 900);++ assert.equal(out.channelUrl, 'https://openvibe.live/@robotlab');++ assert.deepEqual(calls.find((c) => c.method === 'POST').body, { title: 'Robot cam', streaming_method: 'whip' });++ assert.equal(runs.length, 1);++ assert.equal(runs[0].cmd, 'ffmpeg');++ assert.equal(runs[0].args.at(-1), `https://whip.openvibe.live/whip/${KEY}`);++ assert.deepEqual(calls.find((c) => c.method === 'PUT').body, { title: 'Robot cam' });++ assert.ok(calls.some((c) => c.method === 'DELETE' && c.path === '/api/streams/900'), 'the stream is ended, so nothing lingers');++ assert.deepEqual(lines, [++ 'Created slot 41 "Robot cam"',++ 'Live: https://openvibe.live/@robotlab (stream 900) for 30 s',++ 'Ended stream 900; its recording is on openvibe.video once Media finishes it',++ ]);++ assert.ok(!lines.join('\n').includes(KEY), 'the stream key is never printed');++++ // A second run reuses the slot instead of making another.++ calls.length = 0;++ const again = await ensureSlot(sdk, config);++ assert.deepEqual([again.slot.id, again.created], [41, false]);++ assert.ok(!calls.some((c) => c.method === 'POST'));++ await assert.rejects(ensureSlot(sdk, { ...config, slotId: 77 }), /slot 77 is not one of yours/);++++ // FFmpeg missing: a clear error, and no stream left behind.++ calls.length = 0;++ const missing = () => { const c = new EventEmitter(); c.kill = () => {}; setImmediate(() => c.emit('error', new Error('spawn ffmpeg ENOENT'))); return c; };++ await assert.rejects(goLive(sdk, config, { run: missing, log: () => {}, sleep: () => Promise.resolve() }), /could not start ffmpeg: spawn ffmpeg ENOENT/);++ assert.ok(!calls.some((c) => c.method === 'PUT' || c.method === 'DELETE'));++++ console.log('go-live smoke: slot, WHIP URL, FFmpeg arguments, the live session named and ended, key never printed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -66,6 +66,17 @@"node": ">=22"}},++ "examples/go-live": {++ "name": "openvibe-example-go-live",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ },++ "engines": {++ "node": ">=22"++ }++ },"examples/media-uploader": {"name": "openvibe-example-media-uploader","version": "0.2.0",@@@ -275,6 +286,10 @@"resolved": "examples/event-subscriber","link": true},++ "node_modules/openvibe-example-go-live": {++ "resolved": "examples/go-live",++ "link": true++ },"node_modules/openvibe-example-media-uploader": {"resolved": "examples/media-uploader","link": true@@@ -11,13 +11,13 @@ const fs = require('node:fs');const path = require('node:path');const ROOT = path.join(__dirname, '..');--const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];++const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];// The pin every example must share: the repository's own (the pin bot moves them all at once).const ROOT_PKG = require(path.join(ROOT, 'package.json'));const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();--assert.deepEqual(dirs, EXPECTED, 'the nine charter examples and ai-chat');++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat and go-live');function sources(dir) {const out = [];
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> and public APIs, nothing else.
> and public APIs, nothing else.
-**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Nine examples, each with a smoke test that CI runs on
+**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Eleven examples, each with a smoke test that CI runs on
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
is not part of CI or `npm test`, and it was not run against production as part of this release.
@@ -35,6 +35,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
@@ -35,6 +35,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
+| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |
| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
Each example folder has a README, a `.env.example`, its own `package.json` and a
Each example folder has a README, a `.env.example`, its own `package.json` and a
@@ -290,7 +291,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -290,7 +291,7 @@ test/public-surface.test.js, test/structure.test.js
## Owns
## Owns
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint)
+ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) and the go-live example (Live over WHIP from code)
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"deployed": false,
"deployed": false,
"deployment": "none: a repository with CI, not a runtime and not a published package",
"deployment": "none: a repository with CI, not a runtime and not a published package",
"features": [
"features": [
- "ten copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider)",
+ "eleven copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended)",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"create-openvibe-app scaffolder",
"create-openvibe-app scaffolder",
"npm run e2e against the real platform (on demand)",
"npm run e2e against the real platform (on demand)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+# A Live API token: openvibe.live → Dashboard → API tokens, scopes "read" and "stream" (the "Stream Controller" preset).
+# It can start and end your streams; keep it out of git.
+OV_LIVE_TOKEN=
+# Optional:
+# OV_LIVE_URL=https://openvibe.live
+# OV_SLOT_ID=123 # publish to this slot; otherwise one titled OV_TITLE is found or created (streaming method WHIP)
+# OV_TITLE=Live from code
+# OV_CATEGORY=Science & Technology
+# OV_SOURCE=test # test (pattern + tone), a video file path, or camera:/dev/video0 (Linux)
+# OV_SECONDS=60 # 0 runs until Ctrl-C
+# OV_FFMPEG=ffmpeg # FFmpeg 8.0 or later (it has the WHIP muxer)
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+# Go live from code
+
+Stream to [OpenVibe.Live](https://openvibe.live) from a script: a robot's camera, a test rig, a
+video file on a loop. No OBS, no browser and no follower minimum. A Live API token sets up the slot,
+and FFmpeg publishes over [WHIP](https://openvibe.live/docs/whip).
+
+```bash
+node --env-file=.env go-live.js
+# Created slot 41 "Live from code"
+# Live: https://openvibe.live/@you (stream 900) for 60 s
+# Ended stream 900; its recording is on openvibe.video once Media finishes it
+
+OV_SOURCE=talk.mp4 OV_SECONDS=0 node --env-file=.env go-live.js # a file, until it ends or Ctrl-C
+```
+
+## What it proves
+
+- **A Live API token** (`hbt_…`, scopes `read` and `stream`) is enough to go live. It is the
+ bearer on Live's own routes (`openvibe-sdk/live`):
+ - `slots.list` and `slots.create` (streaming method `whip`);
+ - the slot's profile (`GET /api/streams/managed/:id/profile`) for its WHIP URL;
+ - `streams.mine`, `streams.update` and `streams.end`.
+
+ Money, staff and credential routes refuse API tokens, so this token can't touch your balance.
+- **Posting to the WHIP URL starts the live session.** There is no "Go live" call: the slot goes
+ live when media flows, and the script finds that session to name it.
+- **What Live accepts over WHIP:**
+ - H.264 Constrained Baseline without B-frames (or VP8), and Opus at 48 kHz;
+ - one encoding each, no simulcast;
+ - no STUN or TURN, since Live is an ICE-lite server on a public address.
+
+ `ffmpegArgs()` sets exactly that.
+- **Nothing lingers.** FFmpeg ends its WHIP session when it stops (`OV_SECONDS`, the file's end, or
+ Ctrl-C). The script also ends the stream, which covers a crash, and the recording appears on
+ openvibe.video once Media finishes it.
+- **The stream key stays secret.** It is a password: anyone holding it can broadcast to your
+ channel. It goes to FFmpeg as an argument and is never printed.
+- A slot whose ingest is OpenRestream has no Live WHIP URL, and the script says so rather than
+ publishing somewhere that would refuse it.
+
+## Files
+
+| File | What |
+|---|---|
+| `go-live.js` | `loadConfig()`, `createLive()`, `ensureSlot()`, `whipUrl()`, `ffmpegArgs()`, `waitForSession()`, `goLive()`, CLI |
+| `test/smoke.test.js` | the whole run against a stand-in for Live's routes and a fake FFmpeg |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The test hands the SDK a `fetch` that answers like Live's routes (`server/streaming/routes.js` and
+`server/auth/routes.js` on OpenVibe.Live's main) and a fake FFmpeg. It checks:
+- the slot is created once and reused after;
+- the WHIP URL comes from the profile;
+- FFmpeg gets Live's codecs and the URL;
+- the session is named once live and ended when FFmpeg stops;
+- a missing FFmpeg is a clear error;
+- the key never appears in the output.
+
+Nothing reaches the network.
+
+## Run it against the real platform
+
+1. On [openvibe.live](https://openvibe.live), open **Dashboard → API tokens** and create a token
+ with the **Stream Controller** preset (`read`, `stream`, `control`) or just `read` and `stream`.
+2. Install **FFmpeg 8.0 or later**. 8.0 added the WHIP muxer: `ffmpeg -hide_banner -muxers | grep whip`
+ should list it. Your FFmpeg also needs libx264 and libopus, as most builds have.
+3. `cp .env.example .env`, set `OV_LIVE_TOKEN`, then `npm install && npm start`.
+
+This goes live on your own channel: viewers can see it, and it is recorded. Use `OV_SECONDS` for a
+short test, and delete the slot from the dashboard afterwards if you don't want it.
+Your channel's page is `https://openvibe.live/@<you>`.
+
+To stream from a browser tab instead, with no code at all, use openvibe.live's **Go live** button
+or the [single-file WHIP publisher](https://openvibe.live/whip-publisher.html).
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+/**
+ * Go live on OpenVibe.Live from code: no OBS, no browser, no follower minimum.
+ *
+ * A Live API token (scopes read + stream) finds or creates a WHIP slot, reads the slot's WHIP URL, and FFmpeg 8+
+ * publishes a source over WHIP (a test pattern and a tone by default; a video file or a camera). Posting to the WHIP
+ * URL starts the live session, so there is no "Go live" call; the stream is live once media flows. The script then
+ * names the session, prints the channel's address, and ends the stream when FFmpeg stops (OV_SECONDS, the file's
+ * end, or Ctrl-C), so nothing lingers.
+ *
+ * node --env-file=.env go-live.js
+ * OV_SOURCE=talk.mp4 node --env-file=.env go-live.js
+ *
+ * The stream key is a password (anyone holding it can broadcast to the channel): it goes to FFmpeg as an argument
+ * and is never printed.
+ */
+const { spawn } = require('node:child_process');
+const { createClient } = require('openvibe-sdk/core');
+const { createLiveClient } = require('openvibe-sdk/live');
+
+const TOKEN_RE = /^hbt_[A-Za-z0-9_-]{8,}$/;
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_LIVE_TOKEN'].filter((k) => !env[k]);
+ if (missing.length) throw new Error(`missing environment variables: ${missing.join(', ')}`);
+ if (!TOKEN_RE.test(env.OV_LIVE_TOKEN)) throw new Error('OV_LIVE_TOKEN is a Live API token (hbt_…) with the read and stream scopes');
+ const seconds = env.OV_SECONDS == null || env.OV_SECONDS === '' ? 60 : Number(env.OV_SECONDS);
+ if (!Number.isFinite(seconds) || seconds < 0) throw new Error('OV_SECONDS is a number of seconds (0 runs until stopped)');
+ const slotId = env.OV_SLOT_ID ? Number(env.OV_SLOT_ID) : null;
+ if (env.OV_SLOT_ID && !Number.isInteger(slotId)) throw new Error('OV_SLOT_ID is a slot id (a number)');
+ return {
+ token: env.OV_LIVE_TOKEN,
+ liveUrl: String(env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/+$/, ''),
+ slotId,
+ title: String(env.OV_TITLE || 'Live from code').slice(0, 140),
+ category: env.OV_CATEGORY || null,
+ source: env.OV_SOURCE || 'test',
+ seconds,
+ ffmpeg: env.OV_FFMPEG || 'ffmpeg',
+ };
+}
+
+/** The core client and Live's: the API token is the bearer on Live's own routes, never on the WHIP request. */
+function createLive(config, { fetch } = {}) {
+ const client = createClient({ token: config.token, baseUrls: { live: config.liveUrl }, ...(fetch ? { fetch } : {}) });
+ return { client, live: createLiveClient(client) };
+}
+
+/** OV_SLOT_ID, else a WHIP or browser slot with this title, else a new WHIP slot. */
+async function ensureSlot({ live }, config) {
+ const { managed_streams: slots = [] } = await live.slots.list();
+ if (config.slotId) {
+ const slot = slots.find((s) => Number(s.id) === config.slotId);
+ if (!slot) throw new Error(`slot ${config.slotId} is not one of yours`);
+ return { slot, created: false };
+ }
+ const found = slots.find((s) => s.title === config.title && ['whip', 'browser'].includes(s.streaming_method));
+ if (found) return { slot: found, created: false };
+ const { managed_stream: slot } = await live.slots.create({
+ title: config.title, streaming_method: 'whip', ...(config.category ? { category: config.category } : {}),
+ });
+ return { slot, created: true };
+}
+
+/** The slot's WHIP URL (docs: https://openvibe.live/docs/whip#getting-your-whip-url). */
+async function whipUrl({ client }, slotId) {
+ const profile = await client.json({ service: 'live', audience: 'openvibe.live', path: `/api/streams/managed/${Number(slotId)}/profile` });
+ if (!profile.stream_key || !profile.whip_url_base) {
+ // A slot OpenRestream ingests publishes there, not to Live's WHIP.
+ throw new Error(`slot ${slotId} has no Live WHIP URL${profile.ingest_authority ? ` (its ingest is ${profile.ingest_authority})` : ''}`);
+ }
+ return `${String(profile.whip_url_base).replace(/\/+$/, '')}/whip/${profile.stream_key}`;
+}
+
+/**
+ * FFmpeg's arguments: what Live accepts over WHIP (docs/whip#media-requirements): H.264 Constrained Baseline with
+ * no B-frames, and Opus at 48 kHz, one encoding each. 'test' is a moving pattern with a clock and a 440 Hz tone; a
+ * path is read at its own speed; 'camera:<device>' is a V4L2 camera with the default microphone (Linux).
+ */
+function ffmpegArgs(url, { source = 'test', seconds = 60 } = {}) {
+ const input = source === 'test'
+ ? ['-re', '-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000']
+ : source.startsWith('camera:')
+ ? ['-f', 'v4l2', '-framerate', '30', '-video_size', '1280x720', '-i', source.slice(7), '-f', 'pulse', '-i', 'default']
+ : ['-re', '-i', source];
+ return [
+ '-hide_banner', '-loglevel', 'warning', ...input,
+ ...(seconds > 0 ? ['-t', String(seconds)] : []),
+ '-c:v', 'libx264', '-profile:v', 'baseline', '-level', '3.1', '-pix_fmt', 'yuv420p', '-bf', '0',
+ '-g', '60', '-b:v', '2500k', '-maxrate', '2500k', '-bufsize', '5000k', '-tune', 'zerolatency',
+ '-c:a', 'libopus', '-ar', '48000', '-ac', '2', '-b:a', '128k',
+ '-f', 'whip', url,
+ ];
+}
+
+/** The live session the slot started, once Live shows it (media takes a few seconds to flow). */
+async function waitForSession({ live }, slotId, { attempts = 20, delayMs = 1500, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
+ for (let i = 0; i < attempts; i++) {
+ const { streams = [] } = await live.streams.mine();
+ const s = streams.find((x) => Number(x.managed_stream_id) === Number(slotId) && (x.is_live === true || x.is_live === 1));
+ if (s) return s;
+ await sleep(delayMs);
+ }
+ return null;
+}
+
+/**
+ * Publish until FFmpeg stops, then make sure the stream ended. Returns { stream, channelUrl, code }.
+ * `run` starts FFmpeg (child_process.spawn by default) and `log` prints progress.
+ */
+async function goLive(sdk, config, { run = (cmd, args) => spawn(cmd, args, { stdio: ['ignore', 'inherit', 'inherit'] }), log = console.log, sleep } = {}) {
+ const { slot, created } = await ensureSlot(sdk, config);
+ log(`${created ? 'Created' : 'Using'} slot ${slot.id} "${slot.title}"`);
+ const url = await whipUrl(sdk, slot.id);
+ const ff = run(config.ffmpeg, ffmpegArgs(url, config));
+ const exited = new Promise((resolve) => {
+ ff.once('error', (err) => resolve({ code: null, error: err }));
+ ff.once('exit', (code) => resolve({ code }));
+ });
+ const stop = () => { try { ff.kill('SIGINT'); } catch { /* already gone */ } }; // FFmpeg DELETEs its WHIP session on SIGINT
+ process.once('SIGINT', stop);
+ try {
+ const stream = await Promise.race([waitForSession(sdk, slot.id, { sleep }), exited.then(() => null)]);
+ let channelUrl = null;
+ if (stream) {
+ await sdk.live.streams.update(stream.id, { title: config.title, ...(config.category ? { category: config.category } : {}) });
+ const { user } = await sdk.client.json({ service: 'live', audience: 'openvibe.live', path: '/api/auth/me' });
+ channelUrl = await sdk.live.channels.url(user.username);
+ log(`Live: ${channelUrl} (stream ${stream.id})${config.seconds > 0 ? ` for ${config.seconds} s` : ' until Ctrl-C'}`);
+ } else {
+ log('FFmpeg stopped before the stream went live (does `ffmpeg -muxers` list whip? FFmpeg 8.0 added it; it also needs libx264 and libopus)');
+ }
+ const { code, error } = await exited;
+ if (error) throw new Error(`could not start ${config.ffmpeg}: ${error.message}`);
+ // FFmpeg ends the WHIP session on a clean exit; ending the stream as well covers a crash, so nothing lingers.
+ if (stream) {
+ await sdk.live.streams.end(stream.id).catch((err) => { if (err.status !== 404 && err.status !== 409) throw err; });
+ log(`Ended stream ${stream.id}; its recording is on openvibe.video once Media finishes it`);
+ }
+ return { stream, channelUrl, code };
+ } finally {
+ process.removeListener('SIGINT', stop);
+ }
+}
+
+module.exports = { loadConfig, createLive, ensureSlot, whipUrl, ffmpegArgs, waitForSession, goLive };
+
+if (require.main === module) {
+ (async () => {
+ const config = loadConfig();
+ const { code } = await goLive(createLive(config), config);
+ process.exit(code === 0 || code === 255 ? 0 : 1); // 255: FFmpeg stopped by Ctrl-C
+ })().catch((err) => { console.error(err.code ? `${err.code}: ${err.message}` : err.message); process.exit(1); });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-go-live",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot, and FFmpeg publishes over WHIP.",
+ "license": "MIT",
+ "main": "go-live.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env go-live.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * Smoke test: go-live.js against a stand-in for Live's routes (the shapes server/streaming/routes.js and
+ * server/auth/routes.js answer) and a fake FFmpeg. Slot found or created, the WHIP URL read from the slot's profile,
+ * FFmpeg given Live's codecs and the URL, the session named once live, the stream ended when FFmpeg stops, and the
+ * stream key never printed. No network, no FFmpeg.
+ */
+const assert = require('node:assert/strict');
+const { EventEmitter } = require('node:events');
+const { loadConfig, createLive, ensureSlot, whipUrl, ffmpegArgs, goLive } = require('../go-live');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const KEY = 'a'.repeat(32);
+const TOKEN = 'hbt_testtokennotreal';
+const calls = [];
+let slots = [];
+let live = false;
+
+const json = (status, o) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
+async function fakeFetch(url, init = {}) {
+ const u = new URL(String(url));
+ const method = init.method || 'GET';
+ const auth = new Headers(init.headers).get('authorization');
+ const body = init.body ? JSON.parse(init.body) : null;
+ calls.push({ method, path: u.pathname, auth, body });
+ assert.equal(u.origin, 'https://openvibe.live');
+ assert.equal(auth, `Bearer ${TOKEN}`, 'the API token is the bearer on Live\'s routes');
+ if (method === 'GET' && u.pathname === '/api/streams/managed') return json(200, { managed_streams: slots, limit: 5 });
+ if (method === 'POST' && u.pathname === '/api/streams/managed') {
+ const slot = { id: 41, user_id: 7, title: body.title, streaming_method: body.streaming_method, stream_key: KEY };
+ slots.push(slot);
+ return json(201, { managed_stream: slot });
+ }
+ if (method === 'GET' && u.pathname === '/api/streams/managed/41/profile') {
+ return json(200, { managed_stream: { id: 41, title: 'Robot cam' }, stream_key: KEY, whip_url_base: 'https://whip.openvibe.live', rtmp_url: 'rtmp://openvibe.live:1935/live' });
+ }
+ if (method === 'GET' && u.pathname === '/api/streams/managed/42/profile') {
+ return json(200, { managed_stream: { id: 42 }, stream_key: null, whip_url_base: null, ingest_authority: 'openre' });
+ }
+ if (method === 'GET' && u.pathname === '/api/streams/mine') {
+ return json(200, { streams: live ? [{ id: 900, managed_stream_id: 41, is_live: 1, title: 'Robot cam' }, { id: 899, managed_stream_id: 41, is_live: 0 }] : [] });
+ }
+ if (method === 'PUT' && u.pathname === '/api/streams/900') return json(200, { stream: { id: 900, ...body } });
+ if (method === 'GET' && u.pathname === '/api/auth/me') return json(200, { user: { id: 7, username: 'robotlab' }, capabilities: {} });
+ if (method === 'DELETE' && u.pathname === '/api/streams/900') { live = false; return json(200, { message: 'Stream ended' }); }
+ throw new Error(`unexpected ${method} ${u.pathname}`);
+}
+
+/** A stand-in for FFmpeg: media "flows" when it starts, and it exits 0 when told to. */
+function fakeFfmpeg(record) {
+ return (cmd, args) => {
+ record.push({ cmd, args });
+ const child = new EventEmitter();
+ child.kill = () => setImmediate(() => child.emit('exit', 255));
+ live = true;
+ setTimeout(() => child.emit('exit', 0), 30);
+ return child;
+ };
+}
+
+(async () => {
+ // Configuration: the token is required and must be a Live API token.
+ assert.throws(() => loadConfig({}), /missing environment variables: OV_LIVE_TOKEN/);
+ assert.throws(() => loadConfig({ OV_LIVE_TOKEN: 'eyJhbGciOi' }), /Live API token \(hbt_…\)/);
+ assert.throws(() => loadConfig({ OV_LIVE_TOKEN: TOKEN, OV_SECONDS: '-1' }), /OV_SECONDS/);
+ const config = loadConfig({ OV_LIVE_TOKEN: TOKEN, OV_TITLE: 'Robot cam', OV_SECONDS: '30' });
+ assert.deepEqual([config.liveUrl, config.source, config.seconds, config.slotId], ['https://openvibe.live', 'test', 30, null]);
+
+ // FFmpeg gets what Live's WHIP accepts: H.264 baseline without B-frames, Opus 48 kHz, the WHIP muxer, the URL last.
+ const args = ffmpegArgs('https://whip.openvibe.live/whip/k', { source: 'test', seconds: 30 });
+ for (const [flag, value] of [['-c:v', 'libx264'], ['-profile:v', 'baseline'], ['-bf', '0'], ['-c:a', 'libopus'], ['-ar', '48000'], ['-f', 'whip'], ['-t', '30']]) {
+ assert.equal(args[args.indexOf(flag, flag === '-f' ? args.lastIndexOf('-f') : 0) + 1], value, `${flag} ${value}`);
+ }
+ assert.equal(args.at(-1), 'https://whip.openvibe.live/whip/k');
+ assert.ok(args.includes('testsrc2=size=1280x720:rate=30'));
+ assert.deepEqual(ffmpegArgs('u', { source: 'talk.mp4', seconds: 0 }).slice(3, 6), ['-re', '-i', 'talk.mp4'], 'a file plays at its own speed');
+ assert.ok(!ffmpegArgs('u', { source: 'talk.mp4', seconds: 0 }).includes('-t'), 'OV_SECONDS=0 runs until stopped');
+ assert.deepEqual(ffmpegArgs('u', { source: 'camera:/dev/video0' }).slice(3, 11), ['-f', 'v4l2', '-framerate', '30', '-video_size', '1280x720', '-i', '/dev/video0']);
+
+ const sdk = createLive(config, { fetch: fakeFetch });
+
+ // A slot from another ingest has no Live WHIP URL: said plainly.
+ await assert.rejects(whipUrl(sdk, 42), /slot 42 has no Live WHIP URL \(its ingest is openre\)/);
+
+ // The whole run: a new WHIP slot, its WHIP URL, FFmpeg, the session named, the stream ended.
+ const runs = [];
+ const lines = [];
+ const out = await goLive(sdk, config, { run: fakeFfmpeg(runs), log: (l) => lines.push(l), sleep: () => new Promise((r) => setTimeout(r, 1)) });
+ assert.equal(out.code, 0);
+ assert.equal(out.stream.id, 900);
+ assert.equal(out.channelUrl, 'https://openvibe.live/@robotlab');
+ assert.deepEqual(calls.find((c) => c.method === 'POST').body, { title: 'Robot cam', streaming_method: 'whip' });
+ assert.equal(runs.length, 1);
+ assert.equal(runs[0].cmd, 'ffmpeg');
+ assert.equal(runs[0].args.at(-1), `https://whip.openvibe.live/whip/${KEY}`);
+ assert.deepEqual(calls.find((c) => c.method === 'PUT').body, { title: 'Robot cam' });
+ assert.ok(calls.some((c) => c.method === 'DELETE' && c.path === '/api/streams/900'), 'the stream is ended, so nothing lingers');
+ assert.deepEqual(lines, [
+ 'Created slot 41 "Robot cam"',
+ 'Live: https://openvibe.live/@robotlab (stream 900) for 30 s',
+ 'Ended stream 900; its recording is on openvibe.video once Media finishes it',
+ ]);
+ assert.ok(!lines.join('\n').includes(KEY), 'the stream key is never printed');
+
+ // A second run reuses the slot instead of making another.
+ calls.length = 0;
+ const again = await ensureSlot(sdk, config);
+ assert.deepEqual([again.slot.id, again.created], [41, false]);
+ assert.ok(!calls.some((c) => c.method === 'POST'));
+ await assert.rejects(ensureSlot(sdk, { ...config, slotId: 77 }), /slot 77 is not one of yours/);
+
+ // FFmpeg missing: a clear error, and no stream left behind.
+ calls.length = 0;
+ const missing = () => { const c = new EventEmitter(); c.kill = () => {}; setImmediate(() => c.emit('error', new Error('spawn ffmpeg ENOENT'))); return c; };
+ await assert.rejects(goLive(sdk, config, { run: missing, log: () => {}, sleep: () => Promise.resolve() }), /could not start ffmpeg: spawn ffmpeg ENOENT/);
+ assert.ok(!calls.some((c) => c.method === 'PUT' || c.method === 'DELETE'));
+
+ console.log('go-live smoke: slot, WHIP URL, FFmpeg arguments, the live session named and ended, key never printed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -66,6 +66,17 @@
@@ -66,6 +66,17 @@
"node": ">=22"
"node": ">=22"
}
}
},
},
+ "examples/go-live": {
+ "name": "openvibe-example-go-live",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"examples/media-uploader": {
"examples/media-uploader": {
"name": "openvibe-example-media-uploader",
"name": "openvibe-example-media-uploader",
"version": "0.2.0",
"version": "0.2.0",
@@ -275,6 +286,10 @@
@@ -275,6 +286,10 @@
"resolved": "examples/event-subscriber",
"resolved": "examples/event-subscriber",
"link": true
"link": true
},
},
+ "node_modules/openvibe-example-go-live": {
+ "resolved": "examples/go-live",
+ "link": true
+ },
"node_modules/openvibe-example-media-uploader": {
"node_modules/openvibe-example-media-uploader": {
"resolved": "examples/media-uploader",
"resolved": "examples/media-uploader",
"link": true
"link": true
@@ -11,13 +11,13 @@ const fs = require('node:fs');
@@ -11,13 +11,13 @@ const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
+const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
// The pin every example must share: the repository's own (the pin bot moves them all at once).
// The pin every example must share: the repository's own (the pin bot moves them all at once).
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
-assert.deepEqual(dirs, EXPECTED, 'the nine charter examples and ai-chat');
+assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat and go-live');
function sources(dir) {
function sources(dir) {
const out = [];
const out = [];