changeset 1954:3ae1e7f8db23

vidref playback smoothness, autodelete short clips, manual-delete clips, vidref keyboard shortcuts Ignore-this: 6daccf686fd66561029f3252ed4dbafd
author Drew Perttula <drewp@bigasterisk.com>
date Thu, 06 Jun 2019 02:28:28 +0000
parents 9dd331caa23b
children 9ee42b88299b
files bin/vidref light9/ascoltami/webapp.py light9/vidref/index.html light9/vidref/videorecorder.py light9/web/light9-vidref-live.js light9/web/light9-vidref-replay-stack.js light9/web/light9-vidref-replay.js light9/web/style.css
diffstat 8 files changed, 215 insertions(+), 67 deletions(-) [+]
line wrap: on
line diff
--- a/bin/vidref	Thu Jun 06 05:50:34 2019 +0000
+++ b/bin/vidref	Thu Jun 06 02:28:28 2019 +0000
@@ -24,9 +24,9 @@
 from light9.vidref import videorecorder
 from rdflib import URIRef
 from light9.newtypes import Song
-from light9.namespaces import L9
 from rdfdb.syncedgraph import SyncedGraph
 from cycloneerr import PrettyErrorHandler
+from typing import cast
 
 parser = optparse.OptionParser()
 parser.add_option("-v", "--verbose", action="store_true", help="logging.DEBUG")
@@ -96,13 +96,12 @@
         self.set_status(202)
 
 
-def takeUri(songPath: bytes):
-    p = songPath.decode('ascii').split('/')
-    take = p[-1].replace('.mp4', '')
-    song = p[-2].split('_')
-    return URIRef('/'.join(
-        ['http://light9.bigasterisk.com/show', song[-2], song[-1], take]))
+
+class Clips(PrettyErrorHandler, cyclone.web.RequestHandler):
 
+    def delete(self):
+        clip = URIRef(self.get_argument('uri'))
+        videorecorder.deleteClip(clip)
 
 class ReplayMap(PrettyErrorHandler, cyclone.web.RequestHandler):
 
@@ -120,12 +119,12 @@
                          ):].decode('ascii')
 
             clips.append({
-                'uri': takeUri(vid),
+                'uri': videorecorder.takeUri(vid),
                 'videoUrl': url,
                 'songToVideo': pts
             })
 
-        clips.sort(key=lambda c: len(c['songToVideo']))
+        clips.sort(key=lambda c: len(cast(list, c['songToVideo'])))
         clips = clips[-3:]
         clips.sort(key=lambda c: c['uri'], reverse=True)
 
@@ -153,6 +152,7 @@
                 'default_filename': 'setup.html'
             }),
             (r'/live', Live),
