From f8a47a295f3380f08e1437a326cd7268bcc3436b Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Thu, 2 Jul 2020 10:44:32 -0500 Subject: [PATCH 01/14] scene detail: impromptu placeholders --- server/views/scene.ejs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/views/scene.ejs b/server/views/scene.ejs index 098b6ba..1920da1 100644 --- a/server/views/scene.ejs +++ b/server/views/scene.ejs @@ -166,7 +166,7 @@ - <%= shot.shotType %> + <%= shot.shotType == null ? '–' : shot.shotType %> @@ -199,7 +199,7 @@ - <%= durationMsecsToString(shot.duration) %> + <%= shot.duration == null ? '' : durationMsecsToString(shot.duration) %> <%= takesCountByShotId[shot.id] %> @@ -253,8 +253,12 @@ <% } else { %>
+ class="bg-purple-900 flex justify-center items-center" + style="width: calc(76px * var(--aspect-ratio)); height: 76px" + > + + Impromptu Shot +
<% } %> From a2e4796923c6729dc637604f2797d5b9328610d3 Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Thu, 2 Jul 2020 15:11:44 -0500 Subject: [PATCH 02/14] video player - add video player controller js - add xstate - refactor "best take" query --- public/js/index.js | 5 ++ public/js/video-player.js | 134 +++++++++++++++++++++++++++++++++ public/js/xstate.fsm.umd.js | 1 + server/routes/scenes.js | 115 +++++++++++++++++----------- server/views/_video-player.ejs | 123 ++++++++++++++++++++++++++++++ server/views/footer.ejs | 1 + server/views/scene.ejs | 11 +-- 7 files changed, 339 insertions(+), 51 deletions(-) create mode 100644 public/js/video-player.js create mode 100644 public/js/xstate.fsm.umd.js create mode 100644 server/views/_video-player.ejs diff --git a/public/js/index.js b/public/js/index.js index 0e8d378..83406ce 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -6,9 +6,14 @@ import { PlaceholderController, } from './schedule.js' +import VideoPlayer from './video-player.js' + const application = Stimulus.Application.start() + application.register('schedule', ScheduleController) application.register('schedule-event', ScheduleEventController) application.register('inline-editor', InlineEditorController) // form-based Inline Editor application.register('schedule-note', ScheduleNoteController) // similar, but with special validation application.register('placeholder', PlaceholderController) + +application.register('video-player', VideoPlayer) diff --git a/public/js/video-player.js b/public/js/video-player.js new file mode 100644 index 0000000..0c05074 --- /dev/null +++ b/public/js/video-player.js @@ -0,0 +1,134 @@ +const { createMachine, assign, interpret } = XStateFSM + +const machineConfig = { + id: 'player', + initial: 'inactive', + context: { + segments: [], + curr: 0 + }, + states: { + inactive: { + entry: ['deactivate', 'setSrc'], + on: { CLICK: 'activating' } + }, + activating: { + entry: 'activate', + on: { ACTIVE: 'playing' } + }, + playing: { + entry: 'play', + on: { + SEGMENT_ENDED: [ + { + cond: context => context.curr < context.segments.length - 1, + actions: ['nextSegment', 'setSrc'] + }, + { + target: 'inactive', + actions: ['reset'] + } + ] + } + } + } +} + +export default class VideoPlayer extends Stimulus.Controller { + static targets = [ 'video', 'segment', 'invitation', 'controls', 'status', 'progress', 'statusShot', 'statusTake' ] + + initialize () { + this.service = interpret( + createMachine({ + ...machineConfig, + context: { + segments: this.getSegments(this.segmentTargets), + curr: 0 + } + }, { + actions: { + deactivate: this.deactivate.bind(this), + activate: this.activate.bind(this), + play: this.play.bind(this), + setSrc: this.setSrc.bind(this), + + // + // + // context assignment actions + // + reset: assign({ curr: 0 }), + nextSegment: assign({ curr: context => context.curr + 1 }) + } + }) + ).start() + + // for debugging: + // this.service.subscribe(state => console.log(state.value)) + } + + connect () { + this.videoTarget.addEventListener('ended', () => this.service.send('SEGMENT_ENDED')) + } + + // + // + // Controller methods + // + getSegments (el) { + let segments = [] + for (let segment of el) { + const { href } = segment + const { takeId, takeNumber, sceneNumber, shotNumber, impromptu, posterframe } = segment.dataset + segments.push({ + id: takeId, + takeNumber, + src: href, + posterframe, + sceneNumber, + shotNumber, + impromptu: impromptu == '' ? true : false + }) + } + return segments + } + + // + // + // State Machine actions + // + deactivate (context) { + this.invitationTarget.style.display = 'flex' + this.controlsTarget.style.opacity = 0.3 + this.videoTarget.pause() + this.statusTarget.innerText = 'Paused' + } + activate (context) { + this.invitationTarget.style.display = 'none' + this.controlsTarget.style.opacity = 1 + + this.service.send('ACTIVE') + } + play (context, event) { + this.videoTarget.play() + this.statusTarget.innerText = 'Playing' + } + setSrc (context) { + let { curr } = context + let take = context.segments[curr] + let { src } = take + + this.videoTarget.src = src + this.progressTarget.innerText = `${curr + 1} / ${context.segments.length}` + + this.statusShotTarget.innerText = `Shot ${take.impromptu ? 'i' : ''}${take.shotNumber}` + this.statusTakeTarget.innerText = `Take ${take.takeNumber}` + } + + // + // + // Controller events + // + startPlayback (event) { + this.service.send('CLICK') + } +} diff --git a/public/js/xstate.fsm.umd.js b/public/js/xstate.fsm.umd.js new file mode 100644 index 0000000..1da4378 --- /dev/null +++ b/public/js/xstate.fsm.umd.js @@ -0,0 +1 @@ +var t,e;t=this,e=function(t){"use strict";var e;(e=t.InterpreterStatus||(t.InterpreterStatus={}))[e.NotStarted=0]="NotStarted",e[e.Running=1]="Running",e[e.Stopped=2]="Stopped";const n={type:"xstate.init"};function i(t){return void 0===t?[]:[].concat(t)}function s(t,e){return"string"==typeof(t="string"==typeof t&&e&&e[t]?e[t]:t)?{type:t}:"function"==typeof t?{type:t.name,exec:t}:t}function r(t){return e=>t===e}function o(t){return"string"==typeof t?{type:t}:t}function a(t,e){return{value:t,context:e,actions:[],changed:!1,matches:r(t)}}const c=(t,e)=>t.actions.forEach(({exec:n})=>n&&n(t.context,e));t.assign=function(t){return{type:"xstate.assign",assignment:t}},t.createMachine=function(t,e={}){const n={config:t,_options:e,initialState:{value:t.initial,actions:i(t.states[t.initial].entry).map(t=>s(t,e.actions)),context:t.context,matches:r(t.initial)},transition:(e,c)=>{const{value:u,context:f}="string"==typeof e?{value:e,context:t.context}:e,p=o(c),g=t.states[u];if(g.on){const e=i(g.on[p.type]);for(const i of e){if(void 0===i)return a(u,f);const{target:e=u,actions:o=[],cond:c=(()=>!0)}="string"==typeof i?{target:i}:i;let d=f;if(c(f,p)){const i=t.states[e];let a=!1;const c=[].concat(g.exit,o,i.entry).filter(t=>t).map(t=>s(t,n._options.actions)).filter(t=>{if("xstate.assign"===t.type){a=!0;let e=Object.assign({},d);return"function"==typeof t.assignment?e=t.assignment(d,p):Object.keys(t.assignment).forEach(n=>{e[n]="function"==typeof t.assignment[n]?t.assignment[n](d,p):t.assignment[n]}),d=e,!1}return!0});return{value:e,context:d,actions:c,changed:e!==u||c.length>0||a,matches:r(e)}}}}return a(u,f)}};return n},t.interpret=function(e){let i=e.initialState,s=t.InterpreterStatus.NotStarted;const r=new Set,a={_machine:e,send:n=>{s===t.InterpreterStatus.Running&&(i=e.transition(i,n),c(i,o(n)),r.forEach(t=>t(i)))},subscribe:t=>(r.add(t),t(i),{unsubscribe:()=>r.delete(t)}),start:()=>(s=t.InterpreterStatus.Running,c(i,n),a),stop:()=>(s=t.InterpreterStatus.Stopped,r.clear(),a),get state(){return i},get status(){return s}};return a},Object.defineProperty(t,"__esModule",{value:!0})},"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).XStateFSM={}); diff --git a/server/routes/scenes.js b/server/routes/scenes.js index e9a82df..2330133 100644 --- a/server/routes/scenes.js +++ b/server/routes/scenes.js @@ -4,6 +4,8 @@ const Scene = require('../decorators/scene') const Shot = require('../decorators/shot') const Take = require('../decorators/take') +const keyBy = id => (prev, curr) => (prev[curr[id]] = curr, prev) + exports.index = (req, res) => { let { projectId } = req.params @@ -68,48 +70,65 @@ exports.show = (req, res) => { projectId ) - let bestTakesByShotId = {} - for (let shot of shots) { - // best or most recent take - // TODO optimize queries - let mostRecent = get( - `SELECT * - FROM takes - WHERE shot_id = ? - AND project_id = ? - ORDER BY datetime(cut_at) - LIMIT 1`, - shot.id, - projectId - ) - let highestRated = get( - `SELECT * - FROM takes - WHERE rating IS NOT NULL - AND shot_id = ? - AND project_id = ? - ORDER BY rating - LIMIT 1`, - shot.id, - projectId - ) - let take = highestRated || mostRecent || null - // TODO optimize this - bestTakesByShotId[shot.id] = take - ? - take.downloaded - ? { - downloaded: take.downloaded, - src: new Take(take).filenameForThumbnail({ - ...{ scene_number } = scene, - ...{ shot_number, impromptu } = shot - }) - } - : { - downloaded: take.downloaded, - src: null - } - : null } + let bestTakesByShotId = all( + ` + SELECT + best_take.id, + best_take.downloaded, + best_take.rating, + best_take.cut_at, + best_take.metadata_json, + best_take.take_number, + + shots.id as 'shot_id', + shots.shot_number as 'shot_number', + shots.impromptu as 'impromptu', + + scenes.scene_number as 'scene_number' + FROM + shots + JOIN scenes ON scenes.id = shots.scene_id + + -- find the best take + -- e.g.: the single highest rated, most-recent take + JOIN takes as best_take ON (best_take.id = ( + SELECT + id + FROM + takes + WHERE + shot_id = shots.id + ORDER BY + rating DESC, + datetime(cut_at) DESC + LIMIT 1 + )) + AND + shots.scene_id = ? + ORDER BY + impromptu DESC, + shot_number + `, + scene.id + ) + .map(best => { + let take = new Take(best) + return { + ...take, + src: { + thumbnail: take.filenameForThumbnail({ + ...{ scene_number } = best, + ...{ shot_number, impromptu } = best + }), + stream: take.filenameForStream({ + ...{ scene_number } = best, + ...{ shot_number, impromptu } = best + }) + } + } + }) + .reduce(keyBy('shot_id'), {}) + let takesCountByShotId = {} for (let shot of shots) { @@ -123,6 +142,14 @@ exports.show = (req, res) => { takesCountByShotId[shot.id] = takes_count } + let previewTakes = [] + for (let shot of shots) { + let take = bestTakesByShotId[shot.id] + if (take && take.downloaded) { + previewTakes.push(take) + } + } + res.render('scene', { project, scene: new Scene(scene), @@ -130,6 +157,8 @@ exports.show = (req, res) => { project_scenes_count, takesCountByShotId, - bestTakesByShotId + bestTakesByShotId, + + previewTakes }) } diff --git a/server/views/_video-player.ejs b/server/views/_video-player.ejs new file mode 100644 index 0000000..2265679 --- /dev/null +++ b/server/views/_video-player.ejs @@ -0,0 +1,123 @@ +<% if (takes.length) { %> +
+ + + +
+
+
+
+ – / – +
+
+ – +
+
+
+
+
+
+
+
+
+<% } else { %> +
+
+ + Not Available + +
+
+<% } %> diff --git a/server/views/footer.ejs b/server/views/footer.ejs index d44e75b..e537553 100644 --- a/server/views/footer.ejs +++ b/server/views/footer.ejs @@ -9,6 +9,7 @@ --> + <% if (process.env.NODE_ENV !== 'production') { %> <%- include('livereload') %> diff --git a/server/views/scene.ejs b/server/views/scene.ejs index 1920da1..d11919a 100644 --- a/server/views/scene.ejs +++ b/server/views/scene.ejs @@ -93,14 +93,9 @@
- Rough Edit -
- -
+ Rough Assembly
+ <%- include('_video-player', { takes: previewTakes }) %>
@@ -232,7 +227,7 @@ <% } %> From bca6f63e02e809618b7429a024dc34bf1f79166a Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Thu, 2 Jul 2020 15:45:05 -0500 Subject: [PATCH 03/14] scene detail: fix to show non-impromptu shots' takes FIRST in preview --- server/routes/scenes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/scenes.js b/server/routes/scenes.js index 2330133..3ae28d1 100644 --- a/server/routes/scenes.js +++ b/server/routes/scenes.js @@ -106,7 +106,7 @@ exports.show = (req, res) => { AND shots.scene_id = ? ORDER BY - impromptu DESC, + impromptu ASC, shot_number `, scene.id From d2392a5d1a8b488d412af5880de47970147f8f3b Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Thu, 2 Jul 2020 15:50:05 -0500 Subject: [PATCH 04/14] routes/scenes.js: fix shots order --- server/routes/scenes.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/server/routes/scenes.js b/server/routes/scenes.js index 3ae28d1..fecf092 100644 --- a/server/routes/scenes.js +++ b/server/routes/scenes.js @@ -28,7 +28,11 @@ exports.index = (req, res) => { projectId ) - let shots = all('SELECT * FROM shots WHERE project_id = ?', projectId) + let shots = all( + `SELECT * FROM shots + WHERE project_id = ? + ORDER BY impromptu ASC, shot_number + `, project.id) res.render('scenes', { project, @@ -56,12 +60,11 @@ exports.show = (req, res) => { ) let shots = all( - `SELECT * - FROM shots - WHERE scene_id = ? - AND project_id = ?`, - sceneId, projectId - ) + `SELECT * FROM shots + WHERE project_id = ? + AND scene_id = ? + ORDER BY impromptu ASC, shot_number + `, project.id, scene.id) let { project_scenes_count } = get( `SELECT COUNT(id) as project_scenes_count From ab70e838e3a8bbea3dbd945b288f156b602c2372 Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Thu, 2 Jul 2020 15:50:23 -0500 Subject: [PATCH 05/14] scenes list: fix takes count --- server/routes/scenes.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/routes/scenes.js b/server/routes/scenes.js index fecf092..19b9957 100644 --- a/server/routes/scenes.js +++ b/server/routes/scenes.js @@ -14,13 +14,13 @@ exports.index = (req, res) => { let scenes = all(` SELECT scenes.*, - COUNT(shots.id) AS shots_count, - SUM(shots.duration) AS shots_duration, - COUNT(takes.id) AS takes_count + COUNT(DISTINCT shots.id) AS shots_count, + COUNT(DISTINCT takes.id) AS takes_count, + SUM(DISTINCT shots.duration) AS shots_duration FROM scenes - LEFT OUTER JOIN shots ON scenes.id = shots.scene_id - LEFT OUTER JOIN takes ON scenes.id = takes.scene_id + LEFT JOIN takes ON takes.scene_id = scenes.id + LEFT JOIN shots ON shots.scene_id = scenes.id WHERE scenes.project_id = ? GROUP BY 1 From f98b88bc8de0fb26ec2f0e9bd3401756e3451246 Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Thu, 2 Jul 2020 15:51:08 -0500 Subject: [PATCH 06/14] cleanup --- server/routes/scenes.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/routes/scenes.js b/server/routes/scenes.js index 19b9957..63ff4d4 100644 --- a/server/routes/scenes.js +++ b/server/routes/scenes.js @@ -132,7 +132,6 @@ exports.show = (req, res) => { }) .reduce(keyBy('shot_id'), {}) - let takesCountByShotId = {} for (let shot of shots) { let { takes_count } = get( From b4797cbbf4e515108355b1d5be7f1a775c0fdf2d Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Mon, 6 Jul 2020 15:11:20 -0500 Subject: [PATCH 07/14] scene.js: use ffprobe to calculate durations of preview files --- server/routes/scenes.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/server/routes/scenes.js b/server/routes/scenes.js index 63ff4d4..4740793 100644 --- a/server/routes/scenes.js +++ b/server/routes/scenes.js @@ -1,3 +1,7 @@ +const { spawnSync } = require('child_process') +const path = require('path') +const { UPLOADS_PATH } = require('../config') + const { get, all } = require('../db') const Scene = require('../decorators/scene') @@ -6,6 +10,25 @@ const Take = require('../decorators/take') const keyBy = id => (prev, curr) => (prev[curr[id]] = curr, prev) +const getFileDuration = src => { + let { stdout, stderr } = spawnSync( + 'ffprobe', [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + src + ] + ) + stdout = stdout.toString().trim() + stderr = stderr.toString().trim() + if (stderr) { + console.error(stderr) + throw new Error(`Error getting duration of file ${src}\n` + stderr) + } + return parseFloat(stdout) +} + exports.index = (req, res) => { let { projectId } = req.params @@ -130,6 +153,14 @@ exports.show = (req, res) => { } } }) + // TODO store preview file’s duration in `takes.metadata_json` + // (instead of calculating each request) + .map(take => ({ + ...take, + stream_duration: getFileDuration( + path.join(UPLOADS_PATH, 'projects', project.id, 'takes', take.src.stream) + ) + })) .reduce(keyBy('shot_id'), {}) let takesCountByShotId = {} From caf5303b2e9ce190a6e4532f7b1c07af8b8996e4 Mon Sep 17 00:00:00 2001 From: Eric Skogen Date: Mon, 6 Jul 2020 15:11:39 -0500 Subject: [PATCH 08/14] video-player: show elapsed time of total playlist duration - show elapsed time of total playlist duration - use data-action for event handling (ended, timeupdate) --- public/js/video-player.js | 37 ++++++++++++++++----- server/views/_video-player.ejs | 60 ++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/public/js/video-player.js b/public/js/video-player.js index 0c05074..0b187d5 100644 --- a/public/js/video-player.js +++ b/public/js/video-player.js @@ -1,5 +1,9 @@ const { createMachine, assign, interpret } = XStateFSM +function sum (a, b) { + return a + b +} + const machineConfig = { id: 'player', initial: 'inactive', @@ -35,9 +39,9 @@ const machineConfig = { } export default class VideoPlayer extends Stimulus.Controller { - static targets = [ 'video', 'segment', 'invitation', 'controls', 'status', 'progress', 'statusShot', 'statusTake' ] + static targets = [ 'video', 'segment', 'invitation', 'controls', 'status', 'progress', 'current', 'statusShot', 'statusTake' ] - initialize () { + initialize () { this.service = interpret( createMachine({ ...machineConfig, @@ -66,10 +70,6 @@ export default class VideoPlayer extends Stimulus.Controller { // this.service.subscribe(state => console.log(state.value)) } - connect () { - this.videoTarget.addEventListener('ended', () => this.service.send('SEGMENT_ENDED')) - } - // // // Controller methods @@ -78,7 +78,7 @@ export default class VideoPlayer extends Stimulus.Controller { let segments = [] for (let segment of el) { const { href } = segment - const { takeId, takeNumber, sceneNumber, shotNumber, impromptu, posterframe } = segment.dataset + const { takeId, takeNumber, sceneNumber, shotNumber, impromptu, posterframe, duration } = segment.dataset segments.push({ id: takeId, takeNumber, @@ -86,7 +86,8 @@ export default class VideoPlayer extends Stimulus.Controller { posterframe, sceneNumber, shotNumber, - impromptu: impromptu == '' ? true : false + impromptu: impromptu == '' ? true : false, + duration: parseFloat(duration) }) } return segments @@ -118,7 +119,7 @@ export default class VideoPlayer extends Stimulus.Controller { let { src } = take this.videoTarget.src = src - this.progressTarget.innerText = `${curr + 1} / ${context.segments.length}` + this.currentTarget.innerText = `${curr + 1} / ${context.segments.length}` this.statusShotTarget.innerText = `Shot ${take.impromptu ? 'i' : ''}${take.shotNumber}` this.statusTakeTarget.innerText = `Take ${take.takeNumber}` @@ -131,4 +132,22 @@ export default class VideoPlayer extends Stimulus.Controller { startPlayback (event) { this.service.send('CLICK') } + ended (event) { + this.service.send('SEGMENT_ENDED') + } + timeUpdate (event) { + let video = event.target + + if (video.readyState) { + let { segments, curr } = this.service.state.context + let total = segments.map(s => s.duration).reduce(sum) + let passed = 0 + for (let i = 0; i < curr; i++) { + passed += segments[i].duration + } + let elapsed = passed + video.currentTime + let pct = (elapsed / total) * 100 + this.progressTarget.style.width = `${pct}%` + } + } } diff --git a/server/views/_video-player.ejs b/server/views/_video-player.ejs index 2265679..546be66 100644 --- a/server/views/_video-player.ejs +++ b/server/views/_video-player.ejs @@ -9,6 +9,10 @@