diff --git a/README.md b/README.md index c46459949aa61be61a46dad982e78d385100e6bc..d31868a35eec1f574c01dc29724710afe22d6a16 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ This is the list of the available commands: | `deactivate_slot` | Deactivates a slot. Also ensures that the feed is removed for the receivers. <br/><br/> **Usage**: `deactivate_slot <slot>` | `set_annotation` | Sets the annotation of a slot. A simple use case would be displaying the user's name below his feed. <br/><br/> **Usage**: `set_annotation <slot> <annotation>` <br/><br/> `annotation` can be any HTML snippet which will annotate the camera feed of the slot. This is done by appending the snippet to the container which contains the video element of the feed. <br/> If this example snippet is provided, the text *Hello noVNC* will be displayed at the bottom of the feed container: `<div style="box-sizing: border-box; position: absolute; bottom: 0; width: 100%; background: rgba(0, 0, 0, 0.75); color: white; text-align: center; padding: 4px;">Hello noVNC</div>`. <br/><br/> **Important**: The HTML snippet has to have only one parent element. The container uses the CSS declaration `position: fixed`. Thus, working with `position: absolute` is possible and definitely advised. | `remove_annotation` | Removes the annotation of a slot. <br/><br/> **Usage**: `remove_annotation <slot>` +| `set_bitrate_limit` | Sets the bitrate limit for the camera feed transmission of the provided slot. This can be useful to save traffic when the camera feed is only displayed relatively small on the receiver side. Setting a limit of 0 removes the limit. Note that the initial controller bitrate will be equal to the one mentioned in the config file. When deactivating a slot, the controller bitrate is set to its initial value. <br/><br/> **Usage**: `set_bitrate_limit <slot> <bitrate>` ### Camera Control Commands diff --git a/camera-server/src/io-interface/handlers/input-handlers.ts b/camera-server/src/io-interface/handlers/input-handlers.ts index 216d115e5fe548a97e2bec97b58a1c2b391091aa..aeab520ccbc2bf68f76d45e367ed0113bcc170d3 100644 --- a/camera-server/src/io-interface/handlers/input-handlers.ts +++ b/camera-server/src/io-interface/handlers/input-handlers.ts @@ -5,6 +5,9 @@ import { emitSetAnnotation, emitRemoveAnnotation } from '../../socket-io/handlers/common-handlers'; +import { emitControllerBitrateLimit } from '../../socket-io/handlers/sender-handlers'; +import { setBitrate } from '../../janus/handlers'; +import { config } from '../../config/config'; const visibilityCommands = ['hide', 'show']; const geometryCommands = [ @@ -16,7 +19,8 @@ const internalCommands = [ 'deactivate_slot', 'refresh_token', 'set_annotation', - 'remove_annotation' + 'remove_annotation', + 'set_bitrate_limit' ]; const setAnnotation = (slot: number, annotation: string) => { @@ -67,6 +71,7 @@ const handleInternalCommand = ( currentCameraState.feedId = null; currentCameraState.senderSocketId = null; currentCameraState.annotation = null; + currentCameraState.controllerBitrateLimit = config.janusBitrate; break; case 'refresh_token': if (!currentCameraState.slotActive) { @@ -102,6 +107,47 @@ const handleInternalCommand = ( if (currentCameraState.feedActive) { emitRemoveAnnotation(slot); } + break; + case 'set_bitrate_limit': + if (!currentCameraState.slotActive) { + console.log( + `Error: Tried to set controller bitrate limit for slot ${slot} which is not activated` + ); + return; + } + + if (params.length === 0) { + console.log( + `Error: Tried to set controller bitrate limit for slot ${slot} without providing one` + ); + return; + } + + const bitrateLimit = parseInt(params[0]); + if (isNaN(bitrateLimit)) { + console.log( + `Error: Tried to set controller bitrate limit for slot ${slot} with a non-numeric bitrate (${params[0]})` + ); + return; + } + + const prevBitrate = currentCameraState.getCurrentBitrate(); + currentCameraState.controllerBitrateLimit = bitrateLimit; + + if (currentCameraState.feedActive) { + emitControllerBitrateLimit( + currentCameraState.senderSocketId!, + bitrateLimit + ); + + // Can only update bitrate of a specific feed + // Janus doesn't know about the concept of slots + const newBitrate = currentCameraState.getCurrentBitrate(); + if (prevBitrate !== newBitrate) { + setBitrate(slot, newBitrate); + } + } + break; default: console.log( diff --git a/camera-server/src/janus/handlers.ts b/camera-server/src/janus/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cbc88956a2d98e9c653e355da13daffdcf358ef --- /dev/null +++ b/camera-server/src/janus/handlers.ts @@ -0,0 +1,33 @@ +import * as janusAPI from './janus-api'; +import { cameraSlotState } from '../state/camera-slot-state'; + +export const setBitrate = async ( + slot: number, + newBitrate: number +): Promise<boolean> => { + const currentSlotState = cameraSlotState[slot]; + if (!currentSlotState.feedActive) { + console.log(`Tried to set bitrate for inactive slot ${slot}`); + return false; + } + try { + const response = await janusAPI.configureVideoroomBitrate( + currentSlotState.sessionId!, + currentSlotState.videoroomId!, + newBitrate + ); + if (response.data?.janus === 'ack') { + console.log('Set new bitrate for slot ' + slot); + return true; + } else { + console.log(`Error: Could not set new bitrate for slot ${slot}`); + return false; + } + } catch (err) { + console.log( + `Error: An unknown error occurred while setting the bitrate of slot ${slot}:`, + err + ); + return false; + } +}; diff --git a/camera-server/src/socket-io/handlers/sender-handlers.ts b/camera-server/src/socket-io/handlers/sender-handlers.ts index 2c18abaa8de840f4c2cad50fb4834d24f5219740..e13ab469d3681af6e8a9bc2c47d8c911f6d745ff 100644 --- a/camera-server/src/socket-io/handlers/sender-handlers.ts +++ b/camera-server/src/socket-io/handlers/sender-handlers.ts @@ -4,10 +4,27 @@ import { SenderSocket } from '../../models/sender-socket'; import { ValidationError } from '../../models/validation-error'; import { notifyCustomName } from '../../io-interface/handlers/output-handlers'; import { escapeHTML } from '../../util/escape-html'; +import { setBitrate } from '../../janus/handlers'; +import { socketIO } from '../socket-io'; +import { config } from '../../config/config'; + +export const emitControllerBitrateLimit = ( + socketId: string, + bitrateLimit: number +) => { + socketIO + .to(socketId) + .emit('new_controller_bitrate_limit', { bitrateLimit }); +}; const handleSetFeedId = ( socket: SenderSocket, - data: null | { feedId?: string; customName?: string }, + data: null | { + feedId?: string; + sessionId?: number; + videoroomId?: number; + customName?: string; + }, fn: Function ) => { let success = true; @@ -58,6 +75,36 @@ const handleSetFeedId = ( throw new ValidationError('No feed id was provided'); } + const sessionId = data.sessionId; + if (sessionId == null) { + console.log( + 'Error: Got set_feed_id without a session id on slot ' + slot + ); + throw new ValidationError('No session id was provided'); + } + if (typeof sessionId !== 'number') { + console.log( + 'Error: Got set_feed_id event with a non-numeric session id on slot ' + + slot + ); + throw new ValidationError('Session id has to be a number'); + } + + const videoroomId = data.videoroomId; + if (videoroomId == null) { + console.log( + 'Error: Got set_feed_id event with no handle id on slot ' + slot + ); + throw new ValidationError('No handle id was provided'); + } + if (typeof videoroomId !== 'number') { + console.log( + 'Error: Got set_feed_id event with a non-numeric handle id on slot ' + + slot + ); + throw new ValidationError('Handle id has to be a number'); + } + let unescapedCustomName = data.customName; if (unescapedCustomName != null) { unescapedCustomName = unescapedCustomName.trim(); @@ -86,8 +133,16 @@ const handleSetFeedId = ( currentSlotState.feedActive = true; currentSlotState.feedId = feedId; currentSlotState.senderSocketId = socket.id; + currentSlotState.sessionId = sessionId; + currentSlotState.videoroomId = videoroomId; emitNewFeed(slot); + + // Controller set bitrate before feed is sent + // => bitrate of the transmitted feed has to be adjusted + if (currentSlotState.controllerBitrateLimit !== config.janusBitrate) { + setBitrate(slot, currentSlotState.controllerBitrateLimit); + } } catch (e) { if (e instanceof ValidationError) { success = false; @@ -99,6 +154,7 @@ const handleSetFeedId = ( fn({ success, message }); }; + const handleChangeName = ( socket: SenderSocket, data: null | { newName?: string } @@ -157,7 +213,81 @@ const handleChangeName = ( } }; -const handleSenderDisconnect = (socket: SenderSocket, _: string) => { +const handleSetBitrateLimit = async ( + socket: SenderSocket, + data: null | { bitrateLimit?: number }, + fn: Function +) => { + let success = true; + let message = ''; + + try { + const slot = socket.cameraSlot; + const currentSlotState = cameraSlotState[slot]; + + if (!currentSlotState.feedActive) { + console.log( + `Error: Got set_bitrate_limit event on slot ${slot} which has no active feed` + ); + throw new ValidationError( + 'There is no camera feed being transmitted' + ); + } + + if (socket.id !== currentSlotState.senderSocketId) { + console.log( + `Error: Got set_bitrate_limit event on slot ${slot} from somebody who is not the sender` + ); + throw new ValidationError('You are not the sender of this slot'); + } + + if (data == null) { + console.log( + `Error: Got set_bitrate_limit event on slot ${slot} without data` + ); + throw new ValidationError('Got no data in the request'); + } + + let { bitrateLimit } = data; + if (bitrateLimit == null) { + console.log( + `Error: Got set_bitrate_limit event on slot ${slot} without bitrate limit` + ); + throw new ValidationError('Got no bitrate limit in the request'); + } + if (typeof bitrateLimit !== 'number') { + console.log( + `Error: Got set_bitrate_limit event on slot ${slot} with a non-numeric bitrate (${bitrateLimit})` + ); + throw new ValidationError('The provided bitrate is not a number'); + } + + if (bitrateLimit < 0) { + bitrateLimit = 0; + } + + const prevBitrate = currentSlotState.getCurrentBitrate(); + currentSlotState.userBitrateLimit = bitrateLimit; + const newBitrate = currentSlotState.getCurrentBitrate(); + + message = 'Your bitrate limit was updated'; + + if (prevBitrate !== newBitrate) { + setBitrate(slot, newBitrate); + } + } catch (err) { + if (err instanceof ValidationError) { + success = false; + message = err.message; + } else { + throw err; + } + } + + fn({ success, message }); +}; + +const handleSenderDisconnect = (socket: SenderSocket) => { const slot = socket.cameraSlot; const currentSlotState = cameraSlotState[slot]; if ( @@ -168,6 +298,9 @@ const handleSenderDisconnect = (socket: SenderSocket, _: string) => { currentSlotState.feedActive = false; currentSlotState.feedId = null; currentSlotState.senderSocketId = null; + currentSlotState.sessionId = null; + currentSlotState.videoroomId = null; + currentSlotState.userBitrateLimit = 0; emitRemoveFeed(slot); } @@ -176,6 +309,7 @@ const handleSenderDisconnect = (socket: SenderSocket, _: string) => { const registerSenderHandlers = (socket: SenderSocket) => { socket.on('set_feed_id', handleSetFeedId.bind(null, socket)); socket.on('change_name', handleChangeName.bind(null, socket)); + socket.on('set_bitrate_limit', handleSetBitrateLimit.bind(null, socket)); socket.on('disconnect', handleSenderDisconnect.bind(null, socket)); }; diff --git a/camera-server/src/state/camera-slot-state.ts b/camera-server/src/state/camera-slot-state.ts index e923eb4131ec440490137fad802d145112c5a628..07b407efe055922d0b902735993732a6a5c717a9 100644 --- a/camera-server/src/state/camera-slot-state.ts +++ b/camera-server/src/state/camera-slot-state.ts @@ -2,6 +2,7 @@ import { config } from '../config/config'; import { CommandDescriptor } from '../models/command-descriptor'; type NullableString = string | null; +type NullableNumber = number | null; class SingleCameraSlotState { slotActive = false; @@ -9,6 +10,10 @@ class SingleCameraSlotState { feedActive = false; feedId: NullableString = null; senderSocketId: NullableString = null; + sessionId: NullableNumber = null; + videoroomId: NullableNumber = null; + controllerBitrateLimit = config.janusBitrate; + userBitrateLimit = 0; annotation: NullableString = null; visibility: CommandDescriptor = { command: 'show', @@ -16,8 +21,19 @@ class SingleCameraSlotState { }; geometry: CommandDescriptor = { command: 'set_geometry_relative_to_canvas', - params: ['rb', '0', '0', '200', '200'] + params: ['rb', '0', '0', '320', '240'] }; + + getCurrentBitrate(): number { + if ( + this.controllerBitrateLimit === 0 || + (this.userBitrateLimit > 0 && + this.userBitrateLimit < this.controllerBitrateLimit) + ) { + return this.userBitrateLimit; + } + return this.controllerBitrateLimit; + } } const cameraSlotState: SingleCameraSlotState[] = []; diff --git a/sender/camera-sender.js b/sender/camera-sender.js index 0a8e960a80e0097456c8a4dc6c497b802a1ed4df..aedfa9aeea936c449937ff41ca96581f51b3f49b 100644 --- a/sender/camera-sender.js +++ b/sender/camera-sender.js @@ -125,14 +125,16 @@ document.addEventListener('DOMContentLoaded', function() { return; } + janus = new Janus({ server: server, success: function() { + Janus.log('Janus instance created with session id ' + janus.getSessionId()); janus.attach({ plugin: 'janus.plugin.videoroom', success: function(pluginHandle) { videoroomHandle = pluginHandle; - Janus.log('Plugin attached! (' + videoroomHandle.getPlugin() + ', id=' + videoroomHandle.getId() + ')'); + Janus.log('Plugin attached! (' + videoroomHandle.getPlugin() + ', id=' + videoroomHandle.getId() + ')'); hideSpinner(); showInputs(); @@ -160,7 +162,11 @@ document.addEventListener('DOMContentLoaded', function() { }, webrtcState: function(on) { if (on) { - var data = { feedId }; + var data = { + feedId, + sessionId: janus.getSessionId(), + videoroomId: videoroomHandle.getId() + }; if (customNameAllowed) { data.customName = document.getElementById('name-input').value; }