From da14a2e059c6da71cdaa4c7dcca53c85f742cd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=B6ring?= <simon.doering@stud.hs-bochum.de> Date: Fri, 5 Feb 2021 19:04:04 +0100 Subject: [PATCH] Add bitrate limiting by controller to server For this the sender side is also changed so it sends its session and videoroom id while sending its feed id. --- README.md | 1 + .../io-interface/handlers/input-handlers.ts | 48 +++++- camera-server/src/janus/handlers.ts | 33 +++++ .../src/socket-io/handlers/sender-handlers.ts | 138 +++++++++++++++++- camera-server/src/state/camera-slot-state.ts | 18 ++- sender/camera-sender.js | 10 +- 6 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 camera-server/src/janus/handlers.ts diff --git a/README.md b/README.md index c464599..d31868a 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 216d115..aeab520 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 0000000..0cbc889 --- /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 2c18aba..e13ab46 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 e923eb4..07b407e 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 0a8e960..aedfa9a 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; } -- GitLab