├── .gitignore ├── README.md ├── bower.json ├── css └── style.css ├── gulpfile.js ├── img └── icons │ └── arrow_forward.svg ├── index.html ├── main.js ├── package.json └── scripts ├── app.js ├── config.js ├── formattedTimestamps ├── formattedTimestamps.html ├── formattedTimestampsController.js └── formattedTimestampsService.js ├── pipelines.js ├── pipelines ├── mkvPipeline.js └── mp4Pipeline.js ├── timestampsFiltering ├── timestampsFiltering.html ├── timestampsFilteringController.js └── timestampsFilteringService.js ├── videoProcessing ├── videoProcessing.html ├── videoProcessingController.js └── videoProcessingService.js ├── videoSelect ├── videoSelect.html ├── videoSelectController.js └── videoSelectService.js └── wizard ├── wizard.html └── wizardController.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | bower_components 4 | tmp -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cooptional-timestamps 2 | ===================== 3 | 4 | A small tool to generate a table for Reddit with timestamps to specific topics in Co-optional Podcast episodes (based on the text at the bottom of the video updated by TB). 5 | 6 | Prerequisites 7 | ============= 8 | 9 | * [ffmpeg](https://www.ffmpeg.org/) 10 | * [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) 11 | 12 | You need both those utilites to be installed on your system and have [PATH](https://en.wikipedia.org/wiki/PATH_(variable)) variable configured with them. 13 | 14 | Usage 15 | ===== 16 | 17 | Run the app, provide the link, fiddle with settings, clean up the output. 18 | 19 | Tutorial: http://imgur.com/a/PwYEk 20 | 21 | License 22 | ======= 23 | 24 | MIT 25 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cooptional-timestamps", 3 | "description": "", 4 | "main": "main.js", 5 | "authors": [ 6 | "Xylem" 7 | ], 8 | "license": "MIT", 9 | "homepage": "https://github.com/Xylem/cooptional-timestamps", 10 | "moduleType": [ 11 | "node" 12 | ], 13 | "ignore": [ 14 | "**/.*", 15 | "node_modules", 16 | "bower_components", 17 | "test", 18 | "tests" 19 | ], 20 | "dependencies": { 21 | "angular-route": "~1.4.7", 22 | "angular-material": "1.0.0-rc4", 23 | "angular-messages": "~1.4.7", 24 | "angular-motion": "~0.4.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /css/style.css: -------------------------------------------------------------------------------- 1 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { 2 | display: none !important; 3 | } 4 | 5 | main { 6 | height: calc(100vh - 48px); 7 | } 8 | 9 | textarea { 10 | resize: none; 11 | white-space: nowrap; 12 | } -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var childProcess = require('child_process'); 3 | var electron = require('electron-prebuilt'); 4 | 5 | gulp.task('run', function () { 6 | childProcess.spawn(electron, [ '.' ], { 7 | stdio: 'inherit' 8 | }); 9 | }); -------------------------------------------------------------------------------- /img/icons/arrow_forward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Co-optional Timestamps 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | var app = require('app'); 2 | var BrowserWindow = require('browser-window'); 3 | 4 | var mainWindow = null; 5 | 6 | app.on('window-all-closed', function() { 7 | if (process.platform != 'darwin') { 8 | app.quit(); 9 | } 10 | }); 11 | 12 | app.on('ready', function() { 13 | mainWindow = new BrowserWindow({width: 800, height: 600}); 14 | 15 | mainWindow.loadUrl('file://' + __dirname + '/index.html'); 16 | 17 | mainWindow.setMenu(null); 18 | 19 | mainWindow.on('closed', function() { 20 | mainWindow = null; 21 | }); 22 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cooptional-timestamps", 3 | "version": "1.0.1", 4 | "description": "", 5 | "main": "main.js", 6 | "author": "Xylem", 7 | "license": "MIT", 8 | "repository": "Xylem/cooptional-timestamps", 9 | "dependencies": { 10 | "bluebird": "2.10.1", 11 | "bluebird-retry": "0.5.1", 12 | "fast-levenshtein": "1.0.7", 13 | "fluent-ffmpeg": "2.0.1", 14 | "lodash": "3.10.1", 15 | "matroska": "2.2.2", 16 | "mkdirp": "0.5.1", 17 | "node-tesseract": "0.2.7", 18 | "printf": "0.2.3", 19 | "rimraf": "2.4.3", 20 | "stream-to-promise": "1.0.4", 21 | "ytdl-core": "0.6.0" 22 | }, 23 | "devDependencies": { 24 | "electron-prebuilt": "^0.34.1", 25 | "gulp": "3.9.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scripts/app.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | angular.module('app', ['ngRoute', 'ngMaterial', 'ngAnimate', 'ngMessages']). 5 | config(['$routeProvider', '$mdThemingProvider', function ($routeProvider, $mdThemingProvider) { 6 | $mdThemingProvider.theme('default') 7 | .primaryPalette('blue') 8 | .accentPalette('orange'); 9 | 10 | $routeProvider.when('/', { 11 | templateUrl: './scripts/wizard/wizard.html' , 12 | controller: 'wizardController' 13 | }); 14 | 15 | $routeProvider.otherwise({ redirectTo: '/' }); 16 | } 17 | ]); 18 | 19 | })(window.angular); -------------------------------------------------------------------------------- /scripts/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | temporaryDirectory: './tmp', 5 | headerPath: './tmp/header.mp4', 6 | videoPath: './tmp/video.mp4' 7 | }; -------------------------------------------------------------------------------- /scripts/formattedTimestamps/formattedTimestamps.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 7 | 8 |
9 |
-------------------------------------------------------------------------------- /scripts/formattedTimestamps/formattedTimestampsController.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | angular.module('app') 4 | .controller('formattedTimestampsController', ['$scope', 'formattedTimestampsService', 5 | FormattedTimestampsController]); 6 | 7 | function FormattedTimestampsController($scope, formattedTimestampsService) { 8 | var listHeader = 'Approximate timestamps to specific topics\n\n \n\nTopic|Timestamp\n-|-\n'; 9 | var listFooter = '\n\n \n\n^^Prepared ^^using ^^https://github.com/Xylem/cooptional-timestamps'; 10 | 11 | var timestamps = formattedTimestampsService.format($scope.videoData.filteredTimestamps, $scope.videoData.url); 12 | 13 | $scope.timestampList = listHeader + timestamps + listFooter; 14 | } 15 | 16 | })(window.angular); -------------------------------------------------------------------------------- /scripts/formattedTimestamps/formattedTimestampsService.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | var url = require('url'); 5 | var printf = require('printf'); 6 | 7 | angular.module('app').service('formattedTimestampsService', FormattedTimestampsService); 8 | 9 | function FormattedTimestampsService() { 10 | return { 11 | format: format 12 | }; 13 | 14 | function format(timestamps, videoUrl) { 15 | var rawVideoUrl = url.parse(videoUrl, true); 16 | 17 | return timestamps.map(function (timestamp) { 18 | var timestampUrl = angular.copy(rawVideoUrl); 19 | timestampUrl.query.t = timestamp.timestamp; 20 | delete timestampUrl.search; 21 | 22 | return printf('%s|[%s](%s)', timestamp.text, timestamp.time, url.format(timestampUrl)); 23 | }).join('\n'); 24 | } 25 | } 26 | })(window.angular); -------------------------------------------------------------------------------- /scripts/pipelines.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | 5 | var PIPELINES = [ 6 | require('./pipelines/mkvPipeline'), 7 | require('./pipelines/mp4Pipeline') 8 | ]; 9 | 10 | exports.getBestPipeline = function (videoDescriptor) { 11 | return _.find(PIPELINES, function (pipeline) { 12 | return _.find(videoDescriptor.formats, pipeline.requirements); 13 | }); 14 | }; -------------------------------------------------------------------------------- /scripts/pipelines/mkvPipeline.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Promise = require('bluebird'); 4 | 5 | var fs = Promise.promisifyAll(require('fs')); 6 | var path = require('path'); 7 | 8 | var _ = require('lodash'); 9 | var ffmpeg = require('fluent-ffmpeg'); 10 | var matroska = Promise.promisifyAll(require('matroska')); 11 | var mkdirp = Promise.promisify(require('mkdirp')); 12 | var retry = require('bluebird-retry'); 13 | var rimraf = require('rimraf'); 14 | var streamToPromise = require('stream-to-promise'); 15 | var tesseract = Promise.promisifyAll(require('node-tesseract')); 16 | var ytdl = Promise.promisifyAll(require('ytdl-core')); 17 | 18 | var config = require('../config'); 19 | 20 | exports.process = function (url, progressCallback) { 21 | var timestampPromise = mkdirp(config.temporaryDirectory).then(function () { 22 | return ytdl.getInfoAsync(url); 23 | }).then(function(info) { 24 | var length = info.length_seconds; 25 | var webmInfo = _.find(info.formats, { 26 | itag: '247' 27 | }); 28 | 29 | return { 30 | headerEnd: parseInt(webmInfo.index.split('-')[1], 10), 31 | size: parseInt(webmInfo.clen, 10), 32 | length: parseInt(length, 10) 33 | } 34 | }).then(function (videoInfo) { 35 | progressCallback('register', { 36 | name: 'Downloading video header', 37 | maxProgress: videoInfo.headerEnd 38 | }); 39 | 40 | var downloadStream = ytdl(url, { 41 | quality: '247', 42 | range: '0-' + (videoInfo.headerEnd + 1) 43 | }); 44 | 45 | downloadStream.on('data', function (chunk) { 46 | progressCallback('progress', chunk.length); 47 | }); 48 | 49 | var stream = fs.createWriteStream(config.headerPath); 50 | 51 | return streamToPromise(downloadStream.pipe(stream)); 52 | }).then(function () { 53 | var decoder = new matroska.Decoder(); 54 | 55 | return decoder.parseAsync(config.headerPath); 56 | }).then(function (document) { 57 | var segment = document.getFirstChildByName('Segment'); 58 | 59 | var totalSize = segment.getDataSize(); 60 | 61 | return segment.getFirstChildByName('Cues') 62 | .listChildrenByName('CuePoint').map(function (cuePoint) { 63 | return { 64 | time: cuePoint.getFirstChildByName('CueTime').getUInt(), 65 | byte: cuePoint.getFirstChildByName('CueTrackPositions') 66 | .getFirstChildByName('CueClusterPosition') 67 | .getUInt() 68 | }; 69 | }).map(function (chunk, index, array) { 70 | var nextChunk = array[index + 1]; 71 | 72 | chunk.end = nextChunk ? nextChunk.byte : totalSize; 73 | 74 | return chunk; 75 | }).filter(function (chunk, index) { 76 | return index % 2 === 0; 77 | }); 78 | }).then(function (chunks) { 79 | progressCallback('register', { 80 | name: 'Downloading and processing video chunks', 81 | maxProgress: chunks.length 82 | }); 83 | 84 | return chunks; 85 | }).map(function (chunk) { 86 | return retry(function () { 87 | var PATH = config.temporaryDirectory + '/' + chunk.time + '.webm'; 88 | var OUTPUT = config.temporaryDirectory + '/' + chunk.time + '.png'; 89 | 90 | var stream = fs.createWriteStream(PATH); 91 | 92 | fs.createReadStream(config.headerPath).pipe(stream); 93 | 94 | return streamToPromise(stream).then(function () { 95 | stream = fs.createWriteStream(PATH, { 96 | flags: 'a' 97 | }); 98 | 99 | var ytdlStream = ytdl(url, { 100 | quality: '247', 101 | range: chunk.byte + '-' + chunk.end 102 | }); 103 | 104 | var streamPromise = streamToPromise(ytdlStream); 105 | 106 | ytdlStream.pipe(stream); 107 | 108 | return streamPromise; 109 | }).then(function () { 110 | return new Promise(function (resolve, reject) { 111 | ffmpeg(PATH) 112 | .complexFilter([ 113 | 'crop=in_w:30:0:in_h-30[cropped]', 114 | '[cropped]lutrgb=r=negval:g=negval:b=negval[inverted]', 115 | '[inverted]scale=4*in_w:4*in_h' 116 | ]) 117 | .on('end', resolve) 118 | .on('error', resolve) 119 | .output(OUTPUT) 120 | .run(); 121 | }); 122 | }).then(function () { 123 | return tesseract.processAsync(OUTPUT, { 124 | psm: 7 125 | }).then(function (text) { 126 | return { 127 | timestamp: _.floor(chunk.time / 1000), 128 | text: text 129 | }; 130 | }).error(function () {}); 131 | }); 132 | }).then(function (data) { 133 | progressCallback('progress', 1); 134 | 135 | return data; 136 | }); 137 | }, { 138 | concurrency: 10 139 | }).reduce(function (a, b) { 140 | return a.concat(b); 141 | }, []).then(function (timestamps) { 142 | return _.chain(timestamps) 143 | .filter(function (value) { 144 | return value !== undefined; 145 | }) 146 | .each(function (value) { 147 | value.text = value.text.replace(/\n/g, ''); 148 | }) 149 | .sortBy('timestamp') 150 | .value(); 151 | }); 152 | 153 | timestampPromise.then(function () { 154 | rimraf.sync(config.temporaryDirectory); 155 | }); 156 | 157 | return timestampPromise; 158 | }; 159 | 160 | exports.requirements = { 161 | itag: '247' 162 | }; 163 | 164 | exports.name = "WebM/VP9 720p"; -------------------------------------------------------------------------------- /scripts/pipelines/mp4Pipeline.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Promise = require('bluebird'); 4 | 5 | var fs = Promise.promisifyAll(require('fs')); 6 | var path = require('path'); 7 | 8 | var _ = require('lodash'); 9 | var ffmpeg = require('fluent-ffmpeg'); 10 | var mkdirp = Promise.promisify(require('mkdirp')); 11 | var rimraf = require('rimraf'); 12 | var streamToPromise = require('stream-to-promise'); 13 | var tesseract = Promise.promisifyAll(require('node-tesseract')); 14 | var ytdl = Promise.promisifyAll(require('ytdl-core')); 15 | 16 | var config = require('../config'); 17 | 18 | exports.process = function (url, progressCallback) { 19 | var timestampPromise = mkdirp(config.temporaryDirectory) 20 | .then(function () { 21 | return ytdl.getInfoAsync(url); 22 | }).then(function (info) { 23 | var videoInfo = _.find(info.formats, { 24 | itag: '136' 25 | }); 26 | 27 | progressCallback('register', { 28 | name: 'Downloading video', 29 | maxProgress: parseInt(videoInfo.clen, 10) 30 | }); 31 | 32 | var downloadStream = ytdl(url, { 33 | quality: '136' 34 | }); 35 | 36 | downloadStream.on('data', function (chunk) { 37 | progressCallback('progress', chunk.length); 38 | }); 39 | 40 | var videoStream = fs.createWriteStream(config.videoPath); 41 | 42 | return streamToPromise(downloadStream.pipe(videoStream)); 43 | }).then(function () { 44 | progressCallback('register', { 45 | name: 'Generating frames' 46 | }); 47 | 48 | return new Promise(function (resolve, reject) { 49 | ffmpeg(config.videoPath) 50 | .fps(0.1) 51 | .complexFilter([ 52 | 'crop=in_w:30:0:in_h-30[cropped]', 53 | '[cropped]lutrgb=r=negval:g=negval:b=negval[inverted]', 54 | '[inverted]scale=4*in_w:4*in_h' 55 | ]) 56 | .output(config.temporaryDirectory + '/%d0.png') 57 | .on('end', resolve) 58 | .on('error', reject) 59 | .run(); 60 | }); 61 | }).then(function () { 62 | var framesPromise = fs.readdirAsync(config.temporaryDirectory).filter(function (file) { 63 | return path.extname(file) === '.png'; 64 | }); 65 | 66 | framesPromise.then(function (frames) { 67 | progressCallback('register', { 68 | name: 'Reading data from frames', 69 | maxProgress: frames.length 70 | }); 71 | }); 72 | 73 | return framesPromise.map(function (file) { 74 | var timestamp = path.basename(file, '.png'); 75 | var filePath = path.join(config.temporaryDirectory, file); 76 | 77 | return tesseract.processAsync(filePath, { 78 | psm: 7 79 | }).then(function (text) { 80 | progressCallback('progress', 1); 81 | 82 | return { 83 | timestamp: parseInt(timestamp, 10), 84 | text: text 85 | }; 86 | }); 87 | }, { 88 | concurrency: 10 89 | }).reduce(function (a, b) { 90 | return a.concat(b); 91 | }, []); 92 | }) 93 | .then(function (timestamps) { 94 | return _.chain(timestamps) 95 | .filter(function (value) { 96 | return value !== undefined; 97 | }) 98 | .each(function (value) { 99 | value.text = value.text.replace(/\n/g, ''); 100 | }) 101 | .sortBy('timestamp') 102 | .value(); 103 | }); 104 | 105 | timestampPromise.then(function () { 106 | rimraf.sync(config.temporaryDirectory); 107 | }); 108 | 109 | return timestampPromise; 110 | }; 111 | 112 | exports.requirements = { 113 | itag: '136' 114 | }; 115 | 116 | exports.name = "MP4/H.264 720p"; -------------------------------------------------------------------------------- /scripts/timestampsFiltering/timestampsFiltering.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | {{ setting.name }} 6 | 7 | {{ setting.tooltip }} 8 | 9 | 10 |
11 | 12 | 13 |
14 | {{setting.unit}} 15 |
16 |
17 |
18 |
19 | 20 |
21 | Remaining timestamps 22 | 23 | 24 |
25 |

