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)

OP OpenVibers committed 22 days ago · cde649f2b7 · 9 files, +402 −5

Changes

ddiff --git a/README.md b/README.md
iindex a0705c8..07de708 100644
---- a/README.md
++++ b/README.md
@@@ -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 on
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
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.
| [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)
ddiff --git a/STATUS.json b/STATUS.json
iindex 287c934..74945dd 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/examples/go-live/.env.example b/examples/go-live/.env.example
nnew file mode 100644
iindex 0000000..f964eb6
---- /dev/null
++++ b/examples/go-live/.env.example
@@@ -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)
ddiff --git a/examples/go-live/README.md b/examples/go-live/README.md
nnew file mode 100644
iindex 0000000..f0949e6
---- /dev/null
++++ b/examples/go-live/README.md
@@@ -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).
ddiff --git a/examples/go-live/go-live.js b/examples/go-live/go-live.js
nnew file mode 100644
iindex 0000000..e2dcc07
---- /dev/null
++++ b/examples/go-live/go-live.js
@@@ -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); });
++}
ddiff --git a/examples/go-live/package.json b/examples/go-live/package.json
nnew file mode 100644
iindex 0000000..c5d684c
---- /dev/null
++++ b/examples/go-live/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/go-live/test/smoke.test.js b/examples/go-live/test/smoke.test.js
nnew file mode 100644
iindex 0000000..8b0764a
---- /dev/null
++++ b/examples/go-live/test/smoke.test.js
@@@ -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); });
ddiff --git a/package-lock.json b/package-lock.json
iindex 47f58dd..e3d03c6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex 3de207e..29c60a4 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -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 = [];