Added FFmpeg camera plugin [relates to #150]
This commit is contained in:
parent
b21193dc74
commit
df1e03f0af
13 changed files with 360 additions and 205 deletions
|
@ -0,0 +1 @@
|
|||
camera
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
83
platypush/plugins/camera/ffmpeg/__init__.py
Normal file
83
platypush/plugins/camera/ffmpeg/__init__.py
Normal 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:
|
24
platypush/plugins/camera/ffmpeg/model.py
Normal file
24
platypush/plugins/camera/ffmpeg/model.py
Normal 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:
|
|
@ -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():
|
||||
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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
148
platypush/plugins/ffmpeg.py
Normal file
148
platypush/plugins/ffmpeg.py
Normal 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:
|
Loading…
Reference in a new issue