Browse Source

Set bitrate limits for transcoding (fixes #638) (#1135)

* Set bitrate limits for transcoding (fixes #638)

* added optimization script and test, changed stuff

* fix test, improve docs

* re-add optimize-old-videos script

* added documentation

* Don't optimize videos without valid UUID, or redundancy videos

* move getUUIDFromFilename

* fix tests?

* update torrent and file size, some more fixes/improvements

* use higher bitrate for high fps video, adjust bitrates

* add test video

* don't throw error if resolution is undefined

* generate test fixture on the fly

* use random noise video for bitrate test, add promise

* shorten test video to avoid timeout

* use existing function to optimize video

* various fixes

* increase test timeout

* limit test fixture size, add link

* test fixes

* add await

* more test fixes, add -b:v parameter

* replace ffmpeg wiki link

* fix ffmpeg params

* fix unit test

* add test fixture to .gitgnore

* add video transcoding fps model

* add missing file
Felix Ableitner 5 years ago
parent
commit
edb4ffc7e0

+ 1 - 0
.gitignore

@@ -9,6 +9,7 @@
 /test4/
 /test5/
 /test6/
+/server/tests/fixtures/video_high_bitrate_1080p.mp4
 
 # Production
 /storage/

+ 1 - 0
package.json

@@ -51,6 +51,7 @@
     "generate-api-doc": "scripty",
     "parse-log": "node ./dist/scripts/parse-log.js",
     "prune-storage": "node ./dist/scripts/prune-storage.js",
+    "optimize-old-videos": "node ./dist/scripts/optimize-old-videos.js",
     "postinstall": "cd client && yarn install --pure-lockfile",
     "tsc": "tsc",
     "spectacle-docs": "node_modules/spectacle-docs/bin/spectacle.js",

+ 1 - 0
scripts/help.sh

@@ -18,6 +18,7 @@ printf "  reset-password -- -u [user] -> Reset the password of user [user]\n"
 printf "  create-transcoding-job -- -v [video UUID] \n"
 printf "                              -> Create a transcoding job for a particular video\n"
 printf "  prune-storage               -> Delete (after confirmation) unknown video files/thumbnails/previews... (due to a bad video deletion, transcoding job not finished...)\n"
+printf "  optimize-old-videos         -> Re-transcode videos that have a high bitrate, to make them suitable for streaming over slow connections"
 printf "  dev                         -> Watch, run the livereload and run the server so that you can develop the application\n"
 printf "  start                       -> Run the server\n"
 printf "  update-host                 -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n"

+ 36 - 0
scripts/optimize-old-videos.ts

@@ -0,0 +1,36 @@
+import { join } from 'path'
+import { readdir } from 'fs-extra'
+import { CONFIG, VIDEO_TRANSCODING_FPS } from '../server/initializers/constants'
+import { getVideoFileResolution, getVideoFileBitrate, getVideoFileFPS } from '../server/helpers/ffmpeg-utils'
+import { getMaxBitrate } from '../shared/models/videos'
+import { VideoRedundancyModel } from '../server/models/redundancy/video-redundancy'
+import { VideoModel } from '../server/models/video/video'
+import { getUUIDFromFilename } from '../server/helpers/utils'
+import { optimizeVideofile } from '../server/lib/video-transcoding'
+
+run()
+  .then(() => process.exit(0))
+  .catch(err => {
+    console.error(err)
+    process.exit(-1)
+  })
+
+async function run () {
+  const files = await readdir(CONFIG.STORAGE.VIDEOS_DIR)
+  for (const file of files) {
+    const inputPath = join(CONFIG.STORAGE.VIDEOS_DIR, file)
+    const videoBitrate = await getVideoFileBitrate(inputPath)
+    const fps = await getVideoFileFPS(inputPath)
+    const resolution = await getVideoFileResolution(inputPath)
+    const uuid = getUUIDFromFilename(file)
+
+    const isLocalVideo = await VideoRedundancyModel.isLocalByVideoUUIDExists(uuid)
+    const isMaxBitrateExceeded =
+      videoBitrate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)
+    if (uuid && isLocalVideo && isMaxBitrateExceeded) {
+      const videoModel = await VideoModel.loadByUUIDWithFile(uuid)
+      await optimizeVideofile(videoModel, inputPath)
+    }
+  }
+  console.log('Finished optimizing videos')
+}

+ 1 - 9
scripts/prune-storage.ts

@@ -5,6 +5,7 @@ import { VideoModel } from '../server/models/video/video'
 import { initDatabaseModels } from '../server/initializers'
 import { remove, readdir } from 'fs-extra'
 import { VideoRedundancyModel } from '../server/models/redundancy/video-redundancy'
+import { getUUIDFromFilename } from '../server/helpers/utils'
 
 run()
   .then(() => process.exit(0))
@@ -82,15 +83,6 @@ async function pruneDirectory (directory: string, onlyOwned = false) {
   return toDelete
 }
 
-function getUUIDFromFilename (filename: string) {
-  const regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
-  const result = filename.match(regex)
-
-  if (!result || Array.isArray(result) === false) return null
-
-  return result[0]
-}
-
 async function askConfirmation () {
   return new Promise((res, rej) => {
     prompt.start()

+ 19 - 2
server/helpers/ffmpeg-utils.ts

@@ -1,6 +1,6 @@
 import * as ffmpeg from 'fluent-ffmpeg'
 import { join } from 'path'
-import { VideoResolution } from '../../shared/models/videos'
+import { VideoResolution, getTargetBitrate } from '../../shared/models/videos'
 import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers'
 import { processImage } from './image-utils'
 import { logger } from './logger'
@@ -55,6 +55,16 @@ async function getVideoFileFPS (path: string) {
   return 0
 }
 
+async function getVideoFileBitrate (path: string) {
+  return new Promise<number>((res, rej) => {
+    ffmpeg.ffprobe(path, (err, metadata) => {
+      if (err) return rej(err)
+
+      return res(metadata.format.bit_rate)
+    })
+  })
+}
+
 function getDurationFromVideoFile (path: string) {
   return new Promise<number>((res, rej) => {
     ffmpeg.ffprobe(path, (err, metadata) => {
@@ -138,6 +148,12 @@ function transcode (options: TranscodeOptions) {
       command = command.withFPS(fps)
     }
 
+    // Constrained Encoding (VBV)
+    // https://slhck.info/video/2017/03/01/rate-control.html
+    // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
+    const targetBitrate = getTargetBitrate(options.resolution, fps, VIDEO_TRANSCODING_FPS)
+    command.outputOptions([`-b:v ${ targetBitrate }`, `-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`])
+
     command
       .on('error', (err, stdout, stderr) => {
         logger.error('Error in transcoding job.', { stdout, stderr })
@@ -157,7 +173,8 @@ export {
   transcode,
   getVideoFileFPS,
   computeResolutionsToTranscode,
-  audio
+  audio,
+  getVideoFileBitrate
 }
 
 // ---------------------------------------------------------------------------

+ 16 - 1
server/helpers/utils.ts

@@ -77,6 +77,20 @@ async function getVersion () {
   return require('../../../package.json').version
 }
 
+/**
+ * From a filename like "ede4cba5-742b-46fa-a388-9a6eb3a3aeb3.mp4", returns
+ * only the "ede4cba5-742b-46fa-a388-9a6eb3a3aeb3" part. If the filename does
+ * not contain a UUID, returns null.
+ */
+function getUUIDFromFilename (filename: string) {
+  const regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
+  const result = filename.match(regex)
+
+  if (!result || Array.isArray(result) === false) return null
+
+  return result[0]
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -86,5 +100,6 @@ export {
   getSecureTorrentName,
   getServerActor,
   getVersion,
-  generateVideoTmpPath
+  generateVideoTmpPath,
+  getUUIDFromFilename
 }

+ 2 - 2
server/initializers/constants.ts

@@ -3,7 +3,7 @@ import { dirname, join } from 'path'
 import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models'
 import { ActivityPubActorType } from '../../shared/models/activitypub'
 import { FollowState } from '../../shared/models/actors'
-import { VideoAbuseState, VideoImportState, VideoPrivacy } from '../../shared/models/videos'
+import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos'
 // Do not use barrels, remain constants as independent as possible
 import { buildPath, isTestInstance, parseDuration, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
 import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
@@ -393,7 +393,7 @@ const RATES_LIMIT = {
 }
 
 let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour
-const VIDEO_TRANSCODING_FPS = {
+const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
   MIN: 10,
   AVERAGE: 30,
   MAX: 60,

+ 1 - 1
server/lib/activitypub/crawl.ts

@@ -1,7 +1,7 @@
 import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers'
 import { doRequest } from '../../helpers/requests'
 import { logger } from '../../helpers/logger'
-import Bluebird = require('bluebird')
+import * as Bluebird from 'bluebird'
 
 async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) {
   logger.info('Crawling ActivityPub data on %s.', uri)

+ 2 - 2
server/lib/job-queue/handlers/video-file.ts

@@ -8,7 +8,7 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { sequelizeTypescript } from '../../../initializers'
 import * as Bluebird from 'bluebird'
 import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
-import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding'
+import { importVideoFile, transcodeOriginalVideofile, optimizeVideofile } from '../../video-transcoding'
 
 export type VideoFilePayload = {
   videoUUID: string
@@ -56,7 +56,7 @@ async function processVideoFile (job: Bull.Job) {
 
     await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video)
   } else {
-    await optimizeOriginalVideofile(video)
+    await optimizeVideofile(video)
 
     await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo)
   }

+ 10 - 5
server/lib/video-transcoding.ts

@@ -1,5 +1,5 @@
 import { CONFIG } from '../initializers'
-import { join, extname } from 'path'
+import { join, extname, basename } from 'path'
 import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
 import { copy, remove, rename, stat } from 'fs-extra'
 import { logger } from '../helpers/logger'
@@ -7,11 +7,16 @@ import { VideoResolution } from '../../shared/models/videos'
 import { VideoFileModel } from '../models/video/video-file'
 import { VideoModel } from '../models/video/video'
 
-async function optimizeOriginalVideofile (video: VideoModel) {
+async function optimizeVideofile (video: VideoModel, videoInputPath?: string) {
   const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
   const newExtname = '.mp4'
-  const inputVideoFile = video.getOriginalFile()
-  const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
+  let inputVideoFile = null
+  if (videoInputPath == null) {
+    inputVideoFile = video.getOriginalFile()
+    videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
+  } else {
+    inputVideoFile = basename(videoInputPath)
+  }
   const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname)
 
   const transcodeOptions = {
@@ -124,7 +129,7 @@ async function importVideoFile (video: VideoModel, inputFilePath: string) {
 }
 
 export {
-  optimizeOriginalVideofile,
+  optimizeVideofile,
   transcodeOriginalVideofile,
   importVideoFile
 }

+ 58 - 4
server/tests/api/videos/video-transcoder.ts

@@ -4,8 +4,8 @@ import * as chai from 'chai'
 import 'mocha'
 import { omit } from 'lodash'
 import * as ffmpeg from 'fluent-ffmpeg'
-import { VideoDetails, VideoState } from '../../../../shared/models/videos'
-import { getVideoFileFPS, audio } from '../../../helpers/ffmpeg-utils'
+import { VideoDetails, VideoState, getMaxBitrate, VideoResolution } from '../../../../shared/models/videos'
+import { getVideoFileFPS, audio, getVideoFileBitrate, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
 import {
   buildAbsoluteFixturePath,
   doubleFollow,
@@ -20,8 +20,10 @@ import {
   uploadVideo,
   webtorrentAdd
 } from '../../utils'
-import { join } from 'path'
+import { join, basename } from 'path'
 import { waitJobs } from '../../utils/server/jobs'
+import { remove } from 'fs-extra'
+import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants'
 
 const expect = chai.expect
 
@@ -228,7 +230,7 @@ describe('Test video transcoding', function () {
     }
   })
 
-  it('Should wait transcoding before publishing the video', async function () {
+  it('Should wait for transcoding before publishing the video', async function () {
     this.timeout(80000)
 
     {
@@ -281,7 +283,59 @@ describe('Test video transcoding', function () {
     }
   })
 
+  const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4')
+  it('Should respect maximum bitrate values', async function () {
+    this.timeout(160000)
+
+    {
+      // Generate a random, high bitrate video on the fly, so we don't have to include
+      // a large file in the repo. The video needs to have a certain minimum length so
+      // that FFmpeg properly applies bitrate limits.
+      // https://stackoverflow.com/a/15795112
+      await new Promise<void>(async (res, rej) => {
+        ffmpeg()
+          .outputOptions(['-f rawvideo', '-video_size 1920x1080', '-i /dev/urandom'])
+          .outputOptions(['-ac 2', '-f s16le', '-i /dev/urandom', '-t 10'])
+          .outputOptions(['-maxrate 10M', '-bufsize 10M'])
+          .output(tempFixturePath)
+          .on('error', rej)
+          .on('end', res)
+          .run()
+      })
+
+      const bitrate = await getVideoFileBitrate(tempFixturePath)
+      expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 60, VIDEO_TRANSCODING_FPS))
+
+      const videoAttributes = {
+        name: 'high bitrate video',
+        description: 'high bitrate video',
+        fixture: basename(tempFixturePath)
+      }
+
+      await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes)
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const res = await getVideosList(server.url)
+
+        const video = res.body.data.find(v => v.name === videoAttributes.name)
+
+        for (const resolution of ['240', '360', '480', '720', '1080']) {
+          const path = join(root(), 'test2', 'videos', video.uuid + '-' + resolution + '.mp4')
+          const bitrate = await getVideoFileBitrate(path)
+          const fps = await getVideoFileFPS(path)
+          const resolution2 = await getVideoFileResolution(path)
+
+          expect(resolution2.videoFileResolution.toString()).to.equal(resolution)
+          expect(bitrate).to.be.below(getMaxBitrate(resolution2.videoFileResolution, fps, VIDEO_TRANSCODING_FPS))
+        }
+      }
+    }
+  })
+
   after(async function () {
+    remove(tempFixturePath)
     killallServers(servers)
   })
 })

+ 1 - 0
shared/models/videos/index.ts

@@ -21,6 +21,7 @@ export * from './video-update.model'
 export * from './video.model'
 export * from './video-query.type'
 export * from './video-state.enum'
+export * from './video-transcoding-fps.model'
 export * from './caption/video-caption.model'
 export * from './caption/video-caption-update.model'
 export * from './import/video-import-create.model'

+ 55 - 0
shared/models/videos/video-resolution.enum.ts

@@ -1,3 +1,5 @@
+import { VideoTranscodingFPS } from './video-transcoding-fps.model'
+
 export enum VideoResolution {
   H_240P = 240,
   H_360P = 360,
@@ -5,3 +7,56 @@ export enum VideoResolution {
   H_720P = 720,
   H_1080P = 1080
 }
+
+/**
+ * Bitrate targets for different resolutions and frame rates, in bytes per second.
+ * Sources for individual quality levels:
+ * Google Live Encoder: https://support.google.com/youtube/answer/2853702?hl=en
+ * YouTube Video Info (tested with random music video): https://www.h3xed.com/blogmedia/youtube-info.php
+ */
+export function getTargetBitrate (resolution: VideoResolution, fps: number,
+    fpsTranscodingConstants: VideoTranscodingFPS) {
+  switch (resolution) {
+  case VideoResolution.H_240P:
+    // quality according to Google Live Encoder: 300 - 700 Kbps
+    // Quality according to YouTube Video Info: 186 Kbps
+    return 250 * 1000
+  case VideoResolution.H_360P:
+    // quality according to Google Live Encoder: 400 - 1,000 Kbps
+    // Quality according to YouTube Video Info: 480 Kbps
+    return 500 * 1000
+  case VideoResolution.H_480P:
+    // quality according to Google Live Encoder: 500 - 2,000 Kbps
+    // Quality according to YouTube Video Info: 879 Kbps
+    return 900 * 1000
+  case VideoResolution.H_720P:
+    if (fps === fpsTranscodingConstants.MAX) {
+      // quality according to Google Live Encoder: 2,250 - 6,000 Kbps
+      // Quality according to YouTube Video Info: 2634 Kbps
+      return 2600 * 1000
+    } else {
+      // quality according to Google Live Encoder: 1,500 - 4,000 Kbps
+      // Quality according to YouTube Video Info: 1752 Kbps
+      return 1750 * 1000
+    }
+  case VideoResolution.H_1080P: // fallthrough
+  default:
+    if (fps === fpsTranscodingConstants.MAX) {
+      // quality according to Google Live Encoder: 3000 - 6000 Kbps
+      // Quality according to YouTube Video Info: 4387 Kbps
+      return 4400 * 1000
+    } else {
+      // quality according to Google Live Encoder: 3000 - 6000 Kbps
+      // Quality according to YouTube Video Info: 3277 Kbps
+      return 3300 * 1000
+    }
+  }
+}
+
+/**
+ * The maximum bitrate we expect to see on a transcoded video in bytes per second.
+ */
+export function getMaxBitrate (resolution: VideoResolution, fps: number,
+    fpsTranscodingConstants: VideoTranscodingFPS) {
+  return getTargetBitrate(resolution, fps, fpsTranscodingConstants) * 2
+}

+ 6 - 0
shared/models/videos/video-transcoding-fps.model.ts

@@ -0,0 +1,6 @@
+export type VideoTranscodingFPS = {
+  MIN: number,
+  AVERAGE: number,
+  MAX: number,
+  KEEP_ORIGIN_FPS_RESOLUTION_MIN: number
+}

+ 11 - 0
support/doc/tools.md

@@ -187,6 +187,17 @@ To delete them (a confirmation will be demanded first):
 $ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run prune-storage
 ```
 
+### optimize-old-videos.js
+
+Before version v1.0.0-beta.16, Peertube did not specify a bitrate for the transcoding of uploaded videos.
+This means that videos might be encoded into very large files that are too large for streaming. This script
+re-transcodes these videos so that they can be watched properly, even on slow connections.
+
+```
+$ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run optimize-old-videos
+```
+
+
 ### update-host.js
 
 If you started PeerTube with a domain, and then changed it you will have invalid torrent files and invalid URLs in your database.