From 679c12e69c9f3a2d003ee3abe8b8da49f25b2bd3 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 6 Aug 2021 13:35:25 +0200 Subject: [PATCH] Improve target bitrate calculation --- scripts/create-transcoding-job.ts | 4 +- scripts/optimize-old-videos.ts | 7 +- server/controllers/api/videos/upload.ts | 2 +- server/helpers/ffmpeg-utils.ts | 35 +++++--- server/helpers/ffprobe-utils.ts | 16 ++-- .../migrations/0075-video-resolutions.ts | 18 ++-- .../job-queue/handlers/video-file-import.ts | 10 +-- server/lib/job-queue/handlers/video-import.ts | 6 +- .../job-queue/handlers/video-live-ending.ts | 4 +- .../job-queue/handlers/video-transcoding.ts | 4 +- server/lib/live/live-manager.ts | 13 +-- server/lib/live/shared/muxing-session.ts | 9 +- .../transcoding/video-transcoding-profiles.ts | 32 +++---- server/tests/api/live/live.ts | 2 +- server/tests/api/videos/video-transcoder.ts | 30 ++----- server/tests/cli/optimize-old-videos.ts | 20 ++--- server/tests/cli/print-transcode-command.ts | 10 +-- server/tests/helpers/core-utils.ts | 38 ++++++++ shared/core-utils/index.ts | 1 + shared/core-utils/videos/bitrate.ts | 86 ++++++++++++++++++ shared/core-utils/videos/index.ts | 1 + shared/extra-utils/miscs/generate.ts | 14 +++ shared/models/videos/video-resolution.enum.ts | 89 ------------------- .../models/videos/video-transcoding.model.ts | 16 +++- 24 files changed, 263 insertions(+), 204 deletions(-) create mode 100644 shared/core-utils/videos/bitrate.ts create mode 100644 shared/core-utils/videos/index.ts diff --git a/scripts/create-transcoding-job.ts b/scripts/create-transcoding-job.ts index 65e65b616..3a552c19a 100755 --- a/scripts/create-transcoding-job.ts +++ b/scripts/create-transcoding-job.ts @@ -47,13 +47,13 @@ async function run () { if (!video) throw new Error('Video not found.') const dataInput: VideoTranscodingPayload[] = [] - const { videoFileResolution } = await video.getMaxQualityResolution() + const { resolution } = await video.getMaxQualityResolution() // Generate HLS files if (options.generateHls || CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) { const resolutionsEnabled = options.resolution ? [ options.resolution ] - : computeResolutionsToTranscode(videoFileResolution, 'vod').concat([ videoFileResolution ]) + : computeResolutionsToTranscode(resolution, 'vod').concat([ resolution ]) for (const resolution of resolutionsEnabled) { dataInput.push({ diff --git a/scripts/optimize-old-videos.ts b/scripts/optimize-old-videos.ts index bde9d1e01..9e66105dd 100644 --- a/scripts/optimize-old-videos.ts +++ b/scripts/optimize-old-videos.ts @@ -1,9 +1,7 @@ import { registerTSPaths } from '../server/helpers/register-ts-paths' registerTSPaths() -import { VIDEO_TRANSCODING_FPS } from '../server/initializers/constants' import { getDurationFromVideoFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../server/helpers/ffprobe-utils' -import { getMaxBitrate } from '../shared/models/videos' import { VideoModel } from '../server/models/video/video' import { optimizeOriginalVideofile } from '../server/lib/transcoding/video-transcoding' import { initDatabaseModels } from '../server/initializers/database' @@ -11,6 +9,7 @@ import { basename, dirname } from 'path' import { copy, move, remove } from 'fs-extra' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { getVideoFilePath } from '@server/lib/video-paths' +import { getMaxBitrate } from '@shared/core-utils' run() .then(() => process.exit(0)) @@ -42,13 +41,13 @@ async function run () { for (const file of video.VideoFiles) { currentFilePath = getVideoFilePath(video, file) - const [ videoBitrate, fps, resolution ] = await Promise.all([ + const [ videoBitrate, fps, dataResolution ] = await Promise.all([ getVideoFileBitrate(currentFilePath), getVideoFileFPS(currentFilePath), getVideoFileResolution(currentFilePath) ]) - const maxBitrate = getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS) + const maxBitrate = getMaxBitrate({ ...dataResolution, fps }) const isMaxBitrateExceeded = videoBitrate > maxBitrate if (isMaxBitrateExceeded) { console.log( diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts index 408f677ff..89f50714d 100644 --- a/server/controllers/api/videos/upload.ts +++ b/server/controllers/api/videos/upload.ts @@ -239,7 +239,7 @@ async function buildNewFile (video: MVideo, videoPhysicalFile: express.VideoUplo videoFile.resolution = DEFAULT_AUDIO_RESOLUTION } else { videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path) - videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution + videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).resolution } videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname) diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 7f84a049f..830625cc6 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -3,10 +3,18 @@ import * as ffmpeg from 'fluent-ffmpeg' import { readFile, remove, writeFile } from 'fs-extra' import { dirname, join } from 'path' import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants' -import { AvailableEncoders, EncoderOptions, EncoderOptionsBuilder, EncoderProfile, VideoResolution } from '../../shared/models/videos' +import { pick } from '@shared/core-utils' +import { + AvailableEncoders, + EncoderOptions, + EncoderOptionsBuilder, + EncoderOptionsBuilderParams, + EncoderProfile, + VideoResolution +} from '../../shared/models/videos' import { CONFIG } from '../initializers/config' import { execPromise, promisify0 } from './core-utils' -import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS } from './ffprobe-utils' +import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils' import { processImage } from './image-utils' import { logger } from './logger' @@ -217,13 +225,16 @@ async function getLiveTranscodingCommand (options: { masterPlaylistName: string resolutions: number[] + + // Input information fps: number bitrate: number + ratio: number availableEncoders: AvailableEncoders profile: string }) { - const { rtmpUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName } = options + const { rtmpUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options const input = rtmpUrl const command = getFFmpeg(input, 'live') @@ -253,9 +264,12 @@ async function getLiveTranscodingCommand (options: { availableEncoders, profile, - fps: resolutionFPS, inputBitrate: bitrate, + inputRatio: ratio, + resolution, + fps: resolutionFPS, + streamNum: i, videoType: 'live' as 'live' } @@ -502,7 +516,7 @@ function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptio // Run encoder builder depending on available encoders // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one // If the default one does not exist, check the next encoder -async function getEncoderBuilderResult (options: { +async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & { streamType: 'video' | 'audio' input: string @@ -510,13 +524,8 @@ async function getEncoderBuilderResult (options: { profile: string videoType: 'vod' | 'live' - - resolution: number - inputBitrate: number - fps?: number - streamNum?: number }) { - const { availableEncoders, input, profile, resolution, streamType, fps, inputBitrate, streamNum, videoType } = options + const { availableEncoders, profile, streamType, videoType } = options const encodersToTry = availableEncoders.encodersToTry[videoType][streamType] const encoders = availableEncoders.available[videoType] @@ -546,7 +555,7 @@ async function getEncoderBuilderResult (options: { } } - const result = await builder({ input, resolution, inputBitrate, fps, streamNum }) + const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ])) return { result, @@ -581,6 +590,7 @@ async function presetVideo (options: { // Audio encoder const parsedAudio = await getAudioStream(input, probe) const bitrate = await getVideoFileBitrate(input, probe) + const { ratio } = await getVideoFileResolution(input, probe) let streamsToProcess: StreamType[] = [ 'audio', 'video' ] @@ -600,6 +610,7 @@ async function presetVideo (options: { profile, fps, inputBitrate: bitrate, + inputRatio: ratio, videoType: 'vod' as 'vod' }) diff --git a/server/helpers/ffprobe-utils.ts b/server/helpers/ffprobe-utils.ts index bc87e49b1..e58444b07 100644 --- a/server/helpers/ffprobe-utils.ts +++ b/server/helpers/ffprobe-utils.ts @@ -1,5 +1,6 @@ import * as ffmpeg from 'fluent-ffmpeg' -import { getMaxBitrate, VideoFileMetadata, VideoResolution } from '../../shared/models/videos' +import { getMaxBitrate } from '@shared/core-utils' +import { VideoFileMetadata, VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos' import { CONFIG } from '../initializers/config' import { VIDEO_TRANSCODING_FPS } from '../initializers/constants' import { logger } from './logger' @@ -75,7 +76,7 @@ function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) { } } -async function getVideoStreamSize (path: string, existingProbe?: ffmpeg.FfprobeData) { +async function getVideoStreamSize (path: string, existingProbe?: ffmpeg.FfprobeData): Promise<{ width: number, height: number }> { const videoStream = await getVideoStreamFromFile(path, existingProbe) return videoStream === null @@ -146,7 +147,10 @@ async function getVideoFileResolution (path: string, existingProbe?: ffmpeg.Ffpr const size = await getVideoStreamSize(path, existingProbe) return { - videoFileResolution: Math.min(size.height, size.width), + width: size.width, + height: size.height, + ratio: Math.max(size.height, size.width) / Math.min(size.height, size.width), + resolution: Math.min(size.height, size.width), isPortraitMode: size.height > size.width } } @@ -243,7 +247,7 @@ async function canDoQuickVideoTranscode (path: string, probe?: ffmpeg.FfprobeDat const videoStream = await getVideoStreamFromFile(path, probe) const fps = await getVideoFileFPS(path, probe) const bitRate = await getVideoFileBitrate(path, probe) - const resolution = await getVideoFileResolution(path, probe) + const resolutionData = await getVideoFileResolution(path, probe) // If ffprobe did not manage to guess the bitrate if (!bitRate) return false @@ -253,7 +257,7 @@ async function canDoQuickVideoTranscode (path: string, probe?: ffmpeg.FfprobeDat if (videoStream['codec_name'] !== 'h264') return false if (videoStream['pix_fmt'] !== 'yuv420p') return false if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false - if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false + if (bitRate > getMaxBitrate({ ...resolutionData, fps })) return false return true } @@ -278,7 +282,7 @@ async function canDoQuickAudioTranscode (path: string, probe?: ffmpeg.FfprobeDat return true } -function getClosestFramerateStandard (fps: number, type: 'HD_STANDARD' | 'STANDARD'): number { +function getClosestFramerateStandard > (fps: number, type: K) { return VIDEO_TRANSCODING_FPS[type].slice(0) .sort((a, b) => fps % a - fps % b)[0] } diff --git a/server/initializers/migrations/0075-video-resolutions.ts b/server/initializers/migrations/0075-video-resolutions.ts index 496125adb..6e8e47acb 100644 --- a/server/initializers/migrations/0075-video-resolutions.ts +++ b/server/initializers/migrations/0075-video-resolutions.ts @@ -27,17 +27,15 @@ function up (utils: { const ext = matches[2] const p = getVideoFileResolution(join(videoFileDir, videoFile)) - .then(height => { + .then(async ({ resolution }) => { const oldTorrentName = uuid + '.torrent' - const newTorrentName = uuid + '-' + height + '.torrent' - return rename(join(torrentDir, oldTorrentName), join(torrentDir, newTorrentName)).then(() => height) - }) - .then(height => { - const newVideoFileName = uuid + '-' + height + '.' + ext - return rename(join(videoFileDir, videoFile), join(videoFileDir, newVideoFileName)).then(() => height) - }) - .then(height => { - const query = 'UPDATE "VideoFiles" SET "resolution" = ' + height + + const newTorrentName = uuid + '-' + resolution + '.torrent' + await rename(join(torrentDir, oldTorrentName), join(torrentDir, newTorrentName)).then(() => resolution) + + const newVideoFileName = uuid + '-' + resolution + '.' + ext + await rename(join(videoFileDir, videoFile), join(videoFileDir, newVideoFileName)).then(() => resolution) + + const query = 'UPDATE "VideoFiles" SET "resolution" = ' + resolution + ' WHERE "videoId" = (SELECT "id" FROM "Videos" WHERE "uuid" = \'' + uuid + '\')' return utils.sequelize.query(query) }) diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts index 4d199f247..2f4abf730 100644 --- a/server/lib/job-queue/handlers/video-file-import.ts +++ b/server/lib/job-queue/handlers/video-file-import.ts @@ -32,7 +32,7 @@ async function processVideoFileImport (job: Bull.Job) { const newResolutionPayload = { type: 'new-resolution-to-webtorrent' as 'new-resolution-to-webtorrent', videoUUID: video.uuid, - resolution: data.videoFileResolution, + resolution: data.resolution, isPortraitMode: data.isPortraitMode, copyCodecs: false, isNewVideo: false @@ -51,13 +51,13 @@ export { // --------------------------------------------------------------------------- async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { - const { videoFileResolution } = await getVideoFileResolution(inputFilePath) + const { resolution } = await getVideoFileResolution(inputFilePath) const { size } = await stat(inputFilePath) const fps = await getVideoFileFPS(inputFilePath) const fileExt = getLowercaseExtension(inputFilePath) - const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === videoFileResolution) + const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === resolution) if (currentVideoFile) { // Remove old file and old torrent @@ -69,9 +69,9 @@ async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { } const newVideoFile = new VideoFileModel({ - resolution: videoFileResolution, + resolution, extname: fileExt, - filename: generateWebTorrentVideoFilename(videoFileResolution, fileExt), + filename: generateWebTorrentVideoFilename(resolution, fileExt), size, fps, videoId: video.id diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 5fd2039b1..fec553f2b 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -114,7 +114,7 @@ async function processFile (downloader: () => Promise, videoImport: MVid throw new Error('The user video quota is exceeded with this video to import.') } - const { videoFileResolution } = await getVideoFileResolution(tempVideoPath) + const { resolution } = await getVideoFileResolution(tempVideoPath) const fps = await getVideoFileFPS(tempVideoPath) const duration = await getDurationFromVideoFile(tempVideoPath) @@ -122,9 +122,9 @@ async function processFile (downloader: () => Promise, videoImport: MVid const fileExt = getLowercaseExtension(tempVideoPath) const videoFileData = { extname: fileExt, - resolution: videoFileResolution, + resolution, size: stats.size, - filename: generateWebTorrentVideoFilename(videoFileResolution, fileExt), + filename: generateWebTorrentVideoFilename(resolution, fileExt), fps, videoId: videoImport.videoId } diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 386ccdc7b..aa5bd573a 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -96,12 +96,12 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt const probe = await ffprobePromise(concatenatedTsFilePath) const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) - const { videoFileResolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe) + const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe) const outputPath = await generateHlsPlaylistResolutionFromTS({ video: videoWithFiles, concatenatedTsFilePath, - resolution: videoFileResolution, + resolution, isPortraitMode, isAAC: audioStream?.codec_name === 'aac' }) diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 2abb351ce..876d1460c 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -136,7 +136,7 @@ async function onVideoFileOptimizer ( if (videoArg === undefined) return undefined // Outside the transaction (IO on disk) - const { videoFileResolution, isPortraitMode } = await videoArg.getMaxQualityResolution() + const { resolution, isPortraitMode } = await videoArg.getMaxQualityResolution() // Maybe the video changed in database, refresh it const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid) @@ -155,7 +155,7 @@ async function onVideoFileOptimizer ( }) const hasHls = await createHlsJobIfEnabled(user, originalFileHLSPayload) - const hasNewResolutions = await createLowerResolutionsJobs(videoDatabase, user, videoFileResolution, isPortraitMode, 'webtorrent') + const hasNewResolutions = await createLowerResolutionsJobs(videoDatabase, user, resolution, isPortraitMode, 'webtorrent') if (!hasHls && !hasNewResolutions) { // No transcoding to do, it's now published diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts index b19ecef6f..2a429fb33 100644 --- a/server/lib/live/live-manager.ts +++ b/server/lib/live/live-manager.ts @@ -202,7 +202,7 @@ class LiveManager { const now = Date.now() const probe = await ffprobePromise(rtmpUrl) - const [ { videoFileResolution }, fps, bitrate ] = await Promise.all([ + const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([ getVideoFileResolution(rtmpUrl, probe), getVideoFileFPS(rtmpUrl, probe), getVideoFileBitrate(rtmpUrl, probe) @@ -210,13 +210,13 @@ class LiveManager { logger.info( '%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)', - rtmpUrl, Date.now() - now, bitrate, fps, videoFileResolution, lTags(sessionId, video.uuid) + rtmpUrl, Date.now() - now, bitrate, fps, resolution, lTags(sessionId, video.uuid) ) - const allResolutions = this.buildAllResolutionsToTranscode(videoFileResolution) + const allResolutions = this.buildAllResolutionsToTranscode(resolution) logger.info( - 'Will mux/transcode live video of original resolution %d.', videoFileResolution, + 'Will mux/transcode live video of original resolution %d.', resolution, { allResolutions, ...lTags(sessionId, video.uuid) } ) @@ -229,6 +229,7 @@ class LiveManager { rtmpUrl, fps, bitrate, + ratio, allResolutions }) } @@ -240,9 +241,10 @@ class LiveManager { rtmpUrl: string fps: number bitrate: number + ratio: number allResolutions: number[] }) { - const { sessionId, videoLive, streamingPlaylist, allResolutions, fps, bitrate, rtmpUrl } = options + const { sessionId, videoLive, streamingPlaylist, allResolutions, fps, bitrate, ratio, rtmpUrl } = options const videoUUID = videoLive.Video.uuid const localLTags = lTags(sessionId, videoUUID) @@ -257,6 +259,7 @@ class LiveManager { streamingPlaylist, rtmpUrl, bitrate, + ratio, fps, allResolutions }) diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts index 62708b14b..a80abc843 100644 --- a/server/lib/live/shared/muxing-session.ts +++ b/server/lib/live/shared/muxing-session.ts @@ -54,9 +54,11 @@ class MuxingSession extends EventEmitter { private readonly streamingPlaylist: MStreamingPlaylistVideo private readonly rtmpUrl: string private readonly fps: number - private readonly bitrate: number private readonly allResolutions: number[] + private readonly bitrate: number + private readonly ratio: number + private readonly videoId: number private readonly videoUUID: string private readonly saveReplay: boolean @@ -85,6 +87,7 @@ class MuxingSession extends EventEmitter { rtmpUrl: string fps: number bitrate: number + ratio: number allResolutions: number[] }) { super() @@ -96,7 +99,10 @@ class MuxingSession extends EventEmitter { this.streamingPlaylist = options.streamingPlaylist this.rtmpUrl = options.rtmpUrl this.fps = options.fps + this.bitrate = options.bitrate + this.ratio = options.bitrate + this.allResolutions = options.allResolutions this.videoId = this.videoLive.Video.id @@ -122,6 +128,7 @@ class MuxingSession extends EventEmitter { resolutions: this.allResolutions, fps: this.fps, bitrate: this.bitrate, + ratio: this.ratio, availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), profile: CONFIG.LIVE.TRANSCODING.PROFILE diff --git a/server/lib/transcoding/video-transcoding-profiles.ts b/server/lib/transcoding/video-transcoding-profiles.ts index 2309f38d4..bca6dfccd 100644 --- a/server/lib/transcoding/video-transcoding-profiles.ts +++ b/server/lib/transcoding/video-transcoding-profiles.ts @@ -1,23 +1,24 @@ import { logger } from '@server/helpers/logger' -import { AvailableEncoders, EncoderOptionsBuilder, getTargetBitrate, VideoResolution } from '../../../shared/models/videos' +import { getAverageBitrate } from '@shared/core-utils' +import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '../../../shared/models/videos' import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils' import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '../../helpers/ffprobe-utils' -import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants' /** * * Available encoders and profiles for the transcoding jobs * These functions are used by ffmpeg-utils that will get the encoders and options depending on the chosen profile * + * Resources: + * * https://slhck.info/video/2017/03/01/rate-control.html + * * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate */ -// Resources: -// * https://slhck.info/video/2017/03/01/rate-control.html -// * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate +const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = async (options: EncoderOptionsBuilderParams) => { + const { fps, inputRatio, inputBitrate } = options + if (!fps) return { outputOptions: [ ] } -const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = async ({ inputBitrate, resolution, fps }) => { - const targetBitrate = buildTargetBitrate({ inputBitrate, resolution, fps }) - if (!targetBitrate) return { outputOptions: [ ] } + const targetBitrate = capBitrate(inputBitrate, getAverageBitrate({ ...options, fps, ratio: inputRatio })) return { outputOptions: [ @@ -29,8 +30,10 @@ const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = async ({ inputBitrat } } -const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = async ({ resolution, fps, inputBitrate, streamNum }) => { - const targetBitrate = buildTargetBitrate({ inputBitrate, resolution, fps }) +const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = async (options: EncoderOptionsBuilderParams) => { + const { streamNum, fps, inputBitrate, inputRatio } = options + + const targetBitrate = capBitrate(inputBitrate, getAverageBitrate({ ...options, fps, ratio: inputRatio })) return { outputOptions: [ @@ -231,14 +234,7 @@ export { // --------------------------------------------------------------------------- -function buildTargetBitrate (options: { - inputBitrate: number - resolution: VideoResolution - fps: number -}) { - const { inputBitrate, resolution, fps } = options - - const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) +function capBitrate (inputBitrate: number, targetBitrate: number) { if (!inputBitrate) return targetBitrate return Math.min(targetBitrate, inputBitrate) diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index 4095cdb1c..ba952aff5 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts @@ -3,7 +3,7 @@ import 'mocha' import * as chai from 'chai' import { basename, join } from 'path' -import { ffprobePromise, getVideoFileBitrate, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' +import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' import { checkLiveCleanupAfterSave, checkLiveSegmentHash, diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index 2a09e95bf..f67752d69 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts @@ -3,6 +3,7 @@ import 'mocha' import * as chai from 'chai' import { omit } from 'lodash' +import { getMaxBitrate } from '@shared/core-utils' import { buildAbsoluteFixturePath, cleanupTests, @@ -17,8 +18,7 @@ import { waitJobs, webtorrentAdd } from '@shared/extra-utils' -import { getMaxBitrate, HttpStatusCode, VideoResolution, VideoState } from '@shared/models' -import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants' +import { HttpStatusCode, VideoState } from '@shared/models' import { canDoQuickTranscode, getAudioStream, @@ -191,15 +191,6 @@ describe('Test video transcoding', function () { it('Should accept and transcode additional extensions', async function () { this.timeout(300_000) - let tempFixturePath: string - - { - tempFixturePath = await generateHighBitrateVideo() - - const bitrate = await getVideoFileBitrate(tempFixturePath) - expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 25, VIDEO_TRANSCODING_FPS)) - } - for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) { const attributes = { name: fixture, @@ -555,14 +546,7 @@ describe('Test video transcoding', function () { it('Should respect maximum bitrate values', async function () { this.timeout(160_000) - let tempFixturePath: string - - { - tempFixturePath = await generateHighBitrateVideo() - - const bitrate = await getVideoFileBitrate(tempFixturePath) - expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 25, VIDEO_TRANSCODING_FPS)) - } + const tempFixturePath = await generateHighBitrateVideo() const attributes = { name: 'high bitrate video', @@ -586,10 +570,12 @@ describe('Test video transcoding', function () { const bitrate = await getVideoFileBitrate(path) const fps = await getVideoFileFPS(path) - const { videoFileResolution } = await getVideoFileResolution(path) + const dataResolution = await getVideoFileResolution(path) + + expect(resolution).to.equal(resolution) - expect(videoFileResolution).to.equal(resolution) - expect(bitrate).to.be.below(getMaxBitrate(videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) + const maxBitrate = getMaxBitrate({ ...dataResolution, fps }) + expect(bitrate).to.be.below(maxBitrate) } } }) diff --git a/server/tests/cli/optimize-old-videos.ts b/server/tests/cli/optimize-old-videos.ts index 579b2e7d8..9b75ae164 100644 --- a/server/tests/cli/optimize-old-videos.ts +++ b/server/tests/cli/optimize-old-videos.ts @@ -2,6 +2,7 @@ import 'mocha' import * as chai from 'chai' +import { getMaxBitrate } from '@shared/core-utils' import { cleanupTests, createMultipleServers, @@ -12,9 +13,7 @@ import { wait, waitJobs } from '@shared/extra-utils' -import { getMaxBitrate, VideoResolution } from '@shared/models' import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../../helpers/ffprobe-utils' -import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants' const expect = chai.expect @@ -30,14 +29,7 @@ describe('Test optimize old videos', function () { await doubleFollow(servers[0], servers[1]) - let tempFixturePath: string - - { - tempFixturePath = await generateHighBitrateVideo() - - const bitrate = await getVideoFileBitrate(tempFixturePath) - expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 25, VIDEO_TRANSCODING_FPS)) - } + const tempFixturePath = await generateHighBitrateVideo() // Upload two videos for our needs await servers[0].videos.upload({ attributes: { name: 'video1', fixture: tempFixturePath } }) @@ -88,10 +80,12 @@ describe('Test optimize old videos', function () { const path = servers[0].servers.buildWebTorrentFilePath(file.fileUrl) const bitrate = await getVideoFileBitrate(path) const fps = await getVideoFileFPS(path) - const resolution = await getVideoFileResolution(path) + const data = await getVideoFileResolution(path) + + expect(data.resolution).to.equal(file.resolution.id) - expect(resolution.videoFileResolution).to.equal(file.resolution.id) - expect(bitrate).to.be.below(getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) + const maxBitrate = getMaxBitrate({ ...data, fps }) + expect(bitrate).to.be.below(maxBitrate) } } }) diff --git a/server/tests/cli/print-transcode-command.ts b/server/tests/cli/print-transcode-command.ts index 3a7969e68..e328a6072 100644 --- a/server/tests/cli/print-transcode-command.ts +++ b/server/tests/cli/print-transcode-command.ts @@ -3,16 +3,16 @@ import 'mocha' import * as chai from 'chai' import { getVideoFileBitrate, getVideoFileFPS } from '@server/helpers/ffprobe-utils' -import { CLICommand } from '@shared/extra-utils' -import { getTargetBitrate, VideoResolution } from '../../../shared/models/videos' -import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants' +import { getMaxBitrate } from '@shared/core-utils' +import { buildAbsoluteFixturePath, CLICommand } from '@shared/extra-utils' +import { VideoResolution } from '../../../shared/models/videos' const expect = chai.expect describe('Test create transcoding jobs', function () { it('Should print the correct command for each resolution', async function () { - const fixturePath = 'server/tests/fixtures/video_short.webm' + const fixturePath = buildAbsoluteFixturePath('video_short.webm') const fps = await getVideoFileFPS(fixturePath) const bitrate = await getVideoFileBitrate(fixturePath) @@ -21,7 +21,7 @@ describe('Test create transcoding jobs', function () { VideoResolution.H_1080P ]) { const command = await CLICommand.exec(`npm run print-transcode-command -- ${fixturePath} -r ${resolution}`) - const targetBitrate = Math.min(getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS), bitrate) + const targetBitrate = Math.min(getMaxBitrate({ resolution, fps, ratio: 16 / 9 }), bitrate) expect(command).to.includes(`-vf scale=w=-2:h=${resolution}`) expect(command).to.includes(`-y -acodec aac -vcodec libx264`) diff --git a/server/tests/helpers/core-utils.ts b/server/tests/helpers/core-utils.ts index d5cac51a3..a6bf5b4c5 100644 --- a/server/tests/helpers/core-utils.ts +++ b/server/tests/helpers/core-utils.ts @@ -4,6 +4,8 @@ import 'mocha' import * as chai from 'chai' import { snakeCase } from 'lodash' import validator from 'validator' +import { getAverageBitrate, getMaxBitrate } from '@shared/core-utils' +import { VideoResolution } from '@shared/models' import { objectConverter, parseBytes } from '../../helpers/core-utils' const expect = chai.expect @@ -46,6 +48,9 @@ describe('Parse Bytes', function () { it('Should be invalid when given invalid value', async function () { expect(parseBytes('6GB 1GB')).to.be.eq(6) }) +}) + +describe('Object', function () { it('Should convert an object', async function () { function keyConverter (k: string) { @@ -94,3 +99,36 @@ describe('Parse Bytes', function () { expect(obj['my_super_key']).to.be.undefined }) }) + +describe('Bitrate', function () { + + it('Should get appropriate max bitrate', function () { + const tests = [ + { resolution: VideoResolution.H_240P, ratio: 16 / 9, fps: 24, min: 600, max: 800 }, + { resolution: VideoResolution.H_360P, ratio: 16 / 9, fps: 24, min: 1200, max: 1600 }, + { resolution: VideoResolution.H_480P, ratio: 16 / 9, fps: 24, min: 2000, max: 2300 }, + { resolution: VideoResolution.H_720P, ratio: 16 / 9, fps: 24, min: 4000, max: 4400 }, + { resolution: VideoResolution.H_1080P, ratio: 16 / 9, fps: 24, min: 8000, max: 10000 }, + { resolution: VideoResolution.H_4K, ratio: 16 / 9, fps: 24, min: 25000, max: 30000 } + ] + + for (const test of tests) { + expect(getMaxBitrate(test)).to.be.above(test.min * 1000).and.below(test.max * 1000) + } + }) + + it('Should get appropriate average bitrate', function () { + const tests = [ + { resolution: VideoResolution.H_240P, ratio: 16 / 9, fps: 24, min: 350, max: 450 }, + { resolution: VideoResolution.H_360P, ratio: 16 / 9, fps: 24, min: 700, max: 900 }, + { resolution: VideoResolution.H_480P, ratio: 16 / 9, fps: 24, min: 1100, max: 1300 }, + { resolution: VideoResolution.H_720P, ratio: 16 / 9, fps: 24, min: 2300, max: 2500 }, + { resolution: VideoResolution.H_1080P, ratio: 16 / 9, fps: 24, min: 4700, max: 5000 }, + { resolution: VideoResolution.H_4K, ratio: 16 / 9, fps: 24, min: 15000, max: 17000 } + ] + + for (const test of tests) { + expect(getAverageBitrate(test)).to.be.above(test.min * 1000).and.below(test.max * 1000) + } + }) +}) diff --git a/shared/core-utils/index.ts b/shared/core-utils/index.ts index 2a7d4d982..e0a6a8087 100644 --- a/shared/core-utils/index.ts +++ b/shared/core-utils/index.ts @@ -5,3 +5,4 @@ export * from './plugins' export * from './renderer' export * from './users' export * from './utils' +export * from './videos' diff --git a/shared/core-utils/videos/bitrate.ts b/shared/core-utils/videos/bitrate.ts new file mode 100644 index 000000000..3d4e47906 --- /dev/null +++ b/shared/core-utils/videos/bitrate.ts @@ -0,0 +1,86 @@ +import { VideoResolution } from "@shared/models" + +type BitPerPixel = { [ id in VideoResolution ]: number } + +// https://bitmovin.com/video-bitrate-streaming-hls-dash/ + +const averageBitPerPixel: BitPerPixel = { + [VideoResolution.H_NOVIDEO]: 0, + [VideoResolution.H_240P]: 0.17, + [VideoResolution.H_360P]: 0.15, + [VideoResolution.H_480P]: 0.12, + [VideoResolution.H_720P]: 0.11, + [VideoResolution.H_1080P]: 0.10, + [VideoResolution.H_1440P]: 0.09, + [VideoResolution.H_4K]: 0.08 +} + +const maxBitPerPixel: BitPerPixel = { + [VideoResolution.H_NOVIDEO]: 0, + [VideoResolution.H_240P]: 0.29, + [VideoResolution.H_360P]: 0.26, + [VideoResolution.H_480P]: 0.22, + [VideoResolution.H_720P]: 0.19, + [VideoResolution.H_1080P]: 0.17, + [VideoResolution.H_1440P]: 0.16, + [VideoResolution.H_4K]: 0.14 +} + +function getAverageBitrate (options: { + resolution: VideoResolution + ratio: number + fps: number +}) { + const targetBitrate = calculateBitrate({ ...options, bitPerPixel: averageBitPerPixel }) + if (!targetBitrate) return 192 * 1000 + + return targetBitrate +} + +function getMaxBitrate (options: { + resolution: VideoResolution + ratio: number + fps: number +}) { + const targetBitrate = calculateBitrate({ ...options, bitPerPixel: maxBitPerPixel }) + if (!targetBitrate) return 256 * 1000 + + return targetBitrate +} + +// --------------------------------------------------------------------------- + +export { + getAverageBitrate, + getMaxBitrate +} + +// --------------------------------------------------------------------------- + +function calculateBitrate (options: { + bitPerPixel: BitPerPixel + resolution: VideoResolution + ratio: number + fps: number +}) { + const { bitPerPixel, resolution, ratio, fps } = options + + const resolutionsOrder = [ + VideoResolution.H_4K, + VideoResolution.H_1440P, + VideoResolution.H_1080P, + VideoResolution.H_720P, + VideoResolution.H_480P, + VideoResolution.H_360P, + VideoResolution.H_240P, + VideoResolution.H_NOVIDEO + ] + + for (const toTestResolution of resolutionsOrder) { + if (toTestResolution <= resolution) { + return resolution * resolution * ratio * fps * bitPerPixel[toTestResolution] + } + } + + throw new Error('Unknown resolution ' + resolution) +} diff --git a/shared/core-utils/videos/index.ts b/shared/core-utils/videos/index.ts new file mode 100644 index 000000000..5a1145f1a --- /dev/null +++ b/shared/core-utils/videos/index.ts @@ -0,0 +1 @@ +export * from './bitrate' diff --git a/shared/extra-utils/miscs/generate.ts b/shared/extra-utils/miscs/generate.ts index 8d6435481..a03a20049 100644 --- a/shared/extra-utils/miscs/generate.ts +++ b/shared/extra-utils/miscs/generate.ts @@ -1,8 +1,20 @@ +import { expect } from 'chai' import * as ffmpeg from 'fluent-ffmpeg' import { ensureDir, pathExists } from 'fs-extra' import { dirname } from 'path' +import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils' +import { getMaxBitrate } from '@shared/core-utils' import { buildAbsoluteFixturePath } from './tests' +async function ensureHasTooBigBitrate (fixturePath: string) { + const bitrate = await getVideoFileBitrate(fixturePath) + const dataResolution = await getVideoFileResolution(fixturePath) + const fps = await getVideoFileFPS(fixturePath) + + const maxBitrate = getMaxBitrate({ ...dataResolution, fps }) + expect(bitrate).to.be.above(maxBitrate) +} + async function generateHighBitrateVideo () { const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4', true) @@ -28,6 +40,8 @@ async function generateHighBitrateVideo () { }) } + await ensureHasTooBigBitrate(tempFixturePath) + return tempFixturePath } diff --git a/shared/models/videos/video-resolution.enum.ts b/shared/models/videos/video-resolution.enum.ts index a5d2ac7fa..24cd2d04d 100644 --- a/shared/models/videos/video-resolution.enum.ts +++ b/shared/models/videos/video-resolution.enum.ts @@ -1,5 +1,3 @@ -import { VideoTranscodingFPS } from './video-transcoding-fps.model' - export const enum VideoResolution { H_NOVIDEO = 0, H_240P = 240, @@ -10,90 +8,3 @@ export const enum VideoResolution { H_1440P = 1440, H_4K = 2160 } - -/** - * Bitrate targets for different resolutions, at VideoTranscodingFPS.AVERAGE. - * - * Sources for individual quality levels: - * Google Live Encoder: https://support.google.com/youtube/answer/2853702?hl=en - * YouTube Video Info: youtube-dl --list-formats, with sample videos - */ -function getBaseBitrate (resolution: number) { - if (resolution === VideoResolution.H_NOVIDEO) { - // audio-only - return 64 * 1000 - } - - if (resolution <= VideoResolution.H_240P) { - // quality according to Google Live Encoder: 300 - 700 Kbps - // Quality according to YouTube Video Info: 285 Kbps - return 320 * 1000 - } - - if (resolution <= VideoResolution.H_360P) { - // quality according to Google Live Encoder: 400 - 1,000 Kbps - // Quality according to YouTube Video Info: 700 Kbps - return 780 * 1000 - } - - if (resolution <= VideoResolution.H_480P) { - // quality according to Google Live Encoder: 500 - 2,000 Kbps - // Quality according to YouTube Video Info: 1300 Kbps - return 1500 * 1000 - } - - if (resolution <= VideoResolution.H_720P) { - // quality according to Google Live Encoder: 1,500 - 4,000 Kbps - // Quality according to YouTube Video Info: 2680 Kbps - return 2800 * 1000 - } - - if (resolution <= VideoResolution.H_1080P) { - // quality according to Google Live Encoder: 3000 - 6000 Kbps - // Quality according to YouTube Video Info: 5081 Kbps - return 5200 * 1000 - } - - if (resolution <= VideoResolution.H_1440P) { - // quality according to Google Live Encoder: 6000 - 13000 Kbps - // Quality according to YouTube Video Info: 8600 (av01) - 17000 (vp9.2) Kbps - return 10_000 * 1000 - } - - // 4K - // quality according to Google Live Encoder: 13000 - 34000 Kbps - return 22_000 * 1000 -} - -/** - * Calculate the target bitrate based on video resolution and FPS. - * - * The calculation is based on two values: - * Bitrate at VideoTranscodingFPS.AVERAGE is always the same as - * getBaseBitrate(). Bitrate at VideoTranscodingFPS.MAX is always - * getBaseBitrate() * 1.4. All other values are calculated linearly - * between these two points. - */ -export function getTargetBitrate (resolution: number, fps: number, fpsTranscodingConstants: VideoTranscodingFPS) { - const baseBitrate = getBaseBitrate(resolution) - // The maximum bitrate, used when fps === VideoTranscodingFPS.MAX - // Based on numbers from Youtube, 60 fps bitrate divided by 30 fps bitrate: - // 720p: 2600 / 1750 = 1.49 - // 1080p: 4400 / 3300 = 1.33 - const maxBitrate = baseBitrate * 1.4 - const maxBitrateDifference = maxBitrate - baseBitrate - const maxFpsDifference = fpsTranscodingConstants.MAX - fpsTranscodingConstants.AVERAGE - // For 1080p video with default settings, this results in the following formula: - // 3300 + (x - 30) * (1320/30) - // Example outputs: - // 1080p10: 2420 kbps, 1080p30: 3300 kbps, 1080p60: 4620 kbps - // 720p10: 1283 kbps, 720p30: 1750 kbps, 720p60: 2450 kbps - return Math.floor(baseBitrate + (fps - fpsTranscodingConstants.AVERAGE) * (maxBitrateDifference / maxFpsDifference)) -} - -/** - * 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 -} diff --git a/shared/models/videos/video-transcoding.model.ts b/shared/models/videos/video-transcoding.model.ts index f1fe4609b..83b8e98a0 100644 --- a/shared/models/videos/video-transcoding.model.ts +++ b/shared/models/videos/video-transcoding.model.ts @@ -2,13 +2,23 @@ import { VideoResolution } from './video-resolution.enum' // Types used by plugins and ffmpeg-utils -export type EncoderOptionsBuilder = (params: { +export type EncoderOptionsBuilderParams = { input: string + resolution: VideoResolution - inputBitrate: number + + // Could be null for "merge audio" transcoding fps?: number + + // Could be undefined if we could not get input bitrate (some RTMP streams for example) + inputBitrate: number + inputRatio: number + + // For lives streamNum?: number -}) => Promise | EncoderOptions +} + +export type EncoderOptionsBuilder = (params: EncoderOptionsBuilderParams) => Promise | EncoderOptions export interface EncoderOptions { copy?: boolean // Copy stream? Default to false -- 2.41.0