{{ item.text }}

26 |

{{ formatAsTime(item.timestamp) }}

27 |
28 |
29 |
30 |
31 |
32 |
33 |
-------------------------------------------------------------------------------- /scripts/timestampsFiltering/timestampsFilteringController.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | angular.module('app') 4 | .controller('timestampsFilteringController', ['$scope', 'timestampsFilteringService', TimestampsFilteringController]); 5 | 6 | function TimestampsFilteringController($scope, timestampsFilteringService) { 7 | $scope.filterSettings = { 8 | minimalTextLength: 10, 9 | minimalLetterContent: 70, 10 | minimalLevenshteinDistance: 9, 11 | timestampsOffset: 40 12 | }; 13 | 14 | $scope.settingsList = [ 15 | { 16 | name: 'Minimal text length', 17 | tooltip: 'Minimal length of the timestamp text for it to be considered valid', 18 | property: 'minimalTextLength', 19 | rangeMin: 0, 20 | rangeMax: 30 21 | }, 22 | { 23 | name: 'Minimal letter content', 24 | tooltip: 'Minimal ratio of letters in timestamp to the entire timestamp text length (removes garbage)', 25 | property: 'minimalLetterContent', 26 | rangeMin: 0, 27 | rangeMax: 100, 28 | unit: '%' 29 | }, 30 | { 31 | name: 'Minimal Levenshtein distance', 32 | tooltip: 'Minimal level of difference between subsequent timestamps required to detect topic change', 33 | property: 'minimalLevenshteinDistance', 34 | rangeMin: 0, 35 | rangeMax: 30 36 | }, 37 | { 38 | name: 'Timestamps offset', 39 | tooltip: 'Number of seconds to offset timestamps backwards to account for lag between topic indicator updates and actual topic changes', 40 | property: 'timestampsOffset', 41 | rangeMin: 0, 42 | rangeMax: 300, 43 | unit: 's' 44 | } 45 | ]; 46 | 47 | $scope.remainingTimestamps = function () { 48 | var filteredTimestamps = timestampsFilteringService.filterTimestamps($scope.videoData.timestamps, 49 | $scope.filterSettings); 50 | 51 | $scope.videoData.filteredTimestamps = angular.copy(filteredTimestamps); 52 | 53 | $scope.videoData.filteredTimestamps.forEach(function (timestamp) { 54 | timestamp.time = timestampsFilteringService.formatAsTime(timestamp.timestamp, 55 | $scope.filterSettings.timestampsOffset); 56 | timestamp.timestamp -= $scope.filterSettings.timestampsOffset; 57 | }); 58 | 59 | return filteredTimestamps; 60 | }; 61 | 62 | $scope.formatAsTime = function (timestamp) { 63 | return timestampsFilteringService.formatAsTime(timestamp, $scope.filterSettings.timestampsOffset); 64 | }; 65 | } 66 | 67 | })(window.angular); -------------------------------------------------------------------------------- /scripts/timestampsFiltering/timestampsFilteringService.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | var _ = require('lodash'); 5 | var levenshtein = require('fast-levenshtein'); 6 | var printf = require('printf'); 7 | 8 | var LETTERS = /[a-zA-Z]/g; 9 | 10 | angular.module('app').service('timestampsFilteringService', TimestampsFilteringService); 11 | 12 | function TimestampsFilteringService() { 13 | return { 14 | filterTimestamps: filterTimestamps, 15 | formatAsTime: formatAsTime 16 | }; 17 | 18 | function filterTimestamps(timestamps, settings) { 19 | var lastTopic = ''; 20 | 21 | return _.chain(timestamps) 22 | .filter(function (value) { 23 | return value.text.length >= settings.minimalTextLength; 24 | }) 25 | .filter(function (value) { 26 | var lettersInLine = value.text.match(LETTERS); 27 | 28 | return lettersInLine && 29 | lettersInLine.length / value.text.length >= settings.minimalLetterContent / 100; 30 | }) 31 | .filter(function (value) { 32 | if (levenshtein.get(lastTopic, value.text) > settings.minimalLevenshteinDistance) { 33 | lastTopic = value.text; 34 | return true; 35 | } 36 | }) 37 | .value(); 38 | } 39 | 40 | function formatAsTime(timestamp, offset) { 41 | var time = Math.max(0, timestamp - offset); 42 | 43 | var second = time % 60; 44 | var minute = ((time - second) % 3600) / 60; 45 | var hour = (time - second - 60 * minute) / 3600; 46 | 47 | return printf('%02d:%02d:%02d', hour, minute, second); 48 | } 49 | } 50 | })(window.angular); -------------------------------------------------------------------------------- /scripts/videoProcessing/videoProcessing.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