+            (r'/clips', Clips),
             (r'/replayMap', ReplayMap),
             (r'/snapshot', Snapshot),
             (r'/snapshot/(.*)', SnapshotPic, {
--- a/light9/ascoltami/webapp.py	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/ascoltami/webapp.py	Thu Jun 06 02:28:28 2019 +0000
@@ -154,6 +154,14 @@
             player.pause()
             player.seek(data['scrub'])
             return
+        if 'action' in data:
+            if data['action'] == 'play':
+                player.resume()
+            elif data['action'] == 'pause':
+                player.pause()
+            else:
+                raise NotImplementedError
+            return
         if player.isPlaying():
             player.pause()
         else:
--- a/light9/vidref/index.html	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/vidref/index.html	Thu Jun 06 02:28:28 2019 +0000
@@ -3,25 +3,50 @@
   <head>
     <title>vidref</title>
     <meta charset="utf-8" />
-    <style>
-     
-    </style>
+     <link rel="stylesheet" href="/style.css">
+
     <script src="/lib/debug/debug-build.js"></script>
     <script>
      debug.enable('*');
     </script>
-    <script src="/lib/jquery/dist/jquery.slim.min.js"></script>
     <script src="/websocket.js"></script>
     <script type="module" src="/light9-vidref-live.js"></script>
     <script type="module" src="/light9-vidref-replay-stack.js"></script>
   </head>
   <body>
-    Live:
+    <h1>vidref</h1>
     <div>
       <light9-vidref-live></light9-vidref-live>
     </div>
-    <light9-vidref-replay-stack></light9-vidref-replay-stack>
-
-stats of all disk usage
+    <light9-vidref-replay-stack id="rs"></light9-vidref-replay-stack>
+    <div class="keys">Keys:
+      <span class="keyCap">s</span> stop,
+      <span class="keyCap">p</span> play,
+      <span class="keyCap">,/.</span> step
+    </div>
+      <script>
+       const log = debug('index');
+       document.addEventListener('keypress', (ev) => {
+         const nudge = (dt) => {
+           const newTime = document.querySelector('#rs').songTime + dt;
+           fetch('/ascoltami/seekPlayOrPause', {
+             method: 'POST',
+             body: JSON.stringify({scrub: newTime}),
+           });
+         };
+         
+         if (ev.code == 'KeyP') {
+           fetch('/ascoltami/seekPlayOrPause',
+                 {method: 'POST', body: JSON.stringify({action: 'play'})}); 
+         } else if (ev.code == 'KeyS') {
+           fetch('/ascoltami/seekPlayOrPause',
+                 {method: 'POST', body: JSON.stringify({action: 'pause'})});
+         } else if (ev.code == 'Comma') {
+           nudge(-.1);
+         } else if (ev.code == 'Period') {
+           nudge(.1);
+         }
+       });
+      </script>
   </body>
 </html>
--- a/light9/vidref/videorecorder.py	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/vidref/videorecorder.py	Thu Jun 06 02:28:28 2019 +0000
@@ -11,6 +11,7 @@
 from gi.repository import Gst
 from rx.subject import BehaviorSubject
 from twisted.internet import threads
+from rdflib import URIRef
 import moviepy.editor
 import numpy
 
@@ -42,6 +43,22 @@
         showconfig.root(), b'video',
         song.replace('http://', '').replace('/', '_').encode('ascii'))
 
+def takeUri(songPath: bytes) -> URIRef:
+    p = songPath.decode('ascii').split('/')
+    take = p[-1].replace('.mp4', '')
+    song = p[-2].split('_')
+    return URIRef('/'.join(
+        ['http://light9.bigasterisk.com/show', song[-2], song[-1], take]))
+
+def deleteClip(uri: URIRef):
+    # uri http://light9.bigasterisk.com/show/dance2019/song6/take_155
+    # path show/dance2019/video/light9.bigasterisk.com_show_dance2019_song6/take_155.*
+    w = uri.split('/')[-4:]
+    path = '/'.join([w[0], w[1], 'video',
+                     f'light9.bigasterisk.com_{w[0]}_{w[1]}_{w[2]}', w[3]])
+    log.info(f'deleting {uri} {path}')
+    for fn in [path + '.mp4', path + '.timing']:
+        os.remove(fn)
 
 class FramesToVideoFiles:
     """
@@ -66,7 +83,7 @@
         self.root = root
         self.nextImg: Optional[CaptureFrame] = None
 
-        self.currentOutputClip = None
+        self.currentOutputClip: Optional[moviepy.editor.VideoClip] = None
         self.currentOutputSong: Optional[Song] = None
         self.nextWriteAction = 'ignore'
         self.frames.subscribe(on_next=self.onFrame)
@@ -101,7 +118,7 @@
         """
         return threads.deferToThread(self._bg_save, outBase)
 
-    def _bg_save(self, outBase):
+    def _bg_save(self, outBase: bytes):
         os.makedirs(os.path.dirname(outBase), exist_ok=True)
         self.frameMap = open(outBase + b'.timing', 'wt')
 
@@ -109,6 +126,8 @@
         # we get to call write_frame on a FFMPEG_VideoWriter instead
         # of it calling us back.
 
+        self.currentClipFrameCount = 0
+
         # (immediately calls make_frame)
         self.currentOutputClip = moviepy.editor.VideoClip(self._bg_make_frame,
                                                           duration=999.)
@@ -117,12 +136,13 @@
         self.currentOutputClip.fps = 10
         log.info(f'write_videofile {outBase} start')
         try:
-            self.currentOutputClip.write_videofile(outBase.decode('ascii') +
-                                                   '.mp4',
+            self.outMp4 = outBase.decode('ascii') + '.mp4'
+            self.currentOutputClip.write_videofile(self.outMp4,
                                                    codec='libx264',
                                                    audio=False,
                                                    preset='ultrafast',
                                                    logger=None,
+                                                   ffmpeg_params=['-g', '10'],
                                                    bitrate='150000')
         except (StopIteration, RuntimeError):
             self.frameMap.close()
@@ -130,6 +150,11 @@
         log.info('write_videofile done')
         self.currentOutputClip = None
 
+        if self.currentClipFrameCount < 400:
+            log.info('too small- deleting')
+            deleteClip(takeUri(self.outMp4.encode('ascii')))
+        
+
     def _bg_make_frame(self, video_time_secs):
         if self.nextWriteAction == 'close':
             raise StopIteration  # the one in write_videofile
@@ -146,6 +171,7 @@
         cf, self.nextImg = self.nextImg, None
 
         self.frameMap.write(f'video {video_time_secs:g} = song {cf.t:g}\n')
+        self.currentClipFrameCount += 1
         return numpy.asarray(cf.img)
 
 
--- a/light9/web/light9-vidref-live.js	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/web/light9-vidref-live.js	Thu Jun 06 02:28:28 2019 +0000
@@ -13,15 +13,6 @@
             enabled: { type: Boolean }
         };
     }
