Added FFmpeg camera plugin [relates to #150]

This commit is contained in:
Fabio Manganiello 2020-09-27 12:51:29 +02:00
parent b21193dc74
commit df1e03f0af
13 changed files with 360 additions and 205 deletions

View file

@ -0,0 +1 @@
camera

View file

@ -1,116 +0,0 @@
@import 'common/vars';
.camera {
min-height: 90%;
margin-top: 4%;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
.camera-container {
position: relative;
background: black;
margin-bottom: 1em;
.frame, .no-frame {
position: absolute;
top: 0;
width: 100%;
height: 100%;
}
.frame {
z-index: 1;
}
.no-frame {
display: flex;
background: rgba(0, 0, 0, 0.1);
color: white;
align-items: center;
justify-content: center;
z-index: 2;
}
}
.url {
width: 640px;
display: flex;
margin: 1em;
.row {
width: 100%;
display: flex;
align-items: center;
}
.name {
width: 140px;
}
input {
width: 500px;
font-weight: normal;
}
}
.params {
margin-top: 1em;
padding: 1em;
width: 640px;
display: flex;
flex-direction: column;
border: $default-border-3;
border-radius: 1em;
label {
font-weight: normal;
}
.head {
display: flex;
justify-content: center;
label {
width: 100%;
display: flex;
justify-content: right;
.name {
margin-right: 1em;
}
}
}
.body {
display: flex;
flex-direction: column;
margin: 0 0 0 -1em;
.row {
width: 100%;
display: flex;
align-items: center;
padding: 0.5em;
.name {
width: 30%;
}
input {
width: 70%;
}
&:nth-child(even) {
background: $default-bg-6;
}
&:hover {
background: $hover-bg;
}
}
}
}
}

View file

@ -71,8 +71,10 @@ class CameraPlugin(Plugin, ABC):
capture_timeout: Optional[float] = 20.0, scale_x: Optional[float] = None, capture_timeout: Optional[float] = 20.0, scale_x: Optional[float] = None,
scale_y: Optional[float] = None, rotate: Optional[float] = None, grayscale: Optional[bool] = None, scale_y: Optional[float] = None, rotate: Optional[float] = None, grayscale: Optional[bool] = None,
color_transform: Optional[Union[int, str]] = None, fps: float = 16, horizontal_flip: bool = False, color_transform: Optional[Union[int, str]] = None, fps: float = 16, horizontal_flip: bool = False,
vertical_flip: bool = False, video_type: Optional[str] = None, stream_format: str = 'mjpeg', vertical_flip: bool = False, input_format: Optional[str] = None, output_format: Optional[str] = None,
listen_port: Optional[int] = 5000, bind_address: str = '0.0.0.0', **kwargs): stream_format: str = 'mjpeg', listen_port: Optional[int] = 5000, bind_address: str = '0.0.0.0',
ffmpeg_bin: str = 'ffmpeg', input_codec: Optional[str] = None, output_codec: Optional[str] = None,
**kwargs):
""" """
:param device: Identifier of the default capturing device. :param device: Identifier of the default capturing device.
:param resolution: Default resolution, as a tuple of two integers. :param resolution: Default resolution, as a tuple of two integers.
@ -96,9 +98,14 @@ class CameraPlugin(Plugin, ABC):
:param fps: Frames per second (default: 25). :param fps: Frames per second (default: 25).
:param horizontal_flip: If set, the images will be flipped on the horizontal axis. :param horizontal_flip: If set, the images will be flipped on the horizontal axis.
:param vertical_flip: If set, the images will be flipped on the vertical axis. :param vertical_flip: If set, the images will be flipped on the vertical axis.
:param video_type: Plugin-specific format/type for the output videos.
:param listen_port: Default port to be used for streaming over TCP (default: 5000). :param listen_port: Default port to be used for streaming over TCP (default: 5000).
:param bind_address: Default bind address for TCP streaming (default: 0.0.0.0, accept any connections). :param bind_address: Default bind address for TCP streaming (default: 0.0.0.0, accept any connections).
:param input_codec: Specify the ffmpeg video codec (``-vcodec``) used for the input.
:param output_codec: Specify the ffmpeg video codec (``-vcodec``) to be used for encoding the output. For some
ffmpeg output formats (e.g. ``h264`` and ``rtp``) this may default to ``libxvid``.
:param input_format: Plugin-specific format/type for the input stream.
:param output_format: Plugin-specific format/type for the output videos.
:param ffmpeg_bin: Path to the ffmpeg binary (default: ``ffmpeg``).
:param stream_format: Default format for the output when streamed to a network device. Available: :param stream_format: Default format for the output when streamed to a network device. Available:
- ``MJPEG`` (default) - ``MJPEG`` (default)
@ -110,26 +117,29 @@ class CameraPlugin(Plugin, ABC):
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
_default_frames_dir = os.path.join(Config.get('workdir'), get_plugin_name_by_class(self), 'frames') self.workdir = os.path.join(Config.get('workdir'), get_plugin_name_by_class(self))
pathlib.Path(self.workdir).mkdir(mode=0o644, exist_ok=True, parents=True)
# noinspection PyArgumentList # noinspection PyArgumentList
self.camera_info = self._camera_info_class(device, color_transform=color_transform, warmup_frames=warmup_frames, self.camera_info = self._camera_info_class(device, color_transform=color_transform, warmup_frames=warmup_frames,
warmup_seconds=warmup_seconds, rotate=rotate, scale_x=scale_x, warmup_seconds=warmup_seconds, rotate=rotate, scale_x=scale_x,
scale_y=scale_y, capture_timeout=capture_timeout, scale_y=scale_y, capture_timeout=capture_timeout, fps=fps,
video_type=video_type, fps=fps, stream_format=stream_format, input_format=input_format, output_format=output_format,
resolution=resolution, grayscale=grayscale, listen_port=listen_port, stream_format=stream_format, resolution=resolution,
grayscale=grayscale, listen_port=listen_port,
horizontal_flip=horizontal_flip, vertical_flip=vertical_flip, horizontal_flip=horizontal_flip, vertical_flip=vertical_flip,
bind_address=bind_address, frames_dir=os.path.abspath( ffmpeg_bin=ffmpeg_bin, input_codec=input_codec,
os.path.expanduser(frames_dir or _default_frames_dir))) output_codec=output_codec, bind_address=bind_address,
frames_dir=os.path.abspath(
os.path.expanduser(frames_dir or
os.path.join(self.workdir, 'frames'))))
self._devices: Dict[Union[int, str], Camera] = {} self._devices: Dict[Union[int, str], Camera] = {}
self._streams: Dict[Union[int, str], Camera] = {} self._streams: Dict[Union[int, str], Camera] = {}
def _merge_info(self, **info) -> CameraInfo: def _merge_info(self, **info) -> CameraInfo:
merged_info = self.camera_info.clone() merged_info = self.camera_info.clone()
for k, v in info.items(): merged_info.set(**info)
if hasattr(merged_info, k):
setattr(merged_info, k, v)
return merged_info return merged_info
def open_device(self, device: Optional[Union[int, str]] = None, stream: bool = False, **params) -> Camera: def open_device(self, device: Optional[Union[int, str]] = None, stream: bool = False, **params) -> Camera:
@ -179,12 +189,12 @@ class CameraPlugin(Plugin, ABC):
""" """
name = camera.info.device name = camera.info.device
self.stop_preview(camera) self.stop_preview(camera)
camera.start_event.clear() self.release_device(camera)
camera.start_event.clear()
if wait_capture: if wait_capture:
self.wait_capture(camera) self.wait_capture(camera)
self.release_device(camera)
if name in self._devices: if name in self._devices:
del self._devices[name] del self._devices[name]
@ -356,7 +366,7 @@ class CameraPlugin(Plugin, ABC):
if duration and camera.info.warmup_seconds: if duration and camera.info.warmup_seconds:
duration = duration + camera.info.warmup_seconds duration = duration + camera.info.warmup_seconds
if video_file: if video_file:
camera.file_writer = self._video_writer_class(camera=camera, video_file=video_file, plugin=self) camera.file_writer = self._video_writer_class(camera=camera, plugin=self, output_file=video_file)
frame_queue = Queue() frame_queue = Queue()
frame_processor = threading.Thread(target=self.frame_processor, frame_processor = threading.Thread(target=self.frame_processor,
@ -373,6 +383,10 @@ class CameraPlugin(Plugin, ABC):
frame_capture_start = time.time() frame_capture_start = time.time()
try: try:
frame = self.capture_frame(camera, **kwargs) frame = self.capture_frame(camera, **kwargs)
if not frame:
self.logger.warning('Invalid frame received, terminating the capture session')
break
frame_queue.put(frame) frame_queue.put(frame)
except AssertionError as e: except AssertionError as e:
self.logger.warning(str(e)) self.logger.warning(str(e))
@ -478,12 +492,13 @@ class CameraPlugin(Plugin, ABC):
return image_file return image_file
@action @action
def take_picture(self, image_file: str, **camera) -> str: def take_picture(self, image_file: str, preview: bool = False, **camera) -> str:
""" """
Alias for :meth:`.capture_image`. Alias for :meth:`.capture_image`.
:param image_file: Path where the output image will be stored. :param image_file: Path where the output image will be stored.
:param camera: Camera parameters override - see constructors parameters. :param camera: Camera parameters override - see constructors parameters.
:param preview: Show a preview of the camera frames.
:return: The local path to the saved image. :return: The local path to the saved image.
""" """
return self.capture_image(image_file, **camera) return self.capture_image(image_file, **camera)
@ -599,11 +614,11 @@ class CameraPlugin(Plugin, ABC):
assert not camera.stream_event.is_set() and camera.info.device not in self._streams, \ assert not camera.stream_event.is_set() and camera.info.device not in self._streams, \
'A streaming session is already running for device {}'.format(camera.info.device) 'A streaming session is already running for device {}'.format(camera.info.device)
self._streams[camera.info.device] = camera
camera.stream_event.set()
camera.stream_thread = threading.Thread(target=self.streaming_thread, kwargs=dict( camera.stream_thread = threading.Thread(target=self.streaming_thread, kwargs=dict(
camera=camera, duration=duration, stream_format=stream_format)) camera=camera, duration=duration, stream_format=stream_format))
self._streams[camera.info.device] = camera
camera.stream_event.set()
camera.stream_thread.start() camera.stream_thread.start()
return self.status(camera.info.device) return self.status(camera.info.device)
@ -624,7 +639,7 @@ class CameraPlugin(Plugin, ABC):
def _stop_streaming(self, camera: Camera): def _stop_streaming(self, camera: Camera):
camera.stream_event.clear() camera.stream_event.clear()
if camera.stream_thread.is_alive(): if camera.stream_thread and camera.stream_thread.is_alive():
camera.stream_thread.join(timeout=5.0) camera.stream_thread.join(timeout=5.0)
if camera.info.device in self._streams: if camera.info.device in self._streams:

View file

@ -0,0 +1,83 @@
import signal
import subprocess
from typing import Optional, Tuple
from PIL import Image
from PIL.Image import Image as ImageType
from platypush.plugins.camera import CameraPlugin
from platypush.plugins.camera.ffmpeg.model import FFmpegCamera, FFmpegCameraInfo
class CameraFfmpegPlugin(CameraPlugin):
"""
Plugin to interact with a camera over FFmpeg.
Requires:
* **ffmpeg** package installed on the system.
"""
_camera_class = FFmpegCamera
_camera_info_class = FFmpegCameraInfo
def __init__(self, device: Optional[str] = None, input_format: str = 'v4l2', ffmpeg_args: Tuple[str] = (), **opts):
"""
:param device: Path to the camera device (e.g. ``/dev/video0``).
:param input_format: FFmpeg input format for the the camera device (default: ``v4l2``).
:param ffmpeg_args: Extra options to be passed to the FFmpeg executable.
:param opts: Camera options - see constructor of :class:`platypush.plugins.camera.CameraPlugin`.
"""
super().__init__(device=device, input_format=input_format, **opts)
self.camera_info.ffmpeg_args = ffmpeg_args or ()
@staticmethod
def _get_warmup_seconds(camera: FFmpegCamera) -> float:
if camera.info.warmup_seconds:
return camera.info.warmup_seconds
if camera.info.warmup_frames and camera.info.fps:
return camera.info.warmup_frames / camera.info.fps
return 0
def prepare_device(self, camera: FFmpegCamera) -> subprocess.Popen:
warmup_seconds = self._get_warmup_seconds(camera)
ffmpeg = [camera.info.ffmpeg_bin, '-y', '-f', camera.info.input_format, '-i', camera.info.device, '-s',
'{}x{}'.format(*camera.info.resolution), '-ss', str(warmup_seconds),
*(('-r', str(camera.info.fps)) if camera.info.fps else ()),
'-pix_fmt', 'rgb24', '-f', 'rawvideo', *camera.info.ffmpeg_args, '-']
self.logger.info('Running FFmpeg: {}'.format(' '.join(ffmpeg)))
proc = subprocess.Popen(ffmpeg, stdout=subprocess.PIPE)
# Start in suspended mode
proc.send_signal(signal.SIGSTOP)
return proc
def start_camera(self, camera: FFmpegCamera, preview: bool = False, *args, **kwargs):
super().start_camera(*args, camera=camera, preview=preview, **kwargs)
if camera.object:
camera.object.send_signal(signal.SIGCONT)
def release_device(self, camera: FFmpegCamera):
if camera.object:
camera.object.terminate()
if camera.object.stdout:
camera.object.stdout.close()
camera.object = None
def wait_capture(self, camera: FFmpegCamera) -> None:
if camera.object and camera.object.poll() is None:
try:
camera.object.wait(timeout=camera.info.capture_timeout)
except Exception as e:
self.logger.warning('Error on FFmpeg capture wait: {}'.format(str(e)))
def capture_frame(self, camera: FFmpegCamera, *args, **kwargs) -> Optional[ImageType]:
raw_size = camera.info.resolution[0] * camera.info.resolution[1] * 3
data = camera.object.stdout.read(raw_size)
if len(data) < raw_size:
return
return Image.frombytes('RGB', camera.info.resolution, data)
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,24 @@
from dataclasses import dataclass
from subprocess import Popen
from typing import Tuple
from platypush.plugins.camera import CameraInfo, Camera
@dataclass
class FFmpegCameraInfo(CameraInfo):
ffmpeg_args: Tuple[str] = ()
def to_dict(self) -> dict:
return {
'ffmpeg_args': list(self.ffmpeg_args or ()),
**super().to_dict()
}
class FFmpegCamera(Camera):
info: FFmpegCameraInfo
object: Popen
# vim:sw=4:ts=4:et:

View file

@ -12,48 +12,57 @@ from platypush.plugins.camera.model.writer.preview import PreviewWriter
@dataclass @dataclass
class CameraInfo: class CameraInfo:
device: Optional[Union[int, str]] device: Optional[Union[int, str]]
resolution: Optional[Tuple[int, int]] = None bind_address: Optional[str] = None
capture_timeout: float = 20.0
color_transform: Optional[str] = None color_transform: Optional[str] = None
ffmpeg_bin: Optional[str] = None
fps: Optional[float] = None
frames_dir: Optional[str] = None frames_dir: Optional[str] = None
rotate: Optional[float] = None grayscale: Optional[bool] = None
horizontal_flip: bool = False horizontal_flip: bool = False
vertical_flip: bool = False input_codec: Optional[str] = None
input_format: Optional[str] = None
listen_port: Optional[int] = None
output_codec: Optional[str] = None
output_format: Optional[str] = None
resolution: Optional[Tuple[int, int]] = None
rotate: Optional[float] = None
scale_x: Optional[float] = None scale_x: Optional[float] = None
scale_y: Optional[float] = None scale_y: Optional[float] = None
stream_format: Optional[str] = None
vertical_flip: bool = False
warmup_frames: int = 0 warmup_frames: int = 0
warmup_seconds: float = 0. warmup_seconds: float = 0.
capture_timeout: float = 20.0
fps: Optional[float] = None
grayscale: Optional[bool] = None
video_type: Optional[str] = None
stream_format: str = 'mjpeg'
listen_port: Optional[int] = None
bind_address: Optional[str] = None
def set(self, **kwargs): def set(self, **kwargs):
for k, v in kwargs.items(): for k, v in kwargs.items():
if hasattr(self, k):
setattr(self, k, v) setattr(self, k, v)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
'device': self.device, 'bind_address': self.bind_address,
'capture_timeout': self.capture_timeout,
'color_transform': self.color_transform, 'color_transform': self.color_transform,
'device': self.device,
'ffmpeg_bin': self.ffmpeg_bin,
'fps': self.fps,
'frames_dir': self.frames_dir, 'frames_dir': self.frames_dir,
'rotate': self.rotate, 'grayscale': self.grayscale,
'horizontal_flip': self.horizontal_flip, 'horizontal_flip': self.horizontal_flip,
'vertical_flip': self.vertical_flip, 'input_codec': self.input_codec,
'input_format': self.input_format,
'listen_port': self.listen_port,
'output_codec': self.output_codec,
'output_format': self.output_format,
'resolution': list(self.resolution or ()),
'rotate': self.rotate,
'scale_x': self.scale_x, 'scale_x': self.scale_x,
'scale_y': self.scale_y, 'scale_y': self.scale_y,
'stream_format': self.stream_format,
'vertical_flip': self.vertical_flip,
'warmup_frames': self.warmup_frames, 'warmup_frames': self.warmup_frames,
'warmup_seconds': self.warmup_seconds, 'warmup_seconds': self.warmup_seconds,
'capture_timeout': self.capture_timeout,
'fps': self.fps,
'grayscale': self.grayscale,
'resolution': list(self.resolution or ()),
'video_type': self.video_type,
'stream_format': self.stream_format,
'listen_port': self.listen_port,
'bind_address': self.bind_address,
} }
def clone(self): def clone(self):