There is no appropriate video format available to start processing. Please try again later.

4 |
5 |
6 |

Using {{ pipeline.name }} pipeline

7 |

{{ progressText }}

8 | 9 |
10 |
-------------------------------------------------------------------------------- /scripts/videoProcessing/videoProcessingController.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | angular.module('app') 4 | .controller('videoProcessingController', ['$scope', 'videoProcessingService', VideoProcessingController]); 5 | 6 | function VideoProcessingController($scope, videoProcessingService) { 7 | $scope.videoData.timestamps = null; 8 | $scope.pipeline = videoProcessingService.getBestPipeline($scope.videoData.descriptors); 9 | 10 | var progress = 0; 11 | $scope.maxProgress = 0; 12 | 13 | function progressUpdate(type, data) { 14 | $scope.$apply(function () { 15 | switch (type) { 16 | case 'register': 17 | $scope.progressText = data.name; 18 | $scope.progressValue = 0; 19 | progress = 0; 20 | $scope.maxProgress = data.maxProgress; 21 | break; 22 | case 'progress': 23 | progress += data; 24 | $scope.progressValue = progress * 100 / $scope.maxProgress; 25 | break; 26 | } 27 | }); 28 | } 29 | 30 | if ($scope.pipeline) { 31 | $scope.pipeline.process($scope.videoData.url, progressUpdate).then(function (timestamps) { 32 | $scope.$apply(function () { 33 | $scope.videoData.timestamps = timestamps; 34 | }); 35 | }).catch(function () { 36 | $scope.$apply(function () { 37 | $scope.videoData.timestamps = null; 38 | }); 39 | }); 40 | } 41 | } 42 | 43 | })(window.angular); -------------------------------------------------------------------------------- /scripts/videoProcessing/videoProcessingService.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | var pipelines = require('./scripts/pipelines'); 5 | 6 | angular.module('app').service('videoProcessingService', VideoProcessingService); 7 | 8 | function VideoProcessingService() { 9 | return { 10 | getBestPipeline: getBestPipeline 11 | }; 12 | 13 | function getBestPipeline(videoDescriptor) { 14 | return pipelines.getBestPipeline(videoDescriptor); 15 | } 16 | } 17 | })(window.angular); -------------------------------------------------------------------------------- /scripts/videoSelect/videoSelect.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |
8 |
This is required.
9 |
This is not a valid URL.
10 |
11 |
12 |
13 | 14 | 15 | 16 |
17 |