-
-    static get styles() {
-        return css`
-        :host {
-            border: 2px solid #46a79f;
-            display: inline-block;
-        }
-        `;
-    }
     
     constructor() {
         super();
@@ -51,13 +42,22 @@
         //close socket
         
     }
+
+    static get styles() {
+        return css`
+        :host {
+            display: inline-block;
+        }
+#live {
+border: 4px solid orange;
+}
+        `;
+    }
     
     render() {
         return html`
-<div>
   <label><input type="checkbox" id="enabled" ?checked="${this.enabled}" @change="${this.onEnabled}">Show live</label>
-  <div id="liveWidget"><img id="live" ></div>
-</div>
+  <div id="liveWidget" style="display: none"><img id="live" ></div>
 `;
 
     }
--- a/light9/web/light9-vidref-replay-stack.js	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/web/light9-vidref-replay-stack.js	Thu Jun 06 02:28:28 2019 +0000
@@ -23,17 +23,19 @@
     setVideoTimesFromSongTime() {
         this.shadowRoot.querySelectorAll('light9-vidref-replay').forEach(
             (r) => {
-                r.setVideoTimeFromSongTime(this.songTime);
+                r.setVideoTimeFromSongTime(this.songTime, this.musicState.playing);
             });
     }
-
+    nudgeTime(dt) {
+        this.songTime += dt;
+        log('song now', this.songTime);
+    }
     fineTime() {       
         if (this.musicState.playing) {
             const sinceLastUpdate = (Date.now() - this.musicState.reportTime) / 1000;
             this.songTime = sinceLastUpdate + this.musicState.tStart;
-            this.songTimeRangeInput.value = this.songTime;
         } else  {
-            //this.songTime = this.musicState.t;
+            this.songTime = this.musicState.t;
         }
         requestAnimationFrame(this.fineTime.bind(this));
     }
@@ -63,7 +65,6 @@
         if (this.musicState.song != this.song) {
             this.song = this.musicState.song;
             this.getReplayMapForSong(this.song);
-
         }
     }
         
@@ -87,8 +88,6 @@
             node.uri = msg[i].uri;
             node.videoUrl = msg[i].videoUrl;
             node.songToVideo = msg[i].songToVideo;
-
-
         });
         this.setVideoTimesFromSongTime();
     }
@@ -114,35 +113,45 @@
             method: 'POST',
             body: JSON.stringify({scrub: st}),
         });
-
     }
 
     static get styles() {
         return css`
         :host {
-         
+           display: inline-block;
         }
         #songTime {
             width: 100%;
         }
-#clips {
-display: flex;
-flex-direction: column;
-}
+        #clips {
+            display: flex;
+            flex-direction: column;
+        }
+        a {
+            color: rgb(97, 97, 255);
+        }
+        #songTime {
+            font-size: 27px;
+        }
         `;
     }
     
     render() {
         return html`
-<div>
-  <div><input id="songTime" type="range" @input="${this.userMovedSongTime}" min="0" max="0" step=".001"></div>
-  <div>${this.musicState.song}</div>
-  <div>showing song time ${rounding(this.songTime, 3)} (${rounding(this.musicState.t, 3)})</div>
-<div>clips:</div>
-<div id="clips">
-   ${this.players}
-</div>
-</div>
+  <div>
+    <input id="songTime" type="range" 
+           .value="${this.songTime}" 
+           @input="${this.userMovedSongTime}" 
+           min="0" max="0" step=".001"></div>
+  <div><a href="${this.musicState.song}">${this.musicState.song}</a></div>
+  <div id="songTime">showing song time ${rounding(this.songTime, 3)}</div>
+  <div>clips:</div>
+  <div id="clips">
+    ${this.players}
+  </div>
+  <div>
+    <button @click="${this.onClipsChanged}">Refresh clips for song</button>
+  </div>
 `;
 
     }
--- a/light9/web/light9-vidref-replay.js	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/web/light9-vidref-replay.js	Thu Jun 06 02:28:28 2019 +0000
@@ -1,6 +1,7 @@
 import { LitElement, TemplateResult, html, css } from '/node_modules/lit-element/lit-element.js';
 import debug from '/lib/debug/debug-build-es6.js';
 import _ from '/lib/underscore/underscore-min-es6.js';
+import { rounding }  from '/node_modules/significant-rounding/index.js';
 
 const log = debug('replay');
 
@@ -12,21 +13,60 @@
             videoUrl: { type: String },
             songToVideo: { type: Object },
             videoTime: { type: Number },