View file

@ -9,8 +9,6 @@ from typing import Optional, IO
from PIL.Image import Image from PIL.Image import Image
logger = logging.getLogger('video-writer')
class VideoWriter(ABC): class VideoWriter(ABC):
""" """
@ -21,6 +19,8 @@ class VideoWriter(ABC):
def __init__(self, camera, plugin, *_, **__): def __init__(self, camera, plugin, *_, **__):
from platypush.plugins.camera import Camera, CameraPlugin from platypush.plugins.camera import Camera, CameraPlugin
self.logger = logging.getLogger(self.__class__.__name__)
self.camera: Camera = camera self.camera: Camera = camera
self.plugin: CameraPlugin = plugin self.plugin: CameraPlugin = plugin
self.closed = False self.closed = False
@ -60,9 +60,9 @@ class FileVideoWriter(VideoWriter, ABC):
""" """
Abstract class to handle frames-to-video file operations. Abstract class to handle frames-to-video file operations.
""" """
def __init__(self, *args, video_file: str, **kwargs): def __init__(self, *args, output_file: str, **kwargs):
VideoWriter.__init__(self, *args, **kwargs) VideoWriter.__init__(self, *args, **kwargs)
self.video_file = os.path.abspath(os.path.expanduser(video_file)) self.output_file = os.path.abspath(os.path.expanduser(output_file))
class StreamWriter(VideoWriter, ABC): class StreamWriter(VideoWriter, ABC):
@ -98,7 +98,7 @@ class StreamWriter(VideoWriter, ABC):
try: try:
self.sock.write(data) self.sock.write(data)
except ConnectionError: except ConnectionError:
logger.warning('Client connection closed') self.logger.info('Client connection closed')
self.close() self.close()
@abstractmethod @abstractmethod

View file

@ -12,13 +12,13 @@ class CvFileWriter(FileVideoWriter):
import cv2 import cv2
super(CvFileWriter, self).__init__(*args, **kwargs) super(CvFileWriter, self).__init__(*args, **kwargs)
video_type = cv2.VideoWriter_fourcc(*(self.camera.info.video_type or 'xvid').upper()) video_type = cv2.VideoWriter_fourcc(*(self.camera.info.output_format or 'xvid').upper())
resolution = ( resolution = (
int(self.camera.info.resolution[0] * (self.camera.info.scale_x or 1.)), int(self.camera.info.resolution[0] * (self.camera.info.scale_x or 1.)),
int(self.camera.info.resolution[1] * (self.camera.info.scale_y or 1.)), int(self.camera.info.resolution[1] * (self.camera.info.scale_y or 1.)),
) )
self.writer = cv2.VideoWriter(self.video_file, video_type, self.camera.info.fps, resolution, False) self.writer = cv2.VideoWriter(self.output_file, video_type, self.camera.info.fps, resolution, False)
def write(self, img): def write(self, img):
if not self.writer: if not self.writer:

View file

@ -1,54 +1,48 @@
import logging
import subprocess import subprocess
import threading import threading
import time import time
from abc import ABC from abc import ABC
from typing import Optional, List from typing import Optional, Tuple
from PIL.Image import Image from PIL.Image import Image
from platypush.plugins.camera.model.camera import Camera
from platypush.plugins.camera.model.writer import VideoWriter, FileVideoWriter, StreamWriter from platypush.plugins.camera.model.writer import VideoWriter, FileVideoWriter, StreamWriter
logger = logging.getLogger('ffmpeg-writer')
class FFmpegWriter(VideoWriter, ABC): class FFmpegWriter(VideoWriter, ABC):
""" """
Generic FFmpeg encoder for camera frames. Generic FFmpeg encoder for camera frames.
""" """
def __init__(self, *args, input_file: str = '-', input_format: str = 'rawvideo', input_codec: Optional[str] = None, def __init__(self, *args, input_file: str = '-', input_format: str = 'rawvideo', output_file: str = '-',
output_file: str = '-', output_format: Optional[str] = None, output_codec: Optional[str] = None, output_format: Optional[str] = None, pix_fmt: Optional[str] = None,
pix_fmt: Optional[str] = None, output_opts: Optional[List[str]] = None, **kwargs): output_opts: Optional[Tuple] = None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.input_file = input_file self.input_file = input_file
self.input_format = input_format self.input_format = input_format
self.input_codec = input_codec
self.output_file = output_file
self.output_format = output_format self.output_format = output_format
self.output_codec = output_codec self.output_file = output_file
self.width, self.height = self.camera.effective_resolution() self.width, self.height = self.camera.effective_resolution()
self.pix_fmt = pix_fmt self.pix_fmt = pix_fmt
self.output_opts = output_opts or [] self.output_opts = output_opts or ()
logger.info('Starting FFmpeg. Command: {}'.format(' '.join(self.ffmpeg_args))) self.logger.info('Starting FFmpeg. Command: {}'.format(' '.join(self.ffmpeg_args)))
self.ffmpeg = subprocess.Popen(self.ffmpeg_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) self.ffmpeg = subprocess.Popen(self.ffmpeg_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
@property @property
def ffmpeg_args(self): def ffmpeg_args(self):
return ['ffmpeg', '-y', return [self.camera.info.ffmpeg_bin, '-y',
'-f', self.input_format, '-f', self.input_format,
*(('-vcodec', self.input_codec) if self.input_codec else ()),
*(('-pix_fmt', self.pix_fmt) if self.pix_fmt else ()), *(('-pix_fmt', self.pix_fmt) if self.pix_fmt else ()),
'-s', '{}x{}'.format(self.width, self.height), '-s', '{}x{}'.format(self.width, self.height),
'-r', str(self.camera.info.fps), '-r', str(self.camera.info.fps),
'-i', self.input_file, '-i', self.input_file,
*(('-f', self.output_format) if self.output_format else ()), *(('-f', self.output_format) if self.output_format else ()),
*self.output_opts, *self.output_opts,
*(('-vcodec', self.output_codec) if self.output_codec else ()), *(('-vcodec', self.camera.info.output_codec) if self.camera.info.output_codec else ()),
self.output_file] self.output_file]
def is_closed(self): def is_closed(self):
@ -61,7 +55,7 @@ class FFmpegWriter(VideoWriter, ABC):
try: try:
self.ffmpeg.stdin.write(image.convert('RGB').tobytes()) self.ffmpeg.stdin.write(image.convert('RGB').tobytes())
except Exception as e: except Exception as e:
logger.warning('FFmpeg send error: {}'.format(str(e))) self.logger.warning('FFmpeg send error: {}'.format(str(e)))
self.close() self.close()
def close(self): def close(self):
@ -77,7 +71,7 @@ class FFmpegWriter(VideoWriter, ABC):
try: try:
self.ffmpeg.wait(timeout=5.0) self.ffmpeg.wait(timeout=5.0)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning('FFmpeg has not returned - killing it') self.logger.warning('FFmpeg has not returned - killing it')
self.ffmpeg.kill() self.ffmpeg.kill()
if self.ffmpeg and self.ffmpeg.stdout: if self.ffmpeg and self.ffmpeg.stdout:
@ -95,9 +89,9 @@ class FFmpegFileWriter(FileVideoWriter, FFmpegWriter):
Write camera frames to a file using FFmpeg. Write camera frames to a file using FFmpeg.
""" """
def __init__(self, *args, video_file: str, **kwargs): def __init__(self, *args, output_file: str, **kwargs):
FileVideoWriter.__init__(self, *args, video_file=video_file, **kwargs) FileVideoWriter.__init__(self, *args, output_file=output_file, **kwargs)
FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_file=self.video_file, **kwargs) FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_file=self.output_file, **kwargs)
class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC): class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
@ -105,12 +99,11 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
Stream camera frames using FFmpeg. Stream camera frames using FFmpeg.
""" """
def __init__(self, *args, output_format: str, **kwargs): def __init__(self, *args, output_format: str, output_opts: Optional[Tuple] = None, **kwargs):
StreamWriter.__init__(self, *args, **kwargs) StreamWriter.__init__(self, *args, **kwargs)
FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_format=output_format, FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_format=output_format, output_opts=output_opts or (
output_opts=[ '-tune', 'zerolatency', '-preset', 'superfast', '-trellis', '0',
'-tune', '-zerolatency', '-preset', 'superfast', '-trellis', '0', '-fflags', 'nobuffer'), **kwargs)
'-fflags', 'nobuffer'], **kwargs)
self._reader = threading.Thread(target=self._reader_thread) self._reader = threading.Thread(target=self._reader_thread)
self._reader.start() self._reader.start()
@ -124,7 +117,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
try: try:
data = self.ffmpeg.stdout.read(1 << 15) data = self.ffmpeg.stdout.read(1 << 15)
except Exception as e: except Exception as e:
logger.warning('FFmpeg reader error: {}'.format(str(e))) self.logger.warning('FFmpeg reader error: {}'.format(str(e)))
break break
if not data: if not data:
@ -132,7 +125,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
if self.frame is None: if self.frame is None:
latency = time.time() - start_time latency = time.time() - start_time
logger.info('FFmpeg stream latency: {} secs'.format(latency)) self.logger.info('FFmpeg stream latency: {} secs'.format(latency))
with self.ready: with self.ready:
self.frame = data self.frame = data
@ -149,7 +142,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC):
try: try:
self.ffmpeg.stdin.write(data) self.ffmpeg.stdin.write(data)
except Exception as e: except Exception as e:
logger.warning('FFmpeg send error: {}'.format(str(e))) self.logger.warning('FFmpeg send error: {}'.format(str(e)))
self.close() self.close()
def close(self): def close(self):
@ -169,8 +162,10 @@ class MKVStreamWriter(FFmpegStreamWriter):
class H264StreamWriter(FFmpegStreamWriter): class H264StreamWriter(FFmpegStreamWriter):
mimetype = 'video/h264' mimetype = 'video/h264'
def __init__(self, *args, **kwargs): def __init__(self, camera: Camera, *args, **kwargs):
super().__init__(*args, output_format='h264', **kwargs) if not camera.info.output_codec:
camera.info.output_codec = 'libxvid'
super().__init__(camera, *args, output_format='h264', **kwargs)
class H265StreamWriter(FFmpegStreamWriter): class H265StreamWriter(FFmpegStreamWriter):

