Skip to content

Commit

Permalink
- Add switch to use docker or direct ffmpeg call
Browse files Browse the repository at this point in the history
- Add overlay options for the stream (fullstream, or watermarks)
- Update the docker image to only need the full command, allowing more expert manipulation of the ffmpeg call
- Add default watermark image
- Add lots of more options in settings
- Rework settings to have categories and some extra help
- Start updating the documentation
  • Loading branch information
TheSin- committed Dec 31, 2021
1 parent a7fac33 commit f593c1f
Show file tree
Hide file tree
Showing 6 changed files with 415 additions and 75 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://raw.githubusercontent.com/jneilliii/Octoprint-RTMPStreamer/master/tab_screenshot.jpg">

## Prerequisites for Streaming
Follow the instructions found [here](docker_instructions.md) to install and configure docker/ffmpeg for use with this plugin for Live streaming. This is not necessary if you just want to view a url in an iframe on a tab.
Follow the instructions found [here](docker_instructions.md) to install and configure docker/ffmpeg for use with this plugin for Live streaming. This is not necessary if you just want to view a url in an iframe on a tab or if your octoprint system has ffmpeg.

## Setup
Once the prerequisites are met and the test command is successfull enter the resolution, stream url, and view url in the RTMP Streamer settings.
Expand All @@ -28,6 +28,10 @@ http://192.168.1.101/webcam/?action=stream instead of /webcam/?action=stream
## TODO:
* [X] Additional testing.
* [X] Proper error messaging for debugging (e.g. no fully qualified url provided)
* [ ] Add Dynamic Overlay settings
* [ ] Add Dynamic Overlay build function
* [ ] Add Dynamic Overlay profiles, with some pre builts as examples
* [ ] Detect is ffmpeg is available, if not require docker automatically

## Get Help

Expand Down
20 changes: 2 additions & 18 deletions docker_instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,12 @@
sudo usermod pi -aG docker
sudo reboot

**Clone Repository and Build Docker Image**

cd ~
git clone https://github.com/jneilliii/rtmpstreamer --depth 1
cd rtmpstreamer
docker build -t octoprint/rtmpstreamer .

**Test**

Run the following command replacing `<ip>`, `<stream url>`, `<stream resolution>` and `<stream framerate>` with appropriate values. For the resolution setting use the format equivalent to 640x480.

docker run --privileged --name RTMPStreamer -ti octoprint/rtmpstreamer:latest http://<ip>/webcam/?action=stream <stream resolution> <stream framerate> <stream url> null

Stream should go live and re-encode the OctoPrint stream to provided url. Once verified close ffmpeg and remove docker container.

ctrl+c
docker rm RTMPStreamer
docker pull kolisko/rpi-ffmpeg:latest

**OctoPrint Settings**

- Enter your stream url used above in the OctoPrint-RTMPStreamer plugin settings.
- Change your webcam stream url to a fully quliafied url using the ip address of your pi like

`http://192.168.1.101/webcam/?action=stream`
`http://127.0.0.1/webcam/?action=stream`

245 changes: 202 additions & 43 deletions octoprint_rtmpstreamer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@

import octoprint.plugin
from octoprint.server import user_permission
import os
import PIL
import docker
import shlex
import shutil
import subprocess


class rtmpstreamer(octoprint.plugin.StartupPlugin,
Expand All @@ -14,28 +19,76 @@ class rtmpstreamer(octoprint.plugin.StartupPlugin,
octoprint.plugin.EventHandlerPlugin):

def __init__(self):
self.client = docker.from_env()
self.client = None
self.container = None
self.image = None
self.ffmpeg = None

self.frame_rate_default = 5
self.stream_resolution_default = "640x480"
self.ffmpeg_cmd_default = (
"ffmpeg -re -f mjpeg -framerate {frame_rate} -i {webcam_url} {overlay_cmd} " # Video input
"-ar 44100 -ac 2 -acodec pcm_s16le -f s16le -ac 2 -i /dev/zero " # Audio input
"-acodec aac -ab 128k " # Audio output
"-s {stream_resolution} -vcodec h264 -pix_fmt yuv420p -framerate {frame_rate} -g {gop_size} -vb 700k -strict experimental {filter} " # Video output
"-f flv {stream_url}") # Output stream
self.overlay_image_default = "jneilliii.png"
self.docker_image_default = "kolisko/rpi-ffmpeg:latest"
self.docker_container_default = "RTMPStreamer"

##~~ StartupPlugin
def on_after_startup(self):
self._logger.info("OctoPrint-RTMPStreamer loaded! Checking stream status.")
try:
self.container = self.client.containers.get('RTMPStreamer')
self._logger.info("%s is streaming " % self.container.name)
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=True))
except Exception as e:
self._logger.error(str(e))
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=False))

if self._settings.get(["use_docker"]):
self._get_image()
self._check_stream()
if self._settings.get(["auto_start_on_power_up"]) and self._settings.get(["stream_url"]) != "":
self._logger.info("Auto starting stream on start up.")
self.startStream()
self._start_stream()

