Commit 19dd4d38 authored by schneefux's avatar schneefux

Remove live server, conversion and xvfb

parent 5a277af6
Pipeline #42 failed with stages
# bbb-recorder
Bigbluebutton recordings export to `webm` or `mp4` & live broadcasting. This is an example how I have implemented BBB recordings to distibutable file.
Create a backup of a Big Blue Button recording for personal use.
1. Videos will be copy to `/var/www/bigbluebutton-default/record`
3. Can be converted to `mp4`. Default `webm`
2. Specify bitrate to control quality of the exported video by adjusting `videoBitsPerSecond` property in `background.js`
### Dependencies
1. xvfb (`apt install xvfb`)
2. Google Chrome stable
3. npm modules listed in package.json
4. Everything inside `dependencies_check.sh` (run `./dependencies_check.sh` to install all)
The latest Google Chrome stable build should be use.
```sh
curl -sS -o - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list
apt-get -y update
apt-get -y install google-chrome-stable
```
FFmpeg (if not installed already & have plan for mp4 or RTMP)
```sh
sudo add-apt-repository ppa:jonathonf/ffmpeg-4
sudo apt-get update
sudo apt-get install ffmpeg
```
### Usage
Clone the project first:
```javascript
git clone https://github.com/jibon57/bbb-recorder
cd bbb-recorder
npm install --ignore-scripts
```
### Recording export
```sh
node export.js "https://BBB_HOST/playback/presentation/2.0/playback.html?meetingId=MEETING_ID" meeting.webm 10 true
```
**Options**
You can pass 4 args
1) BBB recording link
2) Export file name. Should be `.webm` at end
3) Duration of recording in seconds. Default 10 seconds
4) Convert to mp4 or not (true for convert to mp4). Default false
### Live recording
You can also use `liveJoin.js` to live join meeting as a recorder & perform recording like this:
```sh
node liveJoin.js "https://BBB_HOST/bigbluebutton/api/join?meetingId=MEETING_ID...." liveRecord.webm 0 true
```
Here `0` mean no limit. Recording will auto stop after meeting end or kickout of recorder user. You can also set time limit like this:
```sh
node liveJoin.js "https://BBB_HOST/bigbluebutton/api/join?meetingId=MEETING_ID...." liveRecord.webm 60 true
```
### Live RTMP broadcasting (Experimental)
Sometime you may want to broadcast meeting via RTMP. I did some experiment on it & got success but not 100%. To test you can use `ffmpegServer.js` to run websocket server & `liveRTMP.js` to join the meeting. You'll have to edit `rtmpUrl` & `ffmpegServer` info inside `config.json` file (if need).
1) First run websocket server by `node ffmpegServer.js`
2) Then in another terminal tab
```sh
node liveRTMP.js "https://BBB_HOST/bigbluebutton/api/join?meetingId=MEETING_ID...."
```
You can also set duration otherwise it will close after meeting end or kickout:
```sh
node liveRTMP.js "https://BBB_HOST/bigbluebutton/api/join?meetingId=MEETING_ID...." 20
```
Check the process of websocket server, `ffmpeg` should start sending data to RTMP server.
**Note:**
If presenter do nothing in the meeting room that time `ffmpeg` may exit with error & will try to reconnect again. So, it's recommend from me to keep webcam on. Actually I don't have much experience on `ffmpeg` to resolve those problems. Please contribute your experience.
### How it will work?
When you will run the command that time `Chrome` browser will be open in background & visit the link to perform screen recording. So, if you have set 10 seconds then it will record 10 seconds only. Later it will give you file as webm or mp4.
**Note: It will use extra CPU to process chrome & ffmpeg.**
## Looking for Bigbluebutton shared hosting?
We are offering cheaper [Bigbluebutton shared hosting](https://www.mynaparrot.com/classroom) or Bigbluebutton installation/configuration/loadbalance service. You can send me email jibon[@]mynaparrot.com
### Thanks to
[puppetcam](https://github.com/muralikg/puppetcam). Most of the parts were copied from there.
[Canvas-Streaming-Example](https://github.com/fbsamples/Canvas-Streaming-Example)
* install Chromium
* `npm install`
* `npm run start https://recording-url.example.test filename.webm`
* recording will be stored in `~/Downloads/filename.webm`
......@@ -2,37 +2,21 @@
let recorder = null;
let filename = null;
let ws;
let liveSteam = false;
let ffmpegServer;
let doDownload = true;
chrome.runtime.onConnect.addListener(port => {
port.onMessage.addListener(msg => {
console.log(msg);
console.log('background script received message', msg);
switch (msg.type) {
case 'SET_EXPORT_PATH':
filename = msg.filename
break
case 'FFMPEG_SERVER':
ffmpegServer = msg.ffmpegServer
startWebsock();
break
case 'REC_STOP':
doDownload = true;
recorder.stop()
break
case 'REC_START':
if (liveSteam) {
recorder.start(1000);
} else {
recorder.start();
}
recorder.start();
break
case 'REC_CLIENT_PLAY':
......@@ -65,42 +49,24 @@ chrome.runtime.onConnect.addListener(port => {
recorder = new MediaRecorder(stream, {
videoBitsPerSecond: 2500000,
ignoreMutedMedia: true,
mimeType: 'video/webm;codecs=h264'
mimeType: 'video/webm'
});
recorder.ondataavailable = function (event) {
if (event.data.size > 0) {
chunks.push(event.data);
if (liveSteam) {
ws.send(event.data);
}
}
};
recorder.onstop = function () {
if (liveSteam) {
ws.close();
}
if(!doDownload){
chunks = [];
return;
}
var superBuffer = new Blob(chunks, {
type: 'video/webm'
});
var url = URL.createObjectURL(superBuffer);
// var a = document.createElement('a');
// document.body.appendChild(a);
// a.style = 'display: none';
// a.href = url;
// a.download = 'test.webm';
// a.click();
chrome.downloads.download({
url: url,
filename: filename
url,
filename,
}, () => {
});
}
......@@ -113,6 +79,7 @@ chrome.runtime.onConnect.addListener(port => {
}
})
chrome.downloads.onChanged.addListener(function (delta) {
if (!delta.state || (delta.state.current != 'complete')) {
return;
......@@ -122,26 +89,5 @@ chrome.runtime.onConnect.addListener(port => {
}
catch (e) { }
});
})
function startWebsock() {
ws = new WebSocket(ffmpegServer);
liveSteam = true;
ws.onmessage = function (e) {
console.log(e.data);
if (e.data == "ffmpegClosed") {
doDownload = false;
recorder.stop();
setTimeout(function () {
startWebsock();
recorder.start(1000);
}, 500)
}
}
}
{
"rtmpUrl": "rtmp://a.rtmp.youtube.com/live2/MyKey",
"ffmpegServer": "ws://localhost",
"ffmpegServerPort": 4000,
"auth": "mZFZN4yc"
}
......@@ -4,16 +4,20 @@ window.onload = () => {
// Setup message passing
const port = chrome.runtime.connect(chrome.runtime.id)
port.onMessage.addListener(msg => window.postMessage(msg, '*'))
port.onMessage.addListener(msg => {
console.log('content script received message from puppeteer', msg);
window.postMessage(msg, '*');
})
window.addEventListener('message', event => {
// Relay client messages
if (event.source === window && event.data.type) {
console.log('content script received message from client', JSON.stringify(event.data));
port.postMessage(event.data)
}
if(event.data.type === 'PLAYBACK_COMPLETE'){
if (event.data.type === 'PLAYBACK_COMPLETE'){
port.postMessage({ type: 'REC_STOP' }, '*')
}
if(event.data.downloadComplete){
if (event.data.downloadComplete){
document.querySelector('html').classList.add('downloadComplete')
}
})
......
#!/bin/bash
packages="gconf-service
libasound2
libatk1.0-0
libc6
libcairo2
libcups2
libdbus-1-3
libexpat1
libfontconfig1
libgcc1
libgconf-2-4
libgdk-pixbuf2.0-0
libglib2.0-0
libgtk-3-0
libnspr4
libpango-1.0-0
libpangocairo-1.0-0
libstdc++6
libx11-6
libx11-xcb1
libxcb1
libxcomposite1
libxcursor1
libxdamage1
libxext6
libxfixes3
libxi6
libxrandr2
libxrender1
libxss1
libxtst6
ca-certificates
fonts-liberation
libappindicator1
libnss3
lsb-release
xdg-utils
wget
xvfb
fonts-noto"
declare -a neededPackages
for packageName in $packages; do
if ! dpkg-query -l "$packageName" > /dev/null 2>&1; then
neededPackages[${#neededPackages[@]}]="$packageName"
fi
done
neededCount=${#neededPackages[@]}
if [[ $neededCount -gt 0 ]]; then
echo "-----------------------------------------------------"
echo "Run the following to get all of the required packages"
echo "-----------------------------------------------------"
echo "sudo apt install \\"
for i in "${neededPackages[@]}"; do
output="$i"
if [[ ${neededPackages[@]: -1 } != "$i" ]]; then
output+=" \\"
fi
echo "$output"
done
echo "-----------------------------------------------------"
fi
exit 0
\ No newline at end of file
const puppeteer = require('puppeteer');
const Xvfb = require('xvfb');
var exec = require('child_process').exec;
const fs = require('fs');
const os = require('os');
const homedir = os.homedir();
const platform = os.platform();
var xvfb = new Xvfb({
silent: true,
xvfb_args: ["-screen", "0", "1280x800x24", "-ac", "-nolisten", "tcp", "-dpi", "96", "+extension", "RANDR"]
});
var width = 1280;
var height = 720;
var options = {
headless: false,
args: [
'--enable-usermedia-screen-capturing',
'--allow-http-screen-capture',
'--auto-select-desktop-capture-source=bbbrecorder',
'--load-extension=' + __dirname,
'--disable-extensions-except=' + __dirname,
'--disable-infobars',
'--no-sandbox',
'--shm-size=1gb',
'--disable-dev-shm-usage',
`--window-size=${width},${height}`,
],
}
if(platform == "linux"){
options.executablePath = "/usr/bin/google-chrome"
}else if(platform == "darwin"){
options.executablePath = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
}
async function main() {
try{
if(platform == "linux"){
xvfb.startSync()
}
var url = process.argv[2],
exportname = process.argv[3],
duration = process.argv[4],
convert = process.argv[5]
if(!url){ url = 'https://www.mynaparrot.com/' }
if(!exportname){ exportname = 'export.webm' }
if(!duration){ duration = 10 }
if(!convert){ convert = false }
const browser = await puppeteer.launch(options)
const pages = await browser.pages()
const page = pages[0]
page.on('console', msg => {
var m = msg.text();
console.log('PAGE LOG:', m)
});
await page._client.send('Emulation.clearDeviceMetricsOverride')
await page.goto(url, {waitUntil: 'networkidle2'})
await page.setBypassCSP(true)
await page.waitForSelector('button[class=acorn-play-button]');
await page.$eval('#navbar', element => element.style.display = "none");
await page.$eval('#copyright', element => element.style.display = "none");
await page.$eval('.acorn-controls', element => element.style.display = "none");
await page.click('video[id=video]', {waitUntil: 'domcontentloaded'});
await page.evaluate((x) => {
console.log("REC_START");
window.postMessage({type: 'REC_START'}, '*')
})
// Perform any actions that have to be captured in the exported video
await page.waitFor((duration * 1000))
await page.evaluate(filename=>{
window.postMessage({type: 'SET_EXPORT_PATH', filename: filename}, '*')
window.postMessage({type: 'REC_STOP'}, '*')
}, exportname)
// Wait for download of webm to complete
await page.waitForSelector('html.downloadComplete', {timeout: 0})
await page.close()
await browser.close()
if(platform == "linux"){
xvfb.stopSync()
}
if(convert){
convertAndCopy(exportname)
}else{
copyOnly(exportname)
}
}catch(err) {
console.log(err)
}
}
main()
function convertAndCopy(filename){
var copyFromPath = homedir + "/Downloads";
var copyToPath = "/var/www/bigbluebutton-default/record";
var onlyfileName = filename.split(".webm")
var mp4File = onlyfileName[0] + ".mp4"
var copyFrom = copyFromPath + "/" + filename + ""
var copyTo = copyToPath + "/" + mp4File;
if(!fs.existsSync(copyToPath)){
fs.mkdirSync(copyToPath);
}
console.log(copyTo);
console.log(copyFrom);
var cmd = "ffmpeg -y -i '" + copyFrom + "' -c:v libx264 -preset veryfast -movflags faststart -profile:v high -level 4.2 -max_muxing_queue_size 9999 -vf mpdecimate -vsync vfr '" + copyTo + "'";
console.log("converting using: " + cmd);
exec(cmd, function(err, stdout, stderr) {
if (err) console.log('err:\n' + err);
//if (stderr) console.log('stderr:\n' + stderr);
if(!err){
console.log("Now deleting " + copyFrom)
try {
fs.unlinkSync(copyFrom);
console.log('successfully deleted ' + copyFrom);
} catch (err) {
console.log(err)
}
}
});
}
function copyOnly(filename){
var copyFrom = homedir + "/Downloads/" + filename;
var copyToPath = "/var/www/bigbluebutton-default/record";
var copyTo = copyToPath + "/" + filename;
if(!fs.existsSync(copyToPath)){
fs.mkdirSync(copyToPath);
}
try {
fs.copyFileSync(copyFrom, copyTo)
console.log('successfully copied ' + copyTo);
fs.unlinkSync(copyFrom);
console.log('successfully delete ' + copyFrom);
} catch (err) {
console.log(err)
}
}
const child_process = require('child_process');
const WebSocketServer = require('ws').Server;
const http = require('http');
const fs = require('fs');
var config = JSON.parse(fs.readFileSync("config.json", 'utf8'));
const server = http.createServer().listen(config.ffmpegServerPort, () => {
console.log('Listening...');
});
const wss = new WebSocketServer({
server: server
});
const rtmpUrl = config.rtmpUrl;
wss.on('connection', function connection(ws, req) {
console.log('connection');
let auth;
if ( !(auth = req.url.match(/^\/auth\/(.*)$/)) ) {
ws.terminate();
return;
}
if(auth[1] !== config.auth){
ws.terminate();
return;
}
const ffmpeg = child_process.spawn('ffmpeg', [
// FFmpeg will read input video from STDIN
'-i', '-',
// If we're encoding H.264 in-browser, we can set the video codec to 'copy'
// so that we don't waste any CPU and quality with unnecessary transcoding.
'-vcodec', 'copy',
// use if you need for smooth youtube publishing. Note: will use more CPU
//'-vcodec', 'libx264',
//'-x264-params', 'keyint=120:scenecut=0',
//No browser currently supports encoding AAC, so we must transcode the audio to AAC here on the server.
'-acodec', 'aac',
// remove background noise. You can adjust this values according to your need
'-af', 'highpass=f=200, lowpass=f=3000',
// This option sets the size of this buffer, in packets, for the matching output stream
'-max_muxing_queue_size', '99999',
// better to use veryfast or fast
'-preset', 'veryfast',
//'-vf', 'mpdecimate', '-vsync', 'vfr',
//'-vf', 'mpdecimate,setpts=N/FRAME_RATE/TB',
// FLV is the container format used in conjunction with RTMP
'-f', 'flv',
// The output RTMP URL.
// For debugging, you could set this to a filename like 'test.flv', and play
// the resulting file with VLC.
rtmpUrl
])
// If FFmpeg stops for any reason, close the WebSocket connection.
ffmpeg.on('close', (code, signal) => {
console.log('FFmpeg child process closed, code ' + code + ', signal ' + signal);
//console.log("reconnecting...")
ws.send("ffmpegClosed")
ws.terminate();
});
ffmpeg.stdin.on('error', (e) => {
console.log('FFmpeg STDIN Error', e);
});
ffmpeg.stderr.on('data', (data) => {
console.log('FFmpeg STDERR:', data.toString());
});
// When data comes in from the WebSocket, write it to FFmpeg's STDIN.
ws.on('message', (msg) => {
console.log('DATA', msg);
ffmpeg.stdin.write(msg);
});
// If the client disconnects, stop FFmpeg.
ws.on('close', (e) => {
ffmpeg.kill('SIGINT');
ws.terminate();
});
});
const puppeteer = require('puppeteer');
const Xvfb = require('xvfb');
var exec = require('child_process').exec;
const fs = require('fs');
var config = JSON.parse(fs.readFileSync("config.json", 'utf8'));
const os = require('os');
const homedir = os.homedir();
const platform = os.platform();
const ffmpegServer = config.ffmpegServer + ":" + config.ffmpegServerPort + "/auth/" + config.auth;
var xvfb = new Xvfb({
silent: true,
xvfb_args: ["-screen", "0", "1280x800x24", "-ac", "-nolisten", "tcp", "-dpi", "96", "+extension", "RANDR"]
});
var width = 1280;
var height = 720;
var options = {
var options = {
headless: false,
args: [
'--enable-usermedia-screen-capturing',
......@@ -25,28 +10,16 @@ var options = {
'--disable-extensions-except=' + __dirname,
'--disable-infobars',
'--no-sandbox',
'--shm-size=1gb',
'--disable-dev-shm-usage',
`--window-size=${width},${height}`,
`--window-size=1280,900`, // 1280x720 + infobars
],
}
if(platform == "linux"){
options.executablePath = "/usr/bin/google-chrome"
}else if(platform == "darwin"){
options.executablePath = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
}
async function main() {
try{
if(platform == "linux"){
xvfb.startSync()
}
try {
var url = process.argv[2],
duration = process.argv[3],
exportname = 'liveMeeting.webm'
filename = process.argv[3]
if(!url){ url = 'https://www.mynaparrot.com/' }
//if(!duration){ duration = 10 }
if (!filename) { filename = 'export.webm' }
const browser = await puppeteer.launch(options)
const pages = await browser.pages()
......@@ -62,53 +35,37 @@ async function main() {
await page.goto(url, {waitUntil: 'networkidle2'})
await page.setBypassCSP(true)
await page.evaluate((serverAddress) => {
console.log("FFMPEG_SERVER");
window.postMessage({type: 'FFMPEG_SERVER', ffmpegServer: serverAddress}, '*')
}, ffmpegServer)
await page.waitForSelector('[aria-label="Listen only"]');
await page.click('[aria-label="Listen only"]', {waitUntil: 'domcontentloaded'});
// wait until play button is loaded
await page.waitForSelector('button[class=acorn-play-button]');
await page.$eval('#navbar', element => element.style.display = 'none');
await page.$eval('#copyright', element => element.style.display = 'none');
await page.click('video[id=video]', { waitUntil: 'domcontentloaded' });
await page.$eval('#main-section', element => {
element.style.height = '100vh';
element.style.width = '100vw';
element.style.padding = '0';
element.style['z-index'] = '999';
});
await page.waitForSelector('[id="chat-toggle-button"]');
await page.click('[id="chat-toggle-button"]', {waitUntil: 'domcontentloaded'});
await page.click('button[aria-label="Users and messages toggle"]', {waitUntil: 'domcontentloaded'});
await page.$eval('[class^=navbar]', element => element.style.display = "none");
await page.$eval('.Toastify', element => element.style.display = "none");
await page.waitForSelector('button[aria-label="Leave audio"]');
await page.$eval('[class^=actionsbar] > [class^=center]', element => element.style.display = "none");
await page.evaluate((x) => {
console.log("REC_START");
window.postMessage({type: 'REC_START'}, '*')
})
if(duration > 0){
await page.waitFor((duration * 1000))
}else{
await page.waitForSelector('[class^=modal] > [class^=content] > button[description="Logs you out of the meeting"]', {
timeout: 0
});
}
await page.waitFor(10000);
// Wait until play button becomes pause button
await page.waitForSelector('button.acorn-paused-button', { hidden: true, timeout: 0 });
await page.evaluate(filename=>{
window.postMessage({type: 'SET_EXPORT_PATH', filename: filename}, '*')
await page.evaluate(filename => {
window.postMessage({type: 'SET_EXPORT_PATH', filename}, '*')
window.postMessage({type: 'REC_STOP'}, '*')
}, exportname)
}, filename)
// Wait for download of webm to complete
await page.waitForSelector('html.downloadComplete', {timeout: 0})
await page.waitForSelector('html.downloadComplete', { timeout: 0 })
await page.close()
await browser.close()