View file

@ -4,7 +4,7 @@ from abc import ABC
from platypush.plugins.camera.model.writer import VideoWriter from platypush.plugins.camera.model.writer import VideoWriter
logger = logging.getLogger('cam-preview') logger = logging.getLogger('platypush')
class PreviewWriter(VideoWriter, ABC): class PreviewWriter(VideoWriter, ABC):

View file

@ -5,8 +5,6 @@ import threading
from platypush.plugins.camera.model.writer.image import MJPEGStreamWriter from platypush.plugins.camera.model.writer.image import MJPEGStreamWriter
from platypush.plugins.camera.model.writer.preview import PreviewWriter from platypush.plugins.camera.model.writer.preview import PreviewWriter
logger = logging.getLogger('cam-preview')
class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter): class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter):
""" """
@ -14,6 +12,7 @@ class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.logger = logging.getLogger(self.__class__.__name__)
self.ffplay = subprocess.Popen(['ffplay', '-'], stdin=subprocess.PIPE) self.ffplay = subprocess.Popen(['ffplay', '-'], stdin=subprocess.PIPE)
self._preview_thread = threading.Thread(target=self._ffplay_thread) self._preview_thread = threading.Thread(target=self._ffplay_thread)
self._preview_thread.start() self._preview_thread.start()
@ -28,7 +27,7 @@ class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter):
try: try:
self.ffplay.stdin.write(self.frame) self.ffplay.stdin.write(self.frame)
except Exception as e: except Exception as e:
logger.warning('ffplay write error: {}'.format(str(e))) self.logger.warning('ffplay write error: {}'.format(str(e)))
self.close() self.close()
break break

