├── .github ├── dependabot.yml └── workflows │ ├── npm-publish.yml │ ├── npm-test.yml │ └── release-please.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── README.md ├── lib └── index.js ├── package-lock.json ├── package.json ├── tests ├── index.test.js └── media │ ├── break.mp3 │ ├── cat.jpg │ ├── empty │ ├── pink.png │ ├── pug.gif │ └── text.txt ├── tsconfig.build.json └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: npm 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | cooldown: 9 | semver-major-days: 90 10 | semver-minor-days: 30 11 | 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: monthly 16 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: latest 16 | registry-url: https://registry.npmjs.org/ 17 | - run: npm ci 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 21 | -------------------------------------------------------------------------------- /.github/workflows/npm-test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: latest 19 | - uses: FedericoCarboni/setup-ffmpeg@v3 20 | - run: npm ci 21 | - run: npm test 22 | - uses: codecov/codecov-action@v5 23 | with: 24 | token: ${{ secrets.CODECOV_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/create-github-app-token@v2 17 | id: create-token 18 | with: 19 | app-id: ${{ vars.APP_ID }} 20 | private-key: ${{ secrets.PRIVATE_KEY }} 21 | - uses: googleapis/release-please-action@v4 22 | with: 23 | token: ${{ steps.create-token.outputs.token }} 24 | release-type: node 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/*.d.ts 3 | lcov.info 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "markiscodecoverage.searchCriteria": "lcov*.info" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.0.1](https://github.com/phaux/node-ffmpeg-stream/compare/v1.0.0...v1.0.1) (2025-05-03) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * substr is deprecated ([2d8a7e9](https://github.com/phaux/node-ffmpeg-stream/commit/2d8a7e9614e4417729473214815f792f3f3ac8b8)) 11 | 12 | ## [1.0.0](https://github.com/phaux/node-ffmpeg-stream/compare/v0.7.0...v1.0.0) (2024-08-03) 13 | 14 | 15 | ### ⚠ BREAKING CHANGES 16 | 17 | * passing ffmpeg path via env var is not supported anymore 18 | * removed deprecated functions 19 | 20 | ### Features 21 | 22 | * pass custom ffmpeg path via argument ([#63](https://github.com/phaux/node-ffmpeg-stream/issues/63)) ([af04d72](https://github.com/phaux/node-ffmpeg-stream/commit/af04d723ec7808a861f24997c5b917287351ceaa)) 23 | 24 | 25 | ### Miscellaneous Chores 26 | 27 | * rewrite to vanilla JS ([#61](https://github.com/phaux/node-ffmpeg-stream/issues/61)) ([135e9fd](https://github.com/phaux/node-ffmpeg-stream/commit/135e9fd09fd53bed641992003ec3e454287d4c4b)) 28 | 29 | ## [0.7.0](https://github.com/phaux/node-ffmpeg-stream/compare/v0.6.0...v0.7.0) (2020-11-13) 30 | 31 | ### Features 32 | 33 | - allow specifying the same option multiple times using an array ([#26](https://github.com/phaux/node-ffmpeg-stream/issues/26)) ([bd75cac](https://github.com/phaux/node-ffmpeg-stream/commit/bd75cacb2907354e119526956a7ffe3efa869f7b)) 34 | 35 | ## 0.6.0 (2019-08-28) 36 | 37 | ### Features 38 | 39 | - Rewrite the whole thing ([#13](https://github.com/phaux/node-ffmpeg-stream/issues/13)) ([07c09ca](https://github.com/phaux/node-ffmpeg-stream/commit/07c09ca)) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FFmpeg-Stream 2 | 3 | [![npm](https://img.shields.io/npm/v/ffmpeg-stream)](https://www.npmjs.com/package/ffmpeg-stream) 4 | [![Codecov](https://img.shields.io/codecov/c/gh/phaux/node-ffmpeg-stream)](https://app.codecov.io/gh/phaux/node-ffmpeg-stream) 5 | 6 | Node bindings to ffmpeg command, exposing stream based API. 7 | 8 | > [!NOTE] 9 | > FFmpeg must be installed and available in `PATH`. 10 | > You can set a custom ffmpeg path via an argument (default is just `ffmpeg`). 11 | 12 | ## Examples 13 | 14 | ```js 15 | import { Converter } from "ffmpeg-stream" 16 | import { createReadStream, createWriteStream } from "node:fs" 17 | 18 | async function convert() { 19 | const converter = new Converter() 20 | 21 | // get a writable input stream and pipe an image file to it 22 | const converterInput = converter.createInputStream({ 23 | f: "image2pipe", 24 | vcodec: "mjpeg", 25 | }) 26 | createReadStream(`${__dirname}/cat.jpg`).pipe(converterInput) 27 | 28 | // create an output stream, crop/scale image, save to file via node stream 29 | const converterOutput = converter.createOutputStream({ 30 | f: "image2", 31 | vcodec: "mjpeg", 32 | vf: "crop=300:300,scale=100:100", 33 | }) 34 | converterOutput.pipe(createWriteStream(`${__dirname}/cat_thumb.jpg`)) 35 | 36 | // same, but save to file directly from ffmpeg 37 | converter.createOutputToFile(`${__dirname}/cat_full.jpg`, { 38 | vf: "crop=300:300", 39 | }) 40 | 41 | // start processing 42 | await converter.run() 43 | } 44 | ``` 45 | 46 | # API 47 | 48 | - **class** `Converter` 49 | 50 | Creates a new instance of the ffmpeg converter class. 51 | Converting won't start until `run()` method is called. 52 | 53 | - **method** `createInputStream(options: Options): stream.Writable` 54 | 55 | Defines an ffmpeg input stream. 56 | Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), which specifies the format of the input data. 57 | The returned stream is a writable stream. 58 | 59 | - **method** `createInputFromFile(file: string, options: Options): void` 60 | 61 | Defines an ffmpeg input using specified path. 62 | This is the same as specifying an input on the command line. 63 | 64 | - **method** `createBufferedInputStream(options: Options): stream.Writable` 65 | 66 | This is a mix of `createInputStream` and `createInputFromFile`. 67 | It creates a temporary file and instructs ffmpeg to use it, 68 | then it returns a writable stream attached to that file. 69 | Using this method will cause a huge delay. 70 | 71 | - **method** `createOutputStream(options: Options): stream.Readable` 72 | 73 | Defines an ffmpeg output stream. 74 | Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), which specifies the format of the output data. 75 | The returned stream is a readable stream. 76 | 77 | - **method** `createOutputToFile(file: string, options: Options): void` 78 | 79 | Defines an ffmpeg output using specified path. 80 | This is the same as specifying an output on the command line. 81 | 82 | - **method** `createBufferedOutputStream(options: Options): stream.Readable` 83 | 84 | This is a mix of `createOutputStream` and `createOutputToFile`. 85 | It creates a temporary file and instructs ffmpeg to use it, 86 | then it returns a readable stream attached to that file. 87 | Using this method will cause a huge delay. 88 | 89 | - **method** `run(): Promise` 90 | 91 | Starts the ffmpeg process. 92 | Returns a Promise which resolves on normal exit or kill, but rejects on ffmpeg error. 93 | 94 | - **method** `kill(): void` 95 | 96 | Kills the ffmpeg process. 97 | 98 | - **type** `Options` 99 | 100 | Object of options which you normally pass to the ffmpeg command in the terminal. 101 | Documentation for individual options can be found at [ffmpeg site](https://ffmpeg.org/ffmpeg.html) in audio and video category. 102 | For boolean options specify `true` or `false`. 103 | If you'd like to specify the same argument multiple times you can do so by providing an array of values. E.g. `{ map: ["0:v", "1:a"] }` 104 | 105 | # FAQ 106 | 107 | ## How to get video duration and other stats 108 | 109 | You can use `ffprobe` command for now. It might be implemented in the library in the future, though. 110 | 111 | ## Is there a `progress` or `onFrameEmitted` event 112 | 113 | Currently, no. 114 | 115 | ## Something doesn't work 116 | 117 | Try running your program with `DEBUG=ffmpeg-stream` environment variable. 118 | It will print the ffmpeg command it executes and all the ffmpeg logs. 119 | The command usually looks something like `ffmpeg -f … -i pipe:3 -f … pipe:4`. 120 | `pipe:number` means it uses standard input/output instead of a file. 121 | 122 | ## Error: Muxer does not support non seekable output 123 | 124 | When getting error similar to this: 125 | 126 | ``` 127 | [mp4 @ 0000000000e4db00] muxer does not support non seekable output 128 | Could not write header for output file #0 (incorrect codec parameters ?): Invalid argument 129 | Error initializing output stream 0:1 -- 130 | encoded 0 frames 131 | Conversion failed! 132 | 133 | at ChildProcess. (\node_modules\ffmpeg-stream\lib\index.js:215:27) 134 | at emitTwo (events.js:106:13) 135 | at ChildProcess.emit (events.js:191:7) 136 | at Process.ChildProcess._handle.onexit (internal/child_process.js:215:12) 137 | ``` 138 | 139 | ffmpeg says that the combination of options you specified doesn't support streaming. You can experiment with calling ffmpeg directly and specifying `-` or `pipe:1` as output file. Maybe some other options or different format will work. Streaming sequence of JPEGs over websockets worked flawlessly for me (`{ f: "image2pipe", vcodec: "mjpeg" }`). 140 | 141 | You can also use `createBufferedOutputStream`. That tells the library to save output to a temporary file and then create a node stream from that file. It wont start producing data until the conversion is complete, though. 142 | 143 | ## How to get individual frame data 144 | 145 | You have to set output format to mjpeg and then split the stream manually by looking at the bytes. You can implement a transform stream which does this: 146 | 147 | ```js 148 | import { Transform } from "node:stream" 149 | 150 | class ExtractFrames extends Transform { 151 | constructor(magicNumberHex) { 152 | super({ readableObjectMode: true }) 153 | this.magicNumber = Buffer.from(magicNumberHex, "hex") 154 | this.currentData = Buffer.alloc(0) 155 | } 156 | 157 | _transform(newData, encoding, done) { 158 | // Add new data 159 | this.currentData = Buffer.concat([this.currentData, newData]) 160 | 161 | // Find frames in current data 162 | while (true) { 163 | // Find the start of a frame 164 | const startIndex = this.currentData.indexOf(this.magicNumber) 165 | if (startIndex < 0) break // start of frame not found 166 | 167 | // Find the start of the next frame 168 | const endIndex = this.currentData.indexOf( 169 | this.magicNumber, 170 | startIndex + this.magicNumber.length, 171 | ) 172 | if (endIndex < 0) break // we haven't got the whole frame yet 173 | 174 | // Handle found frame 175 | this.push(this.currentData.slice(startIndex, endIndex)) // emit a frame 176 | this.currentData = this.currentData.slice(endIndex) // remove frame data from current data 177 | if (startIndex > 0) console.error(`Discarded ${startIndex} bytes of invalid data`) 178 | } 179 | 180 | done() 181 | } 182 | 183 | _flush(done) { 184 | this.push(this.currentData) 185 | done() 186 | } 187 | } 188 | ``` 189 | 190 | And then use it like that: 191 | 192 | ```js 193 | import { Converter } from "ffmpeg-stream" 194 | 195 | const converter = new Converter() 196 | 197 | converter 198 | .createOutputStream({ f: "image2pipe", vcodec: "mjpeg" }) 199 | .pipe(new ExtractFrames("FFD8FF")) // use jpg magic number as delimiter 200 | .on("data", frameData => { 201 | /* do things with frame data (instance of Buffer) */ 202 | }) 203 | 204 | converter.run() 205 | ``` 206 | 207 | ## How to create an animation from a set of image files 208 | 209 | > I have images in Amazon S3 bucket (private) so I'm using their SDK to download those. 210 | > I get the files in Buffer objects. 211 | > Is there any way I can use your package to create a video out of it? 212 | > 213 | > So far I've been downloading the files and then using the following command: 214 | > `ffmpeg -framerate 30 -pattern_type glob -i '*.jpg' -c:v libx264 -pix_fmt yuv420p out.mp4` 215 | > 216 | > But now want to do it from my node js application automatically. 217 | 218 | ```js 219 | import { Converter } from "ffmpeg-stream" 220 | 221 | const frames = ["frame1.jpg", "frame2.jpg", ...etc] 222 | 223 | // create converter 224 | const converter = new Converter() 225 | 226 | // create input writable stream (the jpeg frames) 227 | const converterInput = converter.createInputStream({ f: "image2pipe", r: 30 }) 228 | 229 | // create output to file (mp4 video) 230 | converter.createOutputToFile("out.mp4", { 231 | vcodec: "libx264", 232 | pix_fmt: "yuv420p", 233 | }) 234 | 235 | // start the converter, save the promise for later 236 | const convertingFinished = converter.run() 237 | 238 | // pipe all the frames to the converter sequentially 239 | for (const filename of frames) { 240 | // create a promise for every frame and await it 241 | await new Promise((resolve, reject) => { 242 | s3.getObject({ Bucket: "...", Key: filename }) 243 | .createReadStream() 244 | .pipe(converterInput, { end: false }) // pipe to converter, but don't end the input yet 245 | .on("end", resolve) // resolve the promise after the frame finishes 246 | .on("error", reject) 247 | }) 248 | } 249 | converterInput.end() 250 | 251 | // await until the whole process finished just in case 252 | await convertingFinished 253 | ``` 254 | 255 | ## How to stream a video when there's data, otherwise an intermission image 256 | 257 | You can turn your main stream into series of `jpeg` images with output format `mjpeg` and combine it with static image by repeatedly piping a single `jpeg` image when there's no data from main stream. 258 | Then pipe it to second ffmpeg process which combines `jpeg` images into video. 259 | 260 | ```js 261 | import * as fs from "node:fs" 262 | import { Converter } from "ffmpeg-stream" 263 | 264 | // create the joiner ffmpeg process (frames to video) 265 | const joiner = new Converter() 266 | const joinerInput = joiner.createInputStream({ f: "mjpeg" }) 267 | const joinerOutput = joiner.createOutputStream({ f: "whatever format you want" }) 268 | joinerOutput.pipe(/* wherever you want */) 269 | 270 | joiner.run() 271 | 272 | // remember if we are streaming currently 273 | let streaming = false 274 | 275 | /** 276 | * A function which streams a single video. 277 | * 278 | * @param {import("node:stream").Readable} incomingStream - The video stream. 279 | * @param {string} format - The format of the video stream. 280 | * 281 | * @returns {Promise} Promise which resolves when the stream ends. 282 | */ 283 | async function streamVideo(incomingStream, format) { 284 | if (streaming) throw new Error("We are already streaming something else") 285 | streaming = true 286 | 287 | // create the splitter ffmpeg process (video to frames) 288 | const splitter = new Converter() 289 | 290 | // pipe video to splitter process 291 | incomingStream.pipe(splitter.createInputStream({ f: format })) 292 | 293 | // get jpegs and pipe them to joiner process 294 | splitter.createOutputStream({ f: "mjpeg" }).pipe(joinerInput, { end: false }) 295 | 296 | try { 297 | await splitter.run() 298 | } finally { 299 | streaming = false 300 | } 301 | } 302 | 303 | setInterval(() => { 304 | // if we are streaming - do nothing 305 | if (streaming) return 306 | 307 | // pipe a single jpeg file 30 times per second into the joiner process 308 | // TODO: don't actually read the file 30 times per second 309 | fs.createReadStream("intermission_pic.jpg").pipe(joinerInput, { end: false }) 310 | }, 1000 / 30) 311 | ``` 312 | 313 | ## I want intermission image with audio and other complicated stuff 314 | 315 | You should probably use [beamcoder](https://github.com/Streampunk/beamcoder) instead. 316 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import debug from "debug" 2 | import assert from "node:assert" 3 | import { spawn } from "node:child_process" 4 | import { createReadStream, createWriteStream } from "node:fs" 5 | import { unlink } from "node:fs/promises" 6 | import { tmpdir } from "node:os" 7 | import { join } from "node:path" 8 | import { PassThrough } from "node:stream" 9 | 10 | const dbg = debug("ffmpeg-stream") 11 | const EXIT_CODES = [0, 255] 12 | 13 | /** 14 | * A class which wraps a FFmpeg process. 15 | * 16 | * @example 17 | * 18 | * ```js 19 | * import { Converter } from "ffmpeg-stream" 20 | * 21 | * const converter = new Converter() 22 | * 23 | * converter.createInputFromFile("input.mp4") 24 | * converter.createOutputToFile("output.webm") 25 | * 26 | * await converter.run() 27 | * ``` 28 | */ 29 | export class Converter { 30 | /** 31 | * @private 32 | */ 33 | fdCount = 0 34 | 35 | /** 36 | * @private 37 | * @readonly 38 | * @type {ConverterPipe[]} 39 | */ 40 | pipes = [] 41 | 42 | /** 43 | * @private 44 | * @type {import("node:child_process").ChildProcess | undefined} 45 | */ 46 | process 47 | 48 | /** 49 | * @private 50 | */ 51 | killed = false 52 | 53 | /** 54 | * Initializes the converter. 55 | * 56 | * Remember to call {@link Converter.run} to actually start the FFmpeg process. 57 | * 58 | * @param {string} [ffmpegPath] Path to the FFmpeg executable. (default: `"ffmpeg"`) 59 | */ 60 | constructor(ffmpegPath = "ffmpeg") { 61 | /** @private */ 62 | this.ffmpegPath = ffmpegPath 63 | } 64 | 65 | /** 66 | * Defines an FFmpeg input file. 67 | * 68 | * This builds a command like the one you would normally use in the terminal. 69 | * 70 | * @param {string} file Path to the input file. 71 | * @param {ConverterPipeOptions} [options] FFmpeg options for this input. 72 | * 73 | * @example 74 | * 75 | * ```js 76 | * import { Converter } from "ffmpeg-stream" 77 | * 78 | * const converter = new Converter() 79 | * 80 | * converter.createInputFromFile("input.mp4", { r: 30 }) 81 | * // ffmpeg -r 30 -i input.mp4 ... 82 | * 83 | * await converter.run() 84 | * ``` 85 | */ 86 | createInputFromFile(file, options = {}) { 87 | this.pipes.push({ 88 | type: "input", 89 | options, 90 | file, 91 | }) 92 | } 93 | 94 | /** 95 | * Defines an FFmpeg output file. 96 | * 97 | * This builds a command like the one you would normally use in the terminal. 98 | * 99 | * @param {string} file Path to the output file. 100 | * @param {ConverterPipeOptions} [options] FFmpeg options for this output. 101 | * 102 | * @example 103 | * 104 | * ```js 105 | * import { Converter } from "ffmpeg-stream" 106 | * 107 | * const converter = new Converter() 108 | * 109 | * converter.createOutputToFile("output.mp4", { vcodec: "libx264" }) 110 | * // ffmpeg ... -vcodec libx264 output.mp4 111 | * 112 | * await converter.run() 113 | * ``` 114 | */ 115 | createOutputToFile(file, options = {}) { 116 | this.pipes.push({ 117 | type: "output", 118 | options, 119 | file, 120 | }) 121 | } 122 | 123 | /** 124 | * Defines an FFmpeg input stream. 125 | * 126 | * Internally, it adds a special `pipe:` input argument to the FFmpeg command. 127 | * 128 | * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), 129 | * which specifies the format of the input data. 130 | * 131 | * @param {ConverterPipeOptions} options FFmpeg options for this input. 132 | * @returns {import("node:stream").Writable} A stream which will be written to the FFmpeg process' stdio. 133 | * 134 | * @example 135 | * 136 | * ```js 137 | * import { createReadStream } from "node:fs" 138 | * import { Converter } from "ffmpeg-stream" 139 | * 140 | * const converter = new Converter() 141 | * 142 | * createReadStream("input.mp4").pipe( 143 | * converter.createInputStream({ f: "mp4" }), 144 | * ) 145 | * 146 | * await converter.run() 147 | * ``` 148 | */ 149 | createInputStream(options) { 150 | const stream = new PassThrough() 151 | const fd = this.getUniqueFd() 152 | this.pipes.push({ 153 | type: "input", 154 | options, 155 | file: `pipe:${fd}`, 156 | onSpawn: process => { 157 | const stdio = process.stdio[fd] 158 | if (stdio == null) throw Error(`input ${fd} is null`) 159 | debugStream(stream, `input ${fd}`) 160 | if (!("write" in stdio)) throw Error(`input ${fd} is not writable`) 161 | stream.pipe(stdio) 162 | }, 163 | }) 164 | 165 | return stream 166 | } 167 | 168 | /** 169 | * Defines an FFmpeg output stream. 170 | * 171 | * Internally, it adds a special `pipe:` output argument to the FFmpeg command. 172 | * 173 | * Remember to specify the [`f` option](https://ffmpeg.org/ffmpeg.html#Main-options), 174 | * which specifies the format of the output data. 175 | * 176 | * @param {ConverterPipeOptions} options FFmpeg options for this output. 177 | * @returns {import("node:stream").Readable} A stream which will be read from the FFmpeg process' stdio. 178 | * 179 | * @example 180 | * 181 | * ```js 182 | * import { createWriteStream } from "node:fs" 183 | * import { Converter } from "ffmpeg-stream" 184 | * 185 | * const converter = new Converter() 186 | * 187 | * converter.createOutputStream({ f: "mp4" }) 188 | * .pipe(createWriteStream("output.mp4")) 189 | * 190 | * await converter.run() 191 | * ``` 192 | */ 193 | createOutputStream(options) { 194 | const stream = new PassThrough() 195 | const fd = this.getUniqueFd() 196 | this.pipes.push({ 197 | type: "output", 198 | options, 199 | file: `pipe:${fd}`, 200 | onSpawn: process => { 201 | const stdio = process.stdio[fd] 202 | if (stdio == null) throw Error(`output ${fd} is null`) 203 | debugStream(stdio, `output ${fd}`) 204 | stdio.pipe(stream) 205 | }, 206 | }) 207 | return stream 208 | } 209 | 210 | /** 211 | * Defines an FFmpeg input stream from a temporary file. 212 | * 213 | * Creates a temporary file that you can write to and instructs FFmpeg to read from it. 214 | * Note that the actual conversion process will not start until the file is fully written. 215 | * 216 | * Use this method if the format you want to read doesn't support non-seekable input. 217 | * 218 | * @param {ConverterPipeOptions} options FFmpeg options for this input. 219 | * @returns {import("node:stream").Writable} A stream which will be written to the temporary file. 220 | */ 221 | createBufferedInputStream(options) { 222 | const stream = new PassThrough() 223 | const file = getTmpPath("ffmpeg-") 224 | this.pipes.push({ 225 | type: "input", 226 | options, 227 | file, 228 | onBegin: async () => { 229 | await new /** @type {typeof Promise} */ (Promise)((resolve, reject) => { 230 | const writer = createWriteStream(file) 231 | stream.pipe(writer) 232 | stream.on("end", () => { 233 | dbg("input buffered stream end") 234 | resolve() 235 | }) 236 | stream.on("error", err => { 237 | dbg(`input buffered stream error: ${err.message}`) 238 | reject(err) 239 | }) 240 | }) 241 | }, 242 | onFinish: async () => { 243 | await unlink(file) 244 | }, 245 | }) 246 | return stream 247 | } 248 | 249 | /** 250 | * Defines an FFmpeg output stream to a temporary file. 251 | * 252 | * Creates a temporary file that you can read from and instructs FFmpeg to write to it. 253 | * Note that you will be able to read from the file only after the conversion process is finished. 254 | * 255 | * Use this method if the format you want to write doesn't support non-seekable output. 256 | * 257 | * @param {ConverterPipeOptions} options FFmpeg options for this output. 258 | * @returns {import("node:stream").Readable} A stream which will be read from the temporary file. 259 | */ 260 | createBufferedOutputStream(options) { 261 | const stream = new PassThrough() 262 | const file = getTmpPath("ffmpeg-") 263 | this.pipes.push({ 264 | type: "output", 265 | options, 266 | file, 267 | onFinish: async () => { 268 | await new /** @type {typeof Promise} */ (Promise)((resolve, reject) => { 269 | const reader = createReadStream(file) 270 | reader.pipe(stream) 271 | reader.on("end", () => { 272 | dbg("output buffered stream end") 273 | resolve() 274 | }) 275 | reader.on("error", err => { 276 | dbg(`output buffered stream error: ${err.message}`) 277 | reject(err) 278 | }) 279 | }) 280 | await unlink(file) 281 | }, 282 | }) 283 | return stream 284 | } 285 | 286 | /** 287 | * Starts the conversion process. 288 | * 289 | * You can use {@link Converter.kill} to cancel the conversion. 290 | * 291 | * @returns {Promise} Promise which resolves on normal exit or kill, but rejects on ffmpeg error. 292 | */ 293 | async run() { 294 | /** @type {ConverterPipe[]} */ 295 | const pipes = [] 296 | try { 297 | for (const pipe of this.pipes) { 298 | dbg(`prepare ${pipe.type}`) 299 | await pipe.onBegin?.() 300 | pipes.push(pipe) 301 | } 302 | 303 | const args = this.getSpawnArgs() 304 | const stdio = this.getStdioArg() 305 | dbg(`spawn: ${this.ffmpegPath} ${args.join(" ")}`) 306 | dbg(`spawn stdio: ${stdio.join(" ")}`) 307 | this.process = spawn(this.ffmpegPath, args, { stdio }) 308 | const finished = this.handleProcess() 309 | 310 | for (const pipe of this.pipes) { 311 | pipe.onSpawn?.(this.process) 312 | } 313 | 314 | if (this.killed) { 315 | // the converter was already killed so stop it immediately 316 | this.process.kill() 317 | } 318 | 319 | await finished 320 | } finally { 321 | for (const pipe of pipes) { 322 | await pipe.onFinish?.() 323 | } 324 | } 325 | } 326 | 327 | /** 328 | * Stops the conversion process. 329 | */ 330 | kill() { 331 | // kill the process if it already started 332 | this.process?.kill() 333 | // set the flag so it will be killed after it's initialized 334 | this.killed = true 335 | } 336 | 337 | /** 338 | * @private 339 | * @returns {number} 340 | */ 341 | getUniqueFd() { 342 | return this.fdCount++ + 3 343 | } 344 | 345 | /** 346 | * Returns stdio pipes which can be passed to {@link spawn}. 347 | * @private 348 | * @returns {Array<"ignore" | "pipe">} 349 | */ 350 | getStdioArg() { 351 | return [ 352 | "ignore", 353 | "ignore", 354 | "pipe", 355 | .../** @type {typeof Array<"pipe">} */ (Array)(this.fdCount).fill("pipe"), 356 | ] 357 | } 358 | 359 | /** 360 | * Returns arguments which can be passed to {@link spawn}. 361 | * @private 362 | * @returns {string[]} 363 | */ 364 | getSpawnArgs() { 365 | /** @type {string[]} */ 366 | const args = [] 367 | 368 | for (const pipe of this.pipes) { 369 | if (pipe.type !== "input") continue 370 | args.push(...stringifyArgs(pipe.options)) 371 | args.push("-i", pipe.file) 372 | } 373 | for (const pipe of this.pipes) { 374 | if (pipe.type !== "output") continue 375 | args.push(...stringifyArgs(pipe.options)) 376 | args.push(pipe.file) 377 | } 378 | 379 | return args 380 | } 381 | 382 | /** 383 | * @private 384 | */ 385 | async handleProcess() { 386 | await new /** @type {typeof Promise} */ (Promise)((resolve, reject) => { 387 | let logSectionNum = 0 388 | /** @type {string[]} */ 389 | const logLines = [] 390 | 391 | assert(this.process != null, "process should be initialized") 392 | 393 | if (this.process.stderr != null) { 394 | this.process.stderr.setEncoding("utf8") 395 | 396 | this.process.stderr.on( 397 | "data", 398 | /** @type {(data: string) => void} */ data => { 399 | const lines = data.split(/\r\n|\r|\n/u) 400 | for (const line of lines) { 401 | // skip empty lines 402 | if (/^\s*$/u.exec(line) != null) continue 403 | // if not indented: increment section counter 404 | if (/^\s/u.exec(line) == null) logSectionNum++ 405 | // only log sections following the first one 406 | if (logSectionNum > 1) { 407 | dbg(`log: ${line}`) 408 | logLines.push(line) 409 | } 410 | } 411 | }, 412 | ) 413 | } 414 | 415 | this.process.on("error", err => { 416 | dbg(`error: ${err.message}`) 417 | reject(err) 418 | }) 419 | 420 | this.process.on("exit", (code, signal) => { 421 | dbg(`exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`) 422 | if (code == null) return resolve() 423 | if (EXIT_CODES.includes(code)) return resolve() 424 | const log = logLines.map(line => ` ${line}`).join("\n") 425 | reject(Error(`Converting failed\n${log}`)) 426 | }) 427 | }) 428 | } 429 | } 430 | 431 | /** 432 | * Stringifies FFmpeg options object into command line arguments array. 433 | * 434 | * @param {ConverterPipeOptions} options 435 | * @returns {string[]} 436 | */ 437 | function stringifyArgs(options) { 438 | /** @type {string[]} */ 439 | const args = [] 440 | 441 | for (const [option, value] of Object.entries(options)) { 442 | if (Array.isArray(value)) { 443 | for (const element of value) { 444 | if (element != null) { 445 | args.push(`-${option}`) 446 | args.push(String(element)) 447 | } 448 | } 449 | } else if (value != null && value !== false) { 450 | args.push(`-${option}`) 451 | if (typeof value != "boolean") { 452 | args.push(String(value)) 453 | } 454 | } 455 | } 456 | 457 | return args 458 | } 459 | 460 | /** 461 | * Returns a random file path in the system's temporary directory. 462 | * 463 | * @param {string} [prefix] 464 | * @param {string} [suffix] 465 | */ 466 | function getTmpPath(prefix = "", suffix = "") { 467 | const dir = tmpdir() 468 | const id = Math.random().toString(32).substring(2, 12) 469 | return join(dir, `${prefix}${id}${suffix}`) 470 | } 471 | 472 | /** 473 | * @param {import("node:stream").Readable | import("node:stream").Writable} stream 474 | * @param {string} name 475 | */ 476 | function debugStream(stream, name) { 477 | stream.on("error", err => { 478 | dbg(`${name} error: ${err.message}`) 479 | }) 480 | stream.on( 481 | "data", 482 | /** @type {(data: Buffer | string) => void} */ data => { 483 | dbg(`${name} data: ${data.length} bytes`) 484 | }, 485 | ) 486 | stream.on("finish", () => { 487 | dbg(`${name} finish`) 488 | }) 489 | } 490 | 491 | /** 492 | * Options object for a single input or output of a {@link Converter}. 493 | * 494 | * These are the same options that you normally pass to the ffmpeg command in the terminal. 495 | * Documentation for individual options can be found in the [ffmpeg docs](https://ffmpeg.org/ffmpeg.html#Main-options). 496 | * 497 | * To specify a boolean option, set it to `true`. 498 | * To specify an option multiple times, use an array. 499 | * Options with nullish or `false` values are ignored. 500 | * 501 | * @example 502 | * 503 | * ```js 504 | * const options = { f: "image2", vcodec: "png" } 505 | * ``` 506 | * 507 | * @typedef {Record | null | undefined>} ConverterPipeOptions 508 | */ 509 | 510 | /** 511 | * Data about a single input or output of a {@link Converter}. 512 | * 513 | * @ignore 514 | * @internal 515 | * @typedef {Object} ConverterPipe 516 | * @property {"input" | "output"} type 517 | * @property {ConverterPipeOptions} options 518 | * @property {string} file 519 | * @property {() => Promise} [onBegin] 520 | * @property {(process: import("node:child_process").ChildProcess) => void} [onSpawn] 521 | * @property {() => Promise} [onFinish] 522 | */ 523 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffmpeg-stream", 3 | "version": "1.0.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "ffmpeg-stream", 9 | "version": "1.0.1", 10 | "license": "MIT", 11 | "dependencies": { 12 | "debug": "^4.2.0" 13 | }, 14 | "devDependencies": { 15 | "@types/debug": "^4.1.5", 16 | "@types/node": "^22.0.2", 17 | "file-type": "^20.0.1", 18 | "prettier": "^3.3.2", 19 | "typescript": "^5.5.3" 20 | } 21 | }, 22 | "node_modules/@tokenizer/inflate": { 23 | "version": "0.2.6", 24 | "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.6.tgz", 25 | "integrity": "sha512-SdR/i05U7Xhnsq36iyIq/ZiGGw4PKzw4ww3bOq80Pjj4wyXpqyTcgrgdDdGlcatnlvzNJx8CQw3hp6QZvkUwhA==", 26 | "dev": true, 27 | "license": "MIT", 28 | "dependencies": { 29 | "debug": "^4.3.7", 30 | "fflate": "^0.8.2", 31 | "token-types": "^6.0.0" 32 | }, 33 | "engines": { 34 | "node": ">=16" 35 | }, 36 | "funding": { 37 | "type": "github", 38 | "url": "https://github.com/sponsors/Borewit" 39 | } 40 | }, 41 | "node_modules/@tokenizer/token": { 42 | "version": "0.3.0", 43 | "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", 44 | "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", 45 | "dev": true 46 | }, 47 | "node_modules/@types/debug": { 48 | "version": "4.1.12", 49 | "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", 50 | "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", 51 | "dev": true, 52 | "dependencies": { 53 | "@types/ms": "*" 54 | } 55 | }, 56 | "node_modules/@types/ms": { 57 | "version": "0.7.34", 58 | "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", 59 | "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", 60 | "dev": true 61 | }, 62 | "node_modules/@types/node": { 63 | "version": "22.15.3", 64 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", 65 | "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", 66 | "dev": true, 67 | "license": "MIT", 68 | "dependencies": { 69 | "undici-types": "~6.21.0" 70 | } 71 | }, 72 | "node_modules/debug": { 73 | "version": "4.4.0", 74 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", 75 | "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", 76 | "dependencies": { 77 | "ms": "^2.1.3" 78 | }, 79 | "engines": { 80 | "node": ">=6.0" 81 | }, 82 | "peerDependenciesMeta": { 83 | "supports-color": { 84 | "optional": true 85 | } 86 | } 87 | }, 88 | "node_modules/fflate": { 89 | "version": "0.8.2", 90 | "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", 91 | "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", 92 | "dev": true, 93 | "license": "MIT" 94 | }, 95 | "node_modules/file-type": { 96 | "version": "20.5.0", 97 | "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz", 98 | "integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==", 99 | "dev": true, 100 | "license": "MIT", 101 | "dependencies": { 102 | "@tokenizer/inflate": "^0.2.6", 103 | "strtok3": "^10.2.0", 104 | "token-types": "^6.0.0", 105 | "uint8array-extras": "^1.4.0" 106 | }, 107 | "engines": { 108 | "node": ">=18" 109 | }, 110 | "funding": { 111 | "url": "https://github.com/sindresorhus/file-type?sponsor=1" 112 | } 113 | }, 114 | "node_modules/ieee754": { 115 | "version": "1.2.1", 116 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 117 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 118 | "dev": true, 119 | "funding": [ 120 | { 121 | "type": "github", 122 | "url": "https://github.com/sponsors/feross" 123 | }, 124 | { 125 | "type": "patreon", 126 | "url": "https://www.patreon.com/feross" 127 | }, 128 | { 129 | "type": "consulting", 130 | "url": "https://feross.org/support" 131 | } 132 | ] 133 | }, 134 | "node_modules/ms": { 135 | "version": "2.1.3", 136 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 137 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 138 | }, 139 | "node_modules/peek-readable": { 140 | "version": "6.1.0", 141 | "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-6.1.0.tgz", 142 | "integrity": "sha512-1H5ECS+rPH35Foh4JD/XohQKWsx6Jzn37ESOVTFuCSoI8wMB9r2e2aDSLgHSiyucVrPfoc0DRiipBEP1gr9wLw==", 143 | "dev": true, 144 | "license": "MIT", 145 | "engines": { 146 | "node": ">=18" 147 | }, 148 | "funding": { 149 | "type": "github", 150 | "url": "https://github.com/sponsors/Borewit" 151 | } 152 | }, 153 | "node_modules/prettier": { 154 | "version": "3.5.3", 155 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 156 | "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 157 | "dev": true, 158 | "license": "MIT", 159 | "bin": { 160 | "prettier": "bin/prettier.cjs" 161 | }, 162 | "engines": { 163 | "node": ">=14" 164 | }, 165 | "funding": { 166 | "url": "https://github.com/prettier/prettier?sponsor=1" 167 | } 168 | }, 169 | "node_modules/strtok3": { 170 | "version": "10.2.0", 171 | "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.0.tgz", 172 | "integrity": "sha512-S884oIGzokq3LkL/6jXw5c5oJXRiGt4jB42cuWdaooJdxFMSn99snaShh3cQmJx3jALV2eoyW3rV/TJxwOaBPA==", 173 | "dev": true, 174 | "license": "MIT", 175 | "dependencies": { 176 | "@tokenizer/token": "^0.3.0", 177 | "peek-readable": "^6.1.0" 178 | }, 179 | "engines": { 180 | "node": ">=18" 181 | }, 182 | "funding": { 183 | "type": "github", 184 | "url": "https://github.com/sponsors/Borewit" 185 | } 186 | }, 187 | "node_modules/token-types": { 188 | "version": "6.0.0", 189 | "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", 190 | "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==", 191 | "dev": true, 192 | "dependencies": { 193 | "@tokenizer/token": "^0.3.0", 194 | "ieee754": "^1.2.1" 195 | }, 196 | "engines": { 197 | "node": ">=14.16" 198 | }, 199 | "funding": { 200 | "type": "github", 201 | "url": "https://github.com/sponsors/Borewit" 202 | } 203 | }, 204 | "node_modules/typescript": { 205 | "version": "5.8.3", 206 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 207 | "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 208 | "dev": true, 209 | "license": "Apache-2.0", 210 | "bin": { 211 | "tsc": "bin/tsc", 212 | "tsserver": "bin/tsserver" 213 | }, 214 | "engines": { 215 | "node": ">=14.17" 216 | } 217 | }, 218 | "node_modules/uint8array-extras": { 219 | "version": "1.4.0", 220 | "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", 221 | "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", 222 | "dev": true, 223 | "engines": { 224 | "node": ">=18" 225 | }, 226 | "funding": { 227 | "url": "https://github.com/sponsors/sindresorhus" 228 | } 229 | }, 230 | "node_modules/undici-types": { 231 | "version": "6.21.0", 232 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 233 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 234 | "dev": true, 235 | "license": "MIT" 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffmpeg-stream", 3 | "version": "1.0.1", 4 | "description": "Node bindings to ffmpeg command, exposing stream based API", 5 | "author": "phaux", 6 | "repository": "phaux/node-ffmpeg-stream", 7 | "license": "MIT", 8 | "type": "module", 9 | "keywords": [ 10 | "ffmpeg", 11 | "convert", 12 | "transcode", 13 | "media", 14 | "video" 15 | ], 16 | "main": "./lib/index.js", 17 | "exports": "./lib/index.js", 18 | "scripts": { 19 | "prepare": "tsc -p tsconfig.build.json", 20 | "test": "node --test --test-timeout 10000 --test-reporter spec --test-reporter-destination stdout --test-reporter lcov --test-reporter-destination lcov.info && tsc --noEmit" 21 | }, 22 | "files": [ 23 | "lib" 24 | ], 25 | "prettier": { 26 | "arrowParens": "avoid", 27 | "printWidth": 100, 28 | "semi": false 29 | }, 30 | "devDependencies": { 31 | "@types/debug": "^4.1.5", 32 | "@types/node": "^22.0.2", 33 | "file-type": "^20.0.1", 34 | "prettier": "^3.3.2", 35 | "typescript": "^5.5.3" 36 | }, 37 | "dependencies": { 38 | "debug": "^4.2.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import { fileTypeStream } from "file-type" 2 | import assert from "node:assert/strict" 3 | import { createReadStream, createWriteStream } from "node:fs" 4 | import { mkdir, rm } from "node:fs/promises" 5 | import { afterEach, beforeEach, test } from "node:test" 6 | import { Converter } from "../lib/index.js" 7 | 8 | const mediaDir = `${import.meta.dirname}/media` 9 | 10 | beforeEach(async () => { 11 | await mkdir(`${mediaDir}/output`, { recursive: true }) 12 | }) 13 | 14 | afterEach(async () => { 15 | await rm(`${mediaDir}/output`, { force: true, recursive: true }) 16 | }) 17 | 18 | void test("should do simple streamed conversion", async () => { 19 | const converter = new Converter() 20 | 21 | createReadStream(`${mediaDir}/cat.jpg`).pipe( 22 | converter.createInputStream({ f: "image2pipe", vcodec: "mjpeg" }), 23 | ) 24 | 25 | const check = fileTypeStream(converter.createOutputStream({ f: "image2", vcodec: "png" })).then( 26 | stream => { 27 | assert.equal(stream.fileType?.mime, "image/png") 28 | stream.pipe(createWriteStream(`${mediaDir}/output/cat.png`)) 29 | }, 30 | ) 31 | 32 | await converter.run() 33 | await check 34 | }) 35 | 36 | void test("should do simple buffered conversion", async () => { 37 | const converter = new Converter() 38 | 39 | createReadStream(`${mediaDir}/cat.jpg`).pipe( 40 | converter.createBufferedInputStream({ f: "image2pipe", vcodec: "mjpeg" }), 41 | ) 42 | 43 | const check = fileTypeStream( 44 | converter.createBufferedOutputStream({ f: "image2", vcodec: "png" }), 45 | ).then(stream => { 46 | assert.equal(stream.fileType?.mime, "image/png") 47 | stream.pipe(createWriteStream(`${mediaDir}/output/cat.png`)) 48 | }) 49 | 50 | await converter.run() 51 | await check 52 | }) 53 | 54 | void test("should do file to stream conversion", async () => { 55 | const converter = new Converter() 56 | 57 | converter.createInputFromFile(`${mediaDir}/cat.jpg`) 58 | 59 | const check = fileTypeStream(converter.createOutputStream({ f: "image2", vcodec: "png" })).then( 60 | stream => { 61 | assert.equal(stream.fileType?.mime, "image/png") 62 | stream.pipe(createWriteStream(`${mediaDir}/output/cat.png`)) 63 | }, 64 | ) 65 | 66 | await converter.run() 67 | await check 68 | }) 69 | 70 | void test("should do stream to file conversion", async () => { 71 | const converter = new Converter() 72 | 73 | createReadStream(`${mediaDir}/cat.jpg`).pipe( 74 | converter.createInputStream({ f: "image2pipe", vcodec: "mjpeg" }), 75 | ) 76 | 77 | converter.createOutputToFile(`${mediaDir}/output/cat.png`) 78 | 79 | await converter.run() 80 | }) 81 | 82 | void test("should handle multiple stream outputs", async () => { 83 | const converter = new Converter() 84 | 85 | converter.createInputFromFile(`${mediaDir}/cat.jpg`, {}) 86 | 87 | const check1 = fileTypeStream( 88 | converter.createOutputStream({ 89 | f: "image2", 90 | vcodec: "png", 91 | vf: "crop=50:50", 92 | }), 93 | ).then(stream => { 94 | assert.equal(stream.fileType?.mime, "image/png") 95 | stream.pipe(createWriteStream(`${mediaDir}/output/cat1.png`)) 96 | }) 97 | 98 | const check2 = fileTypeStream( 99 | converter.createOutputStream({ 100 | f: "image2", 101 | vcodec: "mjpeg", 102 | vf: "scale=100:100", 103 | }), 104 | ).then(stream => { 105 | assert.equal(stream.fileType?.mime, "image/jpeg") 106 | stream.pipe(createWriteStream(`${mediaDir}/output/cat2.jpg`)) 107 | }) 108 | 109 | await converter.run() 110 | await Promise.all([check1, check2]) 111 | }) 112 | 113 | void test("should error on invalid input stream", async () => { 114 | const converter = new Converter() 115 | 116 | createReadStream(`${mediaDir}/text.txt`).pipe( 117 | converter.createInputStream({ f: "image2pipe", vcodec: "mjpeg" }), 118 | ) 119 | 120 | const check = fileTypeStream(converter.createOutputStream({ f: "image2", vcodec: "mjpeg" })).then( 121 | stream => { 122 | assert(stream.fileType?.mime == null) 123 | stream.pipe(createWriteStream(`${mediaDir}/output/cat.jpg`)) 124 | }, 125 | ) 126 | 127 | await assert.rejects(converter.run()) 128 | await check 129 | }) 130 | 131 | void test("should output empty stream on kill", async () => { 132 | const converter = new Converter() 133 | 134 | converter.createInputFromFile(`${mediaDir}/cat.jpg`, {}) 135 | 136 | const check = fileTypeStream(converter.createOutputStream({ f: "image2", vcodec: "png" })).then( 137 | stream => { 138 | assert(stream.fileType?.mime == null) 139 | stream.pipe(createWriteStream(`${mediaDir}/output/cat.png`)) 140 | }, 141 | ) 142 | 143 | void converter.run() 144 | converter.kill() 145 | await check 146 | }) 147 | -------------------------------------------------------------------------------- /tests/media/break.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phaux/node-ffmpeg-stream/706b47bbab42f1611ae55ab210db2b339f4539fe/tests/media/break.mp3 -------------------------------------------------------------------------------- /tests/media/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phaux/node-ffmpeg-stream/706b47bbab42f1611ae55ab210db2b339f4539fe/tests/media/cat.jpg -------------------------------------------------------------------------------- /tests/media/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phaux/node-ffmpeg-stream/706b47bbab42f1611ae55ab210db2b339f4539fe/tests/media/empty -------------------------------------------------------------------------------- /tests/media/pink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phaux/node-ffmpeg-stream/706b47bbab42f1611ae55ab210db2b339f4539fe/tests/media/pink.png -------------------------------------------------------------------------------- /tests/media/pug.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phaux/node-ffmpeg-stream/706b47bbab42f1611ae55ab210db2b339f4539fe/tests/media/pug.gif -------------------------------------------------------------------------------- /tests/media/text.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tristique nibh nec risus lobortis, non rhoncus lectus iaculis. Donec iaculis dignissim magna ac finibus. Ut tempus magna quis justo auctor, sed laoreet quam imperdiet. Nulla neque elit, hendrerit vel tristique non, vestibulum a magna. Nam tortor augue, sagittis nec blandit ac, ullamcorper ac eros. Ut quis dui quis ligula malesuada vulputate. Duis vel lacinia odio. 2 | 3 | Nam ut purus ut dui iaculis facilisis. Phasellus rhoncus nibh velit, et luctus libero dapibus in. Sed pretium diam quam, non blandit mi rutrum ut. Aliquam et vestibulum sapien, ultrices dignissim diam. Suspendisse sed risus id mauris gravida efficitur. Praesent justo odio, vehicula eu nisl nec, eleifend hendrerit ante. Mauris ultricies elit ipsum, a venenatis ante tempor efficitur. Aenean lacus enim, vestibulum ut eros sit amet, malesuada tincidunt ipsum. Cras pretium scelerisque feugiat. Pellentesque feugiat lorem sit amet nibh iaculis bibendum. Ut vel ipsum non lectus feugiat elementum non a turpis. 4 | 5 | Vivamus dignissim ligula massa, eget accumsan tortor dapibus ut. Etiam at tortor dui. Proin at nisi sit amet sapien auctor lacinia eget accumsan sem. Nullam convallis felis vitae magna euismod fringilla. Fusce in sapien ultrices, dictum urna non, fermentum lorem. Vestibulum molestie sem vel tincidunt blandit. Donec lacinia lorem sed eleifend tristique. Sed blandit blandit nibh sed porttitor. Etiam sodales arcu at lacus viverra tincidunt. Suspendisse tempus enim ipsum, a lobortis enim pulvinar non. Fusce at ullamcorper nulla, nec vehicula augue. Duis sit amet tincidunt diam. Aliquam non turpis eu diam pulvinar finibus ut eget turpis. Nulla condimentum tristique dui, non porta elit sollicitudin ut. Maecenas feugiat, quam sit amet faucibus tincidunt, massa eros sodales est, sagittis commodo nunc ex eu elit. Vestibulum et nibh quis est efficitur volutpat. 6 | 7 | Aenean sed suscipit magna. Curabitur maximus aliquam condimentum. Morbi et pretium urna. Vivamus porta scelerisque enim bibendum condimentum. Sed orci sapien, condimentum a tortor sed, bibendum gravida magna. Nullam vulputate fringilla dolor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Proin dictum massa et est porttitor, vel pellentesque augue sodales. Cras eu suscipit risus. Quisque mi orci, rutrum id augue quis, commodo placerat dolor. Praesent eget dignissim sem. Phasellus ac tellus magna. Mauris accumsan rhoncus magna dapibus finibus. 8 | 9 | Maecenas eros magna, suscipit ac velit vitae, maximus commodo purus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut vulputate nulla non lectus imperdiet, et tempor nisi tincidunt. Sed augue massa, commodo quis risus sit amet, sodales faucibus ex. Nullam quis nunc ultricies, mollis augue vitae, mattis turpis. Proin ac purus facilisis, faucibus massa at, vulputate libero. Nulla sit amet nunc consequat, consequat enim sed, pulvinar arcu. Aliquam orci lorem, suscipit ac vulputate vel, maximus quis odio. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Praesent non imperdiet erat. Fusce luctus, elit in aliquam luctus, felis orci mollis turpis, eget luctus tellus nulla a magna. Morbi eros nunc, mollis vitae lectus at, rutrum condimentum est. Quisque vel lacus vitae augue aliquet pretium. In vitae viverra eros, id cursus massa. 10 | 11 | Ut sodales orci bibendum finibus posuere. Donec tristique, nunc eu pretium placerat, libero massa semper neque, at malesuada ante turpis ut ligula. Praesent non turpis hendrerit, mollis enim sagittis, mattis erat. Nullam blandit massa nunc, eu porttitor tellus finibus eu. Aenean hendrerit ex odio, quis consectetur orci vulputate vel. Nam blandit magna lacus, ut scelerisque ante sagittis ut. Mauris vel consectetur sapien. Duis consequat dapibus lectus, in porta augue efficitur non. Aliquam nibh orci, congue non quam at, fringilla ultrices nisl. 12 | 13 | Sed feugiat tortor ac nulla lacinia, at suscipit velit tempus. Vivamus risus ex, semper sed ligula quis, efficitur vulputate nibh. Duis commodo massa sed risus venenatis finibus. Integer sed auctor sapien, a elementum nulla. Ut sit amet dui nibh. Praesent quis sagittis mi. Cras in sem eros. Proin porttitor condimentum eleifend. In consequat risus ut velit congue tincidunt. Phasellus aliquet eleifend orci, nec iaculis massa accumsan nec. Fusce vel aliquam tortor, venenatis porta tortor. Aenean nec efficitur dolor. Pellentesque molestie, eros at mattis accumsan, dui dui porttitor mi, a fringilla neque urna ac mauris. Quisque id mattis nibh. 14 | 15 | Morbi id pretium lorem. Nam et ex libero. Quisque ac volutpat lacus. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla cursus tortor eget diam hendrerit, vel elementum nibh posuere. Sed eget enim auctor massa luctus consequat non sit amet turpis. Phasellus pretium neque a orci rutrum, non venenatis elit rhoncus. Maecenas dictum venenatis libero, id ullamcorper diam. Duis blandit neque nec tristique molestie. Nunc sagittis tellus eget efficitur bibendum. Morbi ut aliquet diam. Cras nec lorem massa. Aenean sed lectus id tortor mattis placerat bibendum vitae dolor. 16 | 17 | Proin vitae consectetur nisi. Vivamus placerat auctor egestas. Curabitur tempus elementum quam id egestas. Cras tempor turpis non lectus efficitur sodales. Fusce facilisis lacus dui, sit amet ornare mi fringilla ac. Nulla id cursus urna. Pellentesque eleifend ut enim posuere mattis. Phasellus vulputate libero quis felis placerat ultrices. Etiam quis imperdiet turpis, a posuere diam. Maecenas sem mi, fringilla congue dignissim vitae, molestie luctus velit. Fusce nulla sem, malesuada semper mauris eget, dapibus interdum magna. Maecenas nec leo bibendum, sagittis lectus a, efficitur neque. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent porta erat ac lectus dignissim condimentum. 18 | 19 | Quisque sodales mattis iaculis. Interdum et malesuada fames ac ante ipsum primis in faucibus. Quisque sit amet consequat orci. Nunc laoreet lacinia est a porttitor. Nunc fringilla consectetur dolor, non maximus felis ornare a. Maecenas augue tellus, gravida eleifend neque rutrum, dictum pharetra diam. Vestibulum at felis porttitor, blandit enim in, gravida odio. Morbi hendrerit tristique ultrices. Fusce varius aliquam maximus. Nunc porttitor lobortis nibh, nec consectetur risus finibus vitae. Proin ac magna in arcu varius fermentum vel in purus. Sed et egestas libero. Sed eget purus augue. 20 | 21 | Ut risus risus, mattis in sapien maximus, commodo ultrices orci. Maecenas ornare ante sed accumsan dignissim. Curabitur et tincidunt tortor. Mauris sit amet rhoncus sem. Aenean id nibh lobortis, finibus leo a, eleifend velit. In turpis lectus, dignissim a suscipit nec, feugiat a neque. Interdum et malesuada fames ac ante ipsum primis in faucibus. Donec iaculis elit eu blandit finibus. Fusce laoreet diam ut urna dapibus, vitae bibendum nulla ultricies. Proin id mauris egestas, dapibus elit vitae, lobortis dolor. 22 | 23 | Praesent mollis ex vel orci pulvinar, non fermentum libero cursus. Proin interdum egestas consequat. Vivamus in egestas nisl. Nullam et lectus enim. Mauris mattis posuere accumsan. Quisque condimentum lectus at condimentum egestas. Ut in nunc est. In posuere lectus at molestie ultricies. Curabitur varius ante justo, facilisis sollicitudin odio bibendum id. 24 | 25 | Nunc sapien felis, molestie eu consectetur ut, dignissim ut turpis. Mauris ut pretium mi. Ut ac bibendum tellus. Etiam elit purus, consectetur quis fermentum ac, cursus nec lacus. Vestibulum lacinia malesuada tortor, eu consequat turpis bibendum et. Ut in ligula vitae felis tempus auctor. Maecenas fringilla, mi vel suscipit placerat, dui ex laoreet dui, accumsan porttitor tortor turpis ut neque. Vestibulum id sollicitudin orci, ac accumsan nunc. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Donec pharetra vel ex sit amet porta. 26 | 27 | Curabitur faucibus, enim vel lacinia fringilla, massa sem dapibus turpis, sed ultrices nisi ex eu arcu. Curabitur sagittis tellus ac massa blandit maximus. Nam vitae sagittis libero. Fusce finibus volutpat ligula eu congue. Nam fermentum sem ut mi vulputate, porta varius leo condimentum. Suspendisse non rhoncus felis. Suspendisse sit amet laoreet mi, consectetur elementum odio. Duis faucibus dapibus felis, laoreet dapibus diam blandit a. 28 | 29 | Ut pharetra, magna sed viverra luctus, velit tellus ullamcorper nulla, sit amet tristique sapien eros eu urna. Etiam vel sapien rhoncus, commodo nisl sit amet, rhoncus sapien. Curabitur semper eget nulla ut porta. Donec ullamcorper aliquam diam, nec pretium sem pharetra in. Ut pellentesque odio enim, ut tincidunt nibh tincidunt quis. In dapibus, quam sit amet venenatis gravida, enim nisl tincidunt nisl, sed egestas ligula ex at tortor. Donec a blandit augue. Mauris auctor iaculis arcu quis vestibulum. Phasellus tristique posuere sagittis. Proin laoreet neque maximus, volutpat orci in, pulvinar felis. Vivamus turpis libero, tincidunt nec fringilla a, fermentum sagittis purus. 30 | 31 | Proin condimentum, felis in hendrerit tincidunt, ligula nulla placerat purus, ac porta felis ipsum ac purus. Suspendisse porta dolor eu nisl dignissim aliquam. Suspendisse potenti. Donec venenatis risus eu tellus commodo auctor. Vestibulum tempor purus ipsum, ut lobortis purus scelerisque ut. Phasellus at luctus orci. Donec luctus cursus tincidunt. Nunc auctor ut lorem a pharetra. Pellentesque non lorem ligula. Aenean mollis venenatis lacus, a semper nulla ullamcorper nec. Integer vitae turpis ligula. Cras cursus vehicula pellentesque. Aenean diam sem, blandit vitae placerat non, sagittis eu lacus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent ultrices sodales elementum. 32 | 33 | Integer rutrum nisi varius, blandit orci ac, lacinia magna. In eget imperdiet ligula. Etiam fermentum hendrerit odio, vel interdum neque facilisis eget. Mauris finibus turpis turpis, nec auctor sapien molestie at. Nam mattis lobortis hendrerit. Mauris volutpat sapien eu eros sollicitudin tempus. Nunc et lectus ligula. Maecenas et lectus sit amet lacus viverra sodales. Sed tincidunt lorem vitae pharetra condimentum. Cras nullam. 34 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "noEmit": false 7 | }, 8 | "include": ["lib/**/*.js"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "lib": ["ES2023", "DOM"], 6 | "module": "Node16", 7 | "moduleResolution": "Node16", 8 | "noEmit": true, 9 | "strict": true, 10 | "target": "ES2023" 11 | } 12 | } 13 | --------------------------------------------------------------------------------