├── CHANGES.adoc ├── .gitignore ├── src ├── index.md ├── utils │ ├── logger.js │ └── utils.js ├── constants.js ├── package.json ├── routes │ ├── probe.js │ ├── uploadfile.js │ ├── convert.js │ └── extract.js └── app.js ├── .github └── workflows │ ├── ffmpeg-api.yaml │ └── reusable-container-build-and-push.yaml ├── samples ├── extracted_images.json └── probe_metadata.json ├── Dockerfile └── README.adoc /CHANGES.adoc: -------------------------------------------------------------------------------- 1 | === 0.3 2 | 3 | * Added EXTERNAL_PORT env variable. 4 | 5 | === 0.2 6 | 7 | * Added extract images and audio endpoints. 8 | * Added probe endpoint. 9 | * Major refactoring. 10 | 11 | === 0.1 12 | 13 | * Initial version. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.avi 2 | *.mp3 3 | *.m4a 4 | *.zip 5 | TODO* 6 | uploads/* 7 | dev.dockerfile 8 | 9 | .dockerignore 10 | .ebextensions 11 | nbproject 12 | public 13 | package-lock.json 14 | input 15 | 16 | node-ffmpeg.zip 17 | 18 | /node_modules 19 | # Elastic Beanstalk Files 20 | .elasticbeanstalk/* 21 | !.elasticbeanstalk/*.cfg.yml 22 | !.elasticbeanstalk/*.global.yml 23 | /nbproject/private/ 24 | -------------------------------------------------------------------------------- /src/index.md: -------------------------------------------------------------------------------- 1 | # ffmpeg API 2 | 3 | An web service for converting audio/video files using FFMPEG. 4 | 5 | Sources: https://github.com/samisalkosuo/ffmpeg-api. 6 | 7 | Based on: 8 | 9 | - https://github.com/surebert/docker-ffmpeg-service 10 | - https://github.com/jrottenberg/ffmpeg 11 | - https://github.com/fluent-ffmpeg/node-fluent-ffmpeg 12 | 13 | 14 | # Endpoints 15 | 16 | [API endpoints](./endpoints) 17 | -------------------------------------------------------------------------------- /.github/workflows/ffmpeg-api.yaml: -------------------------------------------------------------------------------- 1 | name: ffmpeg-api container build and push 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | paths: 10 | - './**' 11 | - '!**.adoc' 12 | jobs: 13 | ffmpeg-api-container-build-and-push: 14 | uses: ./.github/workflows/reusable-container-build-and-push.yaml 15 | with: 16 | imagename: kazhar/ffmpeg-api 17 | tagname: $GITHUB_REF_NAME 18 | dockerfile: ./Dockerfile 19 | directory: . 20 | secrets: inherit 21 | -------------------------------------------------------------------------------- /src/utils/logger.js: -------------------------------------------------------------------------------- 1 | //setup custom logger 2 | const { createLogger, format, transports } = require('winston'); 3 | const { combine, timestamp, label, printf } = format; 4 | 5 | const logFormat = printf(({ level, message, label, timestamp }) => { 6 | return `${timestamp} ${level.toUpperCase()}: ${message}`; 7 | }); 8 | 9 | module.exports = createLogger({ 10 | format: combine( 11 | timestamp(), 12 | logFormat 13 | ), 14 | transports: [new transports.Console({ 15 | level: process.env.LOG_LEVEL || 'info' 16 | })] 17 | }); 18 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | 2 | exports.fileSizeLimit = parseInt(process.env.FILE_SIZE_LIMIT_BYTES || "536870912"); //536870912 = 512MB 3 | exports.defaultFFMPEGProcessPriority=10; 4 | exports.serverPort = 3000;//port to listen, NOTE: if using Docker/Kubernetes this port may not be the one clients are using 5 | exports.externalPort = process.env.EXTERNAL_PORT;//external port that server listens, set this if using for example docker container and binding port is other than 3000 6 | exports.keepAllFiles = process.env.KEEP_ALL_FILES || "false"; //if true, do not delete any uploaded/generated files 7 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffmpegapi", 3 | "version": "1.0.0", 4 | "description": "API for FFMPEG, media conversion utility", 5 | "main": "app.js", 6 | "bin" : { "ffmpegapi" : "./app.js" }, 7 | "license": "ISC", 8 | "dependencies": { 9 | "busboy": "~0.3.1", 10 | "compression": "^1.7.4", 11 | "express": "^4.17.1", 12 | "express-readme": "0.0.5", 13 | "fluent-ffmpeg": "^2.1.2", 14 | "express-list-endpoints": "4.0.1", 15 | "fs": "0.0.1-security", 16 | "unique-filename": "^1.1.1", 17 | "winston": "^3.2.1", 18 | "archiver": "^4.0.1" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^6.8.0", 22 | "eslint-config-google": "^0.14.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/probe.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | const ffmpeg = require('fluent-ffmpeg'); 3 | 4 | const logger = require('../utils/logger.js'); 5 | const utils = require('../utils/utils.js'); 6 | 7 | var router = express.Router(); 8 | 9 | //probe input file and return metadata 10 | router.post('/', function (req, res,next) { 11 | 12 | let savedFile = res.locals.savedFile; 13 | logger.debug(`Probing ${savedFile}`); 14 | 15 | //ffmpeg processing... 16 | var ffmpegCommand = ffmpeg(savedFile) 17 | 18 | ffmpegCommand.ffprobe(function(err, metadata) { 19 | if (err) 20 | { 21 | next(err); 22 | } 23 | else 24 | { 25 | utils.deleteFile(savedFile); 26 | res.status(200).send(metadata); 27 | } 28 | 29 | }); 30 | 31 | }); 32 | 33 | module.exports = router -------------------------------------------------------------------------------- /samples/extracted_images.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalfiles": 5, 3 | "description": "Extracted image files and URLs to download them. By default, downloading image also deletes the image from server. Note that port 3000 in the URL may not be the same as the real port, especially if server is running on Docker/Kubernetes.", 4 | "files": [ 5 | { 6 | "name": "ba0f565c-0001.png", 7 | "url": "http://127.0.0.1:3000/video/extract/download/ba0f565c-0001.png" 8 | }, 9 | { 10 | "name": "ba0f565c-0002.png", 11 | "url": "http://127.0.0.1:3000/video/extract/download/ba0f565c-0002.png" 12 | }, 13 | { 14 | "name": "ba0f565c-0003.png", 15 | "url": "http://127.0.0.1:3000/video/extract/download/ba0f565c-0003.png" 16 | }, 17 | { 18 | "name": "ba0f565c-0004.png", 19 | "url": "http://127.0.0.1:3000/video/extract/download/ba0f565c-0004.png" 20 | }, 21 | { 22 | "name": "ba0f565c-0005.png", 23 | "url": "http://127.0.0.1:3000/video/extract/download/ba0f565c-0005.png" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const logger = require('./logger.js') 3 | const constants = require('../constants.js'); 4 | 5 | function deleteFile (filepath) { 6 | if (constants.keepAllFiles === "false") 7 | { 8 | fs.unlinkSync(filepath); 9 | logger.debug(`deleted ${filepath}`); 10 | } 11 | else 12 | { 13 | logger.debug(`NOT deleted ${filepath}`); 14 | } 15 | } 16 | 17 | function downloadFile (filepath,filename,req,res,next) { 18 | 19 | logger.debug(`starting download to client. file: ${filepath}`); 20 | 21 | res.download(filepath, filename, function(err) { 22 | if (err) { 23 | logger.error(`download error: ${err}`); 24 | return next(err); 25 | } 26 | else 27 | { 28 | logger.debug(`download complete ${filepath}`); 29 | let doDelete = req.query.delete || "true"; 30 | //delete file if doDelete is true 31 | if (doDelete === "true" || doDelete === "yes") 32 | { 33 | deleteFile(filepath); 34 | } 35 | } 36 | }); 37 | 38 | } 39 | 40 | 41 | module.exports = { 42 | deleteFile, 43 | downloadFile 44 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ##################################################################### 2 | # 3 | # A Docker image to convert audio and video for web using web API 4 | # 5 | # with 6 | # - FFMPEG (built) 7 | # - NodeJS 8 | # - fluent-ffmpeg 9 | # 10 | # For more on Fluent-FFMPEG, see 11 | # 12 | # https://github.com/fluent-ffmpeg/node-fluent-ffmpeg 13 | # 14 | # Original image and FFMPEG API by Paul Visco 15 | # https://github.com/surebert/docker-ffmpeg-service 16 | # 17 | ##################################################################### 18 | 19 | FROM node:18.14-alpine3.16 as build 20 | 21 | RUN apk add --no-cache git 22 | 23 | # install pkg 24 | RUN npm install -g pkg 25 | 26 | ENV PKG_CACHE_PATH /usr/cache 27 | 28 | WORKDIR /usr/src/app 29 | 30 | # Bundle app source 31 | COPY ./src . 32 | RUN npm install 33 | 34 | # Create single binary file 35 | RUN pkg --targets node18-alpine-x64 /usr/src/app/package.json 36 | 37 | 38 | FROM jrottenberg/ffmpeg:4.2-alpine311 39 | 40 | # Create user and change workdir 41 | RUN adduser --disabled-password --home /home/ffmpgapi ffmpgapi 42 | WORKDIR /home/ffmpgapi 43 | 44 | # Copy files from build stage 45 | COPY --from=build /usr/src/app/ffmpegapi . 46 | COPY --from=build /usr/src/app/index.md . 47 | RUN chown ffmpgapi:ffmpgapi * && chmod 755 ffmpegapi 48 | 49 | EXPOSE 3000 50 | 51 | # Change user 52 | USER ffmpgapi 53 | 54 | ENTRYPOINT [] 55 | CMD [ "./ffmpegapi" ] 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/reusable-container-build-and-push.yaml: -------------------------------------------------------------------------------- 1 | #workflow to build and push container 2 | #called by other workflows 3 | name: Reusable container build and push 4 | 5 | on: 6 | workflow_call: 7 | inputs: 8 | imagename: 9 | required: true 10 | type: string 11 | tagname: 12 | required: true 13 | type: string 14 | dockerfile: 15 | required: true 16 | type: string 17 | directory: 18 | required: true 19 | type: string 20 | secrets: 21 | DOCKER_USERNAME: 22 | required: true 23 | DOCKER_PASSWORD: 24 | required: true 25 | jobs: 26 | build-and-push: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout the code 30 | uses: actions/checkout@v3 31 | - name: Set image tag 32 | id: set_image_tag 33 | run: | 34 | if [[ "${{ inputs.tagname }}" == "main" || "${{ inputs.tagname }}" == "master" ]]; then 35 | echo "tag_name=latest" >> $GITHUB_ENV 36 | else 37 | echo "tag_name=${{ inputs.tagname }}" >> $GITHUB_ENV 38 | fi 39 | - name: Docker Build, Tag & Push 40 | uses: mr-smithers-excellent/docker-build-push@v6 41 | with: 42 | image: ${{ inputs.imagename }} 43 | registry: docker.io 44 | tags: ${{ env.tag_name }} 45 | dockerfile: ${{ inputs.dockerfile }} 46 | directory: ${{ inputs.directory }} 47 | username: ${{ secrets.DOCKER_USERNAME }} 48 | password: ${{ secrets.DOCKER_PASSWORD }} -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const compression = require('compression'); 4 | const all_routes = require('express-list-endpoints'); 5 | 6 | const logger = require('./utils/logger.js'); 7 | const constants = require('./constants.js'); 8 | 9 | fileSizeLimit = constants.fileSizeLimit; 10 | timeout = 3600000; 11 | 12 | // catch SIGINT and SIGTERM and exit 13 | // Using a single function to handle multiple signals 14 | function handle(signal) { 15 | logger.info(`Received ${signal}. Exiting...`); 16 | process.exit(1) 17 | } 18 | //SIGINT is typically CTRL-C 19 | process.on('SIGINT', handle); 20 | //SIGTERM is sent to terminate process, for example docker stop sends SIGTERM 21 | process.on('SIGTERM', handle); 22 | 23 | app.use(compression()); 24 | 25 | //routes to handle file upload for all POST methods 26 | var upload = require('./routes/uploadfile.js'); 27 | app.use(upload); 28 | 29 | //routes to convert audio/video/image files to mp3/mp4/jpg 30 | var convert = require('./routes/convert.js'); 31 | app.use('/convert', convert); 32 | 33 | //routes to extract images or audio from video 34 | var extract = require('./routes/extract.js'); 35 | app.use('/video/extract', extract); 36 | 37 | //routes to probe file info 38 | var probe = require('./routes/probe.js'); 39 | app.use('/probe', probe); 40 | 41 | require('express-readme')(app, { 42 | filename: 'index.md', 43 | routes: ['/'], 44 | }); 45 | 46 | const server = app.listen(constants.serverPort, function() { 47 | let host = server.address().address; 48 | let port = server.address().port; 49 | logger.info('Server started and listening http://'+host+':'+port) 50 | }); 51 | 52 | server.on('connection', function(socket) { 53 | logger.debug(`new connection, timeout: ${timeout}`); 54 | socket.setTimeout(timeout); 55 | socket.server.timeout = timeout; 56 | server.keepAliveTimeout = timeout; 57 | }); 58 | 59 | app.get('/endpoints', function(req, res) { 60 | res.status(200).send(all_routes(app)); 61 | //res.writeHead(200, {'content-type' : 'text/plain'}); 62 | //res.end("Endpoints:\n\n"+JSON.stringify(all_routes(app),null,2)+'\n'); 63 | }); 64 | 65 | app.use(function(req, res, next) { 66 | res.status(404).send({error: 'route not found'}); 67 | }); 68 | 69 | 70 | //custom error handler to return text/plain and message only 71 | app.use(function(err, req, res, next){ 72 | let code = err.statusCode || 500; 73 | let message = err.message; 74 | res.writeHead(code, {'content-type' : 'text/plain'}); 75 | res.end(`${err.message}\n`); 76 | 77 | }); 78 | -------------------------------------------------------------------------------- /src/routes/uploadfile.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | const fs = require('fs'); 3 | const Busboy = require('busboy'); 4 | const uniqueFilename = require('unique-filename'); 5 | 6 | var router = express.Router() 7 | const logger = require('../utils/logger.js') 8 | 9 | //route to handle file upload in all POST requests 10 | //file is saved to res.locals.savedFile and can be used in subsequent routes. 11 | router.use(function (req, res,next) { 12 | 13 | if(req.method == "POST") 14 | { 15 | logger.debug(`${__filename} path: ${req.path}`); 16 | 17 | let bytes = 0; 18 | let hitLimit = false; 19 | let fileName = ''; 20 | var savedFile = uniqueFilename('/tmp/'); 21 | let busboy = new Busboy({ 22 | headers: req.headers, 23 | limits: { 24 | fields: 0, //no non-files allowed 25 | files: 1, 26 | fileSize: fileSizeLimit, 27 | }}); 28 | busboy.on('filesLimit', function() { 29 | logger.error(`upload file size limit hit. max file size ${fileSizeLimit} bytes.`) 30 | }); 31 | busboy.on('fieldsLimit', function() { 32 | let msg="Non-file field detected. Only files can be POSTed."; 33 | logger.error(msg); 34 | let err = new Error(msg); 35 | err.statusCode = 400; 36 | next(err); 37 | }); 38 | 39 | busboy.on('file', function( 40 | fieldname, 41 | file, 42 | filename, 43 | encoding, 44 | mimetype 45 | ) { 46 | file.on('limit', function(file) { 47 | hitLimit = true; 48 | let msg = `${filename} exceeds max size limit. max file size ${fileSizeLimit} bytes.` 49 | logger.error(msg); 50 | res.writeHead(500, {'Connection': 'close'}); 51 | res.end(JSON.stringify({error: msg})); 52 | }); 53 | let log = { 54 | file: filename, 55 | encoding: encoding, 56 | mimetype: mimetype, 57 | }; 58 | logger.debug(`file:${log.file}, encoding: ${log.encoding}, mimetype: ${log.mimetype}`); 59 | file.on('data', function(data) { 60 | bytes += data.length; 61 | }); 62 | file.on('end', function(data) { 63 | log.bytes = bytes; 64 | logger.debug(`file: ${log.file}, encoding: ${log.encoding}, mimetype: ${log.mimetype}, bytes: ${log.bytes}`); 65 | }); 66 | 67 | fileName = filename; 68 | savedFile = savedFile + "-" + fileName; 69 | logger.debug(`uploading ${fileName}`) 70 | let written = file.pipe(fs.createWriteStream(savedFile)); 71 | if (written) { 72 | logger.debug(`${fileName} saved, path: ${savedFile}`) 73 | } 74 | }); 75 | busboy.on('finish', function() { 76 | if (hitLimit) { 77 | utils.deleteFile(savedFile); 78 | return; 79 | } 80 | logger.debug(`upload complete. file: ${fileName}`) 81 | res.locals.savedFile = savedFile; 82 | next(); 83 | }); 84 | return req.pipe(busboy); 85 | } 86 | next(); 87 | }); 88 | 89 | module.exports = router; -------------------------------------------------------------------------------- /src/routes/convert.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | const ffmpeg = require('fluent-ffmpeg'); 3 | 4 | const constants = require('../constants.js'); 5 | const logger = require('../utils/logger.js') 6 | const utils = require('../utils/utils.js') 7 | 8 | var router = express.Router() 9 | 10 | 11 | //routes for /convert 12 | //adds conversion type and format to res.locals. to be used in final post function 13 | router.post('/audio/to/mp3', function (req, res,next) { 14 | 15 | res.locals.conversion="audio"; 16 | res.locals.format="mp3"; 17 | return convert(req,res,next); 18 | }); 19 | 20 | router.post('/audio/to/wav', function (req, res,next) { 21 | 22 | res.locals.conversion="audio"; 23 | res.locals.format="wav"; 24 | return convert(req,res,next); 25 | }); 26 | 27 | router.post('/video/to/mp4', function (req, res,next) { 28 | 29 | res.locals.conversion="video"; 30 | res.locals.format="mp4"; 31 | return convert(req,res,next); 32 | }); 33 | 34 | router.post('/image/to/jpg', function (req, res,next) { 35 | 36 | res.locals.conversion="image"; 37 | res.locals.format="jpg"; 38 | return convert(req,res,next); 39 | }); 40 | 41 | // convert audio or video or image to mp3 or mp4 or jpg 42 | function convert(req,res,next) { 43 | let format = res.locals.format; 44 | let conversion = res.locals.conversion; 45 | logger.debug(`path: ${req.path}, conversion: ${conversion}, format: ${format}`); 46 | 47 | let ffmpegParams ={ 48 | extension: format 49 | }; 50 | if (conversion == "image") 51 | { 52 | ffmpegParams.outputOptions= ['-pix_fmt yuv422p']; 53 | } 54 | if (conversion == "audio") 55 | { 56 | if (format === "mp3") 57 | { 58 | ffmpegParams.outputOptions=['-codec:a libmp3lame' ]; 59 | } 60 | if (format === "wav") 61 | { 62 | ffmpegParams.outputOptions=['-codec:a pcm_s16le' ]; 63 | } 64 | } 65 | if (conversion == "video") 66 | { 67 | ffmpegParams.outputOptions=[ 68 | '-codec:v libx264', 69 | '-profile:v high', 70 | '-r 15', 71 | '-crf 23', 72 | '-preset ultrafast', 73 | '-b:v 500k', 74 | '-maxrate 500k', 75 | '-bufsize 1000k', 76 | '-vf scale=-2:640', 77 | '-threads 8', 78 | '-codec:a libfdk_aac', 79 | '-b:a 128k', 80 | ]; 81 | } 82 | 83 | let savedFile = res.locals.savedFile; 84 | let outputFile = savedFile + '-output.' + ffmpegParams.extension; 85 | logger.debug(`begin conversion from ${savedFile} to ${outputFile}`) 86 | 87 | //ffmpeg processing... converting file... 88 | let ffmpegConvertCommand = ffmpeg(savedFile); 89 | ffmpegConvertCommand 90 | .renice(constants.defaultFFMPEGProcessPriority) 91 | .outputOptions(ffmpegParams.outputOptions) 92 | .on('error', function(err) { 93 | logger.error(`${err}`); 94 | utils.deleteFile(savedFile); 95 | res.writeHead(500, {'Connection': 'close'}); 96 | res.end(JSON.stringify({error: `${err}`})); 97 | }) 98 | .on('end', function() { 99 | utils.deleteFile(savedFile); 100 | return utils.downloadFile(outputFile,null,req,res,next); 101 | }) 102 | .save(outputFile); 103 | 104 | } 105 | 106 | module.exports = router -------------------------------------------------------------------------------- /samples/probe_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "streams": [ 3 | { 4 | "index": 0, 5 | "codec_name": "mpeg4", 6 | "codec_long_name": "unknown", 7 | "profile": 0, 8 | "codec_type": "video", 9 | "codec_time_base": "1001/30000", 10 | "codec_tag_string": "mp4v", 11 | "codec_tag": "0x7634706d", 12 | "width": 1920, 13 | "height": 1080, 14 | "coded_width": 1920, 15 | "coded_height": 1080, 16 | "has_b_frames": 0, 17 | "sample_aspect_ratio": "1:1", 18 | "display_aspect_ratio": "16:9", 19 | "pix_fmt": "yuv420p", 20 | "level": 1, 21 | "color_range": "unknown", 22 | "color_space": "unknown", 23 | "color_transfer": "unknown", 24 | "color_primaries": "unknown", 25 | "chroma_location": "left", 26 | "field_order": "unknown", 27 | "timecode": "N/A", 28 | "refs": 1, 29 | "quarter_sample": "false", 30 | "divx_packed": "false", 31 | "id": "N/A", 32 | "r_frame_rate": "30000/1001", 33 | "avg_frame_rate": "30000/1001", 34 | "time_base": "1/30000", 35 | "start_pts": 0, 36 | "start_time": 0, 37 | "duration_ts": 1798797, 38 | "duration": 59.9599, 39 | "bit_rate": 1775911, 40 | "max_bit_rate": 1775911, 41 | "bits_per_raw_sample": "N/A", 42 | "nb_frames": 1797, 43 | "nb_read_frames": "N/A", 44 | "nb_read_packets": "N/A", 45 | "tags": { 46 | "language": "eng", 47 | "handler_name": "L-SMASH Video Handler", 48 | "encoder": "Lavc58.47.103 mpeg4" 49 | }, 50 | "disposition": { 51 | "default": 1, 52 | "dub": 0, 53 | "original": 0, 54 | "comment": 0, 55 | "lyrics": 0, 56 | "karaoke": 0, 57 | "forced": 0, 58 | "hearing_impaired": 0, 59 | "visual_impaired": 0, 60 | "clean_effects": 0, 61 | "attached_pic": 0, 62 | "timed_thumbnails": 0 63 | } 64 | }, 65 | { 66 | "index": 1, 67 | "codec_name": "aac", 68 | "codec_long_name": "unknown", 69 | "profile": 1, 70 | "codec_type": "audio", 71 | "codec_time_base": "1/44100", 72 | "codec_tag_string": "mp4a", 73 | "codec_tag": "0x6134706d", 74 | "sample_fmt": "fltp", 75 | "sample_rate": 44100, 76 | "channels": 2, 77 | "channel_layout": "stereo", 78 | "bits_per_sample": 0, 79 | "id": "N/A", 80 | "r_frame_rate": "0/0", 81 | "avg_frame_rate": "0/0", 82 | "time_base": "1/44100", 83 | "start_pts": 0, 84 | "start_time": 0, 85 | "duration_ts": 2647411, 86 | "duration": 60.031995, 87 | "bit_rate": 2092, 88 | "max_bit_rate": 128000, 89 | "bits_per_raw_sample": "N/A", 90 | "nb_frames": 2587, 91 | "nb_read_frames": "N/A", 92 | "nb_read_packets": "N/A", 93 | "tags": { 94 | "language": "eng", 95 | "handler_name": "L-SMASH Audio Handler" 96 | }, 97 | "disposition": { 98 | "default": 1, 99 | "dub": 0, 100 | "original": 0, 101 | "comment": 0, 102 | "lyrics": 0, 103 | "karaoke": 0, 104 | "forced": 0, 105 | "hearing_impaired": 0, 106 | "visual_impaired": 0, 107 | "clean_effects": 0, 108 | "attached_pic": 0, 109 | "timed_thumbnails": 0 110 | } 111 | } 112 | ], 113 | "format": { 114 | "filename": "/tmp/5f69ae62-video_2.mov", 115 | "nb_streams": 2, 116 | "nb_programs": 0, 117 | "format_name": "mov,mp4,m4a,3gp,3g2,mj2", 118 | "format_long_name": "unknown", 119 | "start_time": 0, 120 | "duration": 60.056, 121 | "size": 13379040, 122 | "bit_rate": 1782208, 123 | "probe_score": 100, 124 | "tags": { 125 | "major_brand": "qt ", 126 | "minor_version": "512", 127 | "compatible_brands": "qt ", 128 | "encoder": "Lavf58.26.101" 129 | } 130 | }, 131 | "chapters": [] 132 | } 133 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = FFMPEG API 2 | 3 | A web service for converting audio/video/image files using FFMPEG. 4 | 5 | Based on: 6 | 7 | * https://github.com/surebert/docker-ffmpeg-service 8 | * https://github.com/jrottenberg/ffmpeg 9 | * https://github.com/fluent-ffmpeg/node-fluent-ffmpeg 10 | 11 | FFMPEG API is provided as Docker image for easy consumption. 12 | 13 | == Endpoints 14 | 15 | * `GET /` - API Readme. 16 | * `GET /endpoints` - Service endpoints as JSON. 17 | * `POST /convert/audio/to/mp3` - Convert audio file in request body to mp3. Returns mp3-file. 18 | * `POST /convert/audio/to/wav` - Convert audio file in request body to wav. Returns wav-file. 19 | * `POST /convert/video/to/mp4` - Convert video file in request body to mp4. Returns mp4-file. 20 | * `POST /convert/image/to/jpg` - Convert image file to jpg. Returns jpg-file. 21 | * `POST /video/extract/audio` - Extract audio track from POSTed video file. Returns audio track as 1-channel wav-file. 22 | ** Query param: `mono=no` - Returns audio track, all channels. 23 | * `POST /video/extract/images` - Extract images from POSTed video file as PNG. Default FPS is 1. Returns JSON that includes download links to extracted images. 24 | ** Query param: `compress=zip|gzip` - Returns extracted images as _zip_ or _tar.gz_ (gzip). 25 | ** Query param: `fps=2` - Extract images using specified FPS. 26 | * `GET /video/extract/download/:filename` - Downloads extracted image file and deletes it from server. 27 | ** Query param: `delete=no` - does not delete file. 28 | * `POST /probe` - Probe media file, return JSON metadata. 29 | 30 | == Docker image 31 | 32 | === Build your own 33 | 34 | * Clone this repository. 35 | * Build Docker image: 36 | ** `docker build -t ffmpeg-api .` 37 | * Run image in foreground: 38 | ** `docker run -it --rm --name ffmpeg-api -p 3000:3000 ffmpeg-api` 39 | * Run image in background: 40 | ** `docker run -d --name ffmpeg-api -p 3000:3000 ffmpeg-api` 41 | 42 | === Use existing 43 | 44 | * Run image in foreground: 45 | ** `docker run -it --rm --name ffmpeg-api -p 3000:3000 kazhar/ffmpeg-api` 46 | * Run image in background: 47 | ** `docker run -d --name ffmpeg-api -p 3000:3000 kazhar/ffmpeg-api` 48 | 49 | === Environment variables 50 | 51 | * Default log level is _info_. Set log level using environment variable, _LOG_LEVEL_. 52 | ** Set log level to debug: 53 | ** `docker run -it --rm -p 3000:3000 -e LOG_LEVEL=debug kazhar/ffmpeg-api` 54 | * Default maximum file size of uploaded files is 512MB. Use environment variable _FILE_SIZE_LIMIT_BYTES_ to change it: 55 | ** Set max file size to 1MB: 56 | ** `docker run -it --rm -p 3000:3000 -e FILE_SIZE_LIMIT_BYTES=1048576 kazhar/ffmpeg-api` 57 | * All uploaded and converted files are deleted when they've been downloaded. Use environment variable _KEEP_ALL_FILES_ to keep all files inside the container /tmp-directory: 58 | ** `docker run -it --rm -p 3000:3000 -e KEEP_ALL_FILES=true kazhar/ffmpeg-api` 59 | * When running on Docker/Kubernetes, port binding can be different than default 3000. Use _EXTERNAL_PORT_ to set up external port in returned URLs in extracted images JSON: 60 | ** `docker run -it --rm -p 3001:3000 -e EXTERNAL_PORT=3001 kazhar/ffmpeg-api` 61 | 62 | 63 | == Usage 64 | 65 | Input file to FFMPEG API can be anything that ffmpeg supports. See https://www.ffmpeg.org/general.html#Supported-File-Formats_002c-Codecs-or-Features[ffmpeg docs for supported formats]. 66 | 67 | === Convert 68 | 69 | Convert audio/video/image files using the API. 70 | 71 | * `curl -F "file=@input.wav" 127.0.0.1:3000/convert/audio/to/mp3 > output.mp3` 72 | * `curl -F "file=@input.m4a" 127.0.0.1:3000/convert/audio/to/wav > output.wav` 73 | * `curl -F "file=@input.mov" 127.0.0.1:3000/convert/video/to/mp4 > output.mp4` 74 | * `curl -F "file=@input.mp4" 127.0.0.1:3000/convert/videp/to/mp4 > output.mp4` 75 | * `curl -F "file=@input.tiff" 127.0.0.1:3000/convert/image/to/jpg > output.jpg` 76 | * `curl -F "file=@input.png" 127.0.0.1:3000/convert/image/to/jpg > output.jpg` 77 | 78 | === Extract images 79 | 80 | Extract images from video using the API. 81 | 82 | * `curl -F "file=@input.mov" 127.0.0.1:3000/video/extract/images` 83 | ** Returns JSON that lists image download URLs for each extracted image. 84 | ** Default FPS is 1. 85 | ** Images are in PNG-format. 86 | ** See sample: link:./samples/extracted_images.json[extracted_images.json]. 87 | * `curl 127.0.0.1:3000/video/extract/download/ba0f565c-0001.png` 88 | ** Downloads exracted image and deletes it from server. 89 | * `curl 127.0.0.1:3000/video/extract/download/ba0f565c-0001.png?delete=no` 90 | ** Downloads exracted image but does not deletes it from server. 91 | * `curl -F "file=@input.mov" 127.0.0.1:3000/video/extract/images?compress=zip > images.zip` 92 | ** Returns ZIP package of all extracted images. 93 | * `curl -F "file=@input.mov" 127.0.0.1:3000/video/extract/images?compress=gzip > images.tar.gz` 94 | ** Returns GZIP (tar.gz) package of all extracted images. 95 | * `curl -F "file=@input.mov" 127.0.0.1:3000/video/extract/images?fps=0.5` 96 | ** Sets FPS to extract images. FPS=0.5 is every two seconds, FPS=4 is four images per seconds, etc. 97 | 98 | === Extract audio 99 | 100 | Extract audio track from video using the API. 101 | 102 | * `curl -F "file=@input.mov" 127.0.0.1:3000/video/extract/audio` 103 | ** Returns 1-channel WAV-file of video's audio track. 104 | * `curl -F "file=@input.mov" 127.0.0.1:3000/video/extract/audio?mono=no` 105 | ** Returns WAV-file of video's audio track, with all the channels as in input video. 106 | 107 | === Probe 108 | 109 | Probe audio/video/image files using the API. 110 | 111 | * `curl -F "file=@input.mov" 127.0.0.1:3000/probe` 112 | ** Returns JSON metadata of media file. 113 | ** The same JSON metadata as in ffprobe command: `ffprobe -of json -show_streams -show_format input.mov`. 114 | ** See sample of MOV-video metadata: link:./samples/probe_metadata.json[probe_metadata.json]. 115 | 116 | 117 | == Background 118 | 119 | Originally developed by https://github.com/surebert[Paul Visco]. 120 | 121 | Changes include new functionality, updated Node.js version, Docker image based on Alpine, logging and other major refactoring. 122 | -------------------------------------------------------------------------------- /src/routes/extract.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | const fs = require('fs'); 3 | const ffmpeg = require('fluent-ffmpeg'); 4 | const uniqueFilename = require('unique-filename'); 5 | var archiver = require('archiver'); 6 | 7 | const constants = require('../constants.js'); 8 | const logger = require('../utils/logger.js'); 9 | const utils = require('../utils/utils.js'); 10 | 11 | var router = express.Router(); 12 | 13 | 14 | //routes for /video/extract 15 | //extracts audio from video 16 | //extracts images from vide 17 | router.post('/audio', function (req, res,next) { 18 | 19 | res.locals.extract="audio" 20 | return extract(req,res,next); 21 | }); 22 | 23 | router.post('/images', function (req, res,next) { 24 | 25 | res.locals.extract="images" 26 | return extract(req,res,next); 27 | }); 28 | 29 | router.get('/download/:filename', function (req, res,next) { 30 | //download extracted image 31 | let filename = req.params.filename; 32 | let file = `/tmp/${filename}` 33 | return utils.downloadFile(file,null,req,res,next); 34 | }); 35 | 36 | // extract audio or images from video 37 | function extract(req,res,next) { 38 | let extract = res.locals.extract; 39 | logger.debug(`extract ${extract}`); 40 | 41 | let fps = req.query.fps || 1; 42 | //compress = zip or gzip 43 | let compress = req.query.compress || "none"; 44 | let ffmpegParams ={}; 45 | var format = "png"; 46 | if (extract === "images"){ 47 | format = "png" 48 | ffmpegParams.outputOptions=[ 49 | `-vf fps=${fps}` 50 | ]; 51 | } 52 | if (extract === "audio"){ 53 | format = "wav" 54 | ffmpegParams.outputOptions=[ 55 | '-vn', 56 | `-f ${format}` 57 | ]; 58 | let monoAudio = req.query.mono || "yes"; 59 | if (monoAudio === "yes" || monoAudio === "true") 60 | { 61 | logger.debug("extracting audio, 1 channel only") 62 | ffmpegParams.outputOptions.push('-ac 1') 63 | } 64 | else{ 65 | logger.debug("extracting audio, all channels") 66 | } 67 | } 68 | 69 | ffmpegParams.extension = format; 70 | 71 | let savedFile = res.locals.savedFile; 72 | 73 | var outputFile = uniqueFilename('/tmp/') ; 74 | logger.debug(`outputFile ${outputFile}`); 75 | var uniqueFileNamePrefix = outputFile.replace("/tmp/",""); 76 | logger.debug(`uniqueFileNamePrefix ${uniqueFileNamePrefix}`); 77 | 78 | //ffmpeg processing... 79 | var ffmpegCommand = ffmpeg(savedFile); 80 | ffmpegCommand = ffmpegCommand 81 | .renice(constants.defaultFFMPEGProcessPriority) 82 | .outputOptions(ffmpegParams.outputOptions) 83 | .on('error', function(err) { 84 | logger.error(`${err}`); 85 | utils.deleteFile(savedFile); 86 | res.writeHead(500, {'Connection': 'close'}); 87 | res.end(JSON.stringify({error: `${err}`})); 88 | }) 89 | 90 | //extract audio track from video as wav 91 | if (extract === "audio"){ 92 | let wavFile = `${outputFile}.${format}`; 93 | ffmpegCommand 94 | .on('end', function() { 95 | logger.debug(`ffmpeg process ended`); 96 | 97 | utils.deleteFile(savedFile) 98 | return utils.downloadFile(wavFile,null,req,res,next); 99 | }) 100 | .save(wavFile); 101 | 102 | } 103 | 104 | //extract png images from video 105 | if (extract === "images"){ 106 | ffmpegCommand 107 | .output(`${outputFile}-%04d.png`) 108 | .on('end', function() { 109 | logger.debug(`ffmpeg process ended`); 110 | 111 | utils.deleteFile(savedFile) 112 | 113 | //read extracted files 114 | var files = fs.readdirSync('/tmp/').filter(fn => fn.startsWith(uniqueFileNamePrefix)); 115 | 116 | if (compress === "zip" || compress === "gzip") 117 | { 118 | //do zip or tar&gzip of all images and download file 119 | var archive = null; 120 | var extension = ""; 121 | if (compress === "gzip") { 122 | archive = archiver('tar', { 123 | gzip: true, 124 | zlib: { level: 9 } // Sets the compression level. 125 | }); 126 | extension = "tar.gz"; 127 | } 128 | else { 129 | archive = archiver('zip', { 130 | zlib: { level: 9 } // Sets the compression level. 131 | }); 132 | extension = "zip"; 133 | } 134 | 135 | let compressFileName = `${uniqueFileNamePrefix}.${extension}` 136 | let compressFilePath = `/tmp/${compressFileName}` 137 | logger.debug(`starting ${compress} process ${compressFilePath}`); 138 | var compressFile = fs.createWriteStream(compressFilePath); 139 | 140 | archive.on('error', function(err) { 141 | return next(err); 142 | }); 143 | 144 | // pipe archive data to the output file 145 | archive.pipe(compressFile); 146 | 147 | // add files to archive 148 | for (var i=0; i < files.length; i++) { 149 | var file = `/tmp/${files[i]}`; 150 | archive.file(file, {name: files[i]}); 151 | } 152 | 153 | // listen for all archive data to be written 154 | // 'close' event is fired only when a file descriptor is involved 155 | compressFile.on('close', function() { 156 | logger.debug(`${compressFileName}: ${archive.pointer()} total bytes`); 157 | logger.debug('archiver has been finalized and the output file descriptor has closed.'); 158 | 159 | // delete all images 160 | for (var i=0; i < files.length; i++) { 161 | var file = `/tmp/${files[i]}`; 162 | utils.deleteFile(file); 163 | } 164 | 165 | //return compressed file 166 | return utils.downloadFile(compressFilePath,compressFileName,req,res,next); 167 | 168 | }); 169 | // Wait for streams to complete 170 | archive.finalize(); 171 | 172 | } 173 | else 174 | { 175 | //return JSON list of extracted images 176 | 177 | logger.debug(`output files in /tmp`); 178 | var responseJson = {}; 179 | let externalPort = constants.externalPort || constants.serverPort; 180 | responseJson["totalfiles"] = files.length; 181 | responseJson["description"] = `Extracted image files and URLs to download them. By default, downloading image also deletes the image from server. Note that port ${externalPort} in the URL may not be the same as the real port, especially if server is running on Docker/Kubernetes.`; 182 | var filesArray=[]; 183 | for (var i=0; i < files.length; i++) { 184 | var file = files[i]; 185 | logger.debug("file: " + file); 186 | var fileJson={}; 187 | fileJson["name"] = file; 188 | fileJson[`url`] = `${req.protocol}://${req.hostname}:${externalPort}${req.baseUrl}/download/${file}`; 189 | filesArray.push(fileJson); 190 | } 191 | responseJson["files"] = filesArray; 192 | res.status(200).send(responseJson); 193 | 194 | } 195 | }) 196 | .run(); 197 | 198 | } 199 | 200 | } 201 | 202 | module.exports = router --------------------------------------------------------------------------------