├── example └── .gitkeep ├── .travis.yml ├── logo.png ├── .editorconfig ├── bin ├── vidshow ├── vidshow-init └── vidshow-new ├── script.js ├── .eslintrc.js ├── package.json ├── subtitle.ass ├── .gitignore ├── fonts.conf ├── README.md └── yarn.lock /example/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oknoorap/vidshow/HEAD/logo.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /bin/vidshow: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('commander') 4 | .version(require('../package').version) 5 | .usage(' [options]') 6 | .command('init', 'generate a new project in folder') 7 | .command('new', 'generate a new video from images') 8 | .parse(process.argv) -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | module.exports.before = () => { 2 | 3 | } 4 | 5 | module.exports.queue = filename => { 6 | // You can modify subtitle or duration per image 7 | let subtitle 8 | let duration 9 | 10 | return { 11 | filename, 12 | duration, 13 | subtitle 14 | } 15 | } 16 | 17 | module.exports.after = (items, answer, outputPath) => { 18 | 19 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "indent": [ 12 | "error", 13 | 4 14 | ], 15 | "linebreak-style": [ 16 | "error", 17 | "unix" 18 | ], 19 | "quotes": [ 20 | "error", 21 | "single" 22 | ], 23 | "semi": [ 24 | "error", 25 | "never" 26 | ] 27 | } 28 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vidshow", 3 | "version": "0.1.8", 4 | "description": "CLI Tools to Create Slideshow Video", 5 | "main": "index.js", 6 | "preferGlobal": true, 7 | "bin": { 8 | "vidshow": "bin/vidshow", 9 | "vidshow-init": "bin/vidshow-init", 10 | "vidshow-new": "bin/vidshow-new" 11 | }, 12 | "scripts": {}, 13 | "keywords": [ 14 | "ffmpeg", 15 | "cli" 16 | ], 17 | "author": "oknoorap", 18 | "license": "MIT", 19 | "dependencies": { 20 | "clear": "^0.0.1", 21 | "commander": "^2.9.0", 22 | "inquirer": "^3.0.6", 23 | "mkdirp": "^0.5.1", 24 | "moment": "^2.17.1", 25 | "ncp": "^2.0.0", 26 | "rimraf": "^2.6.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /subtitle.ass: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; Script generated by Aegisub 3.2.2 3 | ; http://www.aegisub.org/ 4 | Title: Neon Genesis Evangelion - Episode 26 5 | Original Script: RoRo 6 | PlayDepth: 0 7 | Timer: 100,0000 8 | 9 | [Aegisub Project Garbage] 10 | Last Style Storage: Default 11 | 12 | [V4+ Styles] 13 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 14 | Style: ArialStyle,Arial,12,&H00FFFFFF,&H00FFFFFF,&H80000008,&H80000008,-1,0,0,0,100,100,0,0,1,1,0,2,30,30,30,0 15 | 16 | [Events] 17 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | .DS_Store 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Typescript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # Logo Desig 62 | *.psd 63 | -------------------------------------------------------------------------------- /fonts.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{font}} 4 | 5 | mono 6 | monospace 7 | 8 | 9 | sans-serif 10 | serif 11 | monospace 12 | sans-serif 13 | 14 | 15 | Times 16 | Times New Roman 17 | serif 18 | 19 | 20 | Helvetica 21 | Arial 22 | sans 23 | 24 | 25 | Courier 26 | Courier New 27 | monospace 28 | 29 | 30 | serif 31 | Times New Roman 32 | 33 | 34 | sans 35 | Arial 36 | 37 | 38 | monospace 39 | Andale Mono 40 | 41 | 42 | 43 | Courier New 44 | 45 | 46 | monospace 47 | 48 | 49 | 50 | 51 | Courier 52 | 53 | 54 | monospace 55 | 56 | 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Video Slideshow](https://raw.githubusercontent.com/oknoorap/vidshow/develop/logo.png)](https://github.com/oknoorap/vidshow) 2 | # Video Slideshow 3 | 4 | `vidshow` is a simple cli-tool to generate a slideshow video using native FFMPEG tools. 5 | 6 | --- 7 | [![NPM](https://nodei.co/npm/vidshow.png)](https://nodei.co/npm/vidshow/) 8 | [![GitHub tag](https://img.shields.io/github/tag/oknoorap/vidshow.svg)]() [![Build Status](https://travis-ci.org/oknoorap/vidshow.svg?branch=master)](https://travis-ci.org/oknoorap/vidshow) 9 | --- 10 | ## Install 11 | Since `vidshow` is a cli you should install this package globally. 12 | > `npm install vidshow -g` 13 | 14 | ## Usage 15 | After installation was finished, you can use commands below: 16 | 17 | ### `vidshow init` 18 | Go to your directory and init it as a project, prompt will be appears. 19 | * **FFMPEG Path** (Set your FFMPEG binary path, .exe in windows) 20 | * **Random Music** Directory (Set a directory which has collection of mp3 music) 21 | * **Output Directory** (Set output directory) 22 | * **Font Directory** (Set font directory for subtitle purpose, C:\\Windows\\Fonts in windows) 23 | 24 | After initialized, you'll see these files in your current directory. 25 | * `.vidshow` is an init configuration 26 | * `fonts.json` will be used for subtitle purpose 27 | * `script.js` is a callback (see below) 28 | 29 | #### `script.js` 30 | This file a queue callback which is called in `vidshow new` command, script contains three part. 31 | * `before` will be executed before generator showing a prompt. 32 | * `queue` will be executed when images will be added in queues. You can modify a custom duration or subtitle for each file. 33 | * `finish` will be executed after generator has been finished. 34 | 35 | ### `vidshow new` 36 | Generate a new video from specified directory, prompts will be appears. 37 | * **Video Title** is your video filename. 38 | * **Duration** is how many duration an images should be displaying, before next slide. 39 | * **Image directory** is directory that contains images (shold be contains 3 images or more). 40 | * **Load subtitle** whether you will load a subtitle from file or not. 41 | * **Subtitle** file will be appears if you load a subtitle. 42 | 43 | ## Example 44 | You can see example project in [example](https://github.com/oknoorap/vidshow/tree/develop/example) directory. 45 | 46 | ## Next Todo 47 | * [ ] Using `node-fluent-ffmpeg` 48 | 49 | ## License 50 | MIT 51 | -------------------------------------------------------------------------------- /bin/vidshow-init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const inquirer = require('inquirer') 4 | const fs = require('fs') 5 | const path = require('path') 6 | const mkdirp = require('mkdirp') 7 | const copy = require('ncp') 8 | const rootPath = path.resolve(__dirname, '..') 9 | const configPath = path.resolve(process.cwd(), '.vidshow') 10 | const fontConfigPath = path.resolve(rootPath, 'fonts.conf') 11 | const fontCwdPath = path.resolve(process.cwd(), 'fonts.conf') 12 | let config = {} 13 | 14 | // ncp config 15 | copy.limit = 16 16 | 17 | if (fs.existsSync(configPath)) { 18 | config = JSON.parse(fs.readFileSync(configPath, 'ascii')) 19 | } 20 | 21 | const copyScript = () => { 22 | const scriptFile = 'script.js' 23 | return new Promise((resolve, reject) => { 24 | copy(path.join(rootPath, scriptFile), path.join(process.cwd(), scriptFile), err => { 25 | if (err) reject(err) 26 | resolve() 27 | }) 28 | }) 29 | } 30 | 31 | const copyFont = font => { 32 | return new Promise(resolve => { 33 | let fontConfig = fs.readFileSync(fontConfigPath, 'ascii') 34 | fontConfig = fontConfig.replace(/\{\{font\}\}/g, font) 35 | fs.writeFileSync(fontCwdPath, fontConfig) 36 | resolve() 37 | }) 38 | } 39 | 40 | const questions = [ 41 | { 42 | type: 'text', 43 | name: 'ffmpeg', 44 | message: 'FFMPEG Directory', 45 | default: config.ffmpeg || '', 46 | validate (ffmpeg) { 47 | const exists = fs.existsSync(ffmpeg) 48 | const stats = fs.statSync(ffmpeg) 49 | if (!exists || !stats.isFile()) { 50 | return 'Not a binary file.' 51 | } 52 | return true 53 | }, 54 | filter (dir) { 55 | return path.resolve(dir) 56 | } 57 | }, 58 | 59 | { 60 | type: 'text', 61 | name: 'music', 62 | message: 'Random Music Directory', 63 | default: config.music || '', 64 | validate (music) { 65 | const exists = fs.existsSync(music) 66 | const stats = fs.statSync(music) 67 | if (!exists || !stats.isDirectory()) { 68 | return 'Not a directory.' 69 | } 70 | 71 | let musics = fs.readdirSync(music).filter( 72 | item => ['.mp3'].includes(path.extname(item)) 73 | ) 74 | 75 | if (musics.length < 2) { 76 | return 'Not enough music in directory' 77 | } 78 | 79 | return true 80 | }, 81 | filter (dir) { 82 | return path.resolve(dir) 83 | } 84 | }, 85 | 86 | { 87 | type: 'text', 88 | name: 'output', 89 | message: 'Output Directory', 90 | default: config.output || 'output', 91 | validate (output) { 92 | return output.length > 0 93 | } 94 | }, 95 | 96 | { 97 | type: 'text', 98 | name: 'font', 99 | message: 'Font Directory (for subtitle)', 100 | default: config.font || 'C:\\WINDOWS\\Fonts', 101 | validate (font) { 102 | const exists = fs.existsSync(font) 103 | const stats = fs.statSync(font) 104 | if (!exists || !stats.isDirectory()) { 105 | return 'Not a directory.' 106 | } 107 | return true 108 | }, 109 | filter (dir) { 110 | return path.resolve(dir) 111 | } 112 | } 113 | ] 114 | 115 | inquirer.prompt(questions).then(answer => { 116 | const json = JSON.stringify(answer, true, 2) 117 | 118 | copyScript() 119 | .catch(err => { 120 | console.log(err) 121 | }) 122 | .then(copyFont(answer.font)) 123 | .catch(err => { 124 | console.log(err) 125 | }) 126 | .then(() => { 127 | mkdirp.sync(path.join(process.cwd(), answer.output)) 128 | fs.writeFileSync(configPath, json) 129 | }) 130 | }) -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ansi-escapes@^1.1.0: 6 | version "1.4.0" 7 | resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" 8 | 9 | ansi-regex@^2.0.0: 10 | version "2.1.1" 11 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" 12 | 13 | ansi-styles@^2.2.1: 14 | version "2.2.1" 15 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" 16 | 17 | balanced-match@^0.4.1: 18 | version "0.4.2" 19 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" 20 | 21 | brace-expansion@^1.0.0: 22 | version "1.1.6" 23 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" 24 | dependencies: 25 | balanced-match "^0.4.1" 26 | concat-map "0.0.1" 27 | 28 | chalk@^1.0.0: 29 | version "1.1.3" 30 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" 31 | dependencies: 32 | ansi-styles "^2.2.1" 33 | escape-string-regexp "^1.0.2" 34 | has-ansi "^2.0.0" 35 | strip-ansi "^3.0.0" 36 | supports-color "^2.0.0" 37 | 38 | clear@^0.0.1: 39 | version "0.0.1" 40 | resolved "https://registry.yarnpkg.com/clear/-/clear-0.0.1.tgz#e5186e229d99448179c130311b6f9d30bff6b0ba" 41 | 42 | cli-cursor@^2.1.0: 43 | version "2.1.0" 44 | resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" 45 | dependencies: 46 | restore-cursor "^2.0.0" 47 | 48 | cli-width@^2.0.0: 49 | version "2.1.0" 50 | resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" 51 | 52 | commander@^2.9.0: 53 | version "2.9.0" 54 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" 55 | dependencies: 56 | graceful-readlink ">= 1.0.0" 57 | 58 | concat-map@0.0.1: 59 | version "0.0.1" 60 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 61 | 62 | escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: 63 | version "1.0.5" 64 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 65 | 66 | external-editor@^2.0.1: 67 | version "2.0.1" 68 | resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.1.tgz#4c597c6c88fa6410e41dbbaa7b1be2336aa31095" 69 | dependencies: 70 | tmp "^0.0.31" 71 | 72 | figures@^2.0.0: 73 | version "2.0.0" 74 | resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" 75 | dependencies: 76 | escape-string-regexp "^1.0.5" 77 | 78 | fs.realpath@^1.0.0: 79 | version "1.0.0" 80 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 81 | 82 | glob@^7.0.5: 83 | version "7.1.1" 84 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" 85 | dependencies: 86 | fs.realpath "^1.0.0" 87 | inflight "^1.0.4" 88 | inherits "2" 89 | minimatch "^3.0.2" 90 | once "^1.3.0" 91 | path-is-absolute "^1.0.0" 92 | 93 | "graceful-readlink@>= 1.0.0": 94 | version "1.0.1" 95 | resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" 96 | 97 | has-ansi@^2.0.0: 98 | version "2.0.0" 99 | resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" 100 | dependencies: 101 | ansi-regex "^2.0.0" 102 | 103 | inflight@^1.0.4: 104 | version "1.0.6" 105 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 106 | dependencies: 107 | once "^1.3.0" 108 | wrappy "1" 109 | 110 | inherits@2: 111 | version "2.0.3" 112 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 113 | 114 | inquirer@^3.0.6: 115 | version "3.0.6" 116 | resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.0.6.tgz#e04aaa9d05b7a3cb9b0f407d04375f0447190347" 117 | dependencies: 118 | ansi-escapes "^1.1.0" 119 | chalk "^1.0.0" 120 | cli-cursor "^2.1.0" 121 | cli-width "^2.0.0" 122 | external-editor "^2.0.1" 123 | figures "^2.0.0" 124 | lodash "^4.3.0" 125 | mute-stream "0.0.7" 126 | run-async "^2.2.0" 127 | rx "^4.1.0" 128 | string-width "^2.0.0" 129 | strip-ansi "^3.0.0" 130 | through "^2.3.6" 131 | 132 | is-fullwidth-code-point@^2.0.0: 133 | version "2.0.0" 134 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" 135 | 136 | is-promise@^2.1.0: 137 | version "2.1.0" 138 | resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" 139 | 140 | lodash@^4.3.0: 141 | version "4.17.4" 142 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" 143 | 144 | mimic-fn@^1.0.0: 145 | version "1.1.0" 146 | resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" 147 | 148 | minimatch@^3.0.2: 149 | version "3.0.3" 150 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774" 151 | dependencies: 152 | brace-expansion "^1.0.0" 153 | 154 | minimist@0.0.8: 155 | version "0.0.8" 156 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 157 | 158 | mkdirp@^0.5.1: 159 | version "0.5.1" 160 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 161 | dependencies: 162 | minimist "0.0.8" 163 | 164 | moment@^2.17.1: 165 | version "2.17.1" 166 | resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82" 167 | 168 | mute-stream@0.0.7: 169 | version "0.0.7" 170 | resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" 171 | 172 | ncp@^2.0.0: 173 | version "2.0.0" 174 | resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" 175 | 176 | once@^1.3.0: 177 | version "1.4.0" 178 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 179 | dependencies: 180 | wrappy "1" 181 | 182 | onetime@^2.0.0: 183 | version "2.0.0" 184 | resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.0.tgz#52aa8110e52fc5126ffc667bd8ec21c2ed209ce6" 185 | dependencies: 186 | mimic-fn "^1.0.0" 187 | 188 | os-tmpdir@~1.0.1: 189 | version "1.0.2" 190 | resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" 191 | 192 | path-is-absolute@^1.0.0: 193 | version "1.0.1" 194 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 195 | 196 | restore-cursor@^2.0.0: 197 | version "2.0.0" 198 | resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" 199 | dependencies: 200 | onetime "^2.0.0" 201 | signal-exit "^3.0.2" 202 | 203 | rimraf@^2.6.1: 204 | version "2.6.1" 205 | resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d" 206 | dependencies: 207 | glob "^7.0.5" 208 | 209 | run-async@^2.2.0: 210 | version "2.3.0" 211 | resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" 212 | dependencies: 213 | is-promise "^2.1.0" 214 | 215 | rx@^4.1.0: 216 | version "4.1.0" 217 | resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" 218 | 219 | signal-exit@^3.0.2: 220 | version "3.0.2" 221 | resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" 222 | 223 | string-width@^2.0.0: 224 | version "2.0.0" 225 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.0.0.tgz#635c5436cc72a6e0c387ceca278d4e2eec52687e" 226 | dependencies: 227 | is-fullwidth-code-point "^2.0.0" 228 | strip-ansi "^3.0.0" 229 | 230 | strip-ansi@^3.0.0: 231 | version "3.0.1" 232 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" 233 | dependencies: 234 | ansi-regex "^2.0.0" 235 | 236 | supports-color@^2.0.0: 237 | version "2.0.0" 238 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" 239 | 240 | through@^2.3.6: 241 | version "2.3.8" 242 | resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" 243 | 244 | tmp@^0.0.31: 245 | version "0.0.31" 246 | resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" 247 | dependencies: 248 | os-tmpdir "~1.0.1" 249 | 250 | wrappy@1: 251 | version "1.0.2" 252 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 253 | -------------------------------------------------------------------------------- /bin/vidshow-new: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const inquirer = require('inquirer') 4 | const mkdirp = require('mkdirp') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const rootPath = path.resolve(__dirname, '..') 8 | const configFile = path.join(process.cwd(), '.vidshow') 9 | const scriptFile = path.join(process.cwd(), 'script.js') 10 | const spawn = require('child_process').spawn 11 | const _ = require('lodash') 12 | const clear = require('clear') 13 | const ncp = require('ncp') 14 | const rmrf = require('rimraf') 15 | const moment = require('moment') 16 | 17 | const tmpdir = path.join(process.cwd(), '_tmp') 18 | const videoPath = path.join(tmpdir, 'video.mp4') 19 | const audioPath = path.join(tmpdir, 'audio.mp4') 20 | const finalPath = path.join(tmpdir, 'final.mp4') 21 | 22 | // create temporary dir 23 | mkdirp.sync(tmpdir) 24 | 25 | let config 26 | let beforeScript 27 | let queueScript 28 | let afterScript 29 | 30 | if (!fs.existsSync(configFile)) { 31 | console.log('Config file not found') 32 | process.exit(0) 33 | } 34 | 35 | if (fs.existsSync(scriptFile)) { 36 | const script = require(scriptFile) 37 | beforeScript = script.before 38 | queueScript = script.queue 39 | afterScript = script.after 40 | } 41 | 42 | config = fs.readFileSync(configFile, 'ascii') 43 | try { 44 | config = JSON.parse(config) 45 | } catch (e) { 46 | console.log('Invalid vidshow config') 47 | process.exit(0) 48 | } 49 | 50 | const outputPath = path.resolve(path.join(config.output, moment(Date.now()).format('Y/MM/DD'))) 51 | if (beforeScript) { 52 | beforeScript() 53 | } 54 | 55 | const questions = [ 56 | { 57 | type: 'text', 58 | name: 'title', 59 | message: 'Video Title', 60 | validate (title) { 61 | if (title.length <= 3) { 62 | return 'Length is too short' 63 | } 64 | return true 65 | } 66 | }, 67 | 68 | { 69 | type: 'input', 70 | name: 'duration', 71 | message: 'Duration (each image)', 72 | default: 5, 73 | validate (value) { 74 | const isNumber = value.toString().match(/[0-9]/i) 75 | if (isNumber && value > 3) { 76 | return true 77 | } 78 | return 'Should be a number and larger than 3 seconds' 79 | } 80 | }, 81 | 82 | { 83 | type: 'text', 84 | name: 'imgdir', 85 | message: 'Image Directory', 86 | validate (imgdir) { 87 | const exists = fs.existsSync(imgdir) 88 | const stats = fs.statSync(imgdir) 89 | if (!exists || !stats.isDirectory()) { 90 | return 'Not a directory.' 91 | } 92 | return true 93 | }, 94 | filter (dir) { 95 | return path.resolve(dir) 96 | } 97 | }, 98 | 99 | { 100 | type: 'confirm', 101 | name: 'withSubtitle', 102 | message: 'Load subtitles', 103 | default: false 104 | }, 105 | 106 | { 107 | type: 'text', 108 | name: 'subtitle', 109 | message: 'Subtitle File (optional)', 110 | default: '', 111 | when (answer) { 112 | return answer.withSubtitle 113 | }, 114 | validate (subtitle) { 115 | if (subtitle && subtitle.length > 0) { 116 | const exists = fs.existsSync(subtitle) 117 | const stats = fs.statSync(subtitle) 118 | if (!exists || !stats.isFile()) { 119 | return 'Not a subtitle file.' 120 | } 121 | } 122 | 123 | return true 124 | }, 125 | filter (dir) { 126 | if (dir) { 127 | return path.resolve(dir) 128 | } 129 | return dir 130 | } 131 | } 132 | ] 133 | 134 | const timemarkToSeconds = timemark => { 135 | if (typeof timemark === 'number') { 136 | return timemark; 137 | } 138 | 139 | if (timemark.indexOf(':') === -1 && timemark.indexOf('.') >= 0) { 140 | return Number(timemark); 141 | } 142 | 143 | var parts = timemark.split(':'); 144 | 145 | // add seconds 146 | var secs = Number(parts.pop()); 147 | 148 | if (parts.length) { 149 | // add minutes 150 | secs += Number(parts.pop()) * 60; 151 | } 152 | 153 | if (parts.length) { 154 | // add hours 155 | secs += Number(parts.pop()) * 3600; 156 | } 157 | 158 | return secs; 159 | } 160 | 161 | const generateSubtitle = (items, subtitlePath) => { 162 | clear(true) 163 | console.log('Generating subtitle') 164 | const subtitleFile = path.join(tmpdir, 'subtitle.ass') 165 | 166 | const toDuration = time => { 167 | const strArgs = {minimumIntegerDigits: 2, useGrouping:false} 168 | let minutes = (Math.trunc(time/60)).toLocaleString('en-US', strArgs); 169 | let seconds = (time % 60).toLocaleString('en-US', strArgs); 170 | return `0:${minutes}:${seconds}` 171 | } 172 | 173 | const getDuration = index => { 174 | let finish = 0 175 | for (let i = 0; i <= index; i++) { 176 | finish += items[i].duration 177 | } 178 | 179 | let start = finish - items[index].duration 180 | return { 181 | start: toDuration(start) + '.10', 182 | finish: toDuration(finish) + '.00' 183 | } 184 | } 185 | 186 | if (subtitlePath) { 187 | return new Promise((resolve, reject) => { 188 | ncp(subtitlePath, subtitleFile, err => { 189 | if (err) reject(err) 190 | resolve() 191 | }) 192 | }) 193 | } 194 | 195 | let itemSubtitles = items.filter(item => item.subtitle) 196 | if (!subtitlePath && itemSubtitles.length > 0) { 197 | return new Promise((resolve, reject) => { 198 | let subtitleTpl = fs.readFileSync(path.join(rootPath, 'subtitle.ass'), 'ascii') 199 | 200 | subtitleTpl += items.map((item, index) => { 201 | let { start, finish } = getDuration(index) 202 | return `Dialogue: 0,${start},${finish},ArialStyle,NTP,0,0,0,,${item.subtitle}` 203 | }).join("\n") 204 | 205 | fs.writeFile(subtitleFile, subtitleTpl, err => { 206 | if (err) reject(err) 207 | resolve() 208 | }) 209 | }) 210 | } 211 | } 212 | 213 | const runFfmpeg = ({ status, data }, args) => { 214 | return new Promise(resolve => { 215 | const video = spawn(config.ffmpeg, args, { 216 | stdio: [process.stdin, 'pipe', 'pipe'], 217 | env: { 218 | FC_CONFIG_DIR: process.cwd(), 219 | FONTCONFIG_PATH: process.cwd(), 220 | FONTCONFIG_FILE: path.join(process.cwd(), 'fonts.conf') 221 | } 222 | }); 223 | video.stdout.on('data', data => { 224 | // console.log(`stdout: ${data}`) 225 | }) 226 | video.stderr.on('data', buffer => { 227 | const progress = {} 228 | const line = buffer.toString().replace(/=\s+/g, '=').trim(); 229 | const progressParts = line.split(' '); 230 | 231 | for(var i = 0; i < progressParts.length; i++) { 232 | const progressSplit = progressParts[i].split('=', 2); 233 | const key = progressSplit[0]; 234 | const value = progressSplit[1]; 235 | progress[key] = value; 236 | } 237 | 238 | if (progress.time && data.duration) { 239 | let percent = (timemarkToSeconds(progress.time) / data.duration) * 100 240 | if (percent > 100) { 241 | percent = 100 242 | } 243 | clear(true) 244 | console.log(status) 245 | console.log('===========') 246 | console.log(`Progress: ${parseFloat(percent).toFixed(1)}%`) 247 | } 248 | 249 | if (line.toLowerCase().indexOf('overwrite') > -1) { 250 | console.log('Overwrite [y/N]?') 251 | } 252 | }) 253 | video.on('close', code => { 254 | console.log(`Finish with code ${code}`); 255 | resolve() 256 | }) 257 | }) 258 | } 259 | 260 | const cleanUp = (answer, items) => { 261 | clear(true) 262 | console.log(`Generating video ${answer.title}.mp4`) 263 | 264 | mkdirp.sync(outputPath) 265 | if (afterScript) { 266 | afterScript(items, answer, outputPath) 267 | } 268 | return new Promise((resolve, reject) => { 269 | ncp(path.join(tmpdir, 'final.mp4'), path.resolve(path.join(outputPath, `${answer.title}.mp4`)), err => { 270 | if (err) reject(err) 271 | inquirer.prompt([ 272 | { 273 | type: 'confirm', 274 | name: 'rmtmpdir', 275 | message: 'Delete temporary folder' 276 | } 277 | ]).then(answer => { 278 | if (answer.rmtmpdir) { 279 | rmrf(tmpdir, err => { 280 | if (err) reject(err) 281 | resolve() 282 | }) 283 | } else { 284 | resolve() 285 | } 286 | }) 287 | }) 288 | }) 289 | } 290 | 291 | inquirer.prompt(questions).then(answer => { 292 | const items = [] 293 | const images = fs.readdirSync(answer.imgdir).filter( 294 | item => ['.png', '.jpg'].includes(path.extname(item)) 295 | ) 296 | 297 | if (images.length < 2) { 298 | console.log('Not enough images') 299 | process.exit(0) 300 | } 301 | 302 | images.forEach(item => { 303 | let _filename = path.resolve(answer.imgdir, item) 304 | let _duration = answer.duration 305 | 306 | if (!queueScript) items.push({ filename: _filename, duration: _duration }) 307 | 308 | let { subtitle, duration } = queueScript(_filename) 309 | duration = duration || _duration 310 | items.push({ subtitle, filename: _filename, duration }) 311 | }) 312 | 313 | let duration = 0 314 | items.forEach((item, index) => { 315 | duration += item.duration 316 | }) 317 | 318 | const videoArgs = [] 319 | items.forEach((item, index) => { 320 | videoArgs.push('-loop') 321 | videoArgs.push('1') 322 | videoArgs.push('-t') 323 | videoArgs.push(item.duration) 324 | videoArgs.push('-i') 325 | videoArgs.push(item.filename) 326 | }) 327 | 328 | const videoBlend = [] 329 | items.forEach((item, index) => { 330 | if (index === 0) return 331 | let arg = `[${index}:v][${index - 1}:v]blend=all_expr='A*(if(gte(T,0.5),1,T/0.5))+B*(1-(if(gte(T,0.5),1,T/0.5)))'[b${index}v];` 332 | videoBlend.push(arg) 333 | }) 334 | 335 | const videoCombine = [`[0:v]`] 336 | items.forEach((item, index) => { 337 | if (index === 0) return 338 | let arg = `[b${index}v]` 339 | videoCombine.push(arg) 340 | }) 341 | videoCombine.push(`concat=n=${items.length}:v=1:a=0,setsar=1,format=yuv420p[v]`) 342 | videoBlend.push(videoCombine.join('')) 343 | videoArgs.push(`-filter_complex`) 344 | videoArgs.push(`${videoBlend.join('')}`) 345 | videoArgs.push(`-map`) 346 | videoArgs.push(`[v]`) 347 | videoArgs.push(videoPath) 348 | videoArgs.push(`-y`) 349 | 350 | // Get random audio 351 | let randomAudio = _.sample(fs.readdirSync(config.music)) 352 | randomAudio = path.join(config.music, randomAudio) 353 | const audioArgs = `-i ${videoPath} -i ${randomAudio} -c:v libx264 -r 30 -crf 23 -shortest ${audioPath} -y`.split(' ') 354 | const finalArgs = `-i ${audioPath} -vf ass='_tmp/subtitle.ass' ${finalPath} -y`.split(' ') 355 | 356 | runFfmpeg({ status: 'Converting videos', data: {duration} }, videoArgs) 357 | .then(() => { 358 | runFfmpeg({ status: 'Adding Music', data: {duration} }, audioArgs) 359 | .then(() => { 360 | generateSubtitle(items, answer.subtitle).catch(err => { 361 | console.log(err) 362 | }).then(() => { 363 | runFfmpeg({ status: 'Adding Subtitle', data: {duration} }, finalArgs) 364 | .then(() => { 365 | cleanUp(answer, items).catch(err => { 366 | console.log(err) 367 | }).then(() => { 368 | console.log('DONE') 369 | }) 370 | }) 371 | }) 372 | }) 373 | }) 374 | }) --------------------------------------------------------------------------------