View file

@ -1,11 +1,8 @@
import logging
from multiprocessing import Process, Queue, Event from multiprocessing import Process, Queue, Event
from platypush.plugins.camera.model.writer import VideoWriter from platypush.plugins.camera.model.writer import VideoWriter
from platypush.plugins.camera.model.writer.preview import PreviewWriter from platypush.plugins.camera.model.writer.preview import PreviewWriter
logger = logging.getLogger('cam-preview')
class WxPreviewWriter(PreviewWriter, Process): class WxPreviewWriter(PreviewWriter, Process):
""" """
@ -47,7 +44,7 @@ class WxPreviewWriter(PreviewWriter, Process):
try: try:
self.bitmap_queue.put(image) self.bitmap_queue.put(image)
except Exception as e: except Exception as e:
logger.warning('Could not add an image to the preview queue: {}'.format(str(e))) self.logger.warning('Could not add an image to the preview queue: {}'.format(str(e)))
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

148
platypush/plugins/ffmpeg.py Normal file
View file

@ -0,0 +1,148 @@
import os
import subprocess
import threading
from typing import Callable, Optional, List, Tuple
from platypush.plugins import Plugin, action
class FfmpegPlugin(Plugin):
"""
Generic FFmpeg plugin to interact with media files and devices.
Requires:
* **ffmpeg-python** (``pip install ffmpeg-python``)
* The **ffmpeg** package installed on the system.
"""
def __init__(self, ffmpeg_cmd: str = 'ffmpeg', ffprobe_cmd: str = 'ffprobe', **kwargs):
super().__init__(**kwargs)
self.ffmpeg_cmd = ffmpeg_cmd
self.ffprobe_cmd = ffprobe_cmd
self._threads = {}
self._next_thread_id = 1
self._thread_lock = threading.RLock()
@action
def info(self, filename: str, **kwargs) -> dict:
"""
Get the information of a media file.
:param filename: Path to the media file.
:return: Media file information. Example:
.. code-block:: json
{
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
"profile": "High 4:2:2",
"codec_type": "video",
"codec_time_base": "1/60",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"width": 640,
"height": 480,
"coded_width": 640,
"coded_height": 480,
"closed_captions": 0,
"has_b_frames": 2,
"pix_fmt": "yuv422p",
"level": 30,
"chroma_location": "left",
"field_order": "progressive",
"refs": 1,
"is_avc": "true",
"nal_length_size": "4",
"r_frame_rate": "30/1",
"avg_frame_rate": "30/1",
"time_base": "1/1000",
"start_pts": 0,
"start_time": "0.000000",
"bits_per_raw_sample": "8",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0
},
"tags": {
"ENCODER": "Lavc58.91.100 libx264"
}
}
],
"format": {
"filename": "./output.mkv",
"nb_streams": 1,
"nb_programs": 0,
"format_name": "matroska,webm",
"format_long_name": "Matroska / WebM",
"start_time": "0.000000",
"size": "786432",
"probe_score": 100,
"tags": {
"ENCODER": "Lavf58.45.100"
}
}
}
"""
# noinspection PyPackageRequirements
import ffmpeg
filename = os.path.abspath(os.path.expanduser(filename))
info = ffmpeg.probe(filename, cmd=self.ffprobe_cmd, **kwargs)
return info
@staticmethod
def _poll_thread(proc: subprocess.Popen, packet_size: int, on_packet: Callable[[bytes], None],
on_open: Optional[Callable[[], None]] = None,
on_close: Optional[Callable[[], None]] = None):
try:
if on_open:
on_open()
while proc.poll() is None:
data = proc.stdout.read(packet_size)
on_packet(data)
finally:
if on_close:
on_close()
@action
def start(self, pipeline: List[dict], pipe_stdin: bool = False, pipe_stdout: bool = False,
pipe_stderr: bool = False, quiet: bool = False, overwrite_output: bool = False,
on_packet: Callable[[bytes], None] = None, packet_size: int = 4096):
# noinspection PyPackageRequirements
import ffmpeg
stream = ffmpeg
for step in pipeline:
args = step.pop('args') if 'args' in step else []
stream = getattr(stream, step.pop('method'))(*args, **step)
self.logger.info('Executing {cmd} {args}'.format(cmd=self.ffmpeg_cmd, args=stream.get_args()))
proc = stream.run_async(cmd=self.ffmpeg_cmd, pipe_stdin=pipe_stdin, pipe_stdout=pipe_stdout,
pipe_stderr=pipe_stderr, quiet=quiet, overwrite_output=overwrite_output)
if on_packet:
with self._thread_lock:
self._threads[self._next_thread_id] = threading.Thread(target=self._poll_thread, kwargs=dict(
proc=proc, on_packet=on_packet, packet_size=packet_size))
self._threads[self._next_thread_id].start()
self._next_thread_id += 1
# vim:sw=4:ts=4:et: