Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RTSP streams support #169

Merged
merged 8 commits into from
May 9, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,27 @@ roslaunch web_video_server web_video_server.launch
roslaunch rosbridge_server rosbridge_websocket.launch
```

### Audio
## System dependencies

To view RTSP streams your system requires FFmpeg to be installed.

### On Windows

With Chocolatey:

`choco install ffmpeg`

### On Linux (Debian)

`sudo apt install ffmpeg`

### On Mac

With homebrew:

`brew install ffmpeg`

## Audio

Currently, the audio IO is handled by the [capra_audio](https://github.com/clubcapra/capra_audio_common) node. The UI will simply launch the node as a child process. This only works on linux. Windows support should work with wsl but is not currently implemented. For audio to work, you also need to make sure that the capra_audio launch files are sourced in the terminal you are using to launch the UI.

Expand Down
30 changes: 28 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"extends": "electron-snowpack/config/electron-builder.js"
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5",
"@reduxjs/toolkit": "^1.8.0",
"@xstate/react": "^1.6.3",
"chalk": "^5.0.1",
Expand All @@ -55,6 +56,7 @@
"execa": "^6.1.0",
"lodash": "^4.17.21",
"nanoid": "^3.3.1",
"node-rtsp-stream": "^0.0.9",
"polished": "^4.1.4",
"qr-scanner": "^1.4.1",
"react": "^17.0.2",
Expand Down
25 changes: 25 additions & 0 deletions script/rtspServer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const Stream = require('node-rtsp-stream');

// Get node args
const args = process.argv.slice(2);

GLDuval marked this conversation as resolved.
Show resolved Hide resolved
// Check if required arguments are present
if (args.length < 2) {
const filename = __filename.split('/').pop();
// eslint-disable-next-line no-console
console.error(
`Missing Arguments! Usage: node ${filename} <rtsp-url> <websocket-port>`
);
process.exit(1);
}

new Stream({
name: 'RTSP-Stream',
streamUrl: args[0],
wsPort: parseInt(args[1]),
ffmpegOptions: {
// options ffmpeg flags
'-r': 30,
'-q': 0, // quality video in scale [0, 32]
},
});
3 changes: 2 additions & 1 deletion snowpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module.exports = {
extends: 'electron-snowpack/config/snowpack.js',
mount: {
'src/shared': '/shared',
script: '/script',
},
alias: {
'@/': './src/',
Expand All @@ -12,4 +13,4 @@ module.exports = {
packageOptions: {
knownEntrypoints: ['date-fns/fp/format', 'react-is'],
},
}
};
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '@/main/rtspServer';
import '@/main/audio';
import { log } from '@/main/logger';
import { APP_INFO_QUERY, APP_INFO_TYPE, AUDIO_STOP } from '@/main/preload';
Expand Down
3 changes: 3 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export type AUDIO_MSG_TYPE = {
stdout: string;
};

export const RTSP_START = 'rtsp_start';
export const RTSP_STOP = 'rtsp_stop';

if (contextBridge) {
// This is done this way because otherwise tests would try to import preload and it wouldn't work
// It's possible the issue is mostly in the import order.
Expand Down
6 changes: 6 additions & 0 deletions src/main/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
AUDIO_STOP,
LOG_MSG,
LOG_MSG_TYPE,
RTSP_START,
RTSP_STOP,
} from '@/main/preload';
import { ipcRenderer } from 'electron';

Expand All @@ -29,4 +31,8 @@ export const preload = {
ipcRenderer.on(AUDIO_MSG, (_event, args) => cb(args as AUDIO_MSG_TYPE));
},
},
rtsp: {
start: (url: string) => ipcRenderer.invoke(RTSP_START, url),
stop: (port: number) => ipcRenderer.send(RTSP_STOP, port),
},
};
46 changes: 46 additions & 0 deletions src/main/rtspServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { log } from '@/main/logger';
import { app, ipcMain } from 'electron';
import { ExecaChildProcess, execa } from 'execa';
import path from 'path';
import { isDev } from '@/main/isDev';
import { RTSP_START, RTSP_STOP } from './preload';

interface RtspProcess {
process: ExecaChildProcess;
wsPort: number;
}

const rtspServers: Map<number, RtspProcess> = new Map();

// Stack array of ports from (9000 to 9060) to use for rtsp servers
const ports = Array.from({ length: 61 }, (_, i) => i + 9000);

ipcMain.handle(RTSP_START, (_, url: string) => {
const nextPort = ports.shift() ?? 9000;
const process = execa('node', [
isDev
? './script/rtspServer.js'
: path.join(app.getAppPath(), '../renderer/script/rtspServer.js'),
url,
nextPort.toString(),
]);

rtspServers.set(nextPort, {
process,
wsPort: nextPort,
});

return nextPort;
});

ipcMain.on(RTSP_STOP, (_, port: number) => {
const rtspProcess = rtspServers.get(port);

if (!rtspProcess) {
return;
}
log.info('stopping rtsp process');
ports.push(rtspProcess.wsPort);
rtspProcess.process.kill();
rtspServers.delete(port);
});
9 changes: 9 additions & 0 deletions src/renderer/components/Feed/Feeds/CameraFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as React from 'react';
import { FC, useEffect, useRef, useState } from 'react';
import { log } from '@/renderer/logger';
import { QRFeed } from './QRFeed/QRFeed';
import { RTSPFeed } from './RTSPFeed';

interface Props {
feed: ICameraFeed;
Expand Down Expand Up @@ -145,6 +146,14 @@ const View: FC<Props> = ({ feed }) => {
/>
</QRFeed>
);
case CameraType.RTSP:
return (
<RTSPFeed
url={feed.camera.topic}
flipped={feed.camera.flipped}
rotated={feed.camera.rotated}
/>
);
default:
return <TextFeed text="stream type not supported" />;
}
Expand Down
71 changes: 71 additions & 0 deletions src/renderer/components/Feed/Feeds/RTSPFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useEffect, useRef } from 'react';
import JSMpeg from '@cycjimmy/jsmpeg-player';
import { log } from '@/renderer/logger';

interface Props {
url: string;
flipped: boolean;
rotated: boolean;
}

export const RTSPFeed = ({ url, flipped, rotated }: Props) => {
const videoRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
const videoWrapper = videoRef.current;
const canvas = canvasRef.current;
let videoElement: JSMpeg.VideoElement | null = null;
let port: number | null = null;
const startServer = async () => {
port = (await window.preloadApi.rtsp.start(url)) as number;

if (videoWrapper && canvas) {
log.info(`RTSP server started on port ${port}`);
videoElement = new JSMpeg.VideoElement(
videoWrapper,
`ws://localhost:${port}`,
{
canvas,
autoplay: true,
audio: false,
},
{ videoBufferSize: 2048 * 2048 }
);
}
};

