From df1e03f0af481d021535ddbbe298460fdfcc591c Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 27 Sep 2020 12:51:29 +0200 Subject: [PATCH] Added FFmpeg camera plugin [relates to #150] --- .../css/source/webpanel/plugins/camera.cv | 1 + .../webpanel/plugins/camera.cv/index.scss | 116 -------------- platypush/plugins/camera/__init__.py | 57 ++++--- platypush/plugins/camera/ffmpeg/__init__.py | 83 ++++++++++ platypush/plugins/camera/ffmpeg/model.py | 24 +++ platypush/plugins/camera/model/camera.py | 53 ++++--- .../plugins/camera/model/writer/__init__.py | 10 +- platypush/plugins/camera/model/writer/cv.py | 4 +- .../plugins/camera/model/writer/ffmpeg.py | 57 +++---- .../camera/model/writer/preview/__init__.py | 2 +- .../camera/model/writer/preview/ffplay.py | 5 +- .../model/writer/preview/wx/__init__.py | 5 +- platypush/plugins/ffmpeg.py | 148 ++++++++++++++++++ 13 files changed, 360 insertions(+), 205 deletions(-) create mode 120000 platypush/backend/http/static/css/source/webpanel/plugins/camera.cv delete mode 100644 platypush/backend/http/static/css/source/webpanel/plugins/camera.cv/index.scss create mode 100644 platypush/plugins/camera/ffmpeg/__init__.py create mode 100644 platypush/plugins/camera/ffmpeg/model.py create mode 100644 platypush/plugins/ffmpeg.py diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/camera.cv b/platypush/backend/http/static/css/source/webpanel/plugins/camera.cv new file mode 120000 index 00000000..5f839334 --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/camera.cv @@ -0,0 +1 @@ +camera \ No newline at end of file diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/camera.cv/index.scss b/platypush/backend/http/static/css/source/webpanel/plugins/camera.cv/index.scss deleted file mode 100644 index f8fc3bcc..00000000 --- a/platypush/backend/http/static/css/source/webpanel/plugins/camera.cv/index.scss +++ /dev/null @@ -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; - } - } - } - } -} - diff --git a/platypush/plugins/camera/__init__.py b/platypush/plugins/camera/__init__.py index 761d550c..f51dd7a6 100644 --- a/platypush/plugins/camera/__init__.py +++ b/platypush/plugins/camera/__init__.py @@ -71,8 +71,10 @@ class CameraPlugin(Plugin, ABC): capture_timeout: Optional[float] = 20.0, scale_x: Optional[float] = 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, - vertical_flip: bool = False, video_type: Optional[str] = None, stream_format: str = 'mjpeg', - listen_port: Optional[int] = 5000, bind_address: str = '0.0.0.0', **kwargs): + vertical_flip: bool = False, input_format: Optional[str] = None, output_format: Optional[str] = None, + 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 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 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 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 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: - ``MJPEG`` (default) @@ -110,26 +117,29 @@ class CameraPlugin(Plugin, ABC): """ 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 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, - scale_y=scale_y, capture_timeout=capture_timeout, - video_type=video_type, fps=fps, stream_format=stream_format, - resolution=resolution, grayscale=grayscale, listen_port=listen_port, + scale_y=scale_y, capture_timeout=capture_timeout, fps=fps, + input_format=input_format, output_format=output_format, + stream_format=stream_format, resolution=resolution, + grayscale=grayscale, listen_port=listen_port, horizontal_flip=horizontal_flip, vertical_flip=vertical_flip, - bind_address=bind_address, frames_dir=os.path.abspath( - os.path.expanduser(frames_dir or _default_frames_dir))) + ffmpeg_bin=ffmpeg_bin, input_codec=input_codec, + 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._streams: Dict[Union[int, str], Camera] = {} def _merge_info(self, **info) -> CameraInfo: merged_info = self.camera_info.clone() - for k, v in info.items(): - if hasattr(merged_info, k): - setattr(merged_info, k, v) - + merged_info.set(**info) return merged_info 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 self.stop_preview(camera) - camera.start_event.clear() + self.release_device(camera) + camera.start_event.clear() if wait_capture: self.wait_capture(camera) - self.release_device(camera) if name in self._devices: del self._devices[name] @@ -356,7 +366,7 @@ class CameraPlugin(Plugin, ABC): if duration and camera.info.warmup_seconds: duration = duration + camera.info.warmup_seconds 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_processor = threading.Thread(target=self.frame_processor, @@ -373,6 +383,10 @@ class CameraPlugin(Plugin, ABC): frame_capture_start = time.time() try: frame = self.capture_frame(camera, **kwargs) + if not frame: + self.logger.warning('Invalid frame received, terminating the capture session') + break + frame_queue.put(frame) except AssertionError as e: self.logger.warning(str(e)) @@ -478,12 +492,13 @@ class CameraPlugin(Plugin, ABC): return image_file @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`. :param image_file: Path where the output image will be stored. :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 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, \ '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=camera, duration=duration, stream_format=stream_format)) - self._streams[camera.info.device] = camera - - camera.stream_event.set() camera.stream_thread.start() return self.status(camera.info.device) @@ -624,7 +639,7 @@ class CameraPlugin(Plugin, ABC): def _stop_streaming(self, camera: Camera): 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) if camera.info.device in self._streams: diff --git a/platypush/plugins/camera/ffmpeg/__init__.py b/platypush/plugins/camera/ffmpeg/__init__.py new file mode 100644 index 00000000..78ecc49e --- /dev/null +++ b/platypush/plugins/camera/ffmpeg/__init__.py @@ -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: diff --git a/platypush/plugins/camera/ffmpeg/model.py b/platypush/plugins/camera/ffmpeg/model.py new file mode 100644 index 00000000..3df754aa --- /dev/null +++ b/platypush/plugins/camera/ffmpeg/model.py @@ -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: diff --git a/platypush/plugins/camera/model/camera.py b/platypush/plugins/camera/model/camera.py index 40038bab..ce027ab4 100644 --- a/platypush/plugins/camera/model/camera.py +++ b/platypush/plugins/camera/model/camera.py @@ -12,48 +12,57 @@ from platypush.plugins.camera.model.writer.preview import PreviewWriter @dataclass class CameraInfo: 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 + ffmpeg_bin: Optional[str] = None + fps: Optional[float] = None frames_dir: Optional[str] = None - rotate: Optional[float] = None + grayscale: Optional[bool] = None 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_y: Optional[float] = None + stream_format: Optional[str] = None + vertical_flip: bool = False warmup_frames: int = 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): for k, v in kwargs.items(): - setattr(self, k, v) + if hasattr(self, k): + setattr(self, k, v) def to_dict(self) -> dict: return { - 'device': self.device, + 'bind_address': self.bind_address, + 'capture_timeout': self.capture_timeout, 'color_transform': self.color_transform, + 'device': self.device, + 'ffmpeg_bin': self.ffmpeg_bin, + 'fps': self.fps, 'frames_dir': self.frames_dir, - 'rotate': self.rotate, + 'grayscale': self.grayscale, '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_y': self.scale_y, + 'stream_format': self.stream_format, + 'vertical_flip': self.vertical_flip, 'warmup_frames': self.warmup_frames, '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): diff --git a/platypush/plugins/camera/model/writer/__init__.py b/platypush/plugins/camera/model/writer/__init__.py index 5f8713ab..04147f3f 100644 --- a/platypush/plugins/camera/model/writer/__init__.py +++ b/platypush/plugins/camera/model/writer/__init__.py @@ -9,8 +9,6 @@ from typing import Optional, IO from PIL.Image import Image -logger = logging.getLogger('video-writer') - class VideoWriter(ABC): """ @@ -21,6 +19,8 @@ class VideoWriter(ABC): def __init__(self, camera, plugin, *_, **__): from platypush.plugins.camera import Camera, CameraPlugin + + self.logger = logging.getLogger(self.__class__.__name__) self.camera: Camera = camera self.plugin: CameraPlugin = plugin self.closed = False @@ -60,9 +60,9 @@ class FileVideoWriter(VideoWriter, ABC): """ 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) - 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): @@ -98,7 +98,7 @@ class StreamWriter(VideoWriter, ABC): try: self.sock.write(data) except ConnectionError: - logger.warning('Client connection closed') + self.logger.info('Client connection closed') self.close() @abstractmethod diff --git a/platypush/plugins/camera/model/writer/cv.py b/platypush/plugins/camera/model/writer/cv.py index fa886c64..5f4e4d47 100644 --- a/platypush/plugins/camera/model/writer/cv.py +++ b/platypush/plugins/camera/model/writer/cv.py @@ -12,13 +12,13 @@ class CvFileWriter(FileVideoWriter): import cv2 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 = ( 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.)), ) - 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): if not self.writer: diff --git a/platypush/plugins/camera/model/writer/ffmpeg.py b/platypush/plugins/camera/model/writer/ffmpeg.py index 5aee1ae9..3026f020 100644 --- a/platypush/plugins/camera/model/writer/ffmpeg.py +++ b/platypush/plugins/camera/model/writer/ffmpeg.py @@ -1,54 +1,48 @@ -import logging import subprocess import threading import time from abc import ABC -from typing import Optional, List +from typing import Optional, Tuple from PIL.Image import Image +from platypush.plugins.camera.model.camera import Camera from platypush.plugins.camera.model.writer import VideoWriter, FileVideoWriter, StreamWriter -logger = logging.getLogger('ffmpeg-writer') - - class FFmpegWriter(VideoWriter, ABC): """ Generic FFmpeg encoder for camera frames. """ - def __init__(self, *args, input_file: str = '-', input_format: str = 'rawvideo', input_codec: Optional[str] = None, - output_file: str = '-', output_format: Optional[str] = None, output_codec: Optional[str] = None, - pix_fmt: Optional[str] = None, output_opts: Optional[List[str]] = None, **kwargs): + def __init__(self, *args, input_file: str = '-', input_format: str = 'rawvideo', output_file: str = '-', + output_format: Optional[str] = None, pix_fmt: Optional[str] = None, + output_opts: Optional[Tuple] = None, **kwargs): super().__init__(*args, **kwargs) self.input_file = input_file self.input_format = input_format - self.input_codec = input_codec - self.output_file = output_file self.output_format = output_format - self.output_codec = output_codec + self.output_file = output_file self.width, self.height = self.camera.effective_resolution() 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) @property def ffmpeg_args(self): - return ['ffmpeg', '-y', + return [self.camera.info.ffmpeg_bin, '-y', '-f', self.input_format, - *(('-vcodec', self.input_codec) if self.input_codec else ()), *(('-pix_fmt', self.pix_fmt) if self.pix_fmt else ()), '-s', '{}x{}'.format(self.width, self.height), '-r', str(self.camera.info.fps), '-i', self.input_file, *(('-f', self.output_format) if self.output_format else ()), *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] def is_closed(self): @@ -61,7 +55,7 @@ class FFmpegWriter(VideoWriter, ABC): try: self.ffmpeg.stdin.write(image.convert('RGB').tobytes()) except Exception as e: - logger.warning('FFmpeg send error: {}'.format(str(e))) + self.logger.warning('FFmpeg send error: {}'.format(str(e))) self.close() def close(self): @@ -77,7 +71,7 @@ class FFmpegWriter(VideoWriter, ABC): try: self.ffmpeg.wait(timeout=5.0) except subprocess.TimeoutExpired: - logger.warning('FFmpeg has not returned - killing it') + self.logger.warning('FFmpeg has not returned - killing it') self.ffmpeg.kill() if self.ffmpeg and self.ffmpeg.stdout: @@ -95,9 +89,9 @@ class FFmpegFileWriter(FileVideoWriter, FFmpegWriter): Write camera frames to a file using FFmpeg. """ - def __init__(self, *args, video_file: str, **kwargs): - FileVideoWriter.__init__(self, *args, video_file=video_file, **kwargs) - FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_file=self.video_file, **kwargs) + def __init__(self, *args, output_file: str, **kwargs): + FileVideoWriter.__init__(self, *args, output_file=output_file, **kwargs) + FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_file=self.output_file, **kwargs) class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC): @@ -105,12 +99,11 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC): 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) - FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_format=output_format, - output_opts=[ - '-tune', '-zerolatency', '-preset', 'superfast', '-trellis', '0', - '-fflags', 'nobuffer'], **kwargs) + FFmpegWriter.__init__(self, *args, pix_fmt='rgb24', output_format=output_format, output_opts=output_opts or ( + '-tune', 'zerolatency', '-preset', 'superfast', '-trellis', '0', + '-fflags', 'nobuffer'), **kwargs) self._reader = threading.Thread(target=self._reader_thread) self._reader.start() @@ -124,7 +117,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC): try: data = self.ffmpeg.stdout.read(1 << 15) except Exception as e: - logger.warning('FFmpeg reader error: {}'.format(str(e))) + self.logger.warning('FFmpeg reader error: {}'.format(str(e))) break if not data: @@ -132,7 +125,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC): if self.frame is None: 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: self.frame = data @@ -149,7 +142,7 @@ class FFmpegStreamWriter(StreamWriter, FFmpegWriter, ABC): try: self.ffmpeg.stdin.write(data) except Exception as e: - logger.warning('FFmpeg send error: {}'.format(str(e))) + self.logger.warning('FFmpeg send error: {}'.format(str(e))) self.close() def close(self): @@ -169,8 +162,10 @@ class MKVStreamWriter(FFmpegStreamWriter): class H264StreamWriter(FFmpegStreamWriter): mimetype = 'video/h264' - def __init__(self, *args, **kwargs): - super().__init__(*args, output_format='h264', **kwargs) + def __init__(self, camera: Camera, *args, **kwargs): + if not camera.info.output_codec: + camera.info.output_codec = 'libxvid' + super().__init__(camera, *args, output_format='h264', **kwargs) class H265StreamWriter(FFmpegStreamWriter): diff --git a/platypush/plugins/camera/model/writer/preview/__init__.py b/platypush/plugins/camera/model/writer/preview/__init__.py index 6039b45c..f4e7f2a5 100644 --- a/platypush/plugins/camera/model/writer/preview/__init__.py +++ b/platypush/plugins/camera/model/writer/preview/__init__.py @@ -4,7 +4,7 @@ from abc import ABC from platypush.plugins.camera.model.writer import VideoWriter -logger = logging.getLogger('cam-preview') +logger = logging.getLogger('platypush') class PreviewWriter(VideoWriter, ABC): diff --git a/platypush/plugins/camera/model/writer/preview/ffplay.py b/platypush/plugins/camera/model/writer/preview/ffplay.py index c1a3ce61..f7d9d829 100644 --- a/platypush/plugins/camera/model/writer/preview/ffplay.py +++ b/platypush/plugins/camera/model/writer/preview/ffplay.py @@ -5,8 +5,6 @@ import threading from platypush.plugins.camera.model.writer.image import MJPEGStreamWriter from platypush.plugins.camera.model.writer.preview import PreviewWriter -logger = logging.getLogger('cam-preview') - class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter): """ @@ -14,6 +12,7 @@ class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter): """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.logger = logging.getLogger(self.__class__.__name__) self.ffplay = subprocess.Popen(['ffplay', '-'], stdin=subprocess.PIPE) self._preview_thread = threading.Thread(target=self._ffplay_thread) self._preview_thread.start() @@ -28,7 +27,7 @@ class FFplayPreviewWriter(PreviewWriter, MJPEGStreamWriter): try: self.ffplay.stdin.write(self.frame) except Exception as e: - logger.warning('ffplay write error: {}'.format(str(e))) + self.logger.warning('ffplay write error: {}'.format(str(e))) self.close() break diff --git a/platypush/plugins/camera/model/writer/preview/wx/__init__.py b/platypush/plugins/camera/model/writer/preview/wx/__init__.py index c0b60115..fc695022 100644 --- a/platypush/plugins/camera/model/writer/preview/wx/__init__.py +++ b/platypush/plugins/camera/model/writer/preview/wx/__init__.py @@ -1,11 +1,8 @@ -import logging from multiprocessing import Process, Queue, Event from platypush.plugins.camera.model.writer import VideoWriter from platypush.plugins.camera.model.writer.preview import PreviewWriter -logger = logging.getLogger('cam-preview') - class WxPreviewWriter(PreviewWriter, Process): """ @@ -47,7 +44,7 @@ class WxPreviewWriter(PreviewWriter, Process): try: self.bitmap_queue.put(image) 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: diff --git a/platypush/plugins/ffmpeg.py b/platypush/plugins/ffmpeg.py new file mode 100644 index 00000000..50b44532 --- /dev/null +++ b/platypush/plugins/ffmpeg.py @@ -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: