From 418cdd2737a8e5566cc3f000d7bdffc22f9fdf47 Mon Sep 17 00:00:00 2001 From: Calin Crisan <ccrisan@gmail.com> Date: Sun, 28 Sep 2014 15:09:15 +0300 Subject: [PATCH] implemented timelapse support --- src/handlers.py | 13 +-- src/mediafiles.py | 216 ++++++++++++++++++++++++++------------------ src/remote.py | 17 ++-- static/css/main.css | 2 +- static/js/main.js | 51 +++++++++-- 5 files changed, 190 insertions(+), 109 deletions(-) diff --git a/src/handlers.py b/src/handlers.py index 2f48811..6f9d4ad 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -844,7 +844,7 @@ class PictureHandler(BaseHandler): 'group': group, 'id': camera_id, 'key': key}) self.finish_json({'key': key}) - mediafiles.get_zipped_content(camera_config, media_type='picture', callback=on_zip, prefix=group) + mediafiles.get_zipped_content(camera_config, media_type='picture', callback=on_zip, group=group) else: # remote camera def on_response(response=None, error=None): @@ -856,7 +856,7 @@ class PictureHandler(BaseHandler): logging.debug('prepared zip file for group %(group)s of camera %(id)s with key %(key)s' % { 'group': group, 'id': camera_id, 'key': key}) self.finish_json({'key': key}) - + remote.get_zipped_content(camera_config, media_type='picture', callback=on_response, group=group) @BaseHandler.auth() @@ -889,9 +889,10 @@ class PictureHandler(BaseHandler): else: interval = int(self.get_argument('interval')) + speed = int(self.get_argument('speed')) - logging.debug('preparing timelapse movie for group %(group)s of camera %(id)s with interval %(int)s' % { - 'group': group, 'id': camera_id, 'int': interval}) + logging.debug('preparing timelapse movie for group %(group)s of camera %(id)s with rate %(speed)s/%(int)s' % { + 'group': group, 'id': camera_id, 'speed': speed, 'int': interval}) camera_config = config.get_camera(camera_id) if utils.local_camera(camera_config): @@ -904,7 +905,7 @@ class PictureHandler(BaseHandler): 'group': group, 'id': camera_id, 'key': key}) self.finish_json({'key': key}) - mediafiles.get_timelapse_movie(camera_config, interval, callback=on_timelapse, group=group) + mediafiles.get_timelapse_movie(camera_config, speed, interval, callback=on_timelapse, group=group) else: # remote camera def on_response(response=None, error=None): @@ -917,7 +918,7 @@ class PictureHandler(BaseHandler): 'group': group, 'id': camera_id, 'key': key}) self.finish_json({'key': key}) - remote.get_timelapse_movie(camera_config, interval, callback=on_response, group=group) + remote.get_timelapse_movie(camera_config, speed, interval, callback=on_response, group=group) def try_finish(self, content): try: diff --git a/src/mediafiles.py b/src/mediafiles.py index 38730ff..1ea4e39 100644 --- a/src/mediafiles.py +++ b/src/mediafiles.py @@ -281,7 +281,7 @@ def list_media(camera_config, media_type, callback, prefix=None): now = datetime.datetime.now() delta = now - started if delta.seconds < 120: - ioloop.add_timeout(datetime.timedelta(seconds=0.1), poll_process) + ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process) read_media_list() else: # process did not finish within 2 minutes @@ -313,7 +313,7 @@ def get_media_content(camera_config, path, media_type): return None -def get_zipped_content(camera_config, media_type, callback, prefix): +def get_zipped_content(camera_config, media_type, callback, group): target_dir = camera_config.get('target_dir') if media_type == 'picture': @@ -327,7 +327,7 @@ def get_zipped_content(camera_config, media_type, callback, prefix): # create a subprocess to add files to zip def do_zip(pipe): - mf = _list_media_files(target_dir, exts=exts, prefix=prefix) + mf = _list_media_files(target_dir, exts=exts, prefix=group) paths = [] for (p, st) in mf: # @UnusedVariable path = p[len(target_dir):] @@ -405,90 +405,134 @@ def get_zipped_content(camera_config, media_type, callback, prefix): poll_process() -def get_timelapse_movie(camera_config, interval, callback, prefix): +def get_timelapse_movie(camera_config, speed, interval, callback, group): target_dir = camera_config.get('target_dir') + + # create a subprocess to retrieve media files + def do_list_media(pipe): + mf = _list_media_files(target_dir, exts=_PICTURE_EXTS, prefix=group) + for (p, st) in mf: + timestamp = st.st_mtime -# working = multiprocessing.Value('b') -# working.value = True - -# # create a subprocess to create the files -# def do_zip(pipe): -# mf = _list_media_files(target_dir, exts=exts, prefix=prefix) -# paths = [] -# for (p, st) in mf: # @UnusedVariable -# path = p[len(target_dir):] -# if path.startswith('/'): -# path = path[1:] -# -# paths.append(path) -# -# zip_filename = os.path.join(settings.MEDIA_PATH, '.zip-%s' % int(time.time())) -# logging.debug('adding %d files to zip file "%s"' % (len(paths), zip_filename)) -# -# try: -# with zipfile.ZipFile(zip_filename, mode='w') as f: -# for path in paths: -# full_path = os.path.join(target_dir, path) -# f.write(full_path, path) -# -# except Exception as e: -# logging.error('failed to create zip file "%s": %s' % (zip_filename, e)) -# -# working.value = False -# pipe.close() -# return -# -# logging.debug('reading zip file "%s" into memory' % zip_filename) -# -# try: -# with open(zip_filename, mode='r') as f: -# data = f.read() -# -# working.value = False -# pipe.send(data) -# logging.debug('zip data ready') -# -# except Exception as e: -# logging.error('failed to read zip file "%s": %s' % (zip_filename, e)) -# working.value = False -# -# finally: -# os.remove(zip_filename) -# pipe.close() -# -# logging.debug('starting zip process...') -# -# (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) -# process = multiprocessing.Process(target=do_zip, args=(child_pipe, )) -# process.start() -# -# # poll the subprocess to see when it has finished -# started = datetime.datetime.now() -# -# def poll_process(): -# ioloop = tornado.ioloop.IOLoop.instance() -# if working.value: -# now = datetime.datetime.now() -# delta = now - started -# if delta.seconds < settings.ZIP_TIMEOUT: -# ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_process) -# -# else: # process did not finish within 2 minutes -# logging.error('timeout waiting for the zip process to finish') -# -# callback(None) -# -# else: # finished -# try: -# data = parent_pipe.recv() -# logging.debug('zip process has returned %d bytes' % len(data)) -# -# except: -# data = None -# -# callback(data) -# -# poll_process() + pipe.send({ + 'path': p, + 'timestamp': timestamp + }) + + pipe.close() + + logging.debug('starting media listing process...') + + (parent_pipe, child_pipe) = multiprocessing.Pipe(duplex=False) + process = [multiprocessing.Process(target=do_list_media, args=(child_pipe, ))] + process[0].start() + + started = [datetime.datetime.now()] + media_list = [] + + tmp_filename = os.path.join(settings.MEDIA_PATH, '.%s.avi' % int(time.time())) + + def read_media_list(): + while parent_pipe.poll(): + media_list.append(parent_pipe.recv()) + + def poll_media_list_process(): + ioloop = tornado.ioloop.IOLoop.instance() + if process[0].is_alive(): # not finished yet + now = datetime.datetime.now() + delta = now - started[0] + if delta.seconds < 120: + ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_media_list_process) + read_media_list() + + else: # process did not finish within 2 minutes + logging.error('timeout waiting for the media listing process to finish') + + callback(None) + + else: # finished + read_media_list() + logging.debug('media listing process has returned %(count)s files' % {'count': len(media_list)}) + + if not media_list: + return callback(None) + + pictures = select_pictures(media_list) + make_movie(pictures) + + def select_pictures(media_list): + media_list.sort(key=lambda e: e['timestamp']) + start = media_list[0]['timestamp'] + slices = {} + max_idx = 0 + for m in media_list: + offs = m['timestamp'] - start + pos = float(offs) / interval - 0.5 + idx = int(round(pos)) + max_idx = idx + m['delta'] = abs(pos - idx) + slices.setdefault(idx, []).append(m) + + selected = [] + for i in xrange(max_idx + 1): + slice = slices.get(i) + if not slice: + continue + + selected.append(min(slice, key=lambda m: m['delta'])) + + return selected + + def make_movie(pictures): + cmd = 'rm -f %(tmp_filename)s;' + cmd += 'cat %(jpegs)s | ffmpeg -framerate %(speed)s/%(interval)s -f image2pipe -vcodec mjpeg -i - -vcodec mpeg4 -b %(bitrate)s -f avi %(tmp_filename)s' + + bitrate = 9999990 + + cmd = cmd % { + 'tmp_filename': tmp_filename, + 'jpegs': ' '.join((p['path'] for p in pictures)), + 'interval': interval, + 'speed': speed, + 'bitrate': bitrate + } + process[0] = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True) + started[0] = datetime.datetime.now() + + poll_movie_process() + + def poll_movie_process(): + ioloop = tornado.ioloop.IOLoop.instance() + if process[0].poll() is None: # not finished yet + now = datetime.datetime.now() + delta = now - started[0] + if delta.seconds < 120: + ioloop.add_timeout(datetime.timedelta(seconds=0.5), poll_movie_process) + + else: # process did not finish within 2 minutes + logging.error('timeout waiting for the timelapse movie process to finish') + + callback(None) + + else: # finished + logging.debug('reading timelapse movie file "%s" into memory' % tmp_filename) + + try: + with open(tmp_filename, mode='r') as f: + data = f.read() + + logging.debug('timelapse movie process has returned %d bytes' % len(data)) + + except Exception as e: + logging.error('failed to read timelapse movie file "%s": %s' % (tmp_filename, e)) + return callback(None) + + finally: + os.remove(tmp_filename) + + callback(data) + + poll_media_list_process() def get_media_preview(camera_config, path, media_type, width, height): @@ -591,7 +635,7 @@ def get_picture_cache(camera_id, sequence, width): def get_prepared_cache(key): - return _prepared_files.get(key) + return _prepared_files.pop(key, None) def set_prepared_cache(data): diff --git a/src/remote.py b/src/remote.py index 466f1e5..ad0e804 100644 --- a/src/remote.py +++ b/src/remote.py @@ -333,7 +333,7 @@ def get_media_content(local_config, callback, filename, media_type): http_client.fetch(request, on_response) -def get_zipped_content(local_config, callback, group, media_type): +def get_zipped_content(local_config, media_type, callback, group): host = local_config.get('@host', local_config.get('host')) port = local_config.get('@port', local_config.get('port')) username = local_config.get('@username', local_config.get('username')) @@ -401,7 +401,7 @@ def get_zipped_content(local_config, callback, group, media_type): http_client.fetch(request, on_prepare) -def get_timelapse_movie(local_config, interval, callback, group): +def get_timelapse_movie(local_config, speed, interval, callback, group): host = local_config.get('@host', local_config.get('host')) port = local_config.get('@port', local_config.get('port')) username = local_config.get('@username', local_config.get('username')) @@ -409,15 +409,17 @@ def get_timelapse_movie(local_config, interval, callback, group): uri = local_config.get('@uri', local_config.get('uri')) or '' camera_id = local_config.get('@remote_camera_id', local_config.get('remote_camera_id')) - logging.debug('downloading timelapse movie for group %(group)s of remote camera %(id)s with interval %(int)s on %(url)s' % { + logging.debug('downloading timelapse movie for group %(group)s of remote camera %(id)s with rate %(speed)s/%(int)s on %(url)s' % { 'group': group, 'id': camera_id, + 'speed': speed, 'int': interval, 'url': make_camera_url(local_config)}) - prepare_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s' % { + prepare_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?interval=%(int)s&speed=%(speed)s' % { 'id': camera_id, 'int': interval, + 'speed': speed, 'group': group} # timeout here is 100 times larger than usual - we expect a big delay @@ -425,11 +427,12 @@ def get_timelapse_movie(local_config, interval, callback, group): def on_prepare(response): if response.error: - logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with interval %(int)s on %(url)s: %(msg)s' % { + logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with rate %(speed)s/%(int)s on %(url)s: %(msg)s' % { 'group': group, 'id': camera_id, 'url': make_camera_url(local_config), 'int': interval, + 'speed': speed, 'msg': unicode(response.error)}) return callback(error=unicode(response.error)) @@ -447,18 +450,18 @@ def get_timelapse_movie(local_config, interval, callback, group): download_uri = uri + '/picture/%(id)s/timelapse/%(group)s/?key=%(key)s' % { 'id': camera_id, 'group': group, - 'int': interval, 'key': key} request = _make_request(host, port, username, password, download_uri, timeout=100 * settings.REMOTE_REQUEST_TIMEOUT) def on_download(response): if response.error: - logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with interval %(int)s on %(url)s: %(msg)s' % { + logging.error('failed to download timelapse movie for group %(group)s of remote camera %(id)s with rate %(speed)s/%(int)s on %(url)s: %(msg)s' % { 'group': group, 'id': camera_id, 'url': make_camera_url(local_config), 'int': interval, + 'speed': speed, 'msg': unicode(response.error)}) return callback(error=unicode(response.error)) diff --git a/static/css/main.css b/static/css/main.css index b223588..ca4897d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -671,7 +671,7 @@ img.picture-dialog-progress { opacity: 0.7; } -table.timelapse-dialog select#intervalSelect { +table.timelapse-dialog select { width: 10em; } diff --git a/static/js/main.js b/static/js/main.js index c16b375..ec05646 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1855,11 +1855,11 @@ function runTimelapseDialog(cameraId, groupKey, group) { '<td class="dialog-item-value">' + groupKey + '</td>' + '</tr>' + '<tr>' + - '<td class="dialog-item-label"><span class="dialog-item-label">Include a picture every</span></td>' + + '<td class="dialog-item-label"><span class="dialog-item-label">Include a picture taken every</span></td>' + '<td class="dialog-item-value">' + '<select class="styled timelapse" id="intervalSelect">' + '<option value="1">second</option>' + - '<option value="10">5 seconds</option>' + + '<option value="5">5 seconds</option>' + '<option value="10">10 seconds</option>' + '<option value="30">30 seconds</option>' + '<option value="60">minute</option>' + @@ -1869,11 +1869,26 @@ function runTimelapseDialog(cameraId, groupKey, group) { '<option value="3600">hour</option>' + '</select>' + '</td>' + - '<td><span class="help-mark" title="select the interval of time between each two successive pictures included in the movie">?</span></td>' + + '<td><span class="help-mark" title="choose the interval of time between two selected pictures">?</span></td>' + + '</tr>' + + '<tr>' + + '<td class="dialog-item-label"><span class="dialog-item-label">Timelapse speed factor</span></td>' + + '<td class="dialog-item-value">' + + '<select class="styled timelapse" id="speedSelect">' + + '<option value="1">1x</option>' + + '<option value="10">10x</option>' + + '<option value="100">100x</option>' + + '<option value="1000">1000x</option>' + + '<option value="10000">10000x</option>' + + '<option value="100000">100000x</option>' + + '</select>' + + '</td>' + + '<td><span class="help-mark" title="choose how fast you want the timelapse playback to be">?</span></td>' + '</tr>' + '</table>'); var intervalSelect = content.find('#intervalSelect'); + var speedSelect = content.find('#speedSelect'); runModalDialog({ title: 'Create Timelapse Movie', @@ -1882,10 +1897,12 @@ function runTimelapseDialog(cameraId, groupKey, group) { content: content, onOk: function () { showModalDialog('<div class="modal-progress"></div>', null, null, true); - ajax('GET', '/picture/' + cameraId + '/timelapse/' + groupKey + '/', {interval: intervalSelect.val()}, function (data) { + ajax('GET', '/picture/' + cameraId + '/timelapse/' + groupKey + '/', + {interval: intervalSelect.val(), speed: speedSelect.val()}, function (data) { + hideModalDialog(); /* progress */ hideModalDialog(); /* timelapse dialog */ - downloadFile('/picture/' + cameraId + '/timelapse/' + groupKey + '/key=' + data.key); + downloadFile('/picture/' + cameraId + '/timelapse/' + groupKey + '/?key=' + data.key); }); return false; @@ -1898,6 +1915,7 @@ function runMediaDialog(cameraId, mediaType) { var dialogDiv = $('<div class="media-dialog"></div>'); var mediaListDiv = $('<div class="media-dialog-list"></div>'); var groupsDiv = $('<div class="media-dialog-groups"></div>'); + var buttonsDiv = $('<div class="media-dialog-buttons"></div>'); var groups = {}; var groupKey = null; @@ -1906,7 +1924,6 @@ function runMediaDialog(cameraId, mediaType) { dialogDiv.append(mediaListDiv); if (mediaType == 'picture') { - var buttonsDiv = $('<div class="media-dialog-buttons"></div>'); dialogDiv.append(buttonsDiv); var zippedButton = $('<div class="media-dialog-button">Zipped Pictures</div>'); @@ -1928,10 +1945,23 @@ function runMediaDialog(cameraId, mediaType) { }); } - function setDialogSize() { + function updateDialogSize() { var windowWidth = $(window).width(); var windowHeight = $(window).height(); + if (Object.keys(groups).length == 0) { + groupsDiv.width('auto'); + groupsDiv.height('auto'); + groupsDiv.addClass('small-screen'); + mediaListDiv.width('auto'); + mediaListDiv.height('auto'); + buttonsDiv.hide(); + + return; + } + + buttonsDiv.show(); + if (windowWidth < 1000) { groupsDiv.width(parseInt(windowWidth * 0.8)); groupsDiv.height(''); @@ -1949,13 +1979,13 @@ function runMediaDialog(cameraId, mediaType) { } function onResize() { - setDialogSize(); + updateDialogSize(); updateModalDialogPosition(); } $(window).resize(onResize); - setDialogSize(); + updateDialogSize(); showModalDialog('<div class="modal-progress"></div>'); @@ -1988,6 +2018,8 @@ function runMediaDialog(cameraId, mediaType) { }); }); + updateDialogSize(); + var keys = Object.keys(groups); keys.sort(); keys.reverse(); @@ -2064,6 +2096,7 @@ function runMediaDialog(cameraId, mediaType) { var momentSpan = $('<span class="details-moment">' + entry.momentStr + ', </span>'); var momentShortSpan = $('<span class="details-moment-short">' + entry.momentStrShort + '</span>'); var sizeSpan = $('<span class="details-size">' + entry.sizeStr + '</span>'); + detailsDiv.empty(); detailsDiv.append(momentSpan); detailsDiv.append(momentShortSpan); detailsDiv.append(sizeSpan); -- 2.39.5