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