From 6a342ae159aee04158d2107d31b22d77aeee0fab Mon Sep 17 00:00:00 2001 From: Yury Shevchenko Date: Tue, 17 Aug 2021 13:16:20 -0700 Subject: [PATCH] Video processing initial commit --- .github/workflows/checks.yml | 76 +++++++++++------------ Dockerfile | 3 +- README.md | 7 ++- app/models/attachment.js | 113 ++++++++++++++++++++++++++++++++++- package.json | 1 + yarn.lock | 20 ++++++- 6 files changed, 177 insertions(+), 43 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 97d93225c..2e224bbc3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -4,15 +4,14 @@ on: [push] jobs: build: - runs-on: ubuntu-latest strategy: fail-fast: false matrix: node-version: [12.x, 14.x, 15.x] - postgres-version: ["10.15", "11.10", "12.5", "13.1"] - redis-version: ["6"] + postgres-version: ['10.15', '11.10', '12.5', '13.1'] + redis-version: ['6'] services: db: @@ -25,45 +24,48 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - name: Start Redis v${{ matrix.redis-version }} - uses: supercharge/redis-github-action@1.1.0 - with: - redis-version: ${{ matrix.redis-version }} + - name: Start Redis v${{ matrix.redis-version }} + uses: supercharge/redis-github-action@1.1.0 + with: + redis-version: ${{ matrix.redis-version }} + + - name: install GraphicsMagick + run: sudo apt-get install graphicsmagick + + - name: install ffmpeg + run: sudo apt-get install ffmpeg - - name: install GraphicsMagick - run: sudo apt-get install graphicsmagick + - uses: actions/checkout@v2 - - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - - uses: actions/cache@v2 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: create directories for attachments - run: | - mkdir -p /tmp/pepyatka-media/attachments - mkdir /tmp/pepyatka-media/attachments/thumbnails - mkdir /tmp/pepyatka-media/attachments/thumbnails2 - mkdir /tmp/pepyatka-media/attachments/anotherTestSize + - name: create directories for attachments + run: | + mkdir -p /tmp/pepyatka-media/attachments + mkdir /tmp/pepyatka-media/attachments/thumbnails + mkdir /tmp/pepyatka-media/attachments/thumbnails2 + mkdir /tmp/pepyatka-media/attachments/anotherTestSize - - name: Install dependencies - run: yarn + - name: Install dependencies + run: yarn - - name: run lint - run: yarn lint + - name: run lint + run: yarn lint - - name: run tests - run: yarn test + - name: run tests + run: yarn test diff --git a/Dockerfile b/Dockerfile index efc4c1d74..f604e58bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM node:14-buster RUN apt-get update && \ apt-get install -y \ graphicsmagick \ + ffmpeg \ g++ \ git \ make @@ -18,4 +19,4 @@ RUN rm -rf node_modules && \ ENV NODE_ENV production -CMD ["yarn","start"] \ No newline at end of file +CMD ["yarn","start"] diff --git a/README.md b/README.md index 820aaaa7b..9778215a2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ FreeFeed is based on [Pepyatka](https://github.com/pepyatka/pepyatka-server/) pr brew install redis redis-server /usr/local/etc/redis.conf brew install graphicsmagick +brew install ffmpeg brew install postgres brew services start postgresql createuser -P -s freefeed (enter freefeed as password) @@ -27,6 +28,7 @@ createdb -O freefeed freefeed ### Or setup dependencies via Docker 1. `brew install graphicsmagick` +1. `brew install ffmpeg` 1. [Install and run Docker](https://www.docker.com/get-started) 1. docker-compose up -d @@ -40,7 +42,9 @@ yarn install yarn knex --env production migrate:latest mkdir ./public/files/attachments/thumbnails/ && mkdir ./public/files/attachments/thumbnails2/ ``` -Now create config `config/local.json` with some random secret string: `{ "secret": "myverysecretstring" }` + +Now create config `config/local.json` with some random secret string: `{ "secret": "myverysecretstring" }` + ``` yarn start ``` @@ -90,4 +94,5 @@ You can drop your question [here](https://freefeed.net/support). FreeFeed is licensed under the MIT License. ## License + [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FFreeFeed%2Ffreefeed-server.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FFreeFeed%2Ffreefeed-server?ref=badge_large) diff --git a/app/models/attachment.js b/app/models/attachment.js index b46672dba..443a23804 100644 --- a/app/models/attachment.js +++ b/app/models/attachment.js @@ -1,12 +1,13 @@ import { promises as fs, createReadStream } from 'fs'; import childProcess from 'child_process'; -import { parse as parsePath } from 'path'; +import { basename, dirname, parse as parsePath } from 'path'; import util from 'util'; import config from 'config'; import createDebug from 'debug'; import gm from 'gm'; import { parseFile } from 'music-metadata'; +import fluentFfmpeg from 'fluent-ffmpeg'; import { fromFile } from 'file-type'; import mime from 'mime-types'; import mmm from 'mmmagic'; @@ -266,6 +267,10 @@ export function addModel(dbAdapter) { 'audio/ogg': 'ogg', 'audio/x-wav': 'wav', }; + const supportedVideoTypes = { + 'video/mp4': 'mp4', + 'video/quicktime': 'mp4', + }; this.mimeType = await mimeTypeDetect(tmpAttachmentFileName, tmpAttachmentFile); debug(`Mime-type of ${tmpAttachmentFileName} is ${this.mimeType}`); @@ -298,14 +303,22 @@ export function addModel(dbAdapter) { } else { this.artist = metadata.artist; } + } else if (supportedVideoTypes[this.mimeType]) { + this.mediaType = 'video'; + this.fileExtension = supportedVideoTypes[this.mimeType]; + this.mimeType = 'video/mp4'; + this.noThumbnail = '1'; + this.discardOriginal = true; + await this.handleVideo(tmpAttachmentFile); } else { // Set media properties for 'general' type this.mediaType = 'general'; this.noThumbnail = '1'; } - // Store an original attachment - if (this.s3) { + if (this.discardOriginal) { + await fs.unlink(tmpAttachmentFile); + } else if (this.s3) { await this.uploadToS3( tmpAttachmentFile, config.attachments.path + this.getFilename(), @@ -456,6 +469,100 @@ export function addModel(dbAdapter) { } } + /** + * @param {string} originalFile + */ + async handleVideo(originalFile) { + const thumbnailTmpPath = `${this.file.path}.thumbnail.png`; + const thumbnailTmpFilename = basename(thumbnailTmpPath); + const thumbnailTmpDirname = dirname(thumbnailTmpPath); + const encodedVideoTmpPath = `${this.file.path}.encoded.mp4`; + + this.imageSizes.o = { + url: config.attachments.url + config.attachments.path + this.getFilename(), + }; + + this.fileName = this.getFilename(); + + // encode video and generate a thumbnail + await (() => { + return new Promise((resolve, reject) => { + const ffmpeg = new fluentFfmpeg(); + ffmpeg + .input(originalFile) + .on('codecData', (data) => { + const [h, w] = data.video_details[3].split('x'); + this.imageSizes.o.w = parseInt(w, 10); + this.imageSizes.o.h = parseInt(h, 10); + }) + .audioCodec('aac') + .videoCodec('libx264') + .format('mp4') + .output(encodedVideoTmpPath) + .thumbnail( + { + count: 1, + timemarks: ['00.1'], + size: `?x${config.attachments.imageSizes.t.bounds.height}`, + filename: thumbnailTmpFilename, + }, + thumbnailTmpDirname, + ) + .on('end', resolve) + .on('error', reject) + .run(); + }); + })(); + + // use encoded file size, not the original file size + const resultFileSize = await fs.stat(encodedVideoTmpPath); + this.fileSize = resultFileSize.size; + + this.noThumbnail = '0'; + this.imageSizes.t = { + w: Math.round( + (this.imageSizes.o.w * config.attachments.imageSizes.t.bounds.height) / + this.imageSizes.o.h, + ), + h: config.attachments.imageSizes.t.bounds.height, + url: + config.attachments.url + + config.attachments.imageSizes['t'].path + + this.getFilename('png'), + }; + + const files = [ + { + tmp: thumbnailTmpPath, + mime: 'image/png', + path: config.attachments.storage.rootDir + config.attachments.imageSizes['t'].path, + filename: this.getFilename('png'), + }, + { + tmp: encodedVideoTmpPath, + mime: 'video/mp4', + path: config.attachments.storage.rootDir + config.attachments.path, + filename: this.getFilename(), + }, + ]; + + // Save files (permanently) + if (this.s3) { + await Promise.all( + files.map(async (file) => { + await this.uploadToS3(file.tmp, file.path + file.filename, file.mime); + await fs.unlink(file.tmp); + }), + ); + } else { + await Promise.all( + files.map(async (file) => { + await mvAsync(file.tmp, file.path + file.filename, {}); + }), + ); + } + } + // Upload original attachment or its thumbnail to the S3 bucket async uploadToS3(sourceFile, destPath, mimeType) { const dispositionName = parsePath(this.fileName).name + parsePath(destPath).ext; diff --git a/package.json b/package.json index a23ae8003..d7d84b9d8 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "ejs": "~3.1.6", "ff-url-finder": "~2.3.7", "file-type": "~16.5.2", + "fluent-ffmpeg": "^2.1.2", "gifsicle": "~4.0.1", "gm": "~1.23.1", "grapheme-breaker": "0.3.2", diff --git a/yarn.lock b/yarn.lock index 90efe9599..199362704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1750,6 +1750,13 @@ __metadata: languageName: node linkType: hard +"async@npm:>=0.2.9": + version: 3.2.1 + resolution: "async@npm:3.2.1" + checksum: 2ec69da2266d9418eefbf828a4b238f62cf07a5d5ba2d7fb91bc74aa588e38bbf7aef03df6d267dd38999802e97061768e5f58c8a38c62d71eb5c1a0b2957ef4 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -4527,6 +4534,16 @@ __metadata: languageName: node linkType: hard +"fluent-ffmpeg@npm:^2.1.2": + version: 2.1.2 + resolution: "fluent-ffmpeg@npm:2.1.2" + dependencies: + async: ">=0.2.9" + which: ^1.1.1 + checksum: c86a0e4f9fbb47a03c73eee9fe90e4ae85f6b728e64ad35091f2ac526d071f94e116684209baaef63e2b6d5ccfe322539c9bbecd1098f230dc289bda367ca250 + languageName: node + linkType: hard + "for-in@npm:^1.0.1, for-in@npm:^1.0.2": version: 1.0.2 resolution: "for-in@npm:1.0.2" @@ -4658,6 +4675,7 @@ __metadata: eslint-plugin-you-dont-need-lodash-underscore: ~6.12.0 ff-url-finder: ~2.3.7 file-type: ~16.5.2 + fluent-ffmpeg: ^2.1.2 form-data: ~3.0.1 gifsicle: ~4.0.1 gm: ~1.23.1 @@ -11201,7 +11219,7 @@ typescript@~4.3.5: languageName: node linkType: hard -"which@npm:^1.2.14, which@npm:^1.2.9": +"which@npm:^1.1.1, which@npm:^1.2.14, which@npm:^1.2.9": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: