From 062b38267963625c3fdf92d5cf9be1e019f7d2f3 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Sat, 16 Nov 2013 19:11:35 +0200 Subject: [PATCH] added a media previewer for movies --- doc/todo.txt | 2 +- motioneye.py | 4 +- src/handlers.py | 174 +++++++++++++++++++++++++++++++++++--------- src/mediafiles.py | 133 +++++++++++++++++++-------------- src/remote.py | 49 ++++++++++--- src/server.py | 2 +- static/css/main.css | 2 +- static/js/main.js | 13 ++-- 8 files changed, 272 insertions(+), 107 deletions(-) diff --git a/doc/todo.txt b/doc/todo.txt index d9bf545..34719ed 100644 --- a/doc/todo.txt +++ b/doc/todo.txt @@ -1,5 +1,4 @@ -> layout seems to be too wide for a modern mobile phone --> add a previewer for movies -> make camera frames positions configurable -> add a view log functionality @@ -16,3 +15,4 @@ -> browser compatibility test -> requirements test + \ No newline at end of file diff --git a/motioneye.py b/motioneye.py index 830a432..9a89434 100755 --- a/motioneye.py +++ b/motioneye.py @@ -186,8 +186,8 @@ def _start_cleanup(): return try: - mediafiles.cleanup_pictures() - mediafiles.cleanup_movies() + mediafiles.cleanup_media('picture') + mediafiles.cleanup_media('movie') except Exception as e: logging.error('failed to cleanup media files: %(msg)s' % { diff --git a/src/handlers.py b/src/handlers.py index 5bd95e8..f32b62f 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -19,10 +19,8 @@ import base64 import json import logging import os -import StringIO from tornado.web import RequestHandler, HTTPError, asynchronous -from PIL import Image import config import mediafiles @@ -590,15 +588,16 @@ class PictureHandler(BaseHandler): self.finish_json(remote_list) - remote.list_pictures( + remote.list_media( camera_config.get('@host'), camera_config.get('@port'), camera_config.get('@username'), camera_config.get('@password'), - camera_config.get('@remote_camera_id'), on_response) + camera_config.get('@remote_camera_id'), on_response, + media_type='picture') else: - pictures = mediafiles.list_pictures(camera_config) + pictures = mediafiles.list_media(camera_config, media_type='picture') self.finish_json({ 'mediaList': pictures, @@ -633,7 +632,7 @@ class PictureHandler(BaseHandler): self.finish(response) - remote.get_media( + remote.get_media_content( camera_config.get('@host'), camera_config.get('@port'), camera_config.get('@username'), @@ -644,7 +643,7 @@ class PictureHandler(BaseHandler): media_type='picture') else: - content = mediafiles.get_media_content(camera_config, filename) + content = mediafiles.get_media_content(camera_config, filename, 'picture') pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) self.set_header('Content-Type', 'image/jpeg') @@ -678,7 +677,7 @@ class PictureHandler(BaseHandler): self.set_header('Content-Type', 'image/jpeg') self.finish(response) - remote.get_media( + remote.get_media_preview( camera_config.get('@host'), camera_config.get('@port'), camera_config.get('@username'), @@ -691,55 +690,164 @@ class PictureHandler(BaseHandler): height=self.get_argument('height', None)) else: - content = mediafiles.get_media_content(camera_config, filename) - self.set_header('Content-Type', 'image/jpeg') - - width = self.get_argument('width', None) - height = self.get_argument('height', None) - - if width is None and height is None: - return self.finish(content) - - sio = StringIO.StringIO(content) - image = Image.open(sio) + content = mediafiles.get_media_preview(camera_config, filename, 'picture', + width=self.get_argument('width', None), + height=self.get_argument('height', None)) - width = width and int(width) or image.size[0] - height = height and int(height) or image.size[1] - - image.thumbnail((width, height), Image.ANTIALIAS) - - image.save(self, format='JPEG') - self.finish() + self.set_header('Content-Type', 'image/jpeg') + self.finish(content) class MovieHandler(BaseHandler): @asynchronous def get(self, camera_id, op, filename=None): + if camera_id is not None: + camera_id = int(camera_id) + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + if op == 'list': self.list(camera_id) elif op == 'download': self.download(camera_id, filename) + elif op == 'preview': + self.preview(camera_id, filename) + else: raise HTTPError(400, 'unknown operation') - - @BaseHandler.auth() + + @BaseHandler.auth() def list(self, camera_id): logging.debug('listing movies for camera %(id)s' % {'id': camera_id}) + + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + camera_config = config.get_camera(camera_id) + if camera_config['@proto'] != 'v4l2': + def on_response(remote_list): + camera_url = remote.make_remote_camera_url( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@remote_camera_id')) + + camera_full_url = camera_config['@proto'] + '://' + camera_url + + if remote_list is None: + return self.finish_json({'error': 'Failed to get movie list for %(url)s.' % { + 'url': camera_full_url}}) - # TODO implement me + self.finish_json(remote_list) + + remote.list_media( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@username'), + camera_config.get('@password'), + camera_config.get('@remote_camera_id'), on_response, + media_type='movie') + + else: + movies = mediafiles.list_media(camera_config, media_type='movie') + + self.finish_json({ + 'mediaList': movies, + 'cameraName': camera_config['@name'] + }) - self.finish_json() - @BaseHandler.auth() def download(self, camera_id, filename): logging.debug('downloading movie %(filename)s of camera %(id)s' % { 'filename': filename, 'id': camera_id}) + + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + camera_config = config.get_camera(camera_id) + if camera_config['@proto'] != 'v4l2': + def on_response(response): + camera_url = remote.make_remote_camera_url( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@remote_camera_id')) + + camera_full_url = camera_config['@proto'] + '://' + camera_url + + if response is None: + return self.finish_json({'error': 'Failed to download movie from %(url)s.' % { + 'url': camera_full_url}}) + + pretty_filename = os.path.basename(filename) # no camera name available w/o additional request + self.set_header('Content-Type', 'video/mpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(response) + + remote.get_media_content( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@username'), + camera_config.get('@password'), + camera_config.get('@remote_camera_id'), + on_response, + filename=filename, + media_type='movie') + + else: + content = mediafiles.get_media_content(camera_config, filename, 'movie') + + pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename) + self.set_header('Content-Type', 'video/mpeg') + self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';') + + self.finish(content) - # TODO implement me + @BaseHandler.auth() + def preview(self, camera_id, filename): + logging.debug('previewing movie %(filename)s of camera %(id)s' % { + 'filename': filename, 'id': camera_id}) - self.finish_json() + if camera_id not in config.get_camera_ids(): + raise HTTPError(404, 'no such camera') + + camera_config = config.get_camera(camera_id) + if camera_config['@proto'] != 'v4l2': + def on_response(response): + camera_url = remote.make_remote_camera_url( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@remote_camera_id')) + + camera_full_url = camera_config['@proto'] + '://' + camera_url + + if response is None: + return self.finish_json({'error': 'Failed to get movie preview for %(url)s.' % { + 'url': camera_full_url}}) + + self.set_header('Content-Type', 'image/jpeg') + self.finish(response) + + remote.get_media_preview( + camera_config.get('@host'), + camera_config.get('@port'), + camera_config.get('@username'), + camera_config.get('@password'), + camera_config.get('@remote_camera_id'), + on_response, + filename=filename, + media_type='movie', + width=self.get_argument('width', None), + height=self.get_argument('height', None)) + + else: + content = mediafiles.get_media_preview(camera_config, filename, 'movie', + width=self.get_argument('width', None), + height=self.get_argument('height', None)) + + self.set_header('Content-Type', 'image/jpeg') + self.finish(content) class UpdateHandler(BaseHandler): diff --git a/src/mediafiles.py b/src/mediafiles.py index c346b36..5186370 100644 --- a/src/mediafiles.py +++ b/src/mediafiles.py @@ -18,6 +18,10 @@ import datetime import logging import os.path +import StringIO +import subprocess + +from PIL import Image import config import utils @@ -56,68 +60,70 @@ def _remove_older_files(dir, moment, exts): os.remove(full_path) -def cleanup_pictures(): - logging.debug('cleaning up pictures...') +def cleanup_media(media_type): + logging.debug('cleaning up %(media_type)ss...' % {'media_type': media_type}) + if media_type == 'picture': + exts = _PICTURE_EXTS + + elif media_type == 'movie': + exts = _MOVIE_EXTS + for camera_id in config.get_camera_ids(): camera_config = config.get_camera(camera_id) if camera_config.get('@proto') != 'v4l2': continue - preserve_pictures = camera_config.get('@preserve_pictures') - if preserve_pictures == 0: + preserve_media = camera_config.get('@preserve_%(media_type)ss' % {'media_type': media_type}, 0) + if preserve_media == 0: return # preserve forever - preserve_moment = datetime.datetime.now() - datetime.timedelta(days=preserve_pictures) + preserve_moment = datetime.datetime.now() - datetime.timedelta(days=preserve_media) target_dir = camera_config.get('target_dir') - _remove_older_files(target_dir, preserve_moment, exts=_PICTURE_EXTS) + _remove_older_files(target_dir, preserve_moment, exts=exts) -def cleanup_movies(): - logging.debug('cleaning up movies...') +def make_movie_preview(camera_config, full_path): + logging.debug('creating movie preview for %(path)s...' % {'path': full_path}) - for camera_id in config.get_camera_ids(): - camera_config = config.get_camera(camera_id) - if camera_config.get('@proto') != 'v4l2': - continue - - preserve_movies = camera_config.get('@preserve_movies') - if preserve_movies == 0: - return # preserve forever + framerate = camera_config['framerate'] + pre_capture = camera_config['pre_capture'] + offs = pre_capture / framerate + + cmd = 'ffmpeg -i "%(path)s" -f mjpeg -vframes 1 -ss %(offs)s -y %(path)s.thumb' % { + 'path': full_path, 'offs': offs} + + try: + subprocess.check_call(cmd, shell=True) + + except subprocess.CalledProcessError as e: + logging.error('failed to create movie preview for %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) - preserve_moment = datetime.datetime.now() - datetime.timedelta(days=preserve_movies) - - target_dir = camera_config.get('target_dir') - _remove_older_files(target_dir, preserve_moment, exts=_MOVIE_EXTS) + return None + + return full_path + '.thumb' -def list_pictures(camera_config): +def list_media(camera_config, media_type): target_dir = camera_config.get('target_dir') -# output_all = camera_config.get('output_all') -# output_normal = camera_config.get('output_normal') -# jpeg_filename = camera_config.get('jpeg_filename') -# snapshot_interval = camera_config.get('snapshot_interval') -# snapshot_filename = camera_config.get('snapshot_filename') -# -# if (output_all or output_normal) and jpeg_filename: -# filename = jpeg_filename -# -# elif snapshot_interval and snapshot_filename: -# filename = snapshot_filename -# -# else: -# return [] - - full_paths = _list_media_files(target_dir, exts=_PICTURE_EXTS) - picture_files = [] + + if media_type == 'picture': + exts = _PICTURE_EXTS + + elif media_type == 'movie': + exts = _MOVIE_EXTS + + full_paths = _list_media_files(target_dir, exts=exts) + media_files = [] for p in full_paths: path = p[len(target_dir):] if not path.startswith('/'): path = '/' + path - picture_files.append({ + media_files.append({ 'path': path, 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(os.path.getmtime(p))), 'sizeStr': utils.pretty_size(os.path.getsize(p)), @@ -126,36 +132,57 @@ def list_pictures(camera_config): # TODO files listed here may not belong to the given camera - return picture_files + return media_files -def list_movies(camera_config): +def get_media_content(camera_config, path, media_type): target_dir = camera_config.get('target_dir') - full_paths = _list_media_files(target_dir, exts=_MOVIE_EXTS) - movie_files = [{ - 'path': p[len(target_dir):], - 'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(os.path.getmtime(p))), - 'sizeStr': utils.pretty_size(os.path.getsize(p)), - 'timestamp': os.path.getmtime(p) - } for p in full_paths] + full_path = os.path.join(target_dir, path) - # TODO files listed here may not belong to the given camera + try: + with open(full_path) as f: + return f.read() - return movie_files + except Exception as e: + logging.error('failed to read file %(path)s: %(msg)s' % { + 'path': full_path, 'msg': unicode(e)}) + + return None -def get_media_content(camera_config, path): +def get_media_preview(camera_config, path, media_type, width, height): target_dir = camera_config.get('target_dir') - full_path = os.path.join(target_dir, path) + if media_type == 'movie': + if not os.path.exists(full_path + '.thumb'): + if not make_movie_preview(camera_config, full_path): + return None + + full_path += '.thumb' + try: with open(full_path) as f: - return f.read() + content = f.read() except Exception as e: logging.error('failed to read file %(path)s: %(msg)s' % { 'path': full_path, 'msg': unicode(e)}) return None + + if width is height is None: + return content + + sio = StringIO.StringIO(content) + image = Image.open(sio) + width = width and int(width) or image.size[0] + height = height and int(height) or image.size[1] + + image.thumbnail((width, height), Image.LINEAR) + + sio = StringIO.StringIO() + image.save(sio, format='JPEG') + + return sio.getvalue() diff --git a/src/remote.py b/src/remote.py index 7766293..6a976a0 100644 --- a/src/remote.py +++ b/src/remote.py @@ -208,17 +208,18 @@ def current_picture(host, port, username, password, camera_id, callback): http_client.fetch(request, on_response) -def list_pictures(host, port, username, password, camera_id, callback): - logging.debug('getting picture list for remote camera %(id)s on %(host)s:%(port)s' % { +def list_media(host, port, username, password, camera_id, callback, media_type): + logging.debug('getting media list for remote camera %(id)s on %(host)s:%(port)s' % { 'id': camera_id, 'host': host, 'port': port}) - request = _make_request(host, port, username, password, '/picture/%(id)s/list/' % {'id': camera_id}) + request = _make_request(host, port, username, password, '/%(media_type)s/%(id)s/list/' % { + 'id': camera_id, 'media_type': media_type}) def on_response(response): if response.error: - logging.error('failed to get picture list for remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % { + logging.error('failed to get media list for remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % { 'id': camera_id, 'host': host, 'port': port, @@ -243,16 +244,46 @@ def list_pictures(host, port, username, password, camera_id, callback): http_client.fetch(request, on_response) -def get_media(host, port, username, password, camera_id, callback, filename, media_type, width=None, height=None): - logging.debug('getting file %(filename)s of remote camera %(id)s on %(host)s:%(port)s' % { +def get_media_content(host, port, username, password, camera_id, callback, filename, media_type): + logging.debug('downloading file %(filename)s of remote camera %(id)s on %(host)s:%(port)s' % { 'filename': filename, 'id': camera_id, 'host': host, 'port': port}) - uri = '/%(media_type)s/%(id)s/%(op)s/%(filename)s?' % { + uri = '/%(media_type)s/%(id)s/download/%(filename)s?' % { + 'media_type': media_type, + 'id': camera_id, + 'filename': filename} + + request = _make_request(host, port, username, password, uri) + + def on_response(response): + if response.error: + logging.error('failed to download file %(filename)s of remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % { + 'filename': filename, + 'id': camera_id, + 'host': host, + 'port': port, + 'msg': unicode(response.error)}) + + return callback(None) + + return callback(response.body) + + http_client = AsyncHTTPClient() + http_client.fetch(request, on_response) + + +def get_media_preview(host, port, username, password, camera_id, callback, filename, media_type, width, height): + logging.debug('getting file preview for %(filename)s of remote camera %(id)s on %(host)s:%(port)s' % { + 'filename': filename, + 'id': camera_id, + 'host': host, + 'port': port}) + + uri = '/%(media_type)s/%(id)s/preview/%(filename)s?' % { 'media_type': media_type, - 'op': 'preview' if (width or height) else 'download', 'id': camera_id, 'filename': filename} @@ -266,7 +297,7 @@ def get_media(host, port, username, password, camera_id, callback, filename, med def on_response(response): if response.error: - logging.error('failed to get media file %(filename)s of remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % { + logging.error('failed to get file preview for %(filename)s of remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % { 'filename': filename, 'id': camera_id, 'host': host, diff --git a/src/server.py b/src/server.py index f7ead25..2dd0288 100644 --- a/src/server.py +++ b/src/server.py @@ -47,7 +47,7 @@ application = Application( (r'^/picture/(?P\d+)/(?Pcurrent|list)/?$', handlers.PictureHandler), (r'^/picture/(?P\d+)/(?Pdownload|preview)/(?P.+)/?$', handlers.PictureHandler), (r'^/movie/(?P\d+)/(?Plist)/?$', handlers.MovieHandler), - (r'^/movie/(?P\d+)/(?Pdownload)/(?P.+)/?$', handlers.MovieHandler), + (r'^/movie/(?P\d+)/(?Pdownload|preview)/(?P.+)/?$', handlers.MovieHandler), (r'^/update/?$', handlers.UpdateHandler), ], debug=True, # enables autoreload diff --git a/static/css/main.css b/static/css/main.css index b70020e..5b6fde4 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -477,7 +477,7 @@ div.picture-dialog-prev-arrow, div.picture-dialog-next-arrow { position: absolute; top: 45%; - background-color: rgba(0, 0, 0, 0.3); + background-color: rgba(0, 0, 0, 0.6); background-image: url(../img/arrows.svg); background-size: cover; width: 3em; diff --git a/static/js/main.js b/static/js/main.js index 55fff92..c34c372 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1012,7 +1012,7 @@ function runConfirmDialog(message, onYes) { runModalDialog({title: message, buttons: 'yesno', onYes: onYes}); } -function runPictureDialog(entries, pos) { +function runPictureDialog(entries, pos, mediaType) { var content = $('
'); var img = $(''); @@ -1045,7 +1045,7 @@ function runPictureDialog(entries, pos) { progressImg.css('left', (img.parent().width() - progressImg.width()) / 2); progressImg.css('top', (img.parent().height() - progressImg.height()) / 2); - img.attr('src', '/picture/' + entry.cameraId + '/preview' + entry.path); + img.attr('src', '/' + mediaType + '/' + entry.cameraId + '/preview' + entry.path); img.load(function () { img.width(width); updateModalDialogPosition(); @@ -1082,7 +1082,8 @@ function runPictureDialog(entries, pos) { buttons: [ {caption: 'Close'}, {caption: 'Download', isDefault: true, click: function () { - window.location.href = img.attr('src').replace('preview', 'download'); + var entry = entries[pos]; + window.location.href = '/' + mediaType + '/' + entry.cameraId + '/download' + entry.path; return false; }} @@ -1396,9 +1397,7 @@ function runMediaDialog(cameraId, mediaType) { }; entryDiv[0]._onClick = function () { - if (mediaType === 'picture') { - runPictureDialog(entries, pos); - } + runPictureDialog(entries, pos, mediaType); }; entry.div = entryDiv; @@ -1500,7 +1499,7 @@ function addCameraFrameUi(cameraId, cameraName, framerate) { '' + '
' + '
' + -// '
' + + '
' + '
' + // '
' + '
' + -- 2.39.5