##~~ TemplatePlugin
def get_template_configs(self):
return [dict(type="settings", custom_bindings=False)]

def get_template_vars(self):
return dict(
frame_rate_default = self.frame_rate_default,
ffmpeg_cmd_default = self.ffmpeg_cmd_default,
docker_image_default = self.docker_image_default,
docker_container_default = self.docker_container_default
)

##~~ SettingsPlugin
def get_settings_defaults(self):
return dict(
# put your plugin's default settings here
view_url = "",
stream_url = "",
stream_resolution = self.stream_resolution_default,
use_overlay = True,
use_dynamic_overlay = False,
overlay_style = "wm_br",
overlay_padding = 10,
overlay_file = self._basefolder + "/static/img/" + self.overlay_image_default,
streaming = False,
auto_start = False,
auto_start_on_power_up=False,
use_docker = False,
docker_image = self.docker_image_default,
docker_container = self.docker_container_default,
ffmpeg_cmd = self.ffmpeg_cmd_default,
frame_rate = self.frame_rate_default,

# Default values
frame_rate_default = self.frame_rate_default,
ffmpeg_cmd_default = self.ffmpeg_cmd_default,
docker_image_default = self.docker_image_default,
docker_container_default = self.docker_container_default
)

def get_settings_restricted_paths(self):
return dict(admin=[["stream_url"]])

##~~ AssetPlugin
def get_assets(self):
return dict(
Expand All @@ -46,15 +99,44 @@ def get_assets(self):
##-- EventHandlerPlugin
def on_event(self, event, payload):
if event == "PrintStarted" and self._settings.get(["auto_start"]):
self.startStream()
self._start_stream()

if event in ["PrintDone", "PrintCancelled"] and self._settings.get(["auto_start"]):
self.stopStream()
self._stop_stream()

##~~ SettingsPlugin
def get_settings_defaults(self):
return dict(view_url="", stream_url="", stream_resolution="640x480", stream_framerate="5", streaming=False,
auto_start=False, auto_start_on_power_up=False)
##-- Utility Functions
def _get_client(self):
self.client = docker.from_env()
try:
self.client.ping()
except Exception as e:
self._logger.error("Docker not responding: " + str(e))
self.client = None

def _get_image(self):
self._get_client()
if self.client:
try:
self.image = self.client.images.get(self._settings.get(["docker_image"]))
except Exception as e:
self._logger.error(str(e))
self._logger.error("Please read installation instructions!")
self.image = None

def _get_container(self):
if self._settings.get(["use_docker"]):
self._get_client()
if self.client:
try:
self.container = self.client.containers.get(self._settings.get(["docker_container"]))
except Exception as e:
self.client = None
self.container = None
else:
if self.ffmpeg:
self.container = self.ffmpeg
else:
self.container = self.ffmpeg = None

##~~ SimpleApiPlugin
def get_api_commands(self):
Expand All @@ -67,61 +149,138 @@ def on_api_command(self, command, data):

if command == 'startStream':
self._logger.info("Start stream command received.")
self.startStream()
self._start_stream()
return
if command == 'stopStream':
self._logger.info("Stop stream command received.")
self.stopStream()
self._stop_stream()
if command == 'checkStream':
self._logger.info("Checking stream status.")
if self.container:
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=True))
else:
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=False))
self._check_stream()

##~~ General Functions
def startStream(self):
def _start_stream(self):
if self._settings.global_get(["webcam", "stream"]).startswith("/"):
self._plugin_manager.send_plugin_message(self._identifier, dict(
error="Webcam stream url is incorrect. Please configure OctoPrint's Webcam & Timelapse url to include fullly qualified url, like http://192.168.0.2/webcam/?action=stream",
status=True, streaming=False))
return

