recipes.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. /*jshint node:true*/
  2. 'use strict';
  3. var fs = require('fs');
  4. var path = require('path');
  5. var PassThrough = require('stream').PassThrough;
  6. var async = require('async');
  7. var utils = require('./utils');
  8. /*
  9. * Useful recipes for commands
  10. */
  11. module.exports = function recipes(proto) {
  12. /**
  13. * Execute ffmpeg command and save output to a file
  14. *
  15. * @method FfmpegCommand#save
  16. * @category Processing
  17. * @aliases saveToFile
  18. *
  19. * @param {String} output file path
  20. * @return FfmpegCommand
  21. */
  22. proto.saveToFile =
  23. proto.save = function(output) {
  24. this.output(output).run();
  25. return this;
  26. };
  27. /**
  28. * Execute ffmpeg command and save output to a stream
  29. *
  30. * If 'stream' is not specified, a PassThrough stream is created and returned.
  31. * 'options' will be used when piping ffmpeg output to the output stream
  32. * (@see http://nodejs.org/api/stream.html#stream_readable_pipe_destination_options)
  33. *
  34. * @method FfmpegCommand#pipe
  35. * @category Processing
  36. * @aliases stream,writeToStream
  37. *
  38. * @param {stream.Writable} [stream] output stream
  39. * @param {Object} [options={}] pipe options
  40. * @return Output stream
  41. */
  42. proto.writeToStream =
  43. proto.pipe =
  44. proto.stream = function(stream, options) {
  45. if (stream && !('writable' in stream)) {
  46. options = stream;
  47. stream = undefined;
  48. }
  49. if (!stream) {
  50. if (process.version.match(/v0\.8\./)) {
  51. throw new Error('PassThrough stream is not supported on node v0.8');
  52. }
  53. stream = new PassThrough();
  54. }
  55. this.output(stream, options).run();
  56. return stream;
  57. };
  58. /**
  59. * Generate images from a video
  60. *
  61. * Note: this method makes the command emit a 'filenames' event with an array of
  62. * the generated image filenames.
  63. *
  64. * @method FfmpegCommand#screenshots
  65. * @category Processing
  66. * @aliases takeScreenshots,thumbnail,thumbnails,screenshot
  67. *
  68. * @param {Number|Object} [config=1] screenshot count or configuration object with
  69. * the following keys:
  70. * @param {Number} [config.count] number of screenshots to take; using this option
  71. * takes screenshots at regular intervals (eg. count=4 would take screens at 20%, 40%,
  72. * 60% and 80% of the video length).
  73. * @param {String} [config.folder='.'] output folder
  74. * @param {String} [config.filename='tn.png'] output filename pattern, may contain the following
  75. * tokens:
  76. * - '%s': offset in seconds
  77. * - '%w': screenshot width
  78. * - '%h': screenshot height
  79. * - '%r': screenshot resolution (same as '%wx%h')
  80. * - '%f': input filename
  81. * - '%b': input basename (filename w/o extension)
  82. * - '%i': index of screenshot in timemark array (can be zero-padded by using it like `%000i`)
  83. * @param {Number[]|String[]} [config.timemarks] array of timemarks to take screenshots
  84. * at; each timemark may be a number of seconds, a '[[hh:]mm:]ss[.xxx]' string or a
  85. * 'XX%' string. Overrides 'count' if present.
  86. * @param {Number[]|String[]} [config.timestamps] alias for 'timemarks'
  87. * @param {Boolean} [config.fastSeek] use fast seek (less accurate)
  88. * @param {String} [config.size] screenshot size, with the same syntax as {@link FfmpegCommand#size}
  89. * @param {String} [folder] output folder (legacy alias for 'config.folder')
  90. * @return FfmpegCommand
  91. */
  92. proto.takeScreenshots =
  93. proto.thumbnail =
  94. proto.thumbnails =
  95. proto.screenshot =
  96. proto.screenshots = function(config, folder) {
  97. var self = this;
  98. var source = this._currentInput.source;
  99. config = config || { count: 1 };
  100. // Accept a number of screenshots instead of a config object
  101. if (typeof config === 'number') {
  102. config = {
  103. count: config
  104. };
  105. }
  106. // Accept a second 'folder' parameter instead of config.folder
  107. if (!('folder' in config)) {
  108. config.folder = folder || '.';
  109. }
  110. // Accept 'timestamps' instead of 'timemarks'
  111. if ('timestamps' in config) {
  112. config.timemarks = config.timestamps;
  113. }
  114. // Compute timemarks from count if not present
  115. if (!('timemarks' in config)) {
  116. if (!config.count) {
  117. throw new Error('Cannot take screenshots: neither a count nor a timemark list are specified');
  118. }
  119. var interval = 100 / (1 + config.count);
  120. config.timemarks = [];
  121. for (var i = 0; i < config.count; i++) {
  122. config.timemarks.push((interval * (i + 1)) + '%');
  123. }
  124. }
  125. // Parse size option
  126. if ('size' in config) {
  127. var fixedSize = config.size.match(/^(\d+)x(\d+)$/);
  128. var fixedWidth = config.size.match(/^(\d+)x\?$/);
  129. var fixedHeight = config.size.match(/^\?x(\d+)$/);
  130. var percentSize = config.size.match(/^(\d+)%$/);
  131. if (!fixedSize && !fixedWidth && !fixedHeight && !percentSize) {
  132. throw new Error('Invalid size parameter: ' + config.size);
  133. }
  134. }
  135. // Metadata helper
  136. var metadata;
  137. function getMetadata(cb) {
  138. if (metadata) {
  139. cb(null, metadata);
  140. } else {
  141. self.ffprobe(function(err, meta) {
  142. metadata = meta;
  143. cb(err, meta);
  144. });
  145. }
  146. }
  147. async.waterfall([
  148. // Compute percent timemarks if any
  149. function computeTimemarks(next) {
  150. if (config.timemarks.some(function(t) { return ('' + t).match(/^[\d.]+%$/); })) {
  151. if (typeof source !== 'string') {
  152. return next(new Error('Cannot compute screenshot timemarks with an input stream, please specify fixed timemarks'));
  153. }
  154. getMetadata(function(err, meta) {
  155. if (err) {
  156. next(err);
  157. } else {
  158. // Select video stream with the highest resolution
  159. var vstream = meta.streams.reduce(function(biggest, stream) {
  160. if (stream.codec_type === 'video' && stream.width * stream.height > biggest.width * biggest.height) {
  161. return stream;
  162. } else {
  163. return biggest;
  164. }
  165. }, { width: 0, height: 0 });
  166. if (vstream.width === 0) {
  167. return next(new Error('No video stream in input, cannot take screenshots'));
  168. }
  169. var duration = Number(vstream.duration);
  170. if (isNaN(duration)) {
  171. duration = Number(meta.format.duration);
  172. }
  173. if (isNaN(duration)) {
  174. return next(new Error('Could not get input duration, please specify fixed timemarks'));
  175. }
  176. config.timemarks = config.timemarks.map(function(mark) {
  177. if (('' + mark).match(/^([\d.]+)%$/)) {
  178. return duration * parseFloat(mark) / 100;
  179. } else {
  180. return mark;
  181. }
  182. });
  183. next();
  184. }
  185. });
  186. } else {
  187. next();
  188. }
  189. },
  190. // Turn all timemarks into numbers and sort them
  191. function normalizeTimemarks(next) {
  192. config.timemarks = config.timemarks.map(function(mark) {
  193. return utils.timemarkToSeconds(mark);
  194. }).sort(function(a, b) { return a - b; });
  195. next();
  196. },
  197. // Add '_%i' to pattern when requesting multiple screenshots and no variable token is present
  198. function fixPattern(next) {
  199. var pattern = config.filename || 'tn.png';
  200. if (pattern.indexOf('.') === -1) {
  201. pattern += '.png';
  202. }
  203. if (config.timemarks.length > 1 && !pattern.match(/%(s|0*i)/)) {
  204. var ext = path.extname(pattern);
  205. pattern = path.join(path.dirname(pattern), path.basename(pattern, ext) + '_%i' + ext);
  206. }
  207. next(null, pattern);
  208. },
  209. // Replace filename tokens (%f, %b) in pattern
  210. function replaceFilenameTokens(pattern, next) {
  211. if (pattern.match(/%[bf]/)) {
  212. if (typeof source !== 'string') {
  213. return next(new Error('Cannot replace %f or %b when using an input stream'));
  214. }
  215. pattern = pattern
  216. .replace(/%f/g, path.basename(source))
  217. .replace(/%b/g, path.basename(source, path.extname(source)));
  218. }
  219. next(null, pattern);
  220. },
  221. // Compute size if needed
  222. function getSize(pattern, next) {
  223. if (pattern.match(/%[whr]/)) {
  224. if (fixedSize) {
  225. return next(null, pattern, fixedSize[1], fixedSize[2]);
  226. }
  227. getMetadata(function(err, meta) {
  228. if (err) {
  229. return next(new Error('Could not determine video resolution to replace %w, %h or %r'));
  230. }
  231. var vstream = meta.streams.reduce(function(biggest, stream) {
  232. if (stream.codec_type === 'video' && stream.width * stream.height > biggest.width * biggest.height) {
  233. return stream;
  234. } else {
  235. return biggest;
  236. }
  237. }, { width: 0, height: 0 });
  238. if (vstream.width === 0) {
  239. return next(new Error('No video stream in input, cannot replace %w, %h or %r'));
  240. }
  241. var width = vstream.width;
  242. var height = vstream.height;
  243. if (fixedWidth) {
  244. height = height * Number(fixedWidth[1]) / width;
  245. width = Number(fixedWidth[1]);
  246. } else if (fixedHeight) {
  247. width = width * Number(fixedHeight[1]) / height;
  248. height = Number(fixedHeight[1]);
  249. } else if (percentSize) {
  250. width = width * Number(percentSize[1]) / 100;
  251. height = height * Number(percentSize[1]) / 100;
  252. }
  253. next(null, pattern, Math.round(width / 2) * 2, Math.round(height / 2) * 2);
  254. });
  255. } else {
  256. next(null, pattern, -1, -1);
  257. }
  258. },
  259. // Replace size tokens (%w, %h, %r) in pattern
  260. function replaceSizeTokens(pattern, width, height, next) {
  261. pattern = pattern
  262. .replace(/%r/g, '%wx%h')
  263. .replace(/%w/g, width)
  264. .replace(/%h/g, height);
  265. next(null, pattern);
  266. },
  267. // Replace variable tokens in pattern (%s, %i) and generate filename list
  268. function replaceVariableTokens(pattern, next) {
  269. var filenames = config.timemarks.map(function(t, i) {
  270. return pattern
  271. .replace(/%s/g, utils.timemarkToSeconds(t))
  272. .replace(/%(0*)i/g, function(match, padding) {
  273. var idx = '' + (i + 1);
  274. return padding.substr(0, Math.max(0, padding.length + 1 - idx.length)) + idx;
  275. });
  276. });
  277. self.emit('filenames', filenames);
  278. next(null, filenames);
  279. },
  280. // Create output directory
  281. function createDirectory(filenames, next) {
  282. fs.exists(config.folder, function(exists) {
  283. if (!exists) {
  284. fs.mkdir(config.folder, function(err) {
  285. if (err) {
  286. next(err);
  287. } else {
  288. next(null, filenames);
  289. }
  290. });
  291. } else {
  292. next(null, filenames);
  293. }
  294. });
  295. }
  296. ], function runCommand(err, filenames) {
  297. if (err) {
  298. return self.emit('error', err);
  299. }
  300. var count = config.timemarks.length;
  301. var split;
  302. var filters = [split = {
  303. filter: 'split',
  304. options: count,
  305. outputs: []
  306. }];
  307. if ('size' in config) {
  308. // Set size to generate size filters
  309. self.size(config.size);
  310. // Get size filters and chain them with 'sizeN' stream names
  311. var sizeFilters = self._currentOutput.sizeFilters.get().map(function(f, i) {
  312. if (i > 0) {
  313. f.inputs = 'size' + (i - 1);
  314. }
  315. f.outputs = 'size' + i;
  316. return f;
  317. });
  318. // Input last size filter output into split filter
  319. split.inputs = 'size' + (sizeFilters.length - 1);
  320. // Add size filters in front of split filter
  321. filters = sizeFilters.concat(filters);
  322. // Remove size filters
  323. self._currentOutput.sizeFilters.clear();
  324. }
  325. var first = 0;
  326. for (var i = 0; i < count; i++) {
  327. var stream = 'screen' + i;
  328. split.outputs.push(stream);
  329. if (i === 0) {
  330. first = config.timemarks[i];
  331. self.seekInput(first);
  332. }
  333. self.output(path.join(config.folder, filenames[i]))
  334. .frames(1)
  335. .map(stream);
  336. if (i > 0) {
  337. self.seek(config.timemarks[i] - first);
  338. }
  339. }
  340. self.complexFilter(filters);
  341. self.run();
  342. });
  343. return this;
  344. };
  345. /**
  346. * Merge (concatenate) inputs to a single file
  347. *
  348. * @method FfmpegCommand#concat
  349. * @category Processing
  350. * @aliases concatenate,mergeToFile
  351. *
  352. * @param {String|Writable} target output file or writable stream
  353. * @param {Object} [options] pipe options (only used when outputting to a writable stream)
  354. * @return FfmpegCommand
  355. */
  356. proto.mergeToFile =
  357. proto.concatenate =
  358. proto.concat = function(target, options) {
  359. // Find out which streams are present in the first non-stream input
  360. var fileInput = this._inputs.filter(function(input) {
  361. return !input.isStream;
  362. })[0];
  363. var self = this;
  364. this.ffprobe(this._inputs.indexOf(fileInput), function(err, data) {
  365. if (err) {
  366. return self.emit('error', err);
  367. }
  368. var hasAudioStreams = data.streams.some(function(stream) {
  369. return stream.codec_type === 'audio';
  370. });
  371. var hasVideoStreams = data.streams.some(function(stream) {
  372. return stream.codec_type === 'video';
  373. });
  374. // Setup concat filter and start processing
  375. self.output(target, options)
  376. .complexFilter({
  377. filter: 'concat',
  378. options: {
  379. n: self._inputs.length,
  380. v: hasVideoStreams ? 1 : 0,
  381. a: hasAudioStreams ? 1 : 0
  382. }
  383. })
  384. .run();
  385. });
  386. return this;
  387. };
  388. };