Skip to content

Commit

Permalink
Merge pull request #4 from noblesamurai/loop_option
Browse files Browse the repository at this point in the history
Allows you to not loop.
  • Loading branch information
Tim Allen authored Mar 20, 2020
2 parents 50bb608 + b4f5c53 commit 147fcc6
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 88 deletions.
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
language: node_js
before_install: "! grep PLEASE_FILL_IN_HERE README.md"
node_js:
- '8'
- '9'
- '10'
- '12'
# For the code coverage stuff to work, set your CC_TEST_REPORTER_ID env var.
before_script:
- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,28 @@ command.kill();

## API

<a name="module_ffmpeg-loop"></a>

## ffmpeg-loop
<a name="exp_module_ffmpeg-loop--module.exports"></a>

### module.exports(filename) ⇒ ⏏
Creates an infinitely looping readable stream from a video.
Note: All crop dimensions are for the original video size (not the output size).
Creates an ffmpeg command to loop a video.
Note: All crop dimensions are for the original video size (not the output
size).

**Kind**: Exported function
**Returns**: A fluent ffmpeg process - has pipe() method.

| Param | Type | Description |
| --- | --- | --- |
| filename | <code>string</code> | path to video |
| opts.fps | <code>integer</code> | |
| opts.width | <code>integer</code> | output width |
| opts.height | <code>integer</code> | output height |
| opts.cropWidth | <code>integer</code> | crop width (width and height are required). |
| opts.cropHeight | <code>integer</code> | crop height |
| opts.cropWidth | <code>integer</code> | crop width (width and height are required). |
| opts.cropX | <code>integer</code> | crop x (x and y are optional. If not set, the default is the center position of the video). |
| opts.cropY | <code>integer</code> | crop y |
| opts.start | <code>float</code> | seek to this time before starting. Must be less than video length.|
| opts.fps | <code>integer</code> | |
| opts.height | <code>integer</code> | output height |
| opts.loop | <code>boolean</code> | whether to loop the source clip (defaults to true) |
| opts.start | <code>float</code> | seek to this time before starting. Must be less |
| opts.width | <code>integer</code> | output width than video length. |

Note: To regenerate this section from the jsdoc run `npm run docs` and paste
the output above.
Expand Down
25 changes: 18 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ffmpeg-loop",
"description": "Instantiate ffmpeg in looping mode.",
"version": "2.0.0",
"version": "2.1.0-loop-option.4",
"author": "Tim Allen <[email protected]>",
"license": "BSD",
"main": "src/index.js",
Expand All @@ -21,30 +21,41 @@
"url": "https://github.com/noblesamurai/ffmpeg-loop/issues"
},
"engines": {
"node": "8.x",
"npm": "5.x"
"node": "^10 || ^12",
"npm": "6.x"
},
"files": [
"src"
],
"dependencies": {
"debug": "^4.1.1",
"fluent-ffmpeg": "^2.1.2",
"lodash.last": "^3.0.0"
"lodash.last": "^3.0.0",
"ow": "^0.17.0"
},
"devDependencies": {
"chai": "^3.5.0",
"dirty-chai": "^1.2.2",
"ffmpeg-static": "^2.1.0",
"ffmpeg-static": "^4.0.1",
"jsdoc-to-markdown": "~3.0.0",
"mocha": "~3.3.0",
"mocha": "^7.1.0",
"nyc": "^10.1.2",
"p-event": "^4.1.0",
"semistandard": "*"
},
"peerDependencies": {
"ffmpeg-static": "^2.1.0"
"ffmpeg-static": "^4"
},
"keywords": [],
"nyc": {
"exclude": [
"coverage",
"test"
]
},
"semistandard": {
"env": [
"mocha"
]
}
}
26 changes: 26 additions & 0 deletions src/crop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Generate a crop filter (if required) to the ffmpeg command based on provided opts.
* All crop dimensions are for the original video size (not the output size).
*
* @param {Array<string>} filters The filters array - will be modified in place if applicable
* @param {integer} opts.cropWidth - crop width (width and height are required).
* @param {integer} opts.cropHeight - crop height
* @param {integer} opts.cropX - crop x (x and y are optional. If not set, the
* default is the center position of the video).
* @param {integer} opts.cropY - crop y
* @return {object|false} The crop filter.
*/
function cropFilter (opts) {
const { cropWidth, cropHeight, cropX, cropY } = opts;
if (!cropWidth || isNaN(cropWidth) || !cropHeight || isNaN(cropHeight)) {
return false;
}
const crop = [cropWidth, cropHeight];
if (!isNaN(cropX) && !isNaN(cropY)) crop.push(cropX, cropY);
return {
filter: 'crop',
options: crop.join(':')
};
}

module.exports = cropFilter;
98 changes: 50 additions & 48 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,81 +2,83 @@
* @module ffmpeg-loop
*/

const cropFilter = require('./crop');
const debug = require('debug')('ffmpeg-loop');
const ffmpeg = require('fluent-ffmpeg');
const assert = require('assert');
const last = require('lodash.last');
ffmpeg.setFfmpegPath(require('ffmpeg-static').path);
const ow = require('ow');

ffmpeg.setFfmpegPath(require('ffmpeg-static'));

/**
* In place update filters to append filter.
* @param {Array<object>} filters
* @param {object} filter
* @param {string} [name]
*/
function appendFilter (filters, filter, name) {
const length = filters.length;
if (length > 0) {
const lastPad = `${name || filter.filter}${length - 1}`;
last(filters).outputs = lastPad;
filter.inputs = lastPad;
}
filters.push(filter);
}

/**
* Creates an ffmpeg command to loop a video.
* Note: All crop dimensions are for the original video size (not the output
* size).
*
* @param {string} filename - path to video
* @param {integer} opts.fps
* @param {integer} opts.width - output width
* @param {integer} opts.height - output height
* @param {integer} opts.cropWidth - crop width (width and height are required).
* @param {integer} opts.cropHeight - crop height
* @param {integer} opts.cropWidth - crop width (width and height are required).
* @param {integer} opts.cropX - crop x (x and y are optional. If not set, the
* default is the center position of the video).
* @param {integer} opts.cropY - crop y
* @param {integer} opts.fps
* @param {integer} opts.height - output height
* @param {boolean} opts.loop - whether to loop the source clip (defaults to true)
* @param {float} opts.start - seek to this time before starting. Must be less
* @param {integer} opts.width - output width
* than video length.
* @returns A fluent ffmpeg process - has pipe() method.
*/
module.exports = function (filename, opts) {
['height', 'width', 'fps'].forEach(key => {
assert(!isNaN(opts[key]), `${key} should be number - got ${opts[key]}`);
});
const { start = 0 } = opts;
const filters = [
{ filter: 'concat', options: { n: 2, v: 1, a: 0 }, outputs: 'concat' },
{
ow(opts, ow.object.partialShape({
fps: ow.number,
height: ow.number.integer,
width: ow.number.integer
}));
const { fps, height, loop = true, start = 0, width } = opts;
const filters = [];

if (loop) {
appendFilter(filters, { filter: 'concat', options: { n: 2, v: 1, a: 0 } }, 'concat-inputs');
appendFilter(filters, {
filter: 'setpts',
inputs: 'concat',
options: 'N/(FRAME_RATE*TB)'
}
];
}, 'redo-timecodes');
// repeat last frame forever if not looping
} else appendFilter(filters, { filter: 'tpad', options: { stop: -1, stop_mode: 'clone' } }, 'pad-at-end');

const command = ffmpeg()
// Using -ss and -stream_loop together does not work well, so we have a
// single non-looped version first to seek on.
.input(filename)
.inputOption('-ss', start)
.input(filename)
.inputOption('-stream_loop', -1)
.inputOption('-ss', start);
if (loop) command.input(filename).inputOption('-stream_loop', -1);
command
.noAudio()
.outputFormat('rawvideo')
.outputOption('-vcodec', 'rawvideo')
.outputOption('-pix_fmt', 'rgba')
.outputOption('-s', `${opts.width}x${opts.height}`)
.outputOption('-r', opts.fps);
applyCrop(filters, opts);
command.complexFilter(filters);
.outputOption('-s', `${width}x${height}`)
.outputOption('-r', fps);
const crop = cropFilter(opts);
if (crop) appendFilter(filters, crop);
if (filters.length) command.complexFilter(filters);
command.once('start', debug);
return command;
};

/**
* Apply a crop filter (if required) to the ffmpeg command based on provided opts.
* All crop dimensions are for the original video size (not the output size).
*
* @private
* @param {Array<string>} filters The filters array - will be modified in place if applicable
* @param {integer} opts.cropWidth - crop width (width and height are required).
* @param {integer} opts.cropHeight - crop height
* @param {integer} opts.cropX - crop x (x and y are optional. If not set, the
* default is the center position of the video).
* @param {integer} opts.cropY - crop y
*/
function applyCrop (filters, opts) {
const { cropWidth, cropHeight, cropX, cropY } = opts;
if (!cropWidth || isNaN(cropWidth) || !cropHeight || isNaN(cropHeight)) { return; }
const crop = [cropWidth, cropHeight];
if (!isNaN(cropX) && !isNaN(cropY)) crop.push(cropX, cropY);
last(filters).outputs = 'toCrop';
return filters.push({
filter: 'crop',
inputs: 'toCrop',
options: crop.join(':')
});
}
106 changes: 85 additions & 21 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
const expect = require('chai').expect;
const ffmpegLoop = require('..');
const path = require('path');
const pEvent = require('p-event');

async function start (command) {
return new Promise((resolve, reject) => {
let cmd;
command.once('start', _cmd => {
// We use setImmediate() here in case the 'start' event is already waiting
// to process (in which case the callback will run straight away). We need
// command.pipe() to run below so we can resolve the promise including the
// stream. This seems to happen in the second test case as somehow
// fluent-ffmpeg is already hot and doesn't wait for the command.pipe()
// before the 'start' happens.
setImmediate(() => {
cmd = _cmd;
console.log(cmd);
command.removeAllListeners('error');
resolve({ cmd, stream });
});
});
command.once('error', reject);
const stream = command.pipe();
});
}

describe('ffmpeg loop', function () {
it('should return an ffmpeg proc', function () {
Expand All @@ -11,8 +34,8 @@ describe('ffmpeg loop', function () {
command.kill();
});

it('should apply a crop filter', function (done) {
this.timeout(5000); // this takes a long time on travis for some reason?
it('should apply a crop filter', async function () {
this.timeout(5000);
const opts = {
height: 28,
width: 50,
Expand All @@ -26,24 +49,65 @@ describe('ffmpeg loop', function () {
path.join(__dirname, 'fixtures/user_video-30.mp4'),
opts
);
command.once('start', cmd => {
try {
console.log(cmd);
expect(cmd).to.match(/crop=12:24:0:0/);
} catch (err) {
done(err);
}
});
const stream = command.pipe();
stream.once('data', function (data) {
try {
expect(data).to.be.ok();
command.kill();
done();
} catch (err) {
done(err);
command.kill();
}
});
const { cmd, stream } = await start(command);
expect(cmd).to.match(/crop=12:24:0:0/);
expect(cmd).to.match(/stream_loop/);

const data = await pEvent(stream, 'data');
expect(data).to.be.ok();
command.kill();
await pEvent(command, 'error');
});

it('should apply a crop filter & not loop ok', async function () {
this.timeout(5000);
const opts = {
height: 28,
width: 50,
fps: 30,
cropWidth: 12,
cropHeight: 24,
cropX: 0,
cropY: 0,
loop: false
};
const command = ffmpegLoop(
path.join(__dirname, 'fixtures/user_video-30.mp4'),
opts
);
const { cmd, stream } = await start(command);
expect(cmd).to.match(/crop=12:24:0:0/);
expect(cmd).to.not.match(/stream_loop/);

const data = await pEvent(stream, 'data');
expect(data).to.be.ok();
command.kill();
await pEvent(command, 'error');
});

it('allows you to NOT loop, but you can still go past the end.', async function () {
this.timeout(5000);
const opts = {
fps: 30,
height: 28,
loop: false,
width: 50,
start: 29.9
};
const command = ffmpegLoop(
path.join(__dirname, 'fixtures/user_video-30.mp4'),
opts
);

const { cmd, stream } = await start(command);
expect(cmd).to.not.match(/stream_loop/);

for (let i = 0; i < 100; i++) {
await pEvent(stream, 'data');
}
const data = await pEvent(stream, 'data');
expect(data).to.be.ok();
command.kill();
await pEvent(command, 'error');
});
});

0 comments on commit 147fcc6

Please sign in to comment.