if not self.container:
overlay_cmds = dict(
fs = "-filter_complex \"[1:v][0:v]scale2ref=iw:-1[over][base]; [base][over]overlay=0:0\"",
wm_br = "-filter_complex \"[0:v][1:v] overlay=({stream_width} - {overlay_width} - {overlay_padding}):({stream_height} - {overlay_height} - {overlay_padding})\"",
wm_bl = "-filter_complex \"[0:v][1:v] overlay={overlay_padding}:({stream_height} - {overlay_height} - {overlay_padding})\"",
wm_tr = "-filter_complex \"[0:v][1:v] overlay=({stream_width} - {overlay_width} - {overlay_padding}):{overlay_padding}\"",
wm_tl = "-filter_complex \"[0:v][1:v] overlay={overlay_padding}:{overlay_padding}\""
)
filter_str = ""
filters = []
if self._settings.global_get(["webcam", "flipH"]):
filters.append("hflip")
if self._settings.global_get(["webcam", "flipV"]):
filters.append("vflip")
if self._settings.global_get(["webcam", "rotate90"]):
filters.append("transpose=cclock")
if len(filters) == 0:
filters.append("null")
if len(filters):
filter_str = "-filter:v {}".format(",".join(filters))
gop_size = int(self._settings.get(["frame_rate"])) * 2
overlay_cmd = ""
if self._settings.get(["use_overlay"]):
if os.path.isfile(self._settings.get(["overlay_file"])):
shutil.copy(self._settings.get(["overlay_file"]), "/tmp/overlay.png")
if os.path.isfile("/tmp/overlay.png"):
overlay = PIL.Image.open("/tmp/overlay.png")
overlay_width, overlay_height = overlay.size
stream_width, stream_height = self._settings.get(["stream_resolution"]).split("x")
# Substitute vars in overlay command
overlay_cmd = overlay_cmds[self._settings.get(["overlay_style"])].format(
stream_width = stream_width,
stream_height = stream_height,
overlay_width = overlay_width,
overlay_height = overlay_height,
overlay_padding = self._settings.get(["overlay_padding"]))
if self._settings.get(["use_dynamic_overlay"]):
overlay_cmd = "-pattern_type glob -loop 2 -r 1 -i \"/tmp/overlay*.png\" " + overlay_cmd
else:
overlay_cmd = "-i /tmp/overlay.png " + overlay_cmd
# Substitute vars in ffmpeg command
stream_cmd = self._settings.get(["ffmpeg_cmd"]).format(
overlay_cmd = overlay_cmd,
webcam_url = self._settings.global_get(["webcam", "stream"]),
stream_url = self._settings.get(["stream_url"]),
frame_rate = self._settings.get(["frame_rate"]),
stream_resolution = self._settings.get(["stream_resolution"]),
gop_size = gop_size,
filter = filter_str)
try:
self.container = self.client.containers.run("octoprint/rtmpstreamer:latest",
command=[self._settings.global_get(["webcam", "stream"]),
self._settings.get(["stream_resolution"]),
self._settings.get(["stream_framerate"]),
self._settings.get(["stream_url"]),
",".join(filters)], detach=True, privileged=False,
devices=["/dev/vchiq"], name="RTMPStreamer",
auto_remove=True, network_mode="host")
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=True))
if self._settings.get(["use_docker"]):
self._logger.info("Launching docker container '" + self._settings.get(["docker_container"]) + "':\n" + "| " + stream_cmd)
self._get_client()
self.container = self.client.containers.run(
self._settings.get(["docker_image"]),
command = stream_cmd,
detach = True,
privileged = False,
devices = ["/dev/vchiq"],
volumes = {"/tmp/overlay.png": {"bind": "/tmp/overlay.png", "mode": "ro"}},
name = self._settings.get(["docker_container"]),
auto_remove = True,
network_mode = "host")
else:
self._logger.info("Launching ffmpeg locally:\n" + "| " + stream_cmd)
cmd = shlex.split(stream_cmd, posix=True)
self.ffmpeg = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE ,stdout=subprocess.PIPE)
self._logger.info("Stream ffmpeg pid {}".format(self.ffmpeg.pid))
except Exception as e:
self._plugin_manager.send_plugin_message(self._identifier,
dict(error=str(e), status=True, streaming=False))
self._logger.error(str(e))
self._plugin_manager.send_plugin_message(self._identifier, dict(error=str(e),status=True,streaming=False))
else:
self._logger.info("Stream started successfully")
self._plugin_manager.send_plugin_message(self._identifier, dict(success="Stream started",status=True,streaming=True))
if self._settings.get(["use_dynamic_overlay"]):
self._build_overlay()

def _build_overlay(self):
# FIXME: start dynamic image update process, should be a background loop
# that stops with the stream
return

def stopStream(self):
def _stop_stream(self):
self._get_container()
if self.container:
try:
self.container.stop()
self.container = None
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=False))
if self._settings.get(["use_docker"]):
self._logger.info("Stream stopping docker")
self.container.stop()
else:
self._logger.info("Stream stopping ffmpeg pid {}".format(self.ffmpeg.pid))
if self.ffmpeg.poll():
out, err = self.ffmpeg.communicate()
if err:
self._logger.error("FFMPEG Error: {}".format(err))
self._plugin_manager.send_plugin_message(self._identifier, dict(error=err,status=True,streaming=False))
self.ffmpeg.terminate()
self.ffmpeg.kill()
except Exception as e:
self._plugin_manager.send_plugin_message(self._identifier,
dict(error=str(e), status=True, streaming=False))
self._logger.error(str(e))
self._plugin_manager.send_plugin_message(self._identifier, dict(error=str(e),status=True,streaming=False))
else:
self.ffmpeg = None
self.container = None
self._logger.info("Stream stopped successfully")
self._plugin_manager.send_plugin_message(self._identifier, dict(success="Stream stopped",status=True,streaming=False))
else:
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True,streaming=False))

def _check_stream(self):
self._get_container()
if self.container:
self._logger.info("%s is streaming " % self.container.name)
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True,streaming=True))
else:
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True, streaming=False))
self._logger.info("stream is inactive ")
self._plugin_manager.send_plugin_message(self._identifier, dict(status=True,streaming=False))

##~~ Softwareupdate hook
def get_update_information(self):
Expand Down
Binary file added octoprint_rtmpstreamer/static/img/jneilliii.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit f593c1f

Please sign in to comment.