startServer().catch((e) => log.error(e));

return () => {
if (videoElement && port) {
log.info(`Stopping RTSP server for ${url}`);
window.preloadApi.rtsp.stop(port);
videoElement.destroy();
}
};
}, [url]);

return (
<div
ref={videoRef}
style={{
height: '100%',
objectFit: 'contain',
overflow: 'hidden',
transform: `${flipped ? 'scaleX(-1)' : ''} ${
rotated ? 'rotate(180deg)' : ''
}`,
}}
>
<canvas
ref={canvasRef}
style={{
height: '100%',
objectFit: 'contain',
overflow: 'hidden',
}}
/>
</div>
);
};
1 change: 1 addition & 0 deletions src/renderer/store/modules/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export enum CameraType {
VP8 = 'vp8',
WEBCAM = 'webcam',
QR_CODE = 'qr_code',
RTSP = 'rtsp',
}

export type FeedType =
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/types/jsmpeg.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
declare module '@cycjimmy/jsmpeg-player' {
class VideoElement {
constructor(
videoWrapper: HTMLDivElement,
url: string,
options: {
canvas?: HTMLCanvasElement;
autoplay?: boolean;
audio?: boolean;
autoSetWrapperSize?: boolean;
},
overlayOptions?: {
videoBufferSize?: number;
}
);
paused: boolean;
destroy(): void;
}
}
Loading