{{ videoData.descriptors.title }}

18 |
19 |
20 |
21 |
-------------------------------------------------------------------------------- /scripts/videoSelect/videoSelectController.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | angular.module('app') 4 | .controller('videoSelectController', ['$scope', 'videoSelectService', VideoSelectController]); 5 | 6 | function VideoSelectController($scope, videoSelectService) { 7 | $scope.loadVideoData = function () { 8 | videoSelectService.getVideoData($scope.videoData.url).then(function (data) { 9 | $scope.$apply(function () { 10 | $scope.videoData.descriptors = data; 11 | }); 12 | }).catch(function () { 13 | $scope.$apply(function () { 14 | $scope.videoData.descriptors = null; 15 | }); 16 | }); 17 | }; 18 | } 19 | 20 | })(window.angular); -------------------------------------------------------------------------------- /scripts/videoSelect/videoSelectService.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | var Promise = require('bluebird'); 5 | 6 | var ytdl = Promise.promisifyAll(require('ytdl-core')); 7 | 8 | angular.module('app').service('videoSelectService', VideoSelectService); 9 | 10 | function VideoSelectService() { 11 | return { 12 | getVideoData: getVideoData 13 | }; 14 | 15 | function getVideoData(url) { 16 | return ytdl.getInfoAsync(url); 17 | } 18 | } 19 | })(window.angular); -------------------------------------------------------------------------------- /scripts/wizard/wizard.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 |
10 | 11 | 12 | 13 | 14 |
-------------------------------------------------------------------------------- /scripts/wizard/wizardController.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | var _ = require('lodash'); 5 | 6 | angular.module('app') 7 | .controller('wizardController', ['$scope', WizardController]); 8 | 9 | function WizardController($scope) { 10 | $scope.tabs = [ 11 | { 12 | title: 'Select video', 13 | content: 'videoSelect', 14 | provides: [ 'descriptors' ] 15 | }, 16 | { 17 | title: 'Video processing', 18 | content: 'videoProcessing', 19 | provides: [ 'timestamps' ] 20 | }, 21 | { 22 | title: 'Timestamps filtering', 23 | content: 'timestampsFiltering', 24 | provides: [ 'filteredTimestamps' ] 25 | }, 26 | { 27 | title: 'Formatted timestamps', 28 | content: 'formattedTimestamps', 29 | provides: [ ] 30 | } 31 | ]; 32 | 33 | $scope.videoData = {}; 34 | 35 | $scope.tabReady = function (tabIndex) { 36 | var tab = $scope.tabs[tabIndex]; 37 | 38 | return tab && _.every(tab.provides, function (property) { 39 | var videoDataProperty = $scope.videoData[property]; 40 | return videoDataProperty !== null && videoDataProperty !== undefined; 41 | }); 42 | }; 43 | 44 | $scope.nextStep = function () { 45 | ++$scope.selectedIndex; 46 | }; 47 | } 48 | 49 | })(window.angular); --------------------------------------------------------------------------------