+            outVideoCurrentTime: { type: Number },
+            timeErr: { type: Number },
+            playRate: { type: Number }
         };
     }
-
-    setVideoTimeFromSongTime(songTime) {
-        if (!this.songToVideo || !this.outVideo) {
+    estimateRate() {
+        const n = this.songToVideo.length;
+        const x0 = Math.round(n * .3);
+        const x1 = Math.round(n * .6);
+        const pt0 = this.songToVideo[x0];
+        const pt1 = this.songToVideo[x1];
+        return (pt1[1] - pt0[1]) / (pt1[0] - pt0[0]);
+    }
+    setVideoTimeFromSongTime(songTime, isPlaying) {
+        if (!this.songToVideo || !this.outVideo || this.outVideo.readyState < 1) {
             return;
         }
         const i = _.sortedIndex(this.songToVideo, [songTime],
                                 (row) => { return row[0]; });
         this.videoTime = this.songToVideo[Math.max(0, i - 1)][1];
-        this.outVideo.currentTime = this.videoTime;
+
+        this.outVideoCurrentTime = this.outVideo.currentTime;
+        
+        if (isPlaying) {
+            if (this.outVideo.paused) {
+                this.outVideo.play();
+                this.setRate(this.estimateRate());
+            }
+            const err = this.outVideo.currentTime - this.videoTime;
+            this.timeErr = err;
+
+            if (Math.abs(err) > window.thresh) {
+                this.outVideo.currentTime = this.videoTime;
+                const p = window.p;
+                if (err > 0) {
+                    this.setRate(this.playRate - err * p);
+                } else {
+                    this.setRate(this.playRate - err * p);
+                }
+            }
+        } else {
+            this.outVideo.pause();
+            this.outVideoCurrentTime = this.outVideo.currentTime = this.videoTime;
+            this.timeErr = 0;
+        }
+    }
+    setRate(r) {
+        this.playRate = Math.max(.1, Math.min(4, r));
+        this.outVideo.playbackRate = this.playRate;
     }
 
     firstUpdated() {
         this.outVideo = this.shadowRoot.querySelector('#replay');
+        this.playRate = this.outVideo.playbackRate = 1.0;
     }
 
     onDelete() {
@@ -42,23 +82,45 @@
     static get styles() {
         return css`
         :host {
+            margin: 5px;
             border: 2px solid #46a79f;
+            display: flex;
+            flex-direction: column;
+        }
+        div {
+            padding: 5px;
+        }
+        .num {
             display: inline-block;
+            width: 4em;
+            color: #29ffa0;
+        }
+        a {
+            color: rgb(97, 97, 255);
         }
         `;
     }
     
     render() {
         return html`
-<div>
-  <div><video id="replay" src="${this.videoUrl}"></video></div>
-  <div>take is ${this.uri} (${Object.keys(this.songToVideo).length} frames)</div>
-  <!-- a little canvas showing what coverage we have -->
-  <div>video time is ${this.videoTime}</div>
-  <button @click="${this.onDelete}">Delete</button>
-</div>
-`;
+  <video id="replay" src="${this.videoUrl}"></video>
+  <div>
+    take is <a href="${this.uri}">${this.uri}</a> 
+    (${Object.keys(this.songToVideo).length} frames)
+    <button @click="${this.onDelete}">Delete</button>
+  </div>
+  <!-- here, put a little canvas showing what coverage we have with the 
+       actual/goal time cursors -->
+  <div>
+    video time should be <span class="num">${this.videoTime} </span>
+    actual = <span class="num">${rounding(this.outVideoCurrentTime, 3)}</span>, 
+    err = <span class="num">${rounding(this.timeErr, 3)} </span>
+    rate = <span class="num">${rounding(this.playRate, 3)}</span>
+  </div>
+  `;
 
     }
 }
 customElements.define('light9-vidref-replay', Light9VidrefReplay);
+window.thresh=.3
+window.p=.3
--- a/light9/web/style.css	Thu Jun 06 05:50:34 2019 +0000
+++ b/light9/web/style.css	Thu Jun 06 02:28:28 2019 +0000
@@ -73,6 +73,24 @@
 .key {
     color: #888;
 }
+
+div.keys {
+    margin-top: 10px;
+    padding: 5px;
+}
+
+.keyCap {
+    color: #ccc;
+    background: #525252;
+    display: inline-block;
+    border: 1px outset #b3b3b3;
+    padding: 2px 3px;
+    margin: 3px 0;
+    font-size: 16px;
+    box-shadow: 0.9px 0.9px 0px 2px #565656;
+    border-radius: 2px;
+}
+
 .currentSong button {
     background: #a90707;
 }