diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d82f7c2..2b6a6821 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI Testing -on: pull_request +on: + pull_request: + push: + branches: + - master jobs: test: runs-on: ubuntu-latest @@ -12,7 +16,7 @@ jobs: - name: Install flvtool2 run: sudo gem install flvtool2 - name: Install ffmpeg - run: sudo apt update && sudo apt install -y ffmpeg + run: sudo apt install -y ffmpeg - name: Setup node uses: actions/setup-node@v3 with: @@ -22,3 +26,21 @@ jobs: run: yarn - name: Run tests run: yarn test + - name: Generate coverage report + run: yarn coverage + - name: Store coverage + uses: coverallsapp/github-action@v2 + with: + flag-name: linux-node-${{ matrix.node }} + parallel: true + + finish: + needs: test + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@v2 + with: + parallel-finished: true + carryforward: "linux-node-18,linux-node-20,linux-node-21" diff --git a/.gitignore b/.gitignore index 1cc5826d..604c00ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.project node_modules -lib-cov +.nyc_output *.swp .idea *.iml +coverage diff --git a/Makefile b/Makefile index 26e1b143..53ad230a 100644 --- a/Makefile +++ b/Makefile @@ -7,15 +7,6 @@ test: test-colors: @NODE_ENV=test $(MOCHA) --require should --reporter $(REPORTER) --colors -test-cov: test/coverage.html - -test/coverage.html: lib-cov - @FLUENTFFMPEG_COV=1 NODE_ENV=test $(MOCHA) --require should --reporter html-cov > test/coverage.html - -lib-cov: - @rm -fr ./$@ - @jscoverage lib $@ - publish: @npm version patch -m "version bump" @npm publish @@ -26,4 +17,4 @@ JSDOC_CONF = tools/jsdoc-conf.json doc: $(JSDOC) --configure $(JSDOC_CONF) -.PHONY: test test-cov lib-cov test-colors publish doc \ No newline at end of file +.PHONY: test test-colors publish doc \ No newline at end of file diff --git a/README.md b/README.md index 2a48711a..1403386e 100644 --- a/README.md +++ b/README.md @@ -1459,12 +1459,6 @@ To run unit tests, first make sure you installed npm dependencies (run `npm inst $ make test ``` -If you want to re-generate the test coverage report (filed under test/coverage.html), run - -```sh -$ make test-cov -``` - Make sure your ffmpeg installation is up-to-date to prevent strange assertion errors because of missing codecs/bugfixes. ## Main contributors diff --git a/index.js b/index.js index fb4805dd..68a1522e 100644 --- a/index.js +++ b/index.js @@ -1 +1 @@ -module.exports = require(`./lib${process.env.FLUENTFFMPEG_COV ? '-cov' : ''}/fluent-ffmpeg`); +module.exports = require('./lib/fluent-ffmpeg'); diff --git a/package.json b/package.json index 720307ba..9b191028 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,10 @@ }, "repository": "git://github.com/fluent-ffmpeg/node-fluent-ffmpeg.git", "devDependencies": { + "jsdoc": "^4.0.0", "mocha": "^10.0.0", - "should": "^13.0.0", - "jsdoc": "^4.0.0" + "nyc": "^15.1.0", + "should": "^13.0.0" }, "dependencies": { "async": "^0.2.9", @@ -32,6 +33,7 @@ }, "main": "index", "scripts": { - "test": "make test" + "test": "NODE_ENV=test nyc mocha --require should --reporter spec", + "coverage": "nyc report --reporter=lcov" } } \ No newline at end of file diff --git a/test/coverage.html b/test/coverage.html deleted file mode 100644 index 58996049..00000000 --- a/test/coverage.html +++ /dev/null @@ -1,355 +0,0 @@ -make[1]: entrant dans le répertoire « /home/niko/dev/forks/node-fluent-ffmpeg » -
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var fs = require('fs'); |
5 | 1 | var path = require('path'); |
6 | 1 | var async = require('async'); |
7 | 1 | var utils = require('./utils'); |
8 | ||
9 | /* | |
10 | *! Capability helpers | |
11 | */ | |
12 | ||
13 | 1 | var avCodecRegexp = /^\s*([D ])([E ])([VAS])([S ])([D ])([T ]) ([^ ]+) +(.*)$/; |
14 | 1 | var ffCodecRegexp = /^\s*([D\.])([E\.])([VAS])([I\.])([L\.])([S\.]) ([^ ]+) +(.*)$/; |
15 | 1 | var ffEncodersRegexp = /\(encoders:([^\)]+)\)/; |
16 | 1 | var ffDecodersRegexp = /\(decoders:([^\)]+)\)/; |
17 | 1 | var formatRegexp = /^\s*([D ])([E ]) ([^ ]+) +(.*)$/; |
18 | 1 | var lineBreakRegexp = /\r\n|\r|\n/; |
19 | 1 | var filterRegexp = /^(?: [T\.][S\.][C\.] )?([^ ]+) +(AA?|VV?|\|)->(AA?|VV?|\|) +(.*)$/; |
20 | ||
21 | 1 | var cache = {}; |
22 | ||
23 | function copy(src, dest) { | |
24 | 125 | Object.keys(src).forEach(function(k) { |
25 | 741 | dest[k] = src[k]; |
26 | }); | |
27 | } | |
28 | ||
29 | 1 | module.exports = function(proto) { |
30 | /** | |
31 | * Forget executable paths | |
32 | * | |
33 | * (only used for testing purposes) | |
34 | * | |
35 | * @method FfmpegCommand#_forgetPaths | |
36 | * @private | |
37 | */ | |
38 | 1 | proto._forgetPaths = function() { |
39 | 8 | delete cache.ffmpegPath; |
40 | 8 | delete cache.ffprobePath; |
41 | 8 | delete cache.flvtoolPath; |
42 | }; | |
43 | ||
44 | ||
45 | /** | |
46 | * Check for ffmpeg availability | |
47 | * | |
48 | * If the FFMPEG_PATH environment variable is set, try to use it. | |
49 | * If it is unset or incorrect, try to find ffmpeg in the PATH instead. | |
50 | * | |
51 | * @method FfmpegCommand#_getFfmpegPath | |
52 | * @param {Function} callback callback with signature (err, path) | |
53 | * @private | |
54 | */ | |
55 | 1 | proto._getFfmpegPath = function(callback) { |
56 | 35 | if ('ffmpegPath' in cache) { |
57 | 29 | return callback(null, cache.ffmpegPath); |
58 | } | |
59 | ||
60 | 6 | async.waterfall([ |
61 | // Try FFMPEG_PATH | |
62 | function(cb) { | |
63 | 6 | if (process.env.FFMPEG_PATH) { |
64 | 3 | fs.exists(process.env.FFMPEG_PATH, function(exists) { |
65 | 3 | if (exists) { |
66 | 1 | cb(null, process.env.FFMPEG_PATH); |
67 | } else { | |
68 | 2 | cb(null, ''); |
69 | } | |
70 | }); | |
71 | } else { | |
72 | 3 | cb(null, ''); |
73 | } | |
74 | }, | |
75 | ||
76 | // Search in the PATH | |
77 | function(ffmpeg, cb) { | |
78 | 6 | if (ffmpeg.length) { |
79 | 1 | return cb(null, ffmpeg); |
80 | } | |
81 | ||
82 | 5 | utils.which('ffmpeg', function(err, ffmpeg) { |
83 | 5 | cb(err, ffmpeg); |
84 | }); | |
85 | } | |
86 | ], function(err, ffmpeg) { | |
87 | 6 | if (err) { |
88 | 0 | callback(err); |
89 | } else { | |
90 | 6 | callback(null, cache.ffmpegPath = (ffmpeg || '')); |
91 | } | |
92 | }); | |
93 | }; | |
94 | ||
95 | ||
96 | /** | |
97 | * Check for ffprobe availability | |
98 | * | |
99 | * If the FFPROBE_PATH environment variable is set, try to use it. | |
100 | * If it is unset or incorrect, try to find ffprobe in the PATH instead. | |
101 | * If this still fails, try to find ffprobe in the same directory as ffmpeg. | |
102 | * | |
103 | * @method FfmpegCommand#_getFfprobePath | |
104 | * @param {Function} callback callback with signature (err, path) | |
105 | * @private | |
106 | */ | |
107 | 1 | proto._getFfprobePath = function(callback) { |
108 | 13 | if ('ffprobePath' in cache) { |
109 | 9 | return callback(null, cache.ffprobePath); |
110 | } | |
111 | ||
112 | 4 | var self = this; |
113 | 4 | async.waterfall([ |
114 | // Try FFPROBE_PATH | |
115 | function(cb) { | |
116 | 4 | if (process.env.FFPROBE_PATH) { |
117 | 2 | fs.exists(process.env.FFPROBE_PATH, function(exists) { |
118 | 2 | cb(null, exists ? process.env.FFPROBE_PATH : ''); |
119 | }); | |
120 | } else { | |
121 | 2 | cb(null, ''); |
122 | } | |
123 | }, | |
124 | ||
125 | // Search in the PATH | |
126 | function(ffprobe, cb) { | |
127 | 4 | if (ffprobe.length) { |
128 | 1 | return cb(null, ffprobe); |
129 | } | |
130 | ||
131 | 3 | utils.which('ffprobe', function(err, ffprobe) { |
132 | 3 | cb(err, ffprobe); |
133 | }); | |
134 | }, | |
135 | ||
136 | // Search in the same directory as ffmpeg | |
137 | function(ffprobe, cb) { | |
138 | 4 | if (ffprobe.length) { |
139 | 4 | return cb(null, ffprobe); |
140 | } | |
141 | ||
142 | 0 | self._getFfmpegPath(function(err, ffmpeg) { |
143 | 0 | if (err) { |
144 | 0 | cb(err); |
145 | 0 | } else if (ffmpeg.length) { |
146 | 0 | var name = utils.isWindows ? 'ffprobe.exe' : 'ffprobe'; |
147 | 0 | var ffprobe = path.join(path.dirname(ffmpeg), name); |
148 | 0 | fs.exists(ffprobe, function(exists) { |
149 | 0 | cb(null, exists ? ffprobe : ''); |
150 | }); | |
151 | } else { | |
152 | 0 | cb(null, ''); |
153 | } | |
154 | }); | |
155 | } | |
156 | ], function(err, ffprobe) { | |
157 | 4 | if (err) { |
158 | 0 | callback(err); |
159 | } else { | |
160 | 4 | callback(null, cache.ffprobePath = (ffprobe || '')); |
161 | } | |
162 | }); | |
163 | }; | |
164 | ||
165 | ||
166 | /** | |
167 | * Check for flvtool2/flvmeta availability | |
168 | * | |
169 | * If the FLVTOOL2_PATH or FLVMETA_PATH environment variable are set, try to use them. | |
170 | * If both are either unset or incorrect, try to find flvtool2 or flvmeta in the PATH instead. | |
171 | * | |
172 | * @method FfmpegCommand#_getFlvtoolPath | |
173 | * @param {Function} callback callback with signature (err, path) | |
174 | * @private | |
175 | */ | |
176 | 1 | proto._getFlvtoolPath = function(callback) { |
177 | 29 | if ('flvtoolPath' in cache) { |
178 | 28 | return callback(null, cache.flvtoolPath); |
179 | } | |
180 | ||
181 | 1 | async.waterfall([ |
182 | // Try FLVMETA_PATH | |
183 | function(cb) { | |
184 | 1 | if (process.env.FLVMETA_PATH) { |
185 | 0 | fs.exists(process.env.FLVMETA_PATH, function(exists) { |
186 | 0 | cb(null, exists ? process.env.FLVMETA_PATH : ''); |
187 | }); | |
188 | } else { | |
189 | 1 | cb(null, ''); |
190 | } | |
191 | }, | |
192 | ||
193 | // Try FLVTOOL2_PATH | |
194 | function(flvtool, cb) { | |
195 | 1 | if (flvtool.length) { |
196 | 0 | return cb(null, flvtool); |
197 | } | |
198 | ||
199 | 1 | if (process.env.FLVTOOL2_PATH) { |
200 | 0 | fs.exists(process.env.FLVTOOL2_PATH, function(exists) { |
201 | 0 | cb(null, exists ? process.env.FLVTOOL2_PATH : ''); |
202 | }); | |
203 | } else { | |
204 | 1 | cb(null, ''); |
205 | } | |
206 | }, | |
207 | ||
208 | // Search for flvmeta in the PATH | |
209 | function(flvtool, cb) { | |
210 | 1 | if (flvtool.length) { |
211 | 0 | return cb(null, flvtool); |
212 | } | |
213 | ||
214 | 1 | utils.which('flvmeta', function(err, flvmeta) { |
215 | 1 | cb(err, flvmeta); |
216 | }); | |
217 | }, | |
218 | ||
219 | // Search for flvtool2 in the PATH | |
220 | function(flvtool, cb) { | |
221 | 1 | if (flvtool.length) { |
222 | 1 | return cb(null, flvtool); |
223 | } | |
224 | ||
225 | 0 | utils.which('flvtool2', function(err, flvtool2) { |
226 | 0 | cb(err, flvtool2); |
227 | }); | |
228 | }, | |
229 | ], function(err, flvtool) { | |
230 | 1 | if (err) { |
231 | 0 | callback(err); |
232 | } else { | |
233 | 1 | callback(null, cache.flvtoolPath = (flvtool || '')); |
234 | } | |
235 | }); | |
236 | }; | |
237 | ||
238 | ||
239 | /** | |
240 | * Query ffmpeg for available filters | |
241 | * | |
242 | * Calls 'callback' with a filters object as its second argument. This | |
243 | * object has keys for every available filter, and values are object | |
244 | * with filter data: | |
245 | * - 'description': filter description | |
246 | * - 'input': input type ('audio', 'video' or 'none') | |
247 | * - 'multipleInputs': bool, whether the filter supports multiple inputs | |
248 | * - 'output': output type ('audio', 'video' or 'none') | |
249 | * - 'multipleOutputs': bool, whether the filter supports multiple outputs | |
250 | * | |
251 | * @method FfmpegCommand#availableFilters | |
252 | * @category Capabilities | |
253 | * @aliases getAvailableFilters | |
254 | * | |
255 | * @param {Function} callback callback with signature (err, filters) | |
256 | */ | |
257 | 1 | proto.availableFilters = |
258 | proto.getAvailableFilters = function(callback) { | |
259 | 2 | if ('filters' in cache) { |
260 | 1 | return callback(null, cache.filters); |
261 | } | |
262 | ||
263 | 1 | this._spawnFfmpeg(['-filters'], { captureStdout: true }, function (err, stdout) { |
264 | 1 | if (err) { |
265 | 0 | return callback(err); |
266 | } | |
267 | ||
268 | 1 | var lines = stdout.split('\n'); |
269 | 1 | var data = {}; |
270 | 1 | var types = { A: 'audio', V: 'video', '|': 'none' }; |
271 | ||
272 | 1 | lines.forEach(function(line) { |
273 | 137 | var match = line.match(filterRegexp); |
274 | 137 | if (match) { |
275 | 135 | data[match[1]] = { |
276 | description: match[4], | |
277 | input: types[match[2].charAt(0)], | |
278 | multipleInputs: match[2].length > 1, | |
279 | output: types[match[3].charAt(0)], | |
280 | multipleOutputs: match[3].length > 1 | |
281 | }; | |
282 | } | |
283 | }); | |
284 | ||
285 | 1 | callback(null, cache.filters = data); |
286 | }); | |
287 | }; | |
288 | ||
289 | ||
290 | /** | |
291 | * Query ffmpeg for available codecs | |
292 | * | |
293 | * Calls 'callback' with a codecs object as its second argument. This | |
294 | * object has keys for every available codec, and values are object | |
295 | * with codec data: | |
296 | * - 'description': codec description | |
297 | * - 'canEncode': bool, whether the codec can encode streams | |
298 | * - 'canDecode': bool, whether the codec can decode streams | |
299 | * | |
300 | * Depending on the ffmpeg version, more keys can be available. | |
301 | * | |
302 | * @method FfmpegCommand#availableCodecs | |
303 | * @category Capabilities | |
304 | * @aliases getAvailableCodecs | |
305 | * | |
306 | * @param {Function} callback callback with signature (err, codecs) | |
307 | */ | |
308 | 1 | proto.availableCodecs = |
309 | proto.getAvailableCodecs = function(callback) { | |
310 | 25 | if ('codecs' in cache) { |
311 | 24 | return callback(null, cache.codecs); |
312 | } | |
313 | ||
314 | 1 | this._spawnFfmpeg(['-codecs'], { captureStdout: true }, function(err, stdout) { |
315 | 1 | if (err) { |
316 | 0 | return callback(err); |
317 | } | |
318 | ||
319 | 1 | var lines = stdout.split(lineBreakRegexp); |
320 | 1 | var data = {}; |
321 | ||
322 | 1 | lines.forEach(function(line) { |
323 | 369 | var match = line.match(avCodecRegexp); |
324 | 369 | if (match && match[7] !== '=') { |
325 | 0 | data[match[7]] = { |
326 | type: { 'V': 'video', 'A': 'audio', 'S': 'subtitle' }[match[3]], | |
327 | description: match[8], | |
328 | canDecode: match[1] === 'D', | |
329 | canEncode: match[2] === 'E', | |
330 | drawHorizBand: match[4] === 'S', | |
331 | directRendering: match[5] === 'D', | |
332 | weirdFrameTruncation: match[6] === 'T' | |
333 | }; | |
334 | } | |
335 | ||
336 | 369 | match = line.match(ffCodecRegexp); |
337 | 369 | if (match && match[7] !== '=') { |
338 | 357 | var codecData = data[match[7]] = { |
339 | type: { 'V': 'video', 'A': 'audio', 'S': 'subtitle' }[match[3]], | |
340 | description: match[8], | |
341 | canDecode: match[1] === 'D', | |
342 | canEncode: match[2] === 'E', | |
343 | intraFrameOnly: match[4] === 'I', | |
344 | isLossy: match[5] === 'L', | |
345 | isLossless: match[6] === 'S' | |
346 | }; | |
347 | ||
348 | 357 | var encoders = codecData.description.match(ffEncodersRegexp); |
349 | 357 | encoders = encoders ? encoders[1].trim().split(' ') : []; |
350 | ||
351 | 357 | var decoders = codecData.description.match(ffDecodersRegexp); |
352 | 357 | decoders = decoders ? decoders[1].trim().split(' ') : []; |
353 | ||
354 | 357 | if (encoders.length || decoders.length) { |
355 | 58 | var coderData = {}; |
356 | 58 | copy(codecData, coderData); |
357 | 58 | delete coderData.canEncode; |
358 | 58 | delete coderData.canDecode; |
359 | ||
360 | 58 | encoders.forEach(function(name) { |
361 | 32 | data[name] = {}; |
362 | 32 | copy(coderData, data[name]); |
363 | 32 | data[name].canEncode = true; |
364 | }); | |
365 | ||
366 | 58 | decoders.forEach(function(name) { |
367 | 73 | if (name in data) { |
368 | 38 | data[name].canDecode = true; |
369 | } else { | |
370 | 35 | data[name] = {}; |
371 | 35 | copy(coderData, data[name]); |
372 | 35 | data[name].canDecode = true; |
373 | } | |
374 | }); | |
375 | } | |
376 | } | |
377 | }); | |
378 | ||
379 | 1 | callback(null, cache.codecs = data); |
380 | }); | |
381 | }; | |
382 | ||
383 | ||
384 | /** | |
385 | * Query ffmpeg for available formats | |
386 | * | |
387 | * Calls 'callback' with a formats object as its second argument. This | |
388 | * object has keys for every available format, and values are object | |
389 | * with format data: | |
390 | * - 'description': format description | |
391 | * - 'canMux': bool, whether the format can mux streams into an output file | |
392 | * - 'canDemux': bool, whether the format can demux streams from an input file | |
393 | * | |
394 | * @method FfmpegCommand#availableFormats | |
395 | * @category Capabilities | |
396 | * @aliases getAvailableFormats | |
397 | * | |
398 | * @param {Function} callback callback with signature (err, formats) | |
399 | */ | |
400 | 1 | proto.availableFormats = |
401 | proto.getAvailableFormats = function(callback) { | |
402 | 28 | if ('formats' in cache) { |
403 | 27 | return callback(null, cache.formats); |
404 | } | |
405 | ||
406 | // Run ffmpeg -formats | |
407 | 1 | this._spawnFfmpeg(['-formats'], { captureStdout: true }, function (err, stdout) { |
408 | 1 | if (err) { |
409 | 0 | return callback(err); |
410 | } | |
411 | ||
412 | // Parse output | |
413 | 1 | var lines = stdout.split(lineBreakRegexp); |
414 | 1 | var data = {}; |
415 | ||
416 | 1 | lines.forEach(function(line) { |
417 | 252 | var match = line.match(formatRegexp); |
418 | 252 | if (match) { |
419 | 247 | data[match[3]] = { |
420 | description: match[4], | |
421 | canDemux: match[1] === 'D', | |
422 | canMux: match[2] === 'E' | |
423 | }; | |
424 | } | |
425 | }); | |
426 | ||
427 | 1 | callback(null, cache.formats = data); |
428 | }); | |
429 | }; | |
430 | ||
431 | ||
432 | /** | |
433 | * Check capabilities before executing a command | |
434 | * | |
435 | * Checks whether all used codecs and formats are indeed available | |
436 | * | |
437 | * @method FfmpegCommand#_checkCapabilities | |
438 | * @param {Function} callback callback with signature (err) | |
439 | * @private | |
440 | */ | |
441 | 1 | proto._checkCapabilities = function(callback) { |
442 | 26 | var self = this; |
443 | 26 | async.waterfall([ |
444 | // Get available formats | |
445 | function(cb) { | |
446 | 26 | self.availableFormats(cb); |
447 | }, | |
448 | ||
449 | // Check whether specified formats are available | |
450 | function(formats, cb) { | |
451 | // Output format | |
452 | 26 | var format = self._output.find('-f', 1); |
453 | ||
454 | 26 | if (format) { |
455 | 24 | if (!(format[0] in formats) || !(formats[format[0]].canMux)) { |
456 | 2 | return cb(new Error('Output format ' + format[0] + ' is not available')); |
457 | } | |
458 | } | |
459 | ||
460 | // Input format(s) | |
461 | 24 | var unavailable = self._inputs.reduce(function(fmts, input) { |
462 | 24 | var format = input.before.find('-f', 1); |
463 | 24 | if (format) { |
464 | 4 | if (!(format[0] in formats) || !(formats[format[0]].canDemux)) { |
465 | 1 | fmts.push(format[0]); |
466 | } | |
467 | } | |
468 | ||
469 | 24 | return fmts; |
470 | }, []); | |
471 | ||
472 | 24 | if (unavailable.length === 1) { |
473 | 1 | cb(new Error('Input format ' + unavailable[0] + ' is not available')); |
474 | 23 | } else if (unavailable.length > 1) { |
475 | 0 | cb(new Error('Input formats ' + unavailable.join(', ') + ' are not available')); |
476 | } else { | |
477 | 23 | cb(); |
478 | } | |
479 | }, | |
480 | ||
481 | // Get available codecs | |
482 | function(cb) { | |
483 | 23 | self.availableCodecs(cb); |
484 | }, | |
485 | ||
486 | // Check whether specified codecs are available | |
487 | function(codecs, cb) { | |
488 | // Audio codec | |
489 | 23 | var acodec = self._audio.find('-acodec', 1); |
490 | 23 | if (acodec && acodec[0] !== 'copy') { |
491 | 21 | if (!(acodec[0] in codecs) || codecs[acodec[0]].type !== 'audio' || !(codecs[acodec[0]].canEncode)) { |
492 | 1 | return cb(new Error('Audio codec ' + acodec[0] + ' is not available')); |
493 | } | |
494 | } | |
495 | ||
496 | // Video codec | |
497 | 22 | var vcodec = self._video.find('-vcodec', 1); |
498 | 22 | if (vcodec && vcodec[0] !== 'copy') { |
499 | 20 | if (!(vcodec[0] in codecs) || codecs[vcodec[0]].type !== 'video' || !(codecs[vcodec[0]].canEncode)) { |
500 | 1 | return cb(new Error('Video codec ' + vcodec[0] + ' is not available')); |
501 | } | |
502 | } | |
503 | ||
504 | 21 | cb(); |
505 | } | |
506 | ], callback); | |
507 | }; | |
508 | }; | |
509 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true, laxcomma:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var spawn = require('child_process').spawn; |
5 | ||
6 | ||
7 | 263 | function legacyTag(key) { return key.match(/^TAG:/); } |
8 | 263 | function legacyDisposition(key) { return key.match(/^DISPOSITION:/); } |
9 | ||
10 | ||
11 | 1 | module.exports = function(proto) { |
12 | /** | |
13 | * Run ffprobe on last specified input | |
14 | * | |
15 | * Callback will receive an object as its second argument. This object | |
16 | * has the same format as what the following command returns: | |
17 | * | |
18 | * ffprobe -print_format json -show_streams -show_format INPUTFILE | |
19 | * | |
20 | * @method FfmpegCommand#ffprobe | |
21 | * @category Metadata | |
22 | * | |
23 | * @param {Function} callback callback with signature (err, ffprobeData) | |
24 | * | |
25 | */ | |
26 | 1 | proto.ffprobe = function(callback) { |
27 | 10 | if (!this._currentInput) { |
28 | 1 | return callback(new Error('No input specified')); |
29 | } | |
30 | ||
31 | 9 | if (typeof this._currentInput.source !== 'string') { |
32 | 1 | return callback(new Error('Cannot run ffprobe on non-file input')); |
33 | } | |
34 | ||
35 | // Find ffprobe | |
36 | 8 | var self = this; |
37 | 8 | this._getFfprobePath(function(err, path) { |
38 | 8 | if (err) { |
39 | 0 | return callback(err); |
40 | 8 | } else if (!path) { |
41 | 0 | return callback(new Error('Cannot find ffprobe')); |
42 | } | |
43 | ||
44 | 8 | var stdout = ''; |
45 | 8 | var stdoutClosed = false; |
46 | 8 | var stderr = ''; |
47 | 8 | var stderrClosed = false; |
48 | ||
49 | // Spawn ffprobe | |
50 | 8 | var ffprobe = spawn(path, [ |
51 | '-print_format', 'json', | |
52 | '-show_streams', | |
53 | '-show_format', | |
54 | self._currentInput.source | |
55 | ]); | |
56 | ||
57 | 8 | ffprobe.on('error', function(err) { |
58 | 0 | callback(err); |
59 | }); | |
60 | ||
61 | // Ensure we wait for captured streams to end before calling callback | |
62 | 8 | var exitError = null; |
63 | function handleExit(err) { | |
64 | 24 | if (err) { |
65 | 1 | exitError = err; |
66 | } | |
67 | ||
68 | 24 | if (processExited && stdoutClosed && stderrClosed) { |
69 | 8 | if (exitError) { |
70 | 1 | if (stderr) { |
71 | 1 | exitError.message += '\n' + stderr; |
72 | } | |
73 | ||
74 | 1 | return callback(exitError); |
75 | } | |
76 | ||
77 | // Process output | |
78 | 7 | var data; |
79 | ||
80 | 7 | try { |
81 | 7 | data = JSON.parse(stdout); |
82 | } catch(e) { | |
83 | 0 | return callback(e); |
84 | } | |
85 | ||
86 | // Handle legacy output with "TAG:x" and "DISPOSITION:x" keys | |
87 | 7 | [data.format].concat(data.streams).forEach(function(target) { |
88 | 15 | var legacyTagKeys = Object.keys(target).filter(legacyTag); |
89 | ||
90 | 15 | if (legacyTagKeys.length) { |
91 | 0 | target.tags = target.tags || {}; |
92 | ||
93 | 0 | legacyTagKeys.forEach(function(tagKey) { |
94 | 0 | target.tags[tagKey.substr(4)] = target[tagKey]; |
95 | 0 | delete target[tagKey]; |
96 | }); | |
97 | } | |
98 | ||
99 | 15 | var legacyDispositionKeys = Object.keys(target).filter(legacyDisposition); |
100 | ||
101 | 15 | if (legacyDispositionKeys.length) { |
102 | 0 | target.disposition = target.disposition || {}; |
103 | ||
104 | 0 | legacyDispositionKeys.forEach(function(dispositionKey) { |
105 | 0 | target.disposition[dispositionKey.substr(12)] = target[dispositionKey]; |
106 | 0 | delete target[dispositionKey]; |
107 | }); | |
108 | } | |
109 | }); | |
110 | ||
111 | 7 | callback(null, data); |
112 | } | |
113 | } | |
114 | ||
115 | // Handle ffprobe exit | |
116 | 8 | var processExited = false; |
117 | 8 | ffprobe.on('exit', function(code, signal) { |
118 | 8 | processExited = true; |
119 | ||
120 | 8 | if (code) { |
121 | 1 | handleExit(new Error('ffprobe exited with code ' + code)); |
122 | 7 | } else if (signal) { |
123 | 0 | handleExit(new Error('ffprobe was killed with signal ' + signal)); |
124 | } else { | |
125 | 7 | handleExit(); |
126 | } | |
127 | }); | |
128 | ||
129 | // Handle stdout/stderr streams | |
130 | 8 | ffprobe.stdout.on('data', function(data) { |
131 | 18 | stdout += data; |
132 | }); | |
133 | ||
134 | 8 | ffprobe.stdout.on('close', function() { |
135 | 8 | stdoutClosed = true; |
136 | 8 | handleExit(); |
137 | }); | |
138 | ||
139 | 8 | ffprobe.stderr.on('data', function(data) { |
140 | 33 | stderr += data; |
141 | }); | |
142 | ||
143 | 8 | ffprobe.stderr.on('close', function() { |
144 | 8 | stderrClosed = true; |
145 | 8 | handleExit(); |
146 | }); | |
147 | }); | |
148 | }; | |
149 | }; | |
150 | ||
151 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var path = require('path'); |
5 | 1 | var util = require('util'); |
6 | 1 | var EventEmitter = require('events').EventEmitter; |
7 | ||
8 | 1 | var utils = require('./utils'); |
9 | ||
10 | ||
11 | /** | |
12 | * Create an ffmpeg command | |
13 | * | |
14 | * Can be called with or without the 'new' operator, and the 'input' parameter | |
15 | * may be specified as 'options.source' instead (or passed later with the | |
16 | * addInput method). | |
17 | * | |
18 | * @constructor | |
19 | * @param {String|ReadableStream} [input] input file path or readable stream | |
20 | * @param {Object} [options] command options | |
21 | * @param {Object} [options.logger=<no logging>] logger object with 'error', 'warning', 'info' and 'debug' methods | |
22 | * @param {Number} [options.niceness=0] ffmpeg process niceness, ignored on Windows | |
23 | * @param {Number} [options.priority=0] alias for `niceness` | |
24 | * @param {String} [options.presets="fluent-ffmpeg/lib/presets"] directory to load presets from | |
25 | * @param {String} [options.preset="fluent-ffmpeg/lib/presets"] alias for `presets` | |
26 | * @param {Number} [options.timeout=<no timeout>] ffmpeg processing timeout in seconds | |
27 | * @param {String|ReadableStream} [options.source=<no input>] alias for the `input` parameter | |
28 | */ | |
29 | function FfmpegCommand(input, options) { | |
30 | // Make 'new' optional | |
31 | 207 | if (!(this instanceof FfmpegCommand)) { |
32 | 1 | return new FfmpegCommand(input, options); |
33 | } | |
34 | ||
35 | 206 | EventEmitter.call(this); |
36 | ||
37 | 206 | if (typeof input === 'object' && !('readable' in input)) { |
38 | // Options object passed directly | |
39 | 89 | options = input; |
40 | } else { | |
41 | // Input passed first | |
42 | 117 | options = options || {}; |
43 | 117 | options.source = input; |
44 | } | |
45 | ||
46 | // Add input if present | |
47 | 206 | this._inputs = []; |
48 | 206 | if (options.source) { |
49 | 94 | this.addInput(options.source); |
50 | } | |
51 | ||
52 | // Create argument lists | |
53 | 206 | this._audio = utils.args(); |
54 | 206 | this._audioFilters = utils.args(); |
55 | 206 | this._video = utils.args(); |
56 | 206 | this._videoFilters = utils.args(); |
57 | 206 | this._sizeFilters = utils.args(); |
58 | 206 | this._output = utils.args(); |
59 | ||
60 | // Set default option values | |
61 | 206 | options.presets = options.presets || options.preset || path.join(__dirname, 'presets'); |
62 | 206 | options.niceness = options.niceness || options.priority || 0; |
63 | ||
64 | // Save options | |
65 | 206 | this.options = options; |
66 | ||
67 | // Setup logger | |
68 | 206 | this.logger = options.logger || { |
69 | debug: function() {}, | |
70 | info: function() {}, | |
71 | warn: function() {}, | |
72 | error: function() {} | |
73 | }; | |
74 | } | |
75 | 1 | util.inherits(FfmpegCommand, EventEmitter); |
76 | 1 | module.exports = FfmpegCommand; |
77 | ||
78 | ||
79 | /* Add methods from options submodules */ | |
80 | ||
81 | 1 | require('./options/inputs')(FfmpegCommand.prototype); |
82 | 1 | require('./options/audio')(FfmpegCommand.prototype); |
83 | 1 | require('./options/video')(FfmpegCommand.prototype); |
84 | 1 | require('./options/videosize')(FfmpegCommand.prototype); |
85 | 1 | require('./options/output')(FfmpegCommand.prototype); |
86 | 1 | require('./options/custom')(FfmpegCommand.prototype); |
87 | 1 | require('./options/misc')(FfmpegCommand.prototype); |
88 | ||
89 | ||
90 | /* Add processor methods */ | |
91 | ||
92 | 1 | require('./processor')(FfmpegCommand.prototype); |
93 | ||
94 | ||
95 | /* Add capabilities methods */ | |
96 | ||
97 | 1 | require('./capabilities')(FfmpegCommand.prototype); |
98 | ||
99 | 1 | FfmpegCommand.availableFilters = |
100 | FfmpegCommand.getAvailableFilters = function(callback) { | |
101 | 1 | (new FfmpegCommand()).availableFilters(callback); |
102 | }; | |
103 | ||
104 | 1 | FfmpegCommand.availableCodecs = |
105 | FfmpegCommand.getAvailableCodecs = function(callback) { | |
106 | 1 | (new FfmpegCommand()).availableCodecs(callback); |
107 | }; | |
108 | ||
109 | 1 | FfmpegCommand.availableFormats = |
110 | FfmpegCommand.getAvailableFormats = function(callback) { | |
111 | 1 | (new FfmpegCommand()).availableFormats(callback); |
112 | }; | |
113 | ||
114 | ||
115 | /* Add ffprobe methods */ | |
116 | ||
117 | 1 | require('./ffprobe')(FfmpegCommand.prototype); |
118 | ||
119 | 1 | FfmpegCommand.ffprobe = function(file, callback) { |
120 | 4 | (new FfmpegCommand(file)).ffprobe(callback); |
121 | }; | |
122 | ||
123 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | /* | |
5 | *! Audio-related methods | |
6 | */ | |
7 | ||
8 | 1 | module.exports = function(proto) { |
9 | /** | |
10 | * Disable audio in the output | |
11 | * | |
12 | * @method FfmpegCommand#noAudio | |
13 | * @category Audio | |
14 | * @aliases withNoAudio | |
15 | * @return FfmpegCommand | |
16 | */ | |
17 | 1 | proto.withNoAudio = |
18 | proto.noAudio = function() { | |
19 | 2 | this._audio.clear(); |
20 | 2 | this._audio('-an'); |
21 | ||
22 | 2 | return this; |
23 | }; | |
24 | ||
25 | ||
26 | /** | |
27 | * Specify audio codec | |
28 | * | |
29 | * @method FfmpegCommand#audioCodec | |
30 | * @category Audio | |
31 | * @aliases withAudioCodec | |
32 | * | |
33 | * @param {String} codec audio codec name | |
34 | * @return FfmpegCommand | |
35 | */ | |
36 | 1 | proto.withAudioCodec = |
37 | proto.audioCodec = function(codec) { | |
38 | 26 | this._audio('-acodec', codec); |
39 | 26 | return this; |
40 | }; | |
41 | ||
42 | ||
43 | /** | |
44 | * Specify audio bitrate | |
45 | * | |
46 | * @method FfmpegCommand#audioBitrate | |
47 | * @category Audio | |
48 | * @aliases withAudioBitrate | |
49 | * | |
50 | * @param {String|Number} bitrate audio bitrate in kbps (with an optional 'k' suffix) | |
51 | * @return FfmpegCommand | |
52 | */ | |
53 | 1 | proto.withAudioBitrate = |
54 | proto.audioBitrate = function(bitrate) { | |
55 | 22 | this._audio('-b:a', ('' + bitrate).replace(/k?$/, 'k')); |
56 | 22 | return this; |
57 | }; | |
58 | ||
59 | ||
60 | /** | |
61 | * Specify audio channel count | |
62 | * | |
63 | * @method FfmpegCommand#audioChannels | |
64 | * @category Audio | |
65 | * @aliases withAudioChannels | |
66 | * | |
67 | * @param {Number} channels channel count | |
68 | * @return FfmpegCommand | |
69 | */ | |
70 | 1 | proto.withAudioChannels = |
71 | proto.audioChannels = function(channels) { | |
72 | 22 | this._audio('-ac', channels); |
73 | 22 | return this; |
74 | }; | |
75 | ||
76 | ||
77 | /** | |
78 | * Specify audio frequency | |
79 | * | |
80 | * @method FfmpegCommand#audioFrequency | |
81 | * @category Audio | |
82 | * @aliases withAudioFrequency | |
83 | * | |
84 | * @param {Number} freq audio frequency in Hz | |
85 | * @return FfmpegCommand | |
86 | */ | |
87 | 1 | proto.withAudioFrequency = |
88 | proto.audioFrequency = function(freq) { | |
89 | 20 | this._audio('-ar', freq); |
90 | 20 | return this; |
91 | }; | |
92 | ||
93 | ||
94 | /** | |
95 | * Specify audio quality | |
96 | * | |
97 | * @method FfmpegCommand#audioQuality | |
98 | * @category Audio | |
99 | * @aliases withAudioQuality | |
100 | * | |
101 | * @param {Number} quality audio quality factor | |
102 | * @return FfmpegCommand | |
103 | */ | |
104 | 1 | proto.withAudioQuality = |
105 | proto.audioQuality = function(quality) { | |
106 | 1 | this._audio('-aq', quality); |
107 | 1 | return this; |
108 | }; | |
109 | ||
110 | ||
111 | /** | |
112 | * Specify custom audio filter(s) | |
113 | * | |
114 | * Can be called both with one or many filters, or a filter array. | |
115 | * | |
116 | * @example | |
117 | * command.audioFilters('filter1'); | |
118 | * | |
119 | * @example | |
120 | * command.audioFilters('filter1', 'filter2'); | |
121 | * | |
122 | * @example | |
123 | * command.audioFilters(['filter1', 'filter2']); | |
124 | * | |
125 | * @method FfmpegCommand#audioFilters | |
126 | * @aliases withAudioFilter,withAudioFilters,audioFilter | |
127 | * @category Audio | |
128 | * | |
129 | * @param {String|Array} filters... audio filter strings or string array | |
130 | * @return FfmpegCommand | |
131 | */ | |
132 | 1 | proto.withAudioFilter = |
133 | proto.withAudioFilters = | |
134 | proto.audioFilter = | |
135 | proto.audioFilters = function(filters) { | |
136 | 3 | if (arguments.length > 1) { |
137 | 1 | filters = [].slice.call(arguments); |
138 | } | |
139 | ||
140 | 3 | this._audioFilters(filters); |
141 | 3 | return this; |
142 | }; | |
143 | }; | |
144 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | /* | |
5 | *! Custom options methods | |
6 | */ | |
7 | ||
8 | 1 | module.exports = function(proto) { |
9 | /** | |
10 | * Add custom input option(s) | |
11 | * | |
12 | * When passing a single string or an array, each string containing two | |
13 | * words is split (eg. inputOptions('-option value') is supported) for | |
14 | * compatibility reasons. This is not the case when passing more than | |
15 | * one argument. | |
16 | * | |
17 | * @example | |
18 | * command.inputOptions('option1'); | |
19 | * | |
20 | * @example | |
21 | * command.inputOptions('option1', 'option2'); | |
22 | * | |
23 | * @example | |
24 | * command.inputOptions(['option1', 'option2']); | |
25 | * | |
26 | * @method FfmpegCommand#inputOptions | |
27 | * @category Custom options | |
28 | * @aliases addInputOption,addInputOptions,withInputOption,withInputOptions,inputOption | |
29 | * | |
30 | * @param {...String} options option string(s) or string array | |
31 | * @return FfmpegCommand | |
32 | */ | |
33 | 1 | proto.addInputOption = |
34 | proto.addInputOptions = | |
35 | proto.withInputOption = | |
36 | proto.withInputOptions = | |
37 | proto.inputOption = | |
38 | proto.inputOptions = function(options) { | |
39 | 5 | if (!this._currentInput) { |
40 | 1 | throw new Error('No input specified'); |
41 | } | |
42 | ||
43 | 4 | var doSplit = true; |
44 | ||
45 | 4 | if (arguments.length > 1) { |
46 | 2 | options = [].slice.call(arguments); |
47 | 2 | doSplit = false; |
48 | } | |
49 | ||
50 | 4 | if (!Array.isArray(options)) { |
51 | 1 | options = [options]; |
52 | } | |
53 | ||
54 | 4 | this._currentInput.before(options.reduce(function(options, option) { |
55 | 7 | var split = option.split(' '); |
56 | ||
57 | 7 | if (doSplit && split.length === 2) { |
58 | 3 | options.push(split[0], split[1]); |
59 | } else { | |
60 | 4 | options.push(option); |
61 | } | |
62 | ||
63 | 7 | return options; |
64 | }, [])); | |
65 | 4 | return this; |
66 | }; | |
67 | ||
68 | ||
69 | /** | |
70 | * Add custom output option(s) | |
71 | * | |
72 | * @example | |
73 | * command.outputOptions('option1'); | |
74 | * | |
75 | * @example | |
76 | * command.outputOptions('option1', 'option2'); | |
77 | * | |
78 | * @example | |
79 | * command.outputOptions(['option1', 'option2']); | |
80 | * | |
81 | * @method FfmpegCommand#outputOptions | |
82 | * @category Custom options | |
83 | * @aliases addOutputOption,addOutputOptions,addOption,addOptions,withOutputOption,withOutputOptions,withOption,withOptions,outputOption | |
84 | * | |
85 | * @param {...String} options option string(s) or string array | |
86 | * @return FfmpegCommand | |
87 | */ | |
88 | 1 | proto.addOutputOption = |
89 | proto.addOutputOptions = | |
90 | proto.addOption = | |
91 | proto.addOptions = | |
92 | proto.withOutputOption = | |
93 | proto.withOutputOptions = | |
94 | proto.withOption = | |
95 | proto.withOptions = | |
96 | proto.outputOption = | |
97 | proto.outputOptions = function(options) { | |
98 | 6 | var doSplit = true; |
99 | ||
100 | 6 | if (arguments.length > 1) { |
101 | 2 | options = [].slice.call(arguments); |
102 | 2 | doSplit = false; |
103 | } | |
104 | ||
105 | 6 | if (!Array.isArray(options)) { |
106 | 1 | options = [options]; |
107 | } | |
108 | ||
109 | 6 | this._output(options.reduce(function(options, option) { |
110 | 45 | var split = option.split(' '); |
111 | ||
112 | 45 | if (doSplit && split.length === 2) { |
113 | 19 | options.push(split[0], split[1]); |
114 | } else { | |
115 | 26 | options.push(option); |
116 | } | |
117 | ||
118 | 45 | return options; |
119 | }, [])); | |
120 | 6 | return this; |
121 | }; | |
122 | }; | |
123 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var utils = require('../utils'); |
5 | ||
6 | /* | |
7 | *! Input-related methods | |
8 | */ | |
9 | ||
10 | 1 | module.exports = function(proto) { |
11 | /** | |
12 | * Add an input to command | |
13 | * | |
14 | * Also switches "current input", that is the input that will be affected | |
15 | * by subsequent input-related methods. | |
16 | * | |
17 | * Note: only one stream input is supported for now. | |
18 | * | |
19 | * @method FfmpegCommand#input | |
20 | * @category Input | |
21 | * @aliases mergeAdd,addInput | |
22 | * | |
23 | * @param {String|Readable} source input file path or readable stream | |
24 | * @return FfmpegCommand | |
25 | */ | |
26 | 1 | proto.mergeAdd = |
27 | proto.addInput = | |
28 | proto.input = function(source) { | |
29 | 101 | if (typeof source !== 'string') { |
30 | 6 | if (!('readable' in source)) { |
31 | 1 | throw new Error('Invalid input'); |
32 | } | |
33 | ||
34 | 5 | var hasInputStream = this._inputs.some(function(input) { |
35 | 1 | return typeof input.source !== 'string'; |
36 | }); | |
37 | ||
38 | 5 | if (hasInputStream) { |
39 | 1 | throw new Error('Only one input stream is supported'); |
40 | } | |
41 | ||
42 | 4 | source.pause(); |
43 | } | |
44 | ||
45 | 99 | this._inputs.push(this._currentInput = { |
46 | source: source, | |
47 | before: utils.args(), | |
48 | after: utils.args(), | |
49 | }); | |
50 | ||
51 | 99 | return this; |
52 | }; | |
53 | ||
54 | ||
55 | /** | |
56 | * Specify input format for the last specified input | |
57 | * | |
58 | * @method FfmpegCommand#inputFormat | |
59 | * @category Input | |
60 | * @aliases withInputFormat,fromFormat | |
61 | * | |
62 | * @param {String} format input format | |
63 | * @return FfmpegCommand | |
64 | */ | |
65 | 1 | proto.withInputFormat = |
66 | proto.inputFormat = | |
67 | proto.fromFormat = function(format) { | |
68 | 6 | if (!this._currentInput) { |
69 | 1 | throw new Error('No input specified'); |
70 | } | |
71 | ||
72 | 5 | this._currentInput.before('-f', format); |
73 | 5 | return this; |
74 | }; | |
75 | ||
76 | ||
77 | /** | |
78 | * Specify input FPS for the last specified input | |
79 | * (only valid for raw video formats) | |
80 | * | |
81 | * @method FfmpegCommand#inputFps | |
82 | * @category Input | |
83 | * @aliases withInputFps,withInputFPS,withFpsInput,withFPSInput,inputFPS,inputFps,fpsInput | |
84 | * | |
85 | * @param {Number} fps input FPS | |
86 | * @return FfmpegCommand | |
87 | */ | |
88 | 1 | proto.withInputFps = |
89 | proto.withInputFPS = | |
90 | proto.withFpsInput = | |
91 | proto.withFPSInput = | |
92 | proto.inputFPS = | |
93 | proto.inputFps = | |
94 | proto.fpsInput = | |
95 | proto.FPSInput = function(fps) { | |
96 | 2 | if (!this._currentInput) { |
97 | 1 | throw new Error('No input specified'); |
98 | } | |
99 | ||
100 | 1 | this._currentInput.before('-r', fps); |
101 | 1 | return this; |
102 | }; | |
103 | ||
104 | ||
105 | /** | |
106 | * Specify input seek time for the last specified input | |
107 | * | |
108 | * @method FfmpegCommand#seek | |
109 | * @category Input | |
110 | * @aliases setStartTime,seekTo | |
111 | * | |
112 | * @param {String|Number} seek seek time in seconds or as a '[hh:[mm:]]ss[.xxx]' string | |
113 | * @param {Boolean} [fast=false] use fast (but inexact) seek | |
114 | * @return FfmpegCommand | |
115 | */ | |
116 | 1 | proto.setStartTime = |
117 | proto.seekTo = | |
118 | proto.seek = function(seek, fast) { | |
119 | 4 | if (!this._currentInput) { |
120 | 2 | throw new Error('No input specified'); |
121 | } | |
122 | ||
123 | 2 | if (fast) { |
124 | 1 | this._currentInput.before('-ss', seek); |
125 | } else { | |
126 | 1 | this._currentInput.after('-ss', seek); |
127 | } | |
128 | ||
129 | 2 | return this; |
130 | }; | |
131 | ||
132 | ||
133 | /** | |
134 | * Specify input fast-seek time for the last specified input | |
135 | * | |
136 | * @method FfmpegCommand#fastSeek | |
137 | * @category Input | |
138 | * @aliases fastSeekTo | |
139 | * | |
140 | * @param {String|Number} seek fast-seek time in seconds or as a '[[hh:]mm:]ss[.xxx]' string | |
141 | * @return FfmpegCommand | |
142 | */ | |
143 | 1 | proto.fastSeek = |
144 | proto.fastSeekTo = function(seek) { | |
145 | 1 | return this.seek(seek, true); |
146 | }; | |
147 | ||
148 | ||
149 | /** | |
150 | * Loop over the last specified input | |
151 | * | |
152 | * @method FfmpegCommand#loop | |
153 | * @category Input | |
154 | * | |
155 | * @param {String|Number} [duration] loop duration in seconds or as a '[[hh:]mm:]ss[.xxx]' string | |
156 | * @return FfmpegCommand | |
157 | */ | |
158 | 1 | proto.loop = function(duration) { |
159 | 4 | if (!this._currentInput) { |
160 | 1 | throw new Error('No input specified'); |
161 | } | |
162 | ||
163 | 3 | this._currentInput.before('-loop', '1'); |
164 | ||
165 | 3 | if (typeof duration !== 'undefined') { |
166 | 2 | this.duration(duration); |
167 | } | |
168 | ||
169 | 3 | return this; |
170 | }; | |
171 | }; | |
172 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var path = require('path'); |
5 | ||
6 | /* | |
7 | *! Miscellaneous methods | |
8 | */ | |
9 | ||
10 | 1 | module.exports = function(proto) { |
11 | /** | |
12 | * Use preset | |
13 | * | |
14 | * @method FfmpegCommand#preset | |
15 | * @category Miscellaneous | |
16 | * @aliases usingPreset | |
17 | * | |
18 | * @param {String|Function} preset preset name or preset function | |
19 | */ | |
20 | 1 | proto.usingPreset = |
21 | proto.preset = function(preset) { | |
22 | 23 | if (typeof preset === 'function') { |
23 | 1 | preset(this); |
24 | } else { | |
25 | 22 | try { |
26 | 22 | var modulePath = path.join(this.options.presets, preset); |
27 | 22 | var module = require(modulePath); |
28 | ||
29 | 21 | if (typeof module.load === 'function') { |
30 | 20 | module.load(this); |
31 | } else { | |
32 | 1 | throw new Error('preset ' + modulePath + ' has no load() function'); |
33 | } | |
34 | } catch (err) { | |
35 | 2 | throw new Error('preset ' + modulePath + ' could not be loaded: ' + err.message); |
36 | } | |
37 | } | |
38 | ||
39 | 21 | return this; |
40 | }; | |
41 | ||
42 | ||
43 | /** | |
44 | * Enable experimental codecs | |
45 | * | |
46 | * @method FfmpegCommand#strict | |
47 | * @category Miscellaneous | |
48 | * @aliases withStrictExperimental | |
49 | * | |
50 | * @return FfmpegCommand | |
51 | */ | |
52 | 1 | proto.withStrictExperimental = |
53 | proto.strict = function() { | |
54 | 20 | this._output('-strict', 'experimental'); |
55 | 20 | return this; |
56 | }; | |
57 | ||
58 | ||
59 | /** | |
60 | * Run flvtool2/flvmeta on output | |
61 | * | |
62 | * @method FfmpegCommand#flvmeta | |
63 | * @category Miscellaneous | |
64 | * @aliases updateFlvMetadata | |
65 | * | |
66 | * @return FfmpegCommand | |
67 | */ | |
68 | 1 | proto.updateFlvMetadata = |
69 | proto.flvmeta = function() { | |
70 | 18 | this.options.flvmeta = true; |
71 | 18 | return this; |
72 | }; | |
73 | }; | |
74 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | /* | |
5 | *! Output-related methods | |
6 | */ | |
7 | ||
8 | 1 | module.exports = function(proto) { |
9 | /** | |
10 | * Set output duration | |
11 | * | |
12 | * @method FfmpegCommand#duration | |
13 | * @category Output | |
14 | * @aliases withDuration,setDuration | |
15 | * | |
16 | * @param {String|Number} duration duration in seconds or as a '[[hh:]mm:]ss[.xxx]' string | |
17 | * @return FfmpegCommand | |
18 | */ | |
19 | 1 | proto.withDuration = |
20 | proto.setDuration = | |
21 | proto.duration = function(duration) { | |
22 | 3 | this._output('-t', duration); |
23 | 3 | return this; |
24 | }; | |
25 | ||
26 | ||
27 | /** | |
28 | * Set output format | |
29 | * | |
30 | * @method FfmpegCommand#format | |
31 | * @category Output | |
32 | * @aliases toFormat,withOutputFormat,outputFormat | |
33 | * | |
34 | * @param {String} format output format name | |
35 | * @return FfmpegCommand | |
36 | */ | |
37 | 1 | proto.toFormat = |
38 | proto.withOutputFormat = | |
39 | proto.outputFormat = | |
40 | proto.format = function(format) { | |
41 | 27 | this._output('-f', format); |
42 | 27 | return this; |
43 | }; | |
44 | }; | |
45 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | /* | |
5 | *! Video-related methods | |
6 | */ | |
7 | ||
8 | 1 | module.exports = function(proto) { |
9 | /** | |
10 | * Disable video in the output | |
11 | * | |
12 | * @method FfmpegCommand#noVideo | |
13 | * @category Video | |
14 | * @aliases withNoVideo | |
15 | * | |
16 | * @return FfmpegCommand | |
17 | */ | |
18 | 1 | proto.withNoVideo = |
19 | proto.noVideo = function() { | |
20 | 2 | this._video.clear(); |
21 | 2 | this._video('-vn'); |
22 | ||
23 | 2 | return this; |
24 | }; | |
25 | ||
26 | ||
27 | /** | |
28 | * Specify video codec | |
29 | * | |
30 | * @method FfmpegCommand#videoCodec | |
31 | * @category Video | |
32 | * @aliases withVideoCodec | |
33 | * | |
34 | * @param {String} codec video codec name | |
35 | * @return FfmpegCommand | |
36 | */ | |
37 | 1 | proto.withVideoCodec = |
38 | proto.videoCodec = function(codec) { | |
39 | 27 | this._video('-vcodec', codec); |
40 | 27 | return this; |
41 | }; | |
42 | ||
43 | ||
44 | /** | |
45 | * Specify video bitrate | |
46 | * | |
47 | * @method FfmpegCommand#videoBitrate | |
48 | * @category Video | |
49 | * @aliases withVideoBitrate | |
50 | * | |
51 | * @param {String|Number} bitrate video bitrate in kbps (with an optional 'k' suffix) | |
52 | * @param {Boolean} [constant=false] enforce constant bitrate | |
53 | * @return FfmpegCommand | |
54 | */ | |
55 | 1 | proto.withVideoBitrate = |
56 | proto.videoBitrate = function(bitrate, constant) { | |
57 | 22 | bitrate = ('' + bitrate).replace(/k?$/, 'k'); |
58 | ||
59 | 22 | this._video('-b:v', bitrate); |
60 | 22 | if (constant) { |
61 | 1 | this._video( |
62 | '-maxrate', bitrate, | |
63 | '-minrate', bitrate, | |
64 | '-bufsize', '3M' | |
65 | ); | |
66 | } | |
67 | ||
68 | 22 | return this; |
69 | }; | |
70 | ||
71 | ||
72 | /** | |
73 | * Specify custom video filter(s) | |
74 | * | |
75 | * Can be called both with one or many filters, or a filter array. | |
76 | * | |
77 | * @example | |
78 | * command.videoFilters('filter1'); | |
79 | * | |
80 | * @example | |
81 | * command.videoFilters('filter1', 'filter2'); | |
82 | * | |
83 | * @example | |
84 | * command.videoFilters(['filter1', 'filter2']); | |
85 | * | |
86 | * @method FfmpegCommand#videoFilters | |
87 | * @category Video | |
88 | * @aliases withVideoFilter,withVideoFilters,videoFilter | |
89 | * | |
90 | * @param {String|Array} filters... video filter strings or string array | |
91 | * @return FfmpegCommand | |
92 | */ | |
93 | 1 | proto.withVideoFilter = |
94 | proto.withVideoFilters = | |
95 | proto.videoFilter = | |
96 | proto.videoFilters = function(filters) { | |
97 | 4 | if (arguments.length > 1) { |
98 | 2 | filters = [].slice.call(arguments); |
99 | } | |
100 | ||
101 | 4 | if (Array.isArray(filters)) { |
102 | 2 | this._videoFilters.apply(null, filters); |
103 | } else { | |
104 | 2 | this._videoFilters(filters); |
105 | } | |
106 | ||
107 | 4 | return this; |
108 | }; | |
109 | ||
110 | ||
111 | /** | |
112 | * Specify output FPS | |
113 | * | |
114 | * @method FfmpegCommand#fps | |
115 | * @category Video | |
116 | * @aliases withOutputFps,withOutputFPS,withFpsOutput,withFPSOutput,withFps,withFPS,outputFPS,outputFps,fpsOutput,FPSOutput,FPS | |
117 | * | |
118 | * @param {Number} fps output FPS | |
119 | * @return FfmpegCommand | |
120 | */ | |
121 | 1 | proto.withOutputFps = |
122 | proto.withOutputFPS = | |
123 | proto.withFpsOutput = | |
124 | proto.withFPSOutput = | |
125 | proto.withFps = | |
126 | proto.withFPS = | |
127 | proto.outputFPS = | |
128 | proto.outputFps = | |
129 | proto.fpsOutput = | |
130 | proto.FPSOutput = | |
131 | proto.fps = | |
132 | proto.FPS = function(fps) { | |
133 | 19 | this._video('-r', fps); |
134 | 19 | return this; |
135 | }; | |
136 | ||
137 | ||
138 | /** | |
139 | * Only transcode a certain number of frames | |
140 | * | |
141 | * @method FfmpegCommand#frames | |
142 | * @category Video | |
143 | * @aliases takeFrames,withFrames | |
144 | * | |
145 | * @param {Number} frames frame count | |
146 | * @return FfmpegCommand | |
147 | */ | |
148 | 1 | proto.takeFrames = |
149 | proto.withFrames = | |
150 | proto.frames = function(frames) { | |
151 | 5 | this._video('-vframes', frames); |
152 | 5 | return this; |
153 | }; | |
154 | }; | |
155 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | /* | |
5 | *! Size helpers | |
6 | */ | |
7 | ||
8 | ||
9 | /** | |
10 | * Return filters to pad video to width*height, | |
11 | * | |
12 | * @param {Number} width output width | |
13 | * @param {Number} height output height | |
14 | * @param {Number} aspect video aspect ratio (without padding) | |
15 | * @param {Number} color padding color | |
16 | * @return scale/pad filters | |
17 | * @private | |
18 | */ | |
19 | function getScalePadFilters(width, height, aspect, color) { | |
20 | /* | |
21 | let a be the input aspect ratio, A be the requested aspect ratio | |
22 | ||
23 | if a > A, padding is done on top and bottom | |
24 | if a < A, padding is done on left and right | |
25 | */ | |
26 | ||
27 | 10 | return [ |
28 | /* | |
29 | In both cases, we first have to scale the input to match the requested size. | |
30 | When using computed width/height, we truncate them to multiples of 2 | |
31 | ||
32 | scale= | |
33 | w=if(gt(a, A), width, trunc(height*a/2)*2): | |
34 | h=if(lt(a, A), height, trunc(width/a/2)*2) | |
35 | */ | |
36 | ||
37 | 'scale=\'' + | |
38 | 'w=if(gt(a,' + aspect + '),' + width + ',trunc(' + height + '*a/2)*2):' + | |
39 | 'h=if(lt(a,' + aspect + '),' + height + ',trunc(' + width + '/a/2)*2)\'', | |
40 | ||
41 | /* | |
42 | Then we pad the scaled input to match the target size | |
43 | ||
44 | pad= | |
45 | w=width: | |
46 | h=height: | |
47 | x=if(gt(a, A), 0, (width - iw)/2): | |
48 | y=if(lt(a, A), 0, (height - ih)/2) | |
49 | ||
50 | (here iw and ih refer to the padding input, i.e the scaled output) | |
51 | */ | |
52 | ||
53 | 'pad=\'' + | |
54 | 'w=' + width + ':' + | |
55 | 'h=' + height + ':' + | |
56 | 'x=if(gt(a,' + aspect + '),0,(' + width + '-iw)/2):' + | |
57 | 'y=if(lt(a,' + aspect + '),0,(' + height + '-ih)/2):' + | |
58 | 'color=' + color + '\'' | |
59 | ]; | |
60 | } | |
61 | ||
62 | ||
63 | /** | |
64 | * Recompute size filters | |
65 | * | |
66 | * @param {FfmpegCommand} command | |
67 | * @param {String} key newly-added parameter name ('size', 'aspect' or 'pad') | |
68 | * @param {String} value newly-added parameter value | |
69 | * @return filter string array | |
70 | * @private | |
71 | */ | |
72 | function createSizeFilters(command, key, value) { | |
73 | // Store parameters | |
74 | 80 | var data = command._sizeData = command._sizeData || {}; |
75 | 80 | data[key] = value; |
76 | ||
77 | 80 | if (!('size' in data)) { |
78 | // No size requested, keep original size | |
79 | 2 | return []; |
80 | } | |
81 | ||
82 | // Try to match the different size string formats | |
83 | 78 | var fixedSize = data.size.match(/([0-9]+)x([0-9]+)/); |
84 | 78 | var fixedWidth = data.size.match(/([0-9]+)x\?/); |
85 | 78 | var fixedHeight = data.size.match(/\?x([0-9]+)/); |
86 | 78 | var percentRatio = data.size.match(/\b([0-9]{1,3})%/); |
87 | 78 | var width, height, aspect; |
88 | ||
89 | 78 | if (percentRatio) { |
90 | 5 | var ratio = Number(percentRatio[1]) / 100; |
91 | 5 | return ['scale=trunc(iw*' + ratio + '/2)*2:trunc(ih*' + ratio + '/2)*2']; |
92 | 73 | } else if (fixedSize) { |
93 | // Round target size to multiples of 2 | |
94 | 21 | width = Math.round(Number(fixedSize[1]) / 2) * 2; |
95 | 21 | height = Math.round(Number(fixedSize[2]) / 2) * 2; |
96 | ||
97 | 21 | aspect = width / height; |
98 | ||
99 | 21 | if (data.pad) { |
100 | 5 | return getScalePadFilters(width, height, aspect, data.pad); |
101 | } else { | |
102 | // No autopad requested, rescale to target size | |
103 | 16 | return ['scale=' + width + ':' + height]; |
104 | } | |
105 | 52 | } else if (fixedWidth || fixedHeight) { |
106 | 51 | if ('aspect' in data) { |
107 | // Specified aspect ratio | |
108 | 14 | width = fixedWidth ? fixedWidth[1] : Math.round(Number(fixedHeight[1]) * data.aspect); |
109 | 14 | height = fixedHeight ? fixedHeight[1] : Math.round(Number(fixedWidth[1]) / data.aspect); |
110 | ||
111 | // Round to multiples of 2 | |
112 | 14 | width = Math.round(width / 2) * 2; |
113 | 14 | height = Math.round(height / 2) * 2; |
114 | ||
115 | 14 | if (data.pad) { |
116 | 5 | return getScalePadFilters(width, height, data.aspect, data.pad); |
117 | } else { | |
118 | // No autopad requested, rescale to target size | |
119 | 9 | return ['scale=' + width + ':' + height]; |
120 | } | |
121 | } else { | |
122 | // Keep input aspect ratio | |
123 | ||
124 | 37 | if (fixedWidth) { |
125 | 31 | return ['scale=' + (Math.round(Number(fixedWidth[1]) / 2) * 2) + ':trunc(ow/a/2)*2']; |
126 | } else { | |
127 | 6 | return ['scale=trunc(oh*a/2)*2:' + (Math.round(Number(fixedHeight[1]) / 2) * 2)]; |
128 | } | |
129 | } | |
130 | } else { | |
131 | 1 | throw new Error('Invalid size specified: ' + data.size); |
132 | } | |
133 | } | |
134 | ||
135 | ||
136 | /* | |
137 | *! Video size-related methods | |
138 | */ | |
139 | ||
140 | 1 | module.exports = function(proto) { |
141 | /** | |
142 | * Keep display aspect ratio | |
143 | * | |
144 | * This method is useful when converting an input with non-square pixels to an output format | |
145 | * that does not support non-square pixels. It rescales the input so that the display aspect | |
146 | * ratio is the same. | |
147 | * | |
148 | * @method FfmpegCommand#keepDAR | |
149 | * @category Video size | |
150 | * @aliases keepPixelAspect,keepDisplayAspect,keepDisplayAspectRatio | |
151 | * | |
152 | * @return FfmpegCommand | |
153 | */ | |
154 | 1 | proto.keepPixelAspect = // Only for compatibility, this is not about keeping _pixel_ aspect ratio |
155 | proto.keepDisplayAspect = | |
156 | proto.keepDisplayAspectRatio = | |
157 | proto.keepDAR = function() { | |
158 | 1 | return this.videoFilters( |
159 | 'scale=\'w=if(gt(sar,1),iw*sar,iw):h=if(lt(sar,1),ih/sar,ih)\'', | |
160 | 'setsar=1' | |
161 | ); | |
162 | }; | |
163 | ||
164 | ||
165 | /** | |
166 | * Set output size | |
167 | * | |
168 | * The 'size' parameter can have one of 4 forms: | |
169 | * - 'X%': rescale to xx % of the original size | |
170 | * - 'WxH': specify width and height | |
171 | * - 'Wx?': specify width and compute height from input aspect ratio | |
172 | * - '?xH': specify height and compute width from input aspect ratio | |
173 | * | |
174 | * Note: both dimensions will be truncated to multiples of 2. | |
175 | * | |
176 | * @method FfmpegCommand#size | |
177 | * @category Video size | |
178 | * @aliases withSize,setSize | |
179 | * | |
180 | * @param {String} size size string, eg. '33%', '320x240', '320x?', '?x240' | |
181 | * @return FfmpegCommand | |
182 | */ | |
183 | 1 | proto.withSize = |
184 | proto.setSize = | |
185 | proto.size = function(size) { | |
186 | 52 | var filters = createSizeFilters(this, 'size', size); |
187 | ||
188 | 51 | this._sizeFilters.clear(); |
189 | 51 | this._sizeFilters(filters); |
190 | ||
191 | 51 | return this; |
192 | }; | |
193 | ||
194 | ||
195 | /** | |
196 | * Set output aspect ratio | |
197 | * | |
198 | * @method FfmpegCommand#aspect | |
199 | * @category Video size | |
200 | * @aliases withAspect,withAspectRatio,setAspect,setAspectRatio,aspectRatio | |
201 | * | |
202 | * @param {String|Number} aspect aspect ratio (number or 'X:Y' string) | |
203 | * @return FfmpegCommand | |
204 | */ | |
205 | 1 | proto.withAspect = |
206 | proto.withAspectRatio = | |
207 | proto.setAspect = | |
208 | proto.setAspectRatio = | |
209 | proto.aspect = | |
210 | proto.aspectRatio = function(aspect) { | |
211 | 15 | var a = Number(aspect); |
212 | 15 | if (isNaN(a)) { |
213 | 3 | var match = aspect.match(/^(\d+):(\d+)$/); |
214 | 3 | if (match) { |
215 | 2 | a = Number(match[1]) / Number(match[2]); |
216 | } else { | |
217 | 1 | throw new Error('Invalid aspect ratio: ' + aspect); |
218 | } | |
219 | } | |
220 | ||
221 | 14 | var filters = createSizeFilters(this, 'aspect', a); |
222 | ||
223 | 14 | this._sizeFilters.clear(); |
224 | 14 | this._sizeFilters(filters); |
225 | ||
226 | 14 | return this; |
227 | }; | |
228 | ||
229 | ||
230 | /** | |
231 | * Enable auto-padding the output | |
232 | * | |
233 | * @method FfmpegCommand#autopad | |
234 | * @category Video size | |
235 | * @aliases applyAutopadding,applyAutoPadding,applyAutopad,applyAutoPad,withAutopadding,withAutoPadding,withAutopad,withAutoPad,autoPad | |
236 | * | |
237 | * @param {Boolean} [pad=true] enable/disable auto-padding | |
238 | * @param {String} [color='black'] pad color | |
239 | */ | |
240 | 1 | proto.applyAutopadding = |
241 | proto.applyAutoPadding = | |
242 | proto.applyAutopad = | |
243 | proto.applyAutoPad = | |
244 | proto.withAutopadding = | |
245 | proto.withAutoPadding = | |
246 | proto.withAutopad = | |
247 | proto.withAutoPad = | |
248 | proto.autoPad = | |
249 | proto.autopad = function(pad, color) { | |
250 | // Allow autopad(color) | |
251 | 14 | if (typeof pad === 'string') { |
252 | 1 | color = pad; |
253 | 1 | pad = true; |
254 | } | |
255 | ||
256 | // Allow autopad() and autopad(undefined, color) | |
257 | 14 | if (typeof pad === 'undefined') { |
258 | 1 | pad = true; |
259 | } | |
260 | ||
261 | 14 | var filters = createSizeFilters(this, 'pad', pad ? color || 'black' : false); |
262 | ||
263 | 14 | this._sizeFilters.clear(); |
264 | 14 | this._sizeFilters(filters); |
265 | ||
266 | 14 | return this; |
267 | }; | |
268 | }; | |
269 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true */ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | exports.load = function(ffmpeg) { |
5 | 18 | ffmpeg |
6 | .format('flv') | |
7 | .flvmeta() | |
8 | .size('320x?') | |
9 | .videoBitrate('512k') | |
10 | .videoCodec('libx264') | |
11 | .fps(24) | |
12 | .audioBitrate('96k') | |
13 | .audioCodec('aac') | |
14 | .strict() | |
15 | .audioFrequency(22050) | |
16 | .audioChannels(2); | |
17 | }; | |
18 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true */ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | exports.load = function(ffmpeg) { |
5 | 1 | ffmpeg |
6 | .format('m4v') | |
7 | .videoBitrate('512k') | |
8 | .videoCodec('libx264') | |
9 | .size('320x176') | |
10 | .audioBitrate('128k') | |
11 | .audioCodec('aac') | |
12 | .strict() | |
13 | .audioChannels(1) | |
14 | .outputOptions(['-flags', '+loop', '-cmp', '+chroma', '-partitions','+parti4x4+partp8x8+partb8x8', '-flags2', | |
15 | '+mixed_refs', '-me_method umh', '-subq 5', '-bufsize 2M', '-rc_eq \'blurCplx^(1-qComp)\'', | |
16 | '-qcomp 0.6', '-qmin 10', '-qmax 51', '-qdiff 4', '-level 13' ]); | |
17 | }; | |
18 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var spawn = require('child_process').spawn; |
5 | 1 | var PassThrough = require('stream').PassThrough; |
6 | 1 | var path = require('path'); |
7 | 1 | var fs = require('fs'); |
8 | 1 | var async = require('async'); |
9 | 1 | var utils = require('./utils'); |
10 | ||
11 | ||
12 | /* | |
13 | *! Processor methods | |
14 | */ | |
15 | ||
16 | ||
17 | /** | |
18 | * @param {FfmpegCommand} command | |
19 | * @param {String|Writable} target | |
20 | * @param {Object} [pipeOptions] | |
21 | * @private | |
22 | */ | |
23 | function _process(command, target, pipeOptions) { | |
24 | 19 | var isStream; |
25 | ||
26 | 19 | if (typeof target === 'string') { |
27 | 16 | isStream = false; |
28 | } else { | |
29 | 3 | isStream = true; |
30 | 3 | pipeOptions = pipeOptions || {}; |
31 | } | |
32 | ||
33 | // Ensure we send 'end' or 'error' only once | |
34 | 19 | var ended = false; |
35 | function emitEnd(err, stdout, stderr) { | |
36 | 24 | if (!ended) { |
37 | 19 | ended = true; |
38 | ||
39 | 19 | if (err) { |
40 | 5 | command.emit('error', err, stdout, stderr); |
41 | } else { | |
42 | 14 | command.emit('end', stdout, stderr); |
43 | } | |
44 | } | |
45 | } | |
46 | ||
47 | 19 | command._prepare(function(err, args) { |
48 | 19 | if (err) { |
49 | 1 | return emitEnd(err); |
50 | } | |
51 | ||
52 | 18 | if (isStream) { |
53 | 3 | args.push('pipe:1'); |
54 | ||
55 | 3 | if (command.options.flvmeta) { |
56 | 3 | command.logger.warn('Updating flv metadata is not supported for streams'); |
57 | 3 | command.options.flvmeta = false; |
58 | } | |
59 | } else { | |
60 | 15 | args.push('-y', target); |
61 | } | |
62 | ||
63 | // Get input stream if any | |
64 | 18 | var inputStream = command._inputs.filter(function(input) { |
65 | 18 | return typeof input.source !== 'string'; |
66 | })[0]; | |
67 | ||
68 | // Run ffmpeg | |
69 | 18 | var stdout = null; |
70 | 18 | var stderr = ''; |
71 | 18 | command._spawnFfmpeg( |
72 | args, | |
73 | ||
74 | { niceness: command.options.niceness }, | |
75 | ||
76 | function processCB(ffmpegProc) { | |
77 | 18 | command.ffmpegProc = ffmpegProc; |
78 | 18 | command.emit('start', 'ffmpeg ' + args.join(' ')); |
79 | ||
80 | // Pipe input stream if any | |
81 | 18 | if (inputStream) { |
82 | 2 | inputStream.source.on('error', function(err) { |
83 | 0 | emitEnd(new Error('Input stream error: ' + err.message)); |
84 | 0 | ffmpegProc.kill(); |
85 | }); | |
86 | ||
87 | 2 | inputStream.source.resume(); |
88 | 2 | inputStream.source.pipe(ffmpegProc.stdin); |
89 | } | |
90 | ||
91 | // Setup timeout if requested | |
92 | 18 | var processTimer; |
93 | 18 | if (command.options.timeout) { |
94 | 4 | processTimer = setTimeout(function() { |
95 | 3 | var msg = 'process ran into a timeout (' + command.options.timeout + 's)'; |
96 | ||
97 | 3 | emitEnd(new Error(msg), stdout, stderr); |
98 | 3 | ffmpegProc.kill(); |
99 | }, command.options.timeout * 1000); | |
100 | } | |
101 | ||
102 | 18 | if (isStream) { |
103 | // Pipe ffmpeg stdout to output stream | |
104 | 3 | ffmpegProc.stdout.pipe(target, pipeOptions); |
105 | ||
106 | // Handle output stream events | |
107 | 3 | target.on('close', function() { |
108 | 2 | command.logger.debug('Output stream closed, scheduling kill for ffmpgeg process'); |
109 | ||
110 | // Don't kill process yet, to give a chance to ffmpeg to | |
111 | // terminate successfully first This is necessary because | |
112 | // under load, the process 'exit' event sometimes happens | |
113 | // after the output stream 'close' event. | |
114 | 2 | setTimeout(function() { |
115 | 2 | emitEnd(new Error('Output stream closed')); |
116 | 2 | ffmpegProc.kill(); |
117 | }, 20); | |
118 | }); | |
119 | ||
120 | 3 | target.on('error', function(err) { |
121 | 0 | command.logger.debug('Output stream error, killing ffmpgeg process'); |
122 | 0 | emitEnd(new Error('Output stream error: ' + err.message)); |
123 | 0 | ffmpegProc.kill(); |
124 | }); | |
125 | } else { | |
126 | // Gather ffmpeg stdout | |
127 | 15 | stdout = ''; |
128 | 15 | ffmpegProc.stdout.on('data', function (data) { |
129 | 0 | stdout += data; |
130 | }); | |
131 | } | |
132 | ||
133 | // Process ffmpeg stderr data | |
134 | 18 | command._codecDataSent = false; |
135 | 18 | ffmpegProc.stderr.on('data', function (data) { |
136 | 324 | stderr += data; |
137 | ||
138 | 324 | if (!command._codecDataSent && command.listeners('codecData').length) { |
139 | 11 | utils.extractCodecData(command, stderr); |
140 | } | |
141 | ||
142 | 324 | if (command.listeners('progress').length) { |
143 | 26 | var duration = 0; |
144 | ||
145 | 26 | if (command._ffprobeData && command._ffprobeData.format && command._ffprobeData.format.duration) { |
146 | 21 | duration = Number(command._ffprobeData.format.duration); |
147 | } | |
148 | ||
149 | 26 | utils.extractProgress(command, stderr, duration); |
150 | } | |
151 | }); | |
152 | }, | |
153 | ||
154 | function endCB(err) { | |
155 | 18 | delete command.ffmpegProc; |
156 | ||
157 | 18 | if (err) { |
158 | 4 | emitEnd(err, stdout, stderr); |
159 | } else { | |
160 | 14 | if (command.options.flvmeta) { |
161 | 11 | command._getFlvtoolPath(function(err, flvtool) { |
162 | // No error possible here, _getFlvtoolPath was called by _prepare | |
163 | ||
164 | 11 | spawn(flvtool, ['-U', target]) |
165 | .on('error', function(err) { | |
166 | 0 | emitEnd(new Error('Error running ' + flvtool + ': ' + err.message)); |
167 | }) | |
168 | .on('exit', function(code, signal) { | |
169 | 11 | if (code !== 0 || signal) { |
170 | 0 | emitEnd( |
171 | new Error(flvtool + ' ' + | |
172 | (signal ? 'received signal ' + signal | |
173 | : 'exited with code ' + code)) | |
174 | ); | |
175 | } else { | |
176 | 11 | emitEnd(null, stdout, stderr); |
177 | } | |
178 | }); | |
179 | }); | |
180 | } else { | |
181 | 3 | emitEnd(null, stdout, stderr); |
182 | } | |
183 | } | |
184 | } | |
185 | ); | |
186 | }); | |
187 | } | |
188 | ||
189 | ||
190 | /** | |
191 | * Run ffprobe asynchronously and store data in command | |
192 | * | |
193 | * @param {FfmpegCommand} command | |
194 | * @private | |
195 | */ | |
196 | function runFfprobe(command) { | |
197 | 1 | command.ffprobe(function(err, data) { |
198 | 1 | command._ffprobeData = data; |
199 | }); | |
200 | } | |
201 | ||
202 | ||
203 | 1 | module.exports = function(proto) { |
204 | /** | |
205 | * Emitted just after ffmpeg has been spawned. | |
206 | * | |
207 | * @event FfmpegCommand#start | |
208 | * @param {String} command ffmpeg command line | |
209 | */ | |
210 | ||
211 | /** | |
212 | * Emitted when ffmpeg reports progress information | |
213 | * | |
214 | * @event FfmpegCommand#progress | |
215 | * @param {Object} progress progress object | |
216 | */ | |
217 | ||
218 | /** | |
219 | * Emitted when ffmpeg reports input codec data | |
220 | * | |
221 | * @event FfmpegCommand#codecData | |
222 | * @param {Object} codecData codec data object | |
223 | */ | |
224 | ||
225 | /** | |
226 | * Emitted when an error happens when preparing or running a command | |
227 | * | |
228 | * @event FfmpegCommand#error | |
229 | * @param {Error} error error | |
230 | * @param {String|null} stdout ffmpeg stdout, unless outputting to a stream | |
231 | * @param {String|null} stderr ffmpeg stderr | |
232 | */ | |
233 | ||
234 | /** | |
235 | * Emitted when a command finishes processing | |
236 | * | |
237 | * @event FfmpegCommand#end | |
238 | * @param {Array|null} [filenames] generated filenames when taking screenshots, null otherwise | |
239 | */ | |
240 | ||
241 | ||
242 | /** | |
243 | * Spawn an ffmpeg process | |
244 | * | |
245 | * The 'options' argument may contain the following keys: | |
246 | * - 'niceness': specify process niceness, ignored on Windows (default: 0) | |
247 | * - 'captureStdout': capture stdout and pass it to 'endCB' as its 2nd argument (default: false) | |
248 | * - 'captureStderr': capture stderr and pass it to 'endCB' as its 3rd argument (default: false) | |
249 | * | |
250 | * The 'processCB' callback, if present, is called as soon as the process is created and | |
251 | * receives a nodejs ChildProcess object. It may not be called at all if an error happens | |
252 | * before spawning the process. | |
253 | * | |
254 | * The 'endCB' callback is called either when an error occurs or when the ffmpeg process finishes. | |
255 | * | |
256 | * @method FfmpegCommand#_spawnFfmpeg | |
257 | * @param {Array} args ffmpeg command line argument list | |
258 | * @param {Object} [options] spawn options (see above) | |
259 | * @param {Function} [processCB] callback called with process object when it has been created | |
260 | * @param {Function} endCB callback with signature (err, stdout, stderr) | |
261 | * @private | |
262 | */ | |
263 | 1 | proto._spawnFfmpeg = function(args, options, processCB, endCB) { |
264 | // Enable omitting options | |
265 | 30 | if (typeof options === 'function') { |
266 | 8 | endCB = processCB; |
267 | 8 | processCB = options; |
268 | 8 | options = {}; |
269 | } | |
270 | ||
271 | // Enable omitting processCB | |
272 | 30 | if (typeof endCB === 'undefined') { |
273 | 12 | endCB = processCB; |
274 | 12 | processCB = function() {}; |
275 | } | |
276 | ||
277 | // Find ffmpeg | |
278 | 30 | this._getFfmpegPath(function(err, command) { |
279 | 30 | if (err) { |
280 | 0 | return endCB(err); |
281 | 30 | } else if (!command || command.length === 0) { |
282 | 0 | return endCB(new Error('Cannot find ffmpeg')); |
283 | } | |
284 | ||
285 | // Apply niceness | |
286 | 30 | if (options.niceness && options.niceness !== 0 && !utils.isWindows) { |
287 | 0 | args.unshift('-n', options.niceness, command); |
288 | 0 | command = 'nice'; |
289 | } | |
290 | ||
291 | 30 | var stdout = null; |
292 | 30 | var stdoutClosed = false; |
293 | ||
294 | 30 | var stderr = null; |
295 | 30 | var stderrClosed = false; |
296 | ||
297 | // Spawn process | |
298 | 30 | var ffmpegProc = spawn(command, args, options); |
299 | ||
300 | 30 | if (ffmpegProc.stderr && options.captureStderr) { |
301 | 1 | ffmpegProc.stderr.setEncoding('utf8'); |
302 | } | |
303 | ||
304 | 30 | ffmpegProc.on('error', function(err) { |
305 | 0 | endCB(err); |
306 | }); | |
307 | ||
308 | // Ensure we wait for captured streams to end before calling endCB | |
309 | 30 | var exitError = null; |
310 | function handleExit(err) { | |
311 | 35 | if (err) { |
312 | 4 | exitError = err; |
313 | } | |
314 | ||
315 | 35 | if (processExited && |
316 | (stdoutClosed || !options.captureStdout) && | |
317 | (stderrClosed || !options.captureStderr)) { | |
318 | 30 | endCB(exitError, stdout, stderr); |
319 | } | |
320 | } | |
321 | ||
322 | // Handle process exit | |
323 | 30 | var processExited = false; |
324 | 30 | ffmpegProc.on('exit', function(code, signal) { |
325 | 30 | processExited = true; |
326 | ||
327 | 30 | if (code) { |
328 | 3 | handleExit(new Error('ffmpeg exited with code ' + code)); |
329 | 27 | } else if (signal) { |
330 | 1 | handleExit(new Error('ffmpeg was killed with signal ' + signal)); |
331 | } else { | |
332 | 26 | handleExit(); |
333 | } | |
334 | }); | |
335 | ||
336 | // Capture stdout if specified | |
337 | 30 | if (options.captureStdout) { |
338 | 4 | stdout = ''; |
339 | ||
340 | 4 | ffmpegProc.stdout.on('data', function(data) { |
341 | 11 | stdout += data; |
342 | }); | |
343 | ||
344 | 4 | ffmpegProc.stdout.on('close', function() { |
345 | 4 | stdoutClosed = true; |
346 | 4 | handleExit(); |
347 | }); | |
348 | } | |
349 | ||
350 | // Capture stderr if specified | |
351 | 30 | if (options.captureStderr) { |
352 | 1 | stderr = ''; |
353 | ||
354 | 1 | ffmpegProc.stderr.on('data', function(data) { |
355 | 0 | stderr += data; |
356 | }); | |
357 | ||
358 | 1 | ffmpegProc.stderr.on('close', function() { |
359 | 1 | stderrClosed = true; |
360 | 1 | handleExit(); |
361 | }); | |
362 | } | |
363 | ||
364 | // Call process callback | |
365 | 30 | processCB(ffmpegProc); |
366 | }); | |
367 | }; | |
368 | ||
369 | ||
370 | /** | |
371 | * Build the argument list for an ffmpeg command | |
372 | * | |
373 | * @method FfmpegCommand#_getArguments | |
374 | * @return argument list | |
375 | * @private | |
376 | */ | |
377 | 1 | proto._getArguments = function() { |
378 | 53 | var audioFilters = this._audioFilters.get(); |
379 | 53 | var videoFilters = this._videoFilters.get().concat(this._sizeFilters.get()); |
380 | ||
381 | 53 | return this._inputs.reduce(function(args, input) { |
382 | 54 | var source = (typeof input.source === 'string') ? input.source : '-'; |
383 | ||
384 | 54 | return args.concat( |
385 | input.before.get(), | |
386 | ['-i', source], | |
387 | input.after.get() | |
388 | ); | |
389 | }, []) | |
390 | .concat( | |
391 | this._audio.get(), | |
392 | audioFilters.length ? ['-filter:a', audioFilters.join(',')] : [], | |
393 | this._video.get(), | |
394 | videoFilters.length ? ['-filter:v', videoFilters.join(',')] : [], | |
395 | this._output.get() | |
396 | ); | |
397 | }; | |
398 | ||
399 | ||
400 | /** | |
401 | * Prepare execution of an ffmpeg command | |
402 | * | |
403 | * Checks prerequisites for the execution of the command (codec/format availability, flvtool...), | |
404 | * then builds the argument list for ffmpeg and pass them to 'callback'. | |
405 | * | |
406 | * @method FfmpegCommand#_prepare | |
407 | * @param {Function} callback callback with signature (err, args) | |
408 | * @param {Boolean} [readMetadata=false] read metadata before processing | |
409 | * @private | |
410 | */ | |
411 | 1 | proto._prepare = function(callback, readMetadata) { |
412 | 21 | var self = this; |
413 | ||
414 | 21 | async.waterfall([ |
415 | // Check codecs and formats | |
416 | function(cb) { | |
417 | 21 | self._checkCapabilities(cb); |
418 | }, | |
419 | ||
420 | // Read metadata if required | |
421 | function(cb) { | |
422 | 20 | if (!readMetadata) { |
423 | 18 | return cb(); |
424 | } | |
425 | ||
426 | 2 | self.ffprobe(function(err, data) { |
427 | 2 | if (!err) { |
428 | 2 | self._ffprobeData = data; |
429 | } | |
430 | ||
431 | 2 | cb(); |
432 | }); | |
433 | }, | |
434 | ||
435 | // Check for flvtool2/flvmeta if necessary | |
436 | function(cb) { | |
437 | 20 | if (self.options.flvmeta) { |
438 | 18 | self._getFlvtoolPath(function(err) { |
439 | 18 | cb(err); |
440 | }); | |
441 | } else { | |
442 | 2 | cb(); |
443 | } | |
444 | }, | |
445 | ||
446 | // Build argument list | |
447 | function(cb) { | |
448 | 20 | var args; |
449 | 20 | try { |
450 | 20 | args = self._getArguments(); |
451 | } catch(e) { | |
452 | 0 | return cb(e); |
453 | } | |
454 | ||
455 | 20 | cb(null, args); |
456 | } | |
457 | ], callback); | |
458 | ||
459 | 21 | if (!readMetadata) { |
460 | // Read metadata as soon as 'progress' listeners are added | |
461 | ||
462 | 19 | if (this.listeners('progress').length > 0) { |
463 | // Read metadata in parallel | |
464 | 1 | runFfprobe(this); |
465 | } else { | |
466 | // Read metadata as soon as the first 'progress' listener is added | |
467 | 18 | this.once('newListener', function(event) { |
468 | 0 | if (event === 'progress') { |
469 | 0 | runFfprobe(this); |
470 | } | |
471 | }); | |
472 | } | |
473 | } | |
474 | }; | |
475 | ||
476 | ||
477 | /** | |
478 | * Execute ffmpeg command and save output to a file | |
479 | * | |
480 | * @method FfmpegCommand#save | |
481 | * @category Processing | |
482 | * @aliases saveToFile | |
483 | * | |
484 | * @param {String} output file path | |
485 | * @return FfmpegCommand | |
486 | */ | |
487 | 1 | proto.saveToFile = |
488 | proto.save = function(output) { | |
489 | 16 | _process(this, output); |
490 | }; | |
491 | ||
492 | ||
493 | /** | |
494 | * Execute ffmpeg command and save output to a stream | |
495 | * | |
496 | * If 'stream' is not specified, a PassThrough stream is created and returned. | |
497 | * 'options' will be used when piping ffmpeg output to the output stream | |
498 | * (@see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options) | |
499 | * | |
500 | * @method FfmpegCommand#pipe | |
501 | * @category Processing | |
502 | * @aliases stream,writeToStream | |
503 | * | |
504 | * @param {stream.Writable} [stream] output stream | |
505 | * @param {Object} [options={}] pipe options | |
506 | * @return Output stream | |
507 | */ | |
508 | 1 | proto.writeToStream = |
509 | proto.pipe = | |
510 | proto.stream = function(stream, options) { | |
511 | 3 | if (stream && !('writable' in stream)) { |
512 | 1 | options = stream; |
513 | 1 | stream = undefined; |
514 | } | |
515 | ||
516 | 3 | if (!stream) { |
517 | 1 | if (process.version.match(/v0\.8\./)) { |
518 | 0 | throw new Error('PassThrough stream is not supported on node v0.8'); |
519 | } | |
520 | ||
521 | 1 | stream = new PassThrough(); |
522 | } | |
523 | ||
524 | 3 | _process(this, stream, options); |
525 | 3 | return stream; |
526 | }; | |
527 | ||
528 | ||
529 | /** | |
530 | * Merge (concatenate) inputs to a single file | |
531 | * | |
532 | * Warning: soon to be deprecated | |
533 | * | |
534 | * @method FfmpegCommand#mergeToFile | |
535 | * @category Processing | |
536 | * | |
537 | * @param {String} targetfile output file path | |
538 | */ | |
539 | 1 | proto.mergeToFile = function(targetfile) { |
540 | 1 | var outputfile = path.normalize(targetfile); |
541 | 1 | if(fs.existsSync(outputfile)){ |
542 | 0 | return this.emit('error', new Error('Output file already exists, merge aborted')); |
543 | } | |
544 | ||
545 | 1 | var self = this; |
546 | ||
547 | // creates intermediate copies of each video. | |
548 | function makeIntermediateFile(_mergeSource,_callback) { | |
549 | 3 | var fname = _mergeSource + '.temp.mpg'; |
550 | 3 | var args = self._output.get().concat(['-i', _mergeSource, '-qscale:v', 1, fname]); |
551 | ||
552 | 3 | self._spawnFfmpeg(args, function(err) { |
553 | 3 | _callback(err, fname); |
554 | }); | |
555 | } | |
556 | ||
557 | // concat all created intermediate copies | |
558 | function concatIntermediates(target, intermediatesList, _callback) { | |
559 | 1 | var fname = path.normalize(target) + '.temp.merged.mpg'; |
560 | ||
561 | 1 | var args = [ |
562 | // avoid too many log messages from ffmpeg | |
563 | '-loglevel', 'panic', | |
564 | '-i', 'concat:' + intermediatesList.join('|'), | |
565 | '-c', 'copy', | |
566 | fname | |
567 | ]; | |
568 | ||
569 | 1 | self._spawnFfmpeg(args, {captureStdout:true,captureStderr:true}, function(err) { |
570 | 1 | _callback(err, fname); |
571 | }); | |
572 | } | |
573 | ||
574 | function quantizeConcat(concatResult, numFiles, _callback) { | |
575 | 1 | var args = [ |
576 | '-i', concatResult, | |
577 | '-qscale:v',numFiles, | |
578 | targetfile | |
579 | ]; | |
580 | ||
581 | 1 | self._spawnFfmpeg(args, function(err) { |
582 | 1 | _callback(err); |
583 | }); | |
584 | } | |
585 | ||
586 | function deleteIntermediateFiles(intermediates, callback) { | |
587 | 2 | async.each(intermediates, function(item,cb){ |
588 | 8 | fs.exists(item,function(exists){ |
589 | 8 | if(exists){ |
590 | 4 | fs.unlink(item ,cb); |
591 | } | |
592 | else{ | |
593 | 4 | cb(); |
594 | } | |
595 | ||
596 | }); | |
597 | }, callback); | |
598 | } | |
599 | ||
600 | function makeProgress() { | |
601 | 5 | progress.createdFiles = progress.createdFiles + 1; |
602 | 5 | progress.percent = progress.createdFiles / progress.totalFiles * 100; |
603 | 5 | self.emit('progress', progress); |
604 | } | |
605 | ||
606 | 1 | if (this._inputs.length < 2) { |
607 | 0 | return this.emit('error', new Error('No file added to be merged')); |
608 | } | |
609 | ||
610 | 4 | var mergeList = this._inputs.map(function(input) { return input.source; }); |
611 | ||
612 | 1 | var progress = {frames : 0, |
613 | currentFps: 0, | |
614 | currentKbps: 0, | |
615 | targetSize: 0, | |
616 | timemark: 0, | |
617 | percent: 0, | |
618 | totalFiles: mergeList.length + 2, | |
619 | createdFiles: 0}; | |
620 | ||
621 | 4 | var toDelete = mergeList.map(function(name) { return name + '.temp.mpg'; }); |
622 | 1 | toDelete.push(outputfile + '.temp.merged.mpg'); |
623 | 1 | deleteIntermediateFiles(toDelete); |
624 | ||
625 | 1 | var intermediateFiles = []; |
626 | ||
627 | 1 | async.whilst( |
628 | function(){ | |
629 | 4 | return (mergeList.length !== 0); |
630 | }, | |
631 | function (callback){ | |
632 | 3 | makeIntermediateFile(mergeList.shift(), function(err, createdIntermediateFile) { |
633 | 3 | if(err) { |
634 | 0 | return callback(err); |
635 | } | |
636 | ||
637 | 3 | if(!createdIntermediateFile) { |
638 | 0 | return callback(new Error('Invalid intermediate file')); |
639 | } | |
640 | ||
641 | 3 | intermediateFiles.push(createdIntermediateFile); |
642 | 3 | makeProgress(); |
643 | 3 | callback(); |
644 | }); | |
645 | }, | |
646 | function(err) { | |
647 | 1 | if (err) { |
648 | 0 | return self.emit('error', err); |
649 | } | |
650 | ||
651 | 1 | concatIntermediates(targetfile, intermediateFiles, function(err, concatResult) { |
652 | 1 | if(err) { |
653 | 0 | return self.emit('error', err); |
654 | } | |
655 | ||
656 | 1 | if(!concatResult) { |
657 | 0 | return self.emit('error', new Error('Invalid concat result file')); |
658 | } | |
659 | ||
660 | 1 | makeProgress(); |
661 | 1 | quantizeConcat(concatResult, intermediateFiles.length, function() { |
662 | 1 | makeProgress(); |
663 | // add concatResult to intermediates list so it can be deleted too. | |
664 | 1 | intermediateFiles.push(concatResult); |
665 | 1 | deleteIntermediateFiles(intermediateFiles, function(err) { |
666 | 1 | if (err) { |
667 | 0 | self.emit('error', err); |
668 | } else { | |
669 | 1 | self.emit('end'); |
670 | } | |
671 | }); | |
672 | }); | |
673 | }); | |
674 | } | |
675 | ); | |
676 | }; | |
677 | ||
678 | ||
679 | /** | |
680 | * Take screenshots | |
681 | * | |
682 | * The 'config' parameter may either be the number of screenshots to take or an object | |
683 | * with the following keys: | |
684 | * - 'count': screenshot count | |
685 | * - 'timemarks': array of screenshot timestamps in seconds (defaults to taking screenshots at regular intervals) | |
686 | * - 'filename': screenshot filename pattern (defaults to 'tn_%ss' or 'tn_%ss_%i' for multiple screenshots) | |
687 | * | |
688 | * The 'filename' option may contain tokens that will be replaced for each screenshot taken: | |
689 | * - '%s': offset in seconds | |
690 | * - '%w': screenshot width | |
691 | * - '%h': screenshot height | |
692 | * - '%r': screenshot resolution (eg. '320x240') | |
693 | * - '%f': input filename | |
694 | * - '%b': input basename (filename w/o extension) | |
695 | * - '%i': index of screenshot in timemark array (can be zero-padded by using it like `%000i`) | |
696 | * | |
697 | * @method FfmpegCommand#takeScreenshots | |
698 | * @category Processing | |
699 | * | |
700 | * @param {Number|Object} config screenshot count or configuration object (see above) | |
701 | * @param {String} [folder='.'] output directory | |
702 | */ | |
703 | 1 | proto.takeScreenshots = function(config, folder) { |
704 | 2 | var width, height; |
705 | 2 | var self = this; |
706 | ||
707 | function _computeSize(size) { | |
708 | // Select video stream with biggest resolution | |
709 | 2 | var vstream = self._ffprobeData.streams.reduce(function(max, stream) { |
710 | 2 | if (stream.codec_type !== 'video') return max; |
711 | 2 | return max.width * max.height < stream.width * stream.height ? stream : max; |
712 | }, { width: 0, height: 0 }); | |
713 | ||
714 | 2 | var w = vstream.width; |
715 | 2 | var h = vstream.height; |
716 | 2 | var a = w / h; |
717 | ||
718 | 2 | var fixedSize = size.match(/([0-9]+)x([0-9]+)/); |
719 | 2 | var fixedWidth = size.match(/([0-9]+)x\?/); |
720 | 2 | var fixedHeight = size.match(/\?x([0-9]+)/); |
721 | 2 | var percentRatio = size.match(/\b([0-9]{1,3})%/); |
722 | ||
723 | 2 | if (fixedSize) { |
724 | 0 | width = Number(fixedSize[1]); |
725 | 0 | height = Number(fixedSize[2]); |
726 | 2 | } else if (fixedWidth) { |
727 | 2 | width = Number(fixedWidth[1]); |
728 | 2 | height = width / a; |
729 | 0 | } else if (fixedHeight) { |
730 | 0 | height = Number(fixedHeight[1]); |
731 | 0 | width = height * a; |
732 | } else { | |
733 | 0 | var pc = Number(percentRatio[0]) / 100; |
734 | 0 | width = w * pc; |
735 | 0 | height = h * pc; |
736 | } | |
737 | } | |
738 | ||
739 | function _zeroPad(number, len) { | |
740 | 4 | len = len-String(number).length+2; |
741 | 4 | return new Array(len<0?0:len).join('0')+number; |
742 | } | |
743 | ||
744 | function _renderOutputName(j, offset) { | |
745 | 4 | var result = filename; |
746 | 4 | if(/%0*i/.test(result)) { |
747 | 4 | var numlen = String(result.match(/%(0*)i/)[1]).length; |
748 | 4 | result = result.replace(/%0*i/, _zeroPad(j, numlen)); |
749 | } | |
750 | 4 | result = result.replace('%s', offset); |
751 | 4 | result = result.replace('%w', width); |
752 | 4 | result = result.replace('%h', height); |
753 | 4 | result = result.replace('%r', width+'x'+height); |
754 | 4 | result = result.replace('%f', path.basename(inputfile)); |
755 | 4 | result = result.replace('%b', path.basename(inputfile, path.extname(inputfile))); |
756 | 4 | return result; |
757 | } | |
758 | ||
759 | function _screenShotInternal() { | |
760 | 2 | self._prepare(function(err, args) { |
761 | 2 | if(err) { |
762 | 0 | return self.emit('error', err); |
763 | } | |
764 | ||
765 | 2 | _computeSize(self._sizeData.size); |
766 | ||
767 | 2 | var duration = 0; |
768 | 2 | if (self._ffprobeData && self._ffprobeData.format && self._ffprobeData.format.duration) { |
769 | 2 | duration = Number(self._ffprobeData.format.duration); |
770 | } | |
771 | ||
772 | 2 | if (!duration) { |
773 | 0 | var errString = 'meta data contains no duration, aborting screenshot creation'; |
774 | 0 | return self.emit('error', new Error(errString)); |
775 | } | |
776 | ||
777 | // check if all timemarks are inside duration | |
778 | 2 | if (Array.isArray(timemarks)) { |
779 | 2 | for (var i = 0; i < timemarks.length; i++) { |
780 | /* convert percentage to seconds */ | |
781 | 4 | if( timemarks[i].indexOf('%') > 0 ) { |
782 | 0 | timemarks[i] = (parseInt(timemarks[i], 10) / 100) * duration; |
783 | } | |
784 | 4 | if (parseInt(timemarks[i], 10) > duration) { |
785 | // remove timemark from array | |
786 | 0 | timemarks.splice(i, 1); |
787 | 0 | --i; |
788 | } | |
789 | } | |
790 | // if there are no more timemarks around, add one at end of the file | |
791 | 2 | if (timemarks.length === 0) { |
792 | 0 | timemarks[0] = (duration * 0.9); |
793 | } | |
794 | } | |
795 | // get positions for screenshots (using duration of file minus 10% to remove fade-in/fade-out) | |
796 | 2 | var secondOffset = (duration * 0.9) / screenshotcount; |
797 | ||
798 | // reset iterator | |
799 | 2 | var j = 1; |
800 | ||
801 | 2 | var filenames = []; |
802 | ||
803 | // use async helper function to generate all screenshots and | |
804 | // fire callback just once after work is done | |
805 | 2 | async.until( |
806 | function() { | |
807 | 6 | return j > screenshotcount; |
808 | }, | |
809 | function(taskcallback) { | |
810 | 4 | var offset; |
811 | 4 | if (Array.isArray(timemarks)) { |
812 | // get timemark for current iteration | |
813 | 4 | offset = timemarks[(j - 1)]; |
814 | } else { | |
815 | 0 | offset = secondOffset * j; |
816 | } | |
817 | ||
818 | 4 | var fname = _renderOutputName(j, offset) + (fileextension ? fileextension : '.jpg'); |
819 | 4 | var target = path.join(folder, fname); |
820 | ||
821 | // build screenshot command | |
822 | 4 | var allArgs = [ |
823 | '-ss', Math.floor(offset * 100) / 100 | |
824 | ] | |
825 | .concat(args) | |
826 | .concat([ | |
827 | '-vframes', '1', | |
828 | '-an', | |
829 | '-vcodec', 'mjpeg', | |
830 | '-f', 'rawvideo', | |
831 | '-y', target | |
832 | ]); | |
833 | ||
834 | 4 | j++; |
835 | ||
836 | 4 | self._spawnFfmpeg(allArgs, taskcallback); |
837 | 4 | filenames.push(fname); |
838 | }, | |
839 | function(err) { | |
840 | 2 | if (err) { |
841 | 0 | self.emit('error', err); |
842 | } else { | |
843 | 2 | self.emit('end', filenames); |
844 | } | |
845 | } | |
846 | ); | |
847 | }, true); | |
848 | } | |
849 | ||
850 | 2 | var timemarks, screenshotcount, filename, fileextension; |
851 | 2 | if (typeof config === 'object') { |
852 | // use json object as config | |
853 | 2 | if (config.count) { |
854 | 2 | screenshotcount = config.count; |
855 | } | |
856 | 2 | if (config.timemarks) { |
857 | 2 | timemarks = config.timemarks; |
858 | } | |
859 | 2 | if (config.fileextension){ |
860 | 0 | fileextension = config.fileextension; |
861 | } | |
862 | } else { | |
863 | // assume screenshot count as parameter | |
864 | 0 | screenshotcount = config; |
865 | 0 | timemarks = null; |
866 | } | |
867 | ||
868 | 2 | if (!this._sizeData || !this._sizeData.size) { |
869 | 0 | throw new Error('Size must be specified'); |
870 | } | |
871 | ||
872 | 2 | var inputfile = this._currentInput.source; |
873 | ||
874 | 2 | filename = config.filename || 'tn_%ss'; |
875 | 2 | if(!/%0*i/.test(filename) && Array.isArray(timemarks) && timemarks.length > 1 ) { |
876 | // if there are multiple timemarks but no %i in filename add one | |
877 | // so we won't overwrite the same thumbnail with each timemark | |
878 | 1 | filename += '_%i'; |
879 | } | |
880 | 2 | folder = folder || '.'; |
881 | ||
882 | // check target folder | |
883 | 2 | fs.exists(folder, function(exists) { |
884 | 2 | if (!exists) { |
885 | 2 | fs.mkdir(folder, '0755', function(err) { |
886 | 2 | if (err !== null) { |
887 | 0 | self.emit('error', err); |
888 | } else { | |
889 | 2 | _screenShotInternal(); |
890 | } | |
891 | }); | |
892 | } else { | |
893 | 0 | _screenShotInternal(); |
894 | } | |
895 | }); | |
896 | }; | |
897 | ||
898 | ||
899 | /** | |
900 | * Renice current and/or future ffmpeg processes | |
901 | * | |
902 | * Ignored on Windows platforms. | |
903 | * | |
904 | * @method FfmpegCommand#renice | |
905 | * @category Processing | |
906 | * | |
907 | * @param {Number} [niceness=0] niceness value between -20 (highest priority) and 20 (lowest priority) | |
908 | * @return FfmpegCommand | |
909 | */ | |
910 | 1 | proto.renice = function(niceness) { |
911 | 2 | if (!utils.isWindows) { |
912 | 2 | niceness = niceness || 0; |
913 | ||
914 | 2 | if (niceness < -20 || niceness > 20) { |
915 | 1 | this.logger.warn('Invalid niceness value: ' + niceness + ', must be between -20 and 20'); |
916 | } | |
917 | ||
918 | 2 | niceness = Math.min(20, Math.max(-20, niceness)); |
919 | 2 | this.options.niceness = niceness; |
920 | ||
921 | 2 | if (this.ffmpegProc) { |
922 | 1 | var logger = this.logger; |
923 | 1 | var pid = this.ffmpegProc.pid; |
924 | 1 | var renice = spawn('renice', [niceness, '-p', pid]); |
925 | ||
926 | 1 | renice.on('error', function(err) { |
927 | 0 | logger.warn('could not renice process ' + pid + ': ' + err.message); |
928 | }); | |
929 | ||
930 | 1 | renice.on('exit', function(code, signal) { |
931 | 1 | if (code) { |
932 | 0 | logger.warn('could not renice process ' + pid + ': renice exited with ' + code); |
933 | 1 | } else if (signal) { |
934 | 0 | logger.warn('could not renice process ' + pid + ': renice was killed by signal ' + signal); |
935 | } else { | |
936 | 1 | logger.info('successfully reniced process ' + pid + ' to ' + niceness + ' niceness'); |
937 | } | |
938 | }); | |
939 | } | |
940 | } | |
941 | ||
942 | 2 | return this; |
943 | }; | |
944 | ||
945 | ||
946 | /** | |
947 | * Kill current ffmpeg process, if any | |
948 | * | |
949 | * @method FfmpegCommand#kill | |
950 | * @category Processing | |
951 | * | |
952 | * @param {String} [signal=SIGKILL] signal name | |
953 | * @return FfmpegCommand | |
954 | */ | |
955 | 1 | proto.kill = function(signal) { |
956 | 3 | if (!this.ffmpegProc) { |
957 | 0 | this.options.logger.warn('No running ffmpeg process, cannot send signal'); |
958 | } else { | |
959 | 3 | this.ffmpegProc.kill(signal || 'SIGKILL'); |
960 | } | |
961 | ||
962 | 3 | return this; |
963 | }; | |
964 | }; | |
965 |
Line | Hits | Source |
---|---|---|
1 | /*jshint node:true*/ | |
2 | 'use strict'; | |
3 | ||
4 | 1 | var exec = require('child_process').exec; |
5 | 1 | var isWindows = require('os').platform().match(/win(32|64)/); |
6 | ||
7 | 1 | var whichCache = {}; |
8 | ||
9 | /** | |
10 | * Parse progress line from ffmpeg stderr | |
11 | * | |
12 | * @param {String} line progress line | |
13 | * @return progress object | |
14 | * @private | |
15 | */ | |
16 | function parseProgressLine(line) { | |
17 | 26 | var progress = {}; |
18 | ||
19 | // Remove all spaces after = and trim | |
20 | 26 | line = line.replace(/=\s+/g, '=').trim(); |
21 | 26 | var progressParts = line.split(' '); |
22 | ||
23 | // Split every progress part by "=" to get key and value | |
24 | 26 | for(var i = 0; i < progressParts.length; i++) { |
25 | 110 | var progressSplit = progressParts[i].split('=', 2); |
26 | 110 | var key = progressSplit[0]; |
27 | 110 | var value = progressSplit[1]; |
28 | ||
29 | // This is not a progress line | |
30 | 110 | if(typeof value === 'undefined') |
31 | 14 | return null; |
32 | ||
33 | 96 | progress[key] = value; |
34 | } | |
35 | ||
36 | 12 | return progress; |
37 | } | |
38 | ||
39 | ||
40 | 1 | var utils = module.exports = { |
41 | isWindows: isWindows, | |
42 | ||
43 | /** | |
44 | * Create an argument list | |
45 | * | |
46 | * Returns a function that adds new arguments to the list. | |
47 | * It also has the following methods: | |
48 | * - clear() empties the argument list | |
49 | * - get() returns the argument list | |
50 | * - find(arg, count) finds 'arg' in the list and return the following 'count' items, or undefined if not found | |
51 | * - remove(arg, count) remove 'arg' in the list as well as the following 'count' items | |
52 | * | |
53 | * @private | |
54 | */ | |
55 | args: function() { | |
56 | 1434 | var list = []; |
57 | 1434 | var argfunc = function() { |
58 | 326 | if (arguments.length === 1 && Array.isArray(arguments[0])) { |
59 | 90 | list = list.concat(arguments[0]); |
60 | } else { | |
61 | 236 | list = list.concat([].slice.call(arguments)); |
62 | } | |
63 | }; | |
64 | ||
65 | 1434 | argfunc.clear = function() { |
66 | 83 | list = []; |
67 | }; | |
68 | ||
69 | 1434 | argfunc.get = function() { |
70 | 485 | return list; |
71 | }; | |
72 | ||
73 | 1434 | argfunc.find = function(arg, count) { |
74 | 95 | var index = list.indexOf(arg); |
75 | 95 | if (index !== -1) { |
76 | 69 | return list.slice(index + 1, index + 1 + (count || 0)); |
77 | } | |
78 | }; | |
79 | ||
80 | 1434 | argfunc.remove = function(arg, count) { |
81 | 0 | var index = list.indexOf(arg); |
82 | 0 | if (index !== -1) { |
83 | 0 | list.splice(index, (count || 0) + 1); |
84 | } | |
85 | }; | |
86 | ||
87 | 1434 | return argfunc; |
88 | }, | |
89 | ||
90 | ||
91 | /** | |
92 | * Search for an executable | |
93 | * | |
94 | * Uses 'which' or 'where' depending on platform | |
95 | * | |
96 | * @param {String} name executable name | |
97 | * @param {Function} callback callback with signature (err, path) | |
98 | * @private | |
99 | */ | |
100 | which: function(name, callback) { | |
101 | 9 | if (name in whichCache) { |
102 | 6 | return callback(null, whichCache[name]); |
103 | } | |
104 | ||
105 | 3 | var cmd = 'which ' + name; |
106 | 3 | if (isWindows) { |
107 | 0 | cmd = 'where ' + name + '.exe'; |
108 | } | |
109 | ||
110 | 3 | exec(cmd, function(err, stdout) { |
111 | 3 | if (err) { |
112 | // Treat errors as not found | |
113 | 0 | callback(null, whichCache[name] = ''); |
114 | } else { | |
115 | 3 | callback(null, whichCache[name] = stdout.replace(/\n$/, '')); |
116 | } | |
117 | }); | |
118 | }, | |
119 | ||
120 | ||
121 | /** | |
122 | * Convert a [[hh:]mm:]ss[.xxx] timemark into seconds | |
123 | * | |
124 | * @param {String} timemark timemark string | |
125 | * @return Number | |
126 | * @private | |
127 | */ | |
128 | timemarkToSeconds: function(timemark) { | |
129 | 12 | if(timemark.indexOf(':') === -1 && timemark.indexOf('.') >= 0) |
130 | 0 | return Number(timemark); |
131 | ||
132 | 12 | var parts = timemark.split(':'); |
133 | ||
134 | // add seconds | |
135 | 12 | var secs = Number(parts.pop()); |
136 | ||
137 | 12 | if (parts.length) { |
138 | // add minutes | |
139 | 12 | secs += Number(parts.pop()) * 60; |
140 | } | |
141 | ||
142 | 12 | if (parts.length) { |
143 | // add hours | |
144 | 12 | secs += Number(parts.pop()) * 3600; |
145 | } | |
146 | ||
147 | 12 | return secs; |
148 | }, | |
149 | ||
150 | ||
151 | /** | |
152 | * Extract codec data from ffmpeg stderr and emit 'codecData' event if appropriate | |
153 | * | |
154 | * @param {FfmpegCommand} command event emitter | |
155 | * @param {String} stderr ffmpeg stderr output | |
156 | * @private | |
157 | */ | |
158 | extractCodecData: function(command, stderr) { | |
159 | 11 | var format= /Input #[0-9]+, ([^ ]+),/.exec(stderr); |
160 | 11 | var dur = /Duration\: ([^,]+)/.exec(stderr); |
161 | 11 | var audio = /Audio\: (.*)/.exec(stderr); |
162 | 11 | var video = /Video\: (.*)/.exec(stderr); |
163 | 11 | var codecObject = { format: '', audio: '', video: '', duration: '' }; |
164 | ||
165 | 11 | if (format && format.length > 1) { |
166 | 8 | codecObject.format = format[1]; |
167 | } | |
168 | ||
169 | 11 | if (dur && dur.length > 1) { |
170 | 8 | codecObject.duration = dur[1]; |
171 | } | |
172 | ||
173 | 11 | if (audio && audio.length > 1) { |
174 | 7 | audio = audio[1].split(', '); |
175 | 7 | codecObject.audio = audio[0]; |
176 | 7 | codecObject.audio_details = audio; |
177 | } | |
178 | 11 | if (video && video.length > 1) { |
179 | 7 | video = video[1].split(', '); |
180 | 7 | codecObject.video = video[0]; |
181 | 7 | codecObject.video_details = video; |
182 | } | |
183 | ||
184 | 11 | var codecInfoPassed = /Press (\[q\]|ctrl-c) to stop/.test(stderr); |
185 | 11 | if (codecInfoPassed) { |
186 | 1 | command.emit('codecData', codecObject); |
187 | 1 | command._codecDataSent = true; |
188 | } | |
189 | }, | |
190 | ||
191 | ||
192 | /** | |
193 | * Extract progress data from ffmpeg stderr and emit 'progress' event if appropriate | |
194 | * | |
195 | * @param {FfmpegCommand} command event emitter | |
196 | * @param {Number} [duration=0] expected output duration in seconds | |
197 | */ | |
198 | extractProgress: function(command, stderr, duration) { | |
199 | 26 | var lines = stderr.split(/\r\n|\r|\n/g); |
200 | 26 | var lastline = lines[lines.length - 2]; |
201 | 26 | var progress; |
202 | ||
203 | 26 | if (lastline) { |
204 | 26 | progress = parseProgressLine(lastline); |
205 | } | |
206 | ||
207 | 26 | if (progress) { |
208 | // build progress report object | |
209 | 12 | var ret = { |
210 | frames: parseInt(progress.frame, 10), | |
211 | currentFps: parseInt(progress.fps, 10), | |
212 | currentKbps: parseFloat(progress.bitrate.replace('kbits/s', '')), | |
213 | targetSize: parseInt(progress.size, 10), | |
214 | timemark: progress.time | |
215 | }; | |
216 | ||
217 | // calculate percent progress using duration | |
218 | 12 | if (duration && duration > 0) { |
219 | 12 | ret.percent = (utils.timemarkToSeconds(ret.timemark) / duration) * 100; |
220 | } | |
221 | ||
222 | 12 | command.emit('progress', ret); |
223 | } | |
224 | } | |
225 | }; | |
226 |