From bbaf96d60d4bc1a40bbc88584524c94c1c2b0b9f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 12 Aug 2024 16:17:11 +0200 Subject: [PATCH] Support max FPS configuration --- .gitignore | 1 + config/default.yaml | 10 + config/production.yaml.example | 10 + .../src/ffmpeg-default-transcoding-profile.ts | 4 +- .../models/src/server/custom-config.model.ts | 9 + .../models/src/videos/transcoding/index.ts | 1 - .../video-transcoding-fps.model.ts | 18 - .../src/server/config-command.ts | 50 ++- .../src/api/object-storage/video-imports.ts | 2 +- .../tests/src/api/runners/runner-common.ts | 2 +- .../src/api/runners/runner-vod-transcoding.ts | 2 +- packages/tests/src/api/server/config.ts | 10 +- .../src/api/transcoding/create-transcoding.ts | 2 +- packages/tests/src/api/transcoding/index.ts | 1 + .../src/api/transcoding/transcoder-limits.ts | 276 ++++++++++++++ .../tests/src/api/transcoding/transcoder.ts | 302 +-------------- .../tests/src/api/videos/generate-download.ts | 2 +- packages/tests/src/api/videos/video-files.ts | 2 +- packages/tests/src/api/videos/video-source.ts | 4 +- packages/tests/src/feeds/feeds.ts | 2 +- .../src/peertube-runner/live-transcoding.ts | 58 +++ .../src/peertube-runner/vod-transcoding.ts | 52 +-- packages/tests/src/plugins/plugin-helpers.ts | 2 +- packages/tests/src/shared/generate.ts | 6 +- packages/tests/src/shared/live.ts | 16 + .../tests/src/shared/streaming-playlists.ts | 10 +- server/core/controllers/api/config.ts | 8 +- server/core/helpers/ffmpeg/framerate.ts | 79 +++- .../core/initializers/checker-before-init.ts | 3 +- server/core/initializers/config.ts | 8 + server/core/initializers/constants.ts | 346 ++++++------------ server/core/lib/live/live-manager.ts | 20 +- server/core/lib/live/shared/muxing-session.ts | 13 +- .../job-builders/abstract-job-builder.ts | 20 +- .../transcoding-quick-transcode.ts | 4 +- .../core/lib/transcoding/web-transcoding.ts | 2 +- server/core/middlewares/validators/config.ts | 2 + 37 files changed, 736 insertions(+), 623 deletions(-) delete mode 100644 packages/models/src/videos/transcoding/video-transcoding-fps.model.ts create mode 100644 packages/tests/src/api/transcoding/transcoder-limits.ts diff --git a/.gitignore b/.gitignore index e1b165306..5ee89c6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ yarn-error.log # Big fixtures generated/downloaded on-demand /packages/tests/fixtures/video_high_bitrate_1080p.mp4 /packages/tests/fixtures/video_59fps.mp4 +/packages/tests/fixtures/video_50fps.mp4 /packages/tests/fixtures/transcription/models-v1/ # PeerTube diff --git a/config/default.yaml b/config/default.yaml index 2948d0ea2..c15918c0c 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -606,6 +606,11 @@ transcoding: # Transcode and keep original resolution, even if it's above your maximum enabled resolution always_transcode_original_resolution: true + fps: + # Cap transcoded video FPS + # Max resolution file still keeps the original FPS + max: 60 + # Generate videos in a web compatible format # If you also enabled the hls format, it will multiply videos storage by 2 # If disabled, breaks federation with PeerTube instances < 2.1 @@ -716,6 +721,11 @@ live: # Also transcode original resolution, even if it's above your maximum enabled resolution always_transcode_original_resolution: true + fps: + # Cap transcoded live FPS + # Max resolution stream still keeps the original FPS + max: 60 + video_studio: # Enable video edition by users (cut, add intro/outro, add watermark etc) # If enabled, users can create transcoding tasks as they wish diff --git a/config/production.yaml.example b/config/production.yaml.example index 783b09bb6..56f78d31f 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -616,6 +616,11 @@ transcoding: # Transcode and keep original resolution, even if it's above your maximum enabled resolution always_transcode_original_resolution: true + fps: + # Cap transcoded video FPS + # Max resolution file still keeps the original FPS + max: 60 + # Generate videos in a web compatible format # If you also enabled the hls format, it will multiply videos storage by 2 # If disabled, breaks federation with PeerTube instances < 2.1 @@ -726,6 +731,11 @@ live: # Also transcode original resolution, even if it's above your maximum enabled resolution always_transcode_original_resolution: true + fps: + # Cap transcoded live FPS + # Max resolution stream still keeps the original FPS + max: 60 + video_studio: # Enable video edition by users (cut, add intro/outro, add watermark etc) # If enabled, users can create transcoding tasks as they wish diff --git a/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts b/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts index 2104a6217..26ddf5c22 100644 --- a/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts +++ b/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts @@ -126,7 +126,7 @@ export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeDat return true } -export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise { +export async function canDoQuickVideoTranscode (path: string, maxFPS: number, probe?: FfprobeData): Promise { const videoStream = await getVideoStream(path, probe) const fps = await getVideoStreamFPS(path, probe) const bitRate = await getVideoStreamBitrate(path, probe) @@ -139,7 +139,7 @@ export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeDat if (!videoStream) return false if (videoStream['codec_name'] !== 'h264') return false if (videoStream['pix_fmt'] !== 'yuv420p') return false - if (fps < 2 || fps > 65) return false + if (fps < 2 || fps > maxFPS) return false if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false return true diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts index b085524f6..00190828d 100644 --- a/packages/models/src/server/custom-config.model.ts +++ b/packages/models/src/server/custom-config.model.ts @@ -138,6 +138,10 @@ export interface CustomConfig { alwaysTranscodeOriginalResolution: boolean + fps: { + max: number + } + webVideos: { enabled: boolean } @@ -168,8 +172,13 @@ export interface CustomConfig { } threads: number profile: string + resolutions: ConfigResolutions alwaysTranscodeOriginalResolution: boolean + + fps: { + max: number + } } } diff --git a/packages/models/src/videos/transcoding/index.ts b/packages/models/src/videos/transcoding/index.ts index e1d931bd5..c5a62345b 100644 --- a/packages/models/src/videos/transcoding/index.ts +++ b/packages/models/src/videos/transcoding/index.ts @@ -1,3 +1,2 @@ export * from './video-transcoding-create.model.js' -export * from './video-transcoding-fps.model.js' export * from './video-transcoding.model.js' diff --git a/packages/models/src/videos/transcoding/video-transcoding-fps.model.ts b/packages/models/src/videos/transcoding/video-transcoding-fps.model.ts deleted file mode 100644 index c0a44c5d1..000000000 --- a/packages/models/src/videos/transcoding/video-transcoding-fps.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type VideoTranscodingFPS = { - // Refuse videos with FPS below this limit - HARD_MIN: number - // Cap FPS to this min value - SOFT_MIN: number - - STANDARD: number[] - HD_STANDARD: number[] - - AUDIO_MERGE: number - - AVERAGE: number - - // Cap FPS to this max value - SOFT_MAX: number - - KEEP_ORIGIN_FPS_RESOLUTION_MIN: number -} diff --git a/packages/server-commands/src/server/config-command.ts b/packages/server-commands/src/server/config-command.ts index ea3752712..41803de72 100644 --- a/packages/server-commands/src/server/config-command.ts +++ b/packages/server-commands/src/server/config-command.ts @@ -265,17 +265,42 @@ export class ConfigCommand extends AbstractCommand { }) } - enableTranscoding (options: { - webVideo?: boolean // default true - hls?: boolean // default true - keepOriginal?: boolean // default false - splitAudioAndVideo?: boolean // default false + async enableTranscoding (options: { + webVideo?: boolean + hls?: boolean + keepOriginal?: boolean + splitAudioAndVideo?: boolean - resolutions?: 'min' | 'max' | number[] // default 'max' + resolutions?: 'min' | 'max' | number[] - with0p?: boolean // default false + with0p?: boolean + + alwaysTranscodeOriginalResolution?: boolean + + maxFPS?: number } = {}) { - const { resolutions = 'max', webVideo = true, hls = true, with0p = false, keepOriginal = false, splitAudioAndVideo = false } = options + const { + webVideo, + hls, + with0p, + keepOriginal, + splitAudioAndVideo, + alwaysTranscodeOriginalResolution, + maxFPS + } = options + + let resolutions: ReturnType + + if (Array.isArray(options.resolutions)) { + resolutions = ConfigCommand.getCustomConfigResolutions(options.resolutions) + } else if (typeof options.resolutions === 'string') { + resolutions = ConfigCommand.getConfigResolutions(options.resolutions === 'max', with0p) + } else if (with0p !== undefined) { + const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 }) + + resolutions = existing.transcoding.resolutions + resolutions['0p'] = with0p === true + } return this.updateExistingConfig({ newConfig: { @@ -288,9 +313,9 @@ export class ConfigCommand extends AbstractCommand { allowAudioFiles: true, allowAdditionalExtensions: true, - resolutions: Array.isArray(resolutions) - ? ConfigCommand.getCustomConfigResolutions(resolutions) - : ConfigCommand.getConfigResolutions(resolutions === 'max', with0p), + resolutions, + + alwaysTranscodeOriginalResolution, webVideos: { enabled: webVideo @@ -298,6 +323,9 @@ export class ConfigCommand extends AbstractCommand { hls: { enabled: hls, splitAudioAndVideo + }, + fps: { + max: maxFPS } } } diff --git a/packages/tests/src/api/object-storage/video-imports.ts b/packages/tests/src/api/object-storage/video-imports.ts index ec269e7c0..7cf44387a 100644 --- a/packages/tests/src/api/object-storage/video-imports.ts +++ b/packages/tests/src/api/object-storage/video-imports.ts @@ -75,7 +75,7 @@ describe('Object storage for video import', function () { describe('With transcoding', async function () { before(async function () { - await server.config.enableTranscoding() + await server.config.enableTranscoding({ webVideo: true, hls: true, resolutions: 'max' }) }) it('Should import a video and have sent it to object storage', async function () { diff --git a/packages/tests/src/api/runners/runner-common.ts b/packages/tests/src/api/runners/runner-common.ts index ddcaa4652..f0234b0e6 100644 --- a/packages/tests/src/api/runners/runner-common.ts +++ b/packages/tests/src/api/runners/runner-common.ts @@ -41,7 +41,7 @@ describe('Test runner common actions', function () { await setAccessTokensToServers([ server ]) await setDefaultVideoChannel([ server ]) - await server.config.enableTranscoding({ hls: true, webVideo: true }) + await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'max' }) await server.config.enableRemoteTranscoding() }) diff --git a/packages/tests/src/api/runners/runner-vod-transcoding.ts b/packages/tests/src/api/runners/runner-vod-transcoding.ts index 869fd7621..433e355a8 100644 --- a/packages/tests/src/api/runners/runner-vod-transcoding.ts +++ b/packages/tests/src/api/runners/runner-vod-transcoding.ts @@ -85,7 +85,7 @@ describe('Test runner VOD transcoding', function () { before(async function () { this.timeout(60000) - await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'max' }) }) it('Should error a transcoding job', async function () { diff --git a/packages/tests/src/api/server/config.ts b/packages/tests/src/api/server/config.ts index b644abe59..77c90e4ed 100644 --- a/packages/tests/src/api/server/config.ts +++ b/packages/tests/src/api/server/config.ts @@ -81,6 +81,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.transcoding.resolutions['1440p']).to.be.true expect(data.transcoding.resolutions['2160p']).to.be.true expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true + expect(data.transcoding.fps.max).to.equal(60) expect(data.transcoding.webVideos.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true expect(data.transcoding.hls.splitAudioAndVideo).to.be.false @@ -106,6 +107,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.live.transcoding.resolutions['1440p']).to.be.false expect(data.live.transcoding.resolutions['2160p']).to.be.false expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.true + expect(data.live.transcoding.fps.max).to.equal(60) expect(data.videoStudio.enabled).to.be.false expect(data.videoStudio.remoteRunners.enabled).to.be.false @@ -255,6 +257,9 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { '2160p': false }, alwaysTranscodeOriginalResolution: false, + fps: { + max: 120 + }, webVideos: { enabled: true }, @@ -290,7 +295,10 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig { '1440p': true, '2160p': true }, - alwaysTranscodeOriginalResolution: false + alwaysTranscodeOriginalResolution: false, + fps: { + max: 144 + } } }, videoStudio: { diff --git a/packages/tests/src/api/transcoding/create-transcoding.ts b/packages/tests/src/api/transcoding/create-transcoding.ts index d2de20c7e..a5caaf82a 100644 --- a/packages/tests/src/api/transcoding/create-transcoding.ts +++ b/packages/tests/src/api/transcoding/create-transcoding.ts @@ -77,7 +77,7 @@ function runTests (options: { const video = await servers[0].videos.get({ id: videoUUID }) publishedAt = video.publishedAt as string - await servers[0].config.enableTranscoding() + await servers[0].config.enableTranscoding({ webVideo: true, hls: true, resolutions: 'max' }) await servers[0].config.setTranscodingConcurrency(concurrency) }) diff --git a/packages/tests/src/api/transcoding/index.ts b/packages/tests/src/api/transcoding/index.ts index 07486fe5a..003c17259 100644 --- a/packages/tests/src/api/transcoding/index.ts +++ b/packages/tests/src/api/transcoding/index.ts @@ -2,6 +2,7 @@ export * from './audio-only.js' export * from './create-transcoding.js' export * from './hls.js' export * from './split-audio-and-video.js' +export * from './transcoder-limits.js' export * from './transcoder.js' export * from './update-while-transcoding.js' export * from './video-studio.js' diff --git a/packages/tests/src/api/transcoding/transcoder-limits.ts b/packages/tests/src/api/transcoding/transcoder-limits.ts new file mode 100644 index 000000000..d3f3b45d3 --- /dev/null +++ b/packages/tests/src/api/transcoding/transcoder-limits.ts @@ -0,0 +1,276 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { getAllFiles, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils' +import { + getVideoStreamBitrate, + getVideoStreamDimensionsInfo, + getVideoStreamFPS +} from '@peertube/peertube-ffmpeg' +import { VideoResolution } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { generateHighBitrateVideo, generateVideoWithFramerate } from '@tests/shared/generate.js' +import { expect } from 'chai' + +describe('Test video transcoding limits', function () { + let servers: PeerTubeServer[] = [] + + before(async function () { + this.timeout(30_000) + + // Run servers + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[1].config.enableTranscoding({ + alwaysTranscodeOriginalResolution: true, + hls: true, + webVideo: true, + resolutions: 'max', + with0p: false + }) + }) + + describe('Framerate limits', function () { + + async function testFPS (uuid: string, originFPS: number, averageFPS: number) { + for (const server of servers) { + const video = await server.videos.get({ id: uuid }) + + const files = video.files + const originalFile = files[0] + + expect(originalFile.fps).to.be.closeTo(originFPS, 2) + const path = servers[1].servers.buildWebVideoFilePath(originalFile.fileUrl) + expect(await getVideoStreamFPS(path)).to.be.closeTo(originFPS, 2) + + files.shift() + + for (const file of files) { + expect(file.fps).to.be.closeTo(averageFPS, 2) + + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + expect(await getVideoStreamFPS(path)).to.be.closeTo(averageFPS, 2) + } + } + } + + it('Should transcode a 60 FPS video', async function () { + this.timeout(60_000) + + const attributes = { name: '60fps server 2', fixture: '60fps_720p_small.mp4' } + const { uuid } = await servers[1].videos.upload({ attributes }) + await waitJobs(servers) + + await testFPS(uuid, 60, 30) + }) + + it('Should transcode origin resolution to max FPS', async function () { + this.timeout(360_000) + + let tempFixturePath: string + + { + tempFixturePath = await generateVideoWithFramerate(50, '480x270') + + const fps = await getVideoStreamFPS(tempFixturePath) + expect(fps).to.be.equal(50) + } + + { + const attributes = { name: '50fps', fixture: tempFixturePath } + const { uuid } = await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + await testFPS(uuid, 50, 25) + } + }) + + it('Should downscale to the closest divisor standard framerate', async function () { + this.timeout(360_000) + + let tempFixturePath: string + + { + tempFixturePath = await generateVideoWithFramerate(59) + + const fps = await getVideoStreamFPS(tempFixturePath) + expect(fps).to.be.equal(59) + } + + const attributes = { name: '59fps video', fixture: tempFixturePath } + const { uuid } = await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + await testFPS(uuid, 59, 25) + }) + + it('Should configure max FPS', async function () { + this.timeout(120_000) + + const update = (value: number) => { + return servers[1].config.updateExistingConfig({ + newConfig: { + transcoding: { + fps: { max: value } + } + } + }) + } + + await update(15) + + const attributes = { name: 'capped 15fps', fixture: '60fps_720p_small.mp4' } + const { uuid } = await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + await testFPS(uuid, 15, 15) + + await update(60) + }) + }) + + describe('Bitrate control', function () { + + it('Should respect maximum bitrate values', async function () { + this.timeout(160_000) + + const tempFixturePath = await generateHighBitrateVideo() + + const attributes = { + name: 'high bitrate video', + description: 'high bitrate video', + fixture: tempFixturePath + } + + await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + for (const server of servers) { + const { data } = await server.videos.list() + + const { id } = data.find(v => v.name === attributes.name) + const video = await server.videos.get({ id }) + + for (const resolution of [ 240, 360, 480, 720, 1080 ]) { + const file = video.files.find(f => f.resolution.id === resolution) + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + + const bitrate = await getVideoStreamBitrate(path) + const fps = await getVideoStreamFPS(path) + const dataResolution = await getVideoStreamDimensionsInfo(path) + + expect(resolution).to.equal(resolution) + + const maxBitrate = getMaxTheoreticalBitrate({ ...dataResolution, fps }) + expect(bitrate).to.be.below(maxBitrate) + } + } + }) + + it('Should not transcode to an higher bitrate than the original file but above our low limit', async function () { + this.timeout(160_000) + + const newConfig = { + transcoding: { + enabled: true, + resolutions: { + '144p': true, + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + }, + webVideos: { enabled: true }, + hls: { enabled: true } + } + } + await servers[1].config.updateExistingConfig({ newConfig }) + + const attributes = { + name: 'low bitrate', + fixture: 'low-bitrate.mp4' + } + + const { id } = await servers[1].videos.upload({ attributes }) + + await waitJobs(servers) + + const video = await servers[1].videos.get({ id }) + + const resolutions = [ 240, 360, 480, 720, 1080 ] + for (const r of resolutions) { + const file = video.files.find(f => f.resolution.id === r) + + const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) + const bitrate = await getVideoStreamBitrate(path) + + const inputBitrate = 60_000 + const limit = getMinTheoreticalBitrate({ fps: 10, ratio: 1, resolution: r }) + let belowValue = Math.max(inputBitrate, limit) + belowValue += belowValue * 0.20 // Apply 20% margin because bitrate control is not very precise + + expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue) + } + }) + }) + + describe('Resolution capping', function () { + + it('Should not generate an upper resolution than original file', async function () { + this.timeout(120_000) + + await servers[0].config.enableTranscoding({ + resolutions: [ VideoResolution.H_240P, VideoResolution.H_480P ], + alwaysTranscodeOriginalResolution: false + }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(2) + expect(hlsFiles).to.have.lengthOf(2) + + const resolutions = getAllFiles(video).map(f => f.resolution.id) + expect(resolutions).to.have.members([ 240, 240, 480, 480 ]) + }) + + it('Should only keep the original resolution if all resolutions are disabled', async function () { + this.timeout(120_000) + + await servers[0].config.enableTranscoding({ resolutions: [] }) + + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) + await waitJobs(servers) + + const video = await servers[0].videos.get({ id: uuid }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(1) + expect(hlsFiles).to.have.lengthOf(1) + + expect(video.files[0].resolution.id).to.equal(720) + expect(hlsFiles[0].resolution.id).to.equal(720) + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/transcoding/transcoder.ts b/packages/tests/src/api/transcoding/transcoder.ts index da4f93112..fb8099fd8 100644 --- a/packages/tests/src/api/transcoding/transcoder.ts +++ b/packages/tests/src/api/transcoding/transcoder.ts @@ -1,12 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { getAllFiles, getMaxTheoreticalBitrate, getMinTheoreticalBitrate, omit } from '@peertube/peertube-core-utils' +import { getAllFiles, omit } from '@peertube/peertube-core-utils' import { ffprobePromise, getAudioStream, - getVideoStreamBitrate, - getVideoStreamDimensionsInfo, - getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg' import { HttpStatusCode, VideoFileMetadata, VideoState } from '@peertube/peertube-models' @@ -21,35 +18,9 @@ import { waitJobs } from '@peertube/peertube-server-commands' import { canDoQuickTranscode } from '@peertube/peertube-server/core/lib/transcoding/transcoding-quick-transcode.js' -import { generateHighBitrateVideo, generateVideoWithFramerate } from '@tests/shared/generate.js' import { checkWebTorrentWorks } from '@tests/shared/webtorrent.js' import { expect } from 'chai' -function updateConfigForTranscoding (server: PeerTubeServer) { - return server.config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - allowAdditionalExtensions: true, - allowAudioFiles: true, - hls: { enabled: true }, - webVideos: { enabled: true }, - resolutions: { - '0p': false, - '144p': true, - '240p': true, - '360p': true, - '480p': true, - '720p': true, - '1080p': true, - '1440p': true, - '2160p': true - } - } - } - }) -} - describe('Test video transcoding', function () { let servers: PeerTubeServer[] = [] let video4k: string @@ -64,10 +35,16 @@ describe('Test video transcoding', function () { await doubleFollow(servers[0], servers[1]) - await updateConfigForTranscoding(servers[1]) + await servers[1].config.enableTranscoding({ + alwaysTranscodeOriginalResolution: true, + resolutions: 'max', + hls: true, + webVideo: true, + with0p: false + }) }) - describe('Basic transcoding (or not)', function () { + describe('Common transcoding', function () { it('Should not transcode video on server 1', async function () { this.timeout(60_000) @@ -414,7 +391,7 @@ describe('Test video transcoding', function () { } } - await updateConfigForTranscoding(servers[1]) + await servers[1].config.enableTranscoding({ alwaysTranscodeOriginalResolution: true, hls: true, webVideo: true, with0p: false }) }) } @@ -427,188 +404,13 @@ describe('Test video transcoding', function () { }) }) - describe('Framerate', function () { - - it('Should transcode a 60 FPS video', async function () { - this.timeout(60_000) - - const attributes = { - name: 'my super 30fps name for server 2', - description: 'my super 30fps description for server 2', - fixture: '60fps_720p_small.mp4' - } - await servers[1].videos.upload({ attributes }) - - await waitJobs(servers) - - for (const server of servers) { - const { data } = await server.videos.list() - - const video = data.find(v => v.name === attributes.name) - const videoDetails = await server.videos.get({ id: video.id }) - - expect(videoDetails.files).to.have.lengthOf(5) - expect(videoDetails.files[0].fps).to.be.above(58).and.below(62) - expect(videoDetails.files[1].fps).to.be.below(31) - expect(videoDetails.files[2].fps).to.be.below(31) - expect(videoDetails.files[3].fps).to.be.below(31) - expect(videoDetails.files[4].fps).to.be.below(31) - - for (const resolution of [ 144, 240, 360, 480 ]) { - const file = videoDetails.files.find(f => f.resolution.id === resolution) - const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) - const fps = await getVideoStreamFPS(path) - - expect(fps).to.be.below(31) - } - - const file = videoDetails.files.find(f => f.resolution.id === 720) - const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) - const fps = await getVideoStreamFPS(path) - - expect(fps).to.be.above(58).and.below(62) - } - }) - - it('Should downscale to the closest divisor standard framerate', async function () { - this.timeout(360_000) - - let tempFixturePath: string - - { - tempFixturePath = await generateVideoWithFramerate(59) - - const fps = await getVideoStreamFPS(tempFixturePath) - expect(fps).to.be.equal(59) - } - - const attributes = { - name: '59fps video', - description: '59fps video', - fixture: tempFixturePath - } - - await servers[1].videos.upload({ attributes }) - - await waitJobs(servers) - - for (const server of servers) { - const { data } = await server.videos.list() - - const { id } = data.find(v => v.name === attributes.name) - const video = await server.videos.get({ id }) - - { - const file = video.files.find(f => f.resolution.id === 240) - const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) - const fps = await getVideoStreamFPS(path) - expect(fps).to.be.equal(25) - } - - { - const file = video.files.find(f => f.resolution.id === 720) - const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) - const fps = await getVideoStreamFPS(path) - expect(fps).to.be.equal(59) - } - } - }) - }) - - describe('Bitrate control', function () { - - it('Should respect maximum bitrate values', async function () { - this.timeout(160_000) - - const tempFixturePath = await generateHighBitrateVideo() - - const attributes = { - name: 'high bitrate video', - description: 'high bitrate video', - fixture: tempFixturePath - } - - await servers[1].videos.upload({ attributes }) - - await waitJobs(servers) - - for (const server of servers) { - const { data } = await server.videos.list() - - const { id } = data.find(v => v.name === attributes.name) - const video = await server.videos.get({ id }) - - for (const resolution of [ 240, 360, 480, 720, 1080 ]) { - const file = video.files.find(f => f.resolution.id === resolution) - const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) - - const bitrate = await getVideoStreamBitrate(path) - const fps = await getVideoStreamFPS(path) - const dataResolution = await getVideoStreamDimensionsInfo(path) - - expect(resolution).to.equal(resolution) - - const maxBitrate = getMaxTheoreticalBitrate({ ...dataResolution, fps }) - expect(bitrate).to.be.below(maxBitrate) - } - } - }) - - it('Should not transcode to an higher bitrate than the original file but above our low limit', async function () { - this.timeout(160_000) - - const newConfig = { - transcoding: { - enabled: true, - resolutions: { - '144p': true, - '240p': true, - '360p': true, - '480p': true, - '720p': true, - '1080p': true, - '1440p': true, - '2160p': true - }, - webVideos: { enabled: true }, - hls: { enabled: true } - } - } - await servers[1].config.updateExistingConfig({ newConfig }) - - const attributes = { - name: 'low bitrate', - fixture: 'low-bitrate.mp4' - } - - const { id } = await servers[1].videos.upload({ attributes }) - - await waitJobs(servers) - - const video = await servers[1].videos.get({ id }) - - const resolutions = [ 240, 360, 480, 720, 1080 ] - for (const r of resolutions) { - const file = video.files.find(f => f.resolution.id === r) - - const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl) - const bitrate = await getVideoStreamBitrate(path) - - const inputBitrate = 60_000 - const limit = getMinTheoreticalBitrate({ fps: 10, ratio: 1, resolution: r }) - let belowValue = Math.max(inputBitrate, limit) - belowValue += belowValue * 0.20 // Apply 20% margin because bitrate control is not very precise - - expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue) - } - }) - }) - describe('FFprobe', function () { it('Should provide valid ffprobe data', async function () { this.timeout(160_000) + await servers[1].config.enableTranscoding({ resolutions: 'max' }) + const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid await waitJobs(servers) @@ -667,8 +469,8 @@ describe('Test video transcoding', function () { it('Should correctly detect if quick transcode is possible', async function () { this.timeout(10_000) - expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true - expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false + expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'), 60)).to.be.true + expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'), 60)).to.be.false }) }) @@ -702,82 +504,6 @@ describe('Test video transcoding', function () { }) }) - describe('Bounded transcoding', function () { - - it('Should not generate an upper resolution than original file', async function () { - this.timeout(120_000) - - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - hls: { enabled: true }, - webVideos: { enabled: true }, - resolutions: { - '0p': false, - '144p': false, - '240p': true, - '360p': false, - '480p': true, - '720p': false, - '1080p': false, - '1440p': false, - '2160p': false - }, - alwaysTranscodeOriginalResolution: false - } - } - }) - - const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) - await waitJobs(servers) - - const video = await servers[0].videos.get({ id: uuid }) - const hlsFiles = video.streamingPlaylists[0].files - - expect(video.files).to.have.lengthOf(2) - expect(hlsFiles).to.have.lengthOf(2) - - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare - const resolutions = getAllFiles(video).map(f => f.resolution.id).sort() - expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ]) - }) - - it('Should only keep the original resolution if all resolutions are disabled', async function () { - this.timeout(120_000) - - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - resolutions: { - '0p': false, - '144p': false, - '240p': false, - '360p': false, - '480p': false, - '720p': false, - '1080p': false, - '1440p': false, - '2160p': false - } - } - } - }) - - const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) - await waitJobs(servers) - - const video = await servers[0].videos.get({ id: uuid }) - const hlsFiles = video.streamingPlaylists[0].files - - expect(video.files).to.have.lengthOf(1) - expect(hlsFiles).to.have.lengthOf(1) - - expect(video.files[0].resolution.id).to.equal(720) - expect(hlsFiles[0].resolution.id).to.equal(720) - }) - }) - after(async function () { await cleanupTests(servers) }) diff --git a/packages/tests/src/api/videos/generate-download.ts b/packages/tests/src/api/videos/generate-download.ts index 7f88c3333..0745ed843 100644 --- a/packages/tests/src/api/videos/generate-download.ts +++ b/packages/tests/src/api/videos/generate-download.ts @@ -51,7 +51,7 @@ describe('Test generate download', function () { const resolutions = [ VideoResolution.H_NOVIDEO, VideoResolution.H_144P ] { - await server.config.enableTranscoding({ hls: true, webVideo: true, resolutions }) + await server.config.enableTranscoding({ hls: true, webVideo: true, splitAudioAndVideo: false, resolutions }) await server.videos.quickUpload({ name: 'common-' + seed }) await waitJobs(servers) } diff --git a/packages/tests/src/api/videos/video-files.ts b/packages/tests/src/api/videos/video-files.ts index 8d577e876..a5f3abd00 100644 --- a/packages/tests/src/api/videos/video-files.ts +++ b/packages/tests/src/api/videos/video-files.ts @@ -25,7 +25,7 @@ describe('Test videos files', function () { await doubleFollow(servers[0], servers[1]) - await servers[0].config.enableTranscoding({ hls: true, webVideo: true }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, resolutions: 'max' }) }) describe('When deleting all files', function () { diff --git a/packages/tests/src/api/videos/video-source.ts b/packages/tests/src/api/videos/video-source.ts index cf7f31863..a1509945a 100644 --- a/packages/tests/src/api/videos/video-source.ts +++ b/packages/tests/src/api/videos/video-source.ts @@ -247,7 +247,7 @@ describe('Test video source management', function () { const previousPaths: string[] = [] - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, keepOriginal: true }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, keepOriginal: true, resolutions: 'max' }) const uploadFixture = 'video_short_720p.mp4' const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: uploadFixture }) @@ -527,7 +527,7 @@ describe('Test video source management', function () { const previousPaths: string[] = [] - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, keepOriginal: true }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, keepOriginal: true, resolutions: 'max' }) const fixture1 = 'video_short_360p.mp4' const { uuid: videoUUID } = await servers[0].videos.quickUpload({ diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts index ed36a87bc..755cce2ef 100644 --- a/packages/tests/src/feeds/feeds.ts +++ b/packages/tests/src/feeds/feeds.ts @@ -56,7 +56,7 @@ describe('Test syndication feeds', () => { await doubleFollow(servers[0], servers[1]) await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) - await serverHLSOnly.config.enableTranscoding({ webVideo: false, hls: true, with0p: true }) + await serverHLSOnly.config.enableTranscoding({ webVideo: false, hls: true, with0p: true, resolutions: 'max' }) { const user = await servers[0].users.getMyInfo() diff --git a/packages/tests/src/peertube-runner/live-transcoding.ts b/packages/tests/src/peertube-runner/live-transcoding.ts index cacbb0790..cae259938 100644 --- a/packages/tests/src/peertube-runner/live-transcoding.ts +++ b/packages/tests/src/peertube-runner/live-transcoding.ts @@ -70,6 +70,64 @@ describe('Test Live transcoding in peertube-runner program', function () { await servers[0].videos.remove({ id: video.id }) }) + it('Should cap FPS', async function () { + this.timeout(120000) + + await servers[0].config.updateExistingConfig({ + newConfig: { + live: { + transcoding: { + fps: { max: 48 } + } + } + } + }) + + const { video } = await servers[0].live.quickCreate({ permanentLive: true, saveReplay: false, privacy: VideoPrivacy.PUBLIC }) + + const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ + videoId: video.uuid, + copyCodecs: true, + fixtureName: '60fps_720p_small.mp4' + }) + + await waitUntilLivePublishedOnAllServers(servers, video.uuid) + await waitJobs(servers) + + await testLiveVideoResolutions({ + originServer: servers[0], + sqlCommand: sqlCommandServer1, + servers, + liveVideoId: video.uuid, + resolutions: [ 720, 480, 360, 240, 144 ], + framerates: { + 720: 48, + 480: 30, + 360: 30, + 240: 30, + 144: 30 + }, + objectStorage, + transcoded: true + }) + + await stopFfmpeg(ffmpegCommand) + await waitUntilLiveWaitingOnAllServers(servers, video.uuid) + + const { data } = await servers[0].runnerJobs.list({ sort: '-createdAt' }) + + while (true) { + const liveJob = data.find(d => d.type === 'live-rtmp-hls-transcoding') + expect(liveJob).to.exist + + if (liveJob.state.id === RunnerJobState.COMPLETED) break + + await wait(500) + } + + await servers[0].videos.remove({ id: video.id }) + }) + it('Should transcode audio only RTMP stream', async function () { this.timeout(120000) diff --git a/packages/tests/src/peertube-runner/vod-transcoding.ts b/packages/tests/src/peertube-runner/vod-transcoding.ts index db91a9e0c..6e909dc9a 100644 --- a/packages/tests/src/peertube-runner/vod-transcoding.ts +++ b/packages/tests/src/peertube-runner/vod-transcoding.ts @@ -255,6 +255,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { await doubleFollow(servers[0], servers[1]) + await servers[0].config.enableTranscoding({ resolutions: 'max' }) await servers[0].config.enableRemoteTranscoding() const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken() @@ -304,7 +305,7 @@ describe('Test VOD transcoding in peertube-runner program', function () { describe('Web video & HLS enabled', function () { before(async function () { - await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true }) + await servers[0].config.enableTranscoding({ hls: true, webVideo: true, with0p: true, splitAudioAndVideo: false }) }) runSpecificSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage }) @@ -317,29 +318,33 @@ describe('Test VOD transcoding in peertube-runner program', function () { describe('Common', function () { + it('Should cap max FPS', async function () { + this.timeout(120_000) + + await servers[0].config.enableTranscoding({ maxFPS: 15, resolutions: [ 240, 480, 720 ], hls: true, webVideo: true }) + const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) + await waitJobs(servers, { runnerJobs: true }) + + const video = await servers[0].videos.get({ id: uuid }) + const hlsFiles = video.streamingPlaylists[0].files + + expect(video.files).to.have.lengthOf(3) + expect(hlsFiles).to.have.lengthOf(3) + + const fpsArray = getAllFiles(video).map(f => f.fps) + + for (const fps of fpsArray) { + expect(fps).to.be.at.most(15) + } + }) + it('Should not generate an upper resolution than original file', async function () { this.timeout(120_000) - await servers[0].config.updateExistingConfig({ - newConfig: { - transcoding: { - enabled: true, - hls: { enabled: true }, - webVideos: { enabled: true }, - resolutions: { - '0p': false, - '144p': false, - '240p': true, - '360p': false, - '480p': true, - '720p': false, - '1080p': false, - '1440p': false, - '2160p': false - }, - alwaysTranscodeOriginalResolution: false - } - } + await servers[0].config.enableTranscoding({ + maxFPS: 60, + resolutions: [ 240, 480 ], + alwaysTranscodeOriginalResolution: false }) const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) @@ -351,9 +356,8 @@ describe('Test VOD transcoding in peertube-runner program', function () { expect(video.files).to.have.lengthOf(2) expect(hlsFiles).to.have.lengthOf(2) - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare - const resolutions = getAllFiles(video).map(f => f.resolution.id).sort() - expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ]) + const resolutions = getAllFiles(video).map(f => f.resolution.id) + expect(resolutions).to.have.members([ 240, 240, 480, 480 ]) }) }) }) diff --git a/packages/tests/src/plugins/plugin-helpers.ts b/packages/tests/src/plugins/plugin-helpers.ts index d2bd8596e..494c6257e 100644 --- a/packages/tests/src/plugins/plugin-helpers.ts +++ b/packages/tests/src/plugins/plugin-helpers.ts @@ -285,7 +285,7 @@ describe('Test plugin helpers', function () { before(async function () { this.timeout(240000) - await servers[0].config.enableTranscoding() + await servers[0].config.enableTranscoding({ webVideo: true, hls: true, resolutions: 'max' }) const res = await servers[0].videos.quickUpload({ name: 'video1' }) videoUUID = res.uuid diff --git a/packages/tests/src/shared/generate.ts b/packages/tests/src/shared/generate.ts index ab2ecaf40..a301c7db7 100644 --- a/packages/tests/src/shared/generate.ts +++ b/packages/tests/src/shared/generate.ts @@ -47,7 +47,7 @@ async function generateHighBitrateVideo () { return tempFixturePath } -async function generateVideoWithFramerate (fps = 60) { +async function generateVideoWithFramerate (fps = 120, size = '1280x720') { const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true) await ensureDir(dirname(tempFixturePath)) @@ -60,8 +60,8 @@ async function generateVideoWithFramerate (fps = 60) { return new Promise((res, rej) => { ffmpeg() - .outputOptions([ '-f rawvideo', '-video_size 1280x720', '-i /dev/urandom' ]) - .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) + .outputOptions([ '-f rawvideo', '-video_size ' + size, '-i /dev/urandom' ]) + .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 5' ]) .outputOptions([ `-r ${fps}` ]) .output(tempFixturePath) .on('error', rej) diff --git a/packages/tests/src/shared/live.ts b/packages/tests/src/shared/live.ts index b9432ac69..f0a62d9ce 100644 --- a/packages/tests/src/shared/live.ts +++ b/packages/tests/src/shared/live.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@peertube/peertube-ffmpeg' import { LiveVideo, VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models' import { sha1 } from '@peertube/peertube-node-utils' import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands' @@ -50,7 +51,10 @@ async function testLiveVideoResolutions (options: { servers: PeerTubeServer[] liveVideoId: string + resolutions: number[] + framerates?: { [id: number]: number } + transcoded: boolean hasAudio?: boolean @@ -65,6 +69,7 @@ async function testLiveVideoResolutions (options: { servers, liveVideoId, transcoded, + framerates, objectStorage, hasAudio = true, hasVideo = true, @@ -102,6 +107,7 @@ async function testLiveVideoResolutions (options: { server, playlistUrl: hlsPlaylist.playlistUrl, resolutions, + framerates, transcoded, splittedAudio, hasAudio, @@ -125,6 +131,16 @@ async function testLiveVideoResolutions (options: { objectStorageBaseUrl }) + if (framerates) { + const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, segmentName)) + const { resolution } = await getVideoStreamDimensionsInfo(segmentPath) + + if (resolution) { + const fps = await getVideoStreamFPS(segmentPath) + expect(fps).to.equal(framerates[resolution]) + } + } + const baseUrl = objectStorage ? join(objectStorageBaseUrl, 'hls') : originServer.url + '/static/streaming-playlists/hls' diff --git a/packages/tests/src/shared/streaming-playlists.ts b/packages/tests/src/shared/streaming-playlists.ts index 7187752d3..559730064 100644 --- a/packages/tests/src/shared/streaming-playlists.ts +++ b/packages/tests/src/shared/streaming-playlists.ts @@ -90,6 +90,7 @@ export async function checkResolutionsInMasterPlaylist (options: { server: PeerTubeServer playlistUrl: string resolutions: number[] + framerates?: { [id: number]: number } token?: string transcoded?: boolean // default true withRetry?: boolean // default false @@ -101,6 +102,7 @@ export async function checkResolutionsInMasterPlaylist (options: { server, playlistUrl, resolutions, + framerates, token, hasAudio = true, hasVideo = true, @@ -136,7 +138,13 @@ export async function checkResolutionsInMasterPlaylist (options: { : '' if (transcoded) { - regexp += `,(FRAME-RATE=\\d+,)?CODECS="${codecs}"${audioGroup}` + const framerateRegex = framerates + ? framerates[resolution] + : '\\d+' + + if (!framerateRegex) throw new Error('Unknown framerate for resolution ' + resolution) + + regexp += `,(FRAME-RATE=${framerateRegex},)?CODECS="${codecs}"${audioGroup}` } expect(masterPlaylist).to.match(new RegExp(`${regexp}`)) diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts index fb7cfc75f..4a6475f7e 100644 --- a/server/core/controllers/api/config.ts +++ b/server/core/controllers/api/config.ts @@ -343,6 +343,9 @@ function customConfig (): CustomConfig { '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p'] }, alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION, + fps: { + max: CONFIG.TRANSCODING.FPS.MAX + }, webVideos: { enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED }, @@ -378,7 +381,10 @@ function customConfig (): CustomConfig { '1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'], '2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p'] }, - alwaysTranscodeOriginalResolution: CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION + alwaysTranscodeOriginalResolution: CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION, + fps: { + max: CONFIG.LIVE.TRANSCODING.FPS.MAX + } } }, videoStudio: { diff --git a/server/core/helpers/ffmpeg/framerate.ts b/server/core/helpers/ffmpeg/framerate.ts index 7d06cd3b1..93c68d0db 100644 --- a/server/core/helpers/ffmpeg/framerate.ts +++ b/server/core/helpers/ffmpeg/framerate.ts @@ -1,31 +1,44 @@ -import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js' +import { CONFIG } from '@server/initializers/config.js' +import { logger } from '../logger.js' export function computeOutputFPS (options: { inputFPS: number + isOriginResolution: boolean resolution: number + type: 'vod' | 'live' }) { - const { resolution } = options + const { resolution, isOriginResolution, type } = options + + const settings = type === 'vod' + ? buildTranscodingFPSOptions(CONFIG.TRANSCODING.FPS.MAX) + : buildTranscodingFPSOptions(CONFIG.LIVE.TRANSCODING.FPS.MAX) let fps = options.inputFPS if ( - // On small/medium resolutions, limit FPS + // On small/medium transcoded resolutions, limit FPS + !isOriginResolution && resolution !== undefined && - resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && - fps > VIDEO_TRANSCODING_FPS.AVERAGE + resolution < settings.KEEP_ORIGIN_FPS_RESOLUTION_MIN && + fps > settings.AVERAGE ) { // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value - fps = getClosestFramerateStandard({ fps, type: 'STANDARD' }) + fps = getClosestFramerate({ fps, settings, type: 'STANDARD' }) } - if (fps < VIDEO_TRANSCODING_FPS.HARD_MIN) { - throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${VIDEO_TRANSCODING_FPS.HARD_MIN}`) + if (fps < settings.HARD_MIN) { + throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${settings.HARD_MIN}`) } // Cap min FPS - if (fps < VIDEO_TRANSCODING_FPS.SOFT_MIN) fps = VIDEO_TRANSCODING_FPS.SOFT_MIN + fps = Math.max(fps, settings.TRANSCODED_MIN) + // Cap max FPS - if (fps > VIDEO_TRANSCODING_FPS.SOFT_MAX) fps = getClosestFramerateStandard({ fps, type: 'HD_STANDARD' }) + if (fps > settings.TRANSCODED_MAX) { + fps = getClosestFramerate({ fps, settings, type: 'HD_STANDARD' }) + } + + logger.debug(`Computed output FPS ${fps} for resolution ${resolution}p`, { options, settings }) return fps } @@ -34,12 +47,44 @@ export function computeOutputFPS (options: { // Private // --------------------------------------------------------------------------- -function getClosestFramerateStandard (options: { - fps: number - type: 'HD_STANDARD' | 'STANDARD' -}) { - const { fps, type } = options +function buildTranscodingFPSOptions (maxFPS: number) { + const STANDARD = [ 24, 25, 30 ].filter(v => v <= maxFPS) + if (STANDARD.length === 0) STANDARD.push(maxFPS) - return VIDEO_TRANSCODING_FPS[type].slice(0) - .sort((a, b) => fps % a - fps % b)[0] + const HD_STANDARD = [ 50, 60, maxFPS ].filter(v => v <= maxFPS) + + return { + HARD_MIN: 0.1, + + TRANSCODED_MIN: 1, + + TRANSCODED_MAX: maxFPS, + + STANDARD, + HD_STANDARD, + + AVERAGE: Math.min(30, maxFPS), + + KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) + } +} + +function getClosestFramerate (options: { + fps: number + settings: ReturnType + type: Extract, 'HD_STANDARD' | 'STANDARD'> +}) { + const { fps, settings, type } = options + + const copy = [ ...settings[type] ] + + // Biggest FPS first + const descSorted = copy.sort((a, b) => b - a) + // Find biggest FPS that can be divided by input FPS + const found = descSorted.find(e => fps % e === 0) + + if (found) return found + + // Approximation to the best result + return copy.sort((a, b) => fps % a - fps % b)[0] } diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts index 45f896c01..713e114dc 100644 --- a/server/core/initializers/checker-before-init.ts +++ b/server/core/initializers/checker-before-init.ts @@ -37,6 +37,7 @@ function checkMissedConfig () { 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled', + 'transcoding.fps.max', 'video_studio.enabled', 'video_studio.remote_runners.enabled', 'video_file.update.enabled', 'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live', @@ -85,7 +86,7 @@ function checkMissedConfig () { 'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 'live.transcoding.resolutions.480p', 'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', 'live.transcoding.resolutions.1440p', 'live.transcoding.resolutions.2160p', 'live.transcoding.always_transcode_original_resolution', - 'live.transcoding.remote_runners.enabled', + 'live.transcoding.fps.max', 'live.transcoding.remote_runners.enabled', 'storyboards.enabled' ] diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index f9cbb9151..e365366d8 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -448,6 +448,9 @@ const CONFIG = { get '1440p' () { return config.get('transcoding.resolutions.1440p') }, get '2160p' () { return config.get('transcoding.resolutions.2160p') } }, + FPS: { + get MAX () { return config.get('transcoding.fps.max') } + }, HLS: { get ENABLED () { return config.get('transcoding.hls.enabled') }, get SPLIT_AUDIO_AND_VIDEO () { return config.get('transcoding.hls.split_audio_and_video') } @@ -506,6 +509,11 @@ const CONFIG = { get '1440p' () { return config.get('live.transcoding.resolutions.1440p') }, get '2160p' () { return config.get('live.transcoding.resolutions.2160p') } }, + + FPS: { + get MAX () { return config.get('live.transcoding.fps.max') } + }, + REMOTE_RUNNERS: { get ENABLED () { return config.get('live.transcoding.remote_runners.enabled') } } diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index b869af1c6..c538e2f10 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -31,8 +31,7 @@ import { VideoRateType, VideoResolution, VideoState, - VideoStateType, - VideoTranscodingFPS + VideoStateType } from '@peertube/peertube-models' import { isTestInstance, isTestOrDevInstance, root } from '@peertube/peertube-node-utils' import { RepeatOptions } from 'bullmq' @@ -41,20 +40,20 @@ import { readJsonSync } from 'fs-extra/esm' import invert from 'lodash-es/invert.js' import { join } from 'path' // Do not use barrels, remain constants as independent as possible +import { cpus } from 'os' import { parseDurationToMs, sanitizeHost, sanitizeUrl } from '../helpers/core-utils.js' import { CONFIG, registerConfigChangedHandler } from './config.js' -import { cpus } from 'os' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 865 +export const LAST_MIGRATION_VERSION = 865 // --------------------------------------------------------------------------- -const API_VERSION = 'v1' -const PEERTUBE_VERSION: string = readJsonSync(join(root(), 'package.json')).version +export const API_VERSION = 'v1' +export const PEERTUBE_VERSION: string = readJsonSync(join(root(), 'package.json')).version -const PAGINATION = { +export const PAGINATION = { GLOBAL: { COUNT: { DEFAULT: 15, @@ -68,7 +67,7 @@ const PAGINATION = { } } -const WEBSERVER = { +export const WEBSERVER = { URL: '', HOST: '', SCHEME: '', @@ -84,7 +83,7 @@ const WEBSERVER = { } // Sortable columns per schema -const SORTABLE_COLUMNS = { +export const SORTABLE_COLUMNS = { ADMIN_USERS: [ 'id', 'username', 'videoQuotaUsed', 'createdAt', 'lastLoginDate', 'role' ], USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ], ACCOUNTS: [ 'createdAt' ], @@ -149,7 +148,7 @@ const SORTABLE_COLUMNS = { VIDEO_REDUNDANCIES: [ 'name' ] } -const ROUTE_CACHE_LIFETIME = { +export const ROUTE_CACHE_LIFETIME = { FEEDS: '15 minutes', ROBOTS: '2 hours', SITEMAP: '1 day', @@ -166,27 +165,27 @@ const ROUTE_CACHE_LIFETIME = { // --------------------------------------------------------------------------- // Number of points we add/remove after a successful/bad request -const ACTOR_FOLLOW_SCORE = { +export const ACTOR_FOLLOW_SCORE = { PENALTY: -10, BONUS: 10, BASE: 1000, MAX: 10000 } -const FOLLOW_STATES: { [ id: string ]: FollowState } = { +export const FOLLOW_STATES: { [ id: string ]: FollowState } = { PENDING: 'pending', ACCEPTED: 'accepted', REJECTED: 'rejected' } -const REMOTE_SCHEME = { +export const REMOTE_SCHEME = { HTTP: 'https', WS: 'wss' } // --------------------------------------------------------------------------- -const JOB_ATTEMPTS: { [id in JobType]: number } = { +export const JOB_ATTEMPTS: { [id in JobType]: number } = { 'activitypub-http-broadcast': 1, 'activitypub-http-broadcast-parallel': 1, 'activitypub-http-unicast': 1, @@ -217,7 +216,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = { 'video-transcription': 2 } // Excluded keys are jobs that can be configured by admins -const JOB_CONCURRENCY: { [id in Exclude]: number } = { +export const JOB_CONCURRENCY: { [id in Exclude]: number } = { 'activitypub-http-broadcast': 1, 'activitypub-http-broadcast-parallel': 30, 'activitypub-http-unicast': 30, @@ -245,7 +244,7 @@ const JOB_CONCURRENCY: { [id in Exclude', DESCRIPTION: '', CUSTOM_CSS: '', @@ -1113,34 +1102,34 @@ const CUSTOM_HTML_TAG_COMMENTS = { SERVER_CONFIG: '' } -const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000 -const LOG_FILENAME = 'peertube.log' -const AUDIT_LOG_FILENAME = 'peertube-audit.log' +export const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000 +export const LOG_FILENAME = 'peertube.log' +export const AUDIT_LOG_FILENAME = 'peertube-audit.log' // --------------------------------------------------------------------------- -const TRACKER_RATE_LIMITS = { +export const TRACKER_RATE_LIMITS = { INTERVAL: 60000 * 5, // 5 minutes ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes') } -const P2P_MEDIA_LOADER_PEER_VERSION = 2 +export const P2P_MEDIA_LOADER_PEER_VERSION = 2 // --------------------------------------------------------------------------- -const PLUGIN_GLOBAL_CSS_FILE_NAME = 'plugins-global.css' -const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL_CSS_FILE_NAME) +export const PLUGIN_GLOBAL_CSS_FILE_NAME = 'plugins-global.css' +export const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL_CSS_FILE_NAME) -let PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 1000 * 60 * 5 // 5 minutes +export let PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 1000 * 60 * 5 // 5 minutes -const DEFAULT_THEME_NAME = 'default' -const DEFAULT_USER_THEME_NAME = 'instance-default' +export const DEFAULT_THEME_NAME = 'default' +export const DEFAULT_USER_THEME_NAME = 'instance-default' // --------------------------------------------------------------------------- -const SEARCH_INDEX = { +export const SEARCH_INDEX = { ROUTES: { VIDEOS: '/api/v1/search/videos', VIDEO_CHANNELS: '/api/v1/search/video-channels' @@ -1149,7 +1138,7 @@ const SEARCH_INDEX = { // --------------------------------------------------------------------------- -const STATS_TIMESERIE = { +export const STATS_TIMESERIE = { MAX_DAYS: 365 * 10 // Around 10 years } @@ -1231,9 +1220,15 @@ registerConfigChangedHandler(() => { updateWebserverConfig() }) +export async function loadLanguages () { + if (Object.keys(VIDEO_LANGUAGES).length !== 0) return + + Object.assign(VIDEO_LANGUAGES, await buildLanguages()) +} + // --------------------------------------------------------------------------- -const FILES_CONTENT_HASH = { +export const FILES_CONTENT_HASH = { MANIFEST: generateContentHash(), FAVICON: generateContentHash(), LOGO: generateContentHash() @@ -1241,7 +1236,7 @@ const FILES_CONTENT_HASH = { // --------------------------------------------------------------------------- -const VIDEO_FILTERS = { +export const VIDEO_FILTERS = { WATERMARK: { SIZE_RATIO: 1 / 10, HORIZONTAL_MARGIN_RATIO: 1 / 20, @@ -1250,116 +1245,7 @@ const VIDEO_FILTERS = { } // --------------------------------------------------------------------------- - -export { - WEBSERVER, - API_VERSION, - ENCRYPTION, - VIDEO_LIVE, - PEERTUBE_VERSION, - LAZY_STATIC_PATHS, - OBJECT_STORAGE_PROXY_PATHS, - SEARCH_INDEX, - DIRECTORIES, - RESUMABLE_UPLOAD_SESSION_LIFETIME, - RUNNER_JOB_STATES, - USER_EXPORT_STATES, - USER_IMPORT_STATES, - P2P_MEDIA_LOADER_PEER_VERSION, - STORYBOARD, - ACTOR_IMAGES_SIZE, - ACCEPT_HEADERS, - BCRYPT_SALT_SIZE, - TRACKER_RATE_LIMITS, - VIDEO_COMMENTS_POLICY, - FILES_CACHE, - LOG_FILENAME, - CONSTRAINTS_FIELDS, - EMBED_SIZE, - REDUNDANCY, - USER_EXPORT_FILE_PREFIX, - JOB_CONCURRENCY, - JOB_ATTEMPTS, - AP_CLEANER, - LAST_MIGRATION_VERSION, - CUSTOM_HTML_TAG_COMMENTS, - STATS_TIMESERIE, - BROADCAST_CONCURRENCY, - AUDIT_LOG_FILENAME, - USER_IMPORT, - PAGINATION, - ACTOR_FOLLOW_SCORE, - PREVIEWS_SIZE, - REMOTE_SCHEME, - FOLLOW_STATES, - DEFAULT_USER_THEME_NAME, - SERVER_ACTOR_NAME, - TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME, - JWT_TOKEN_USER_EXPORT_FILE_LIFETIME, - PLUGIN_GLOBAL_CSS_FILE_NAME, - PLUGIN_GLOBAL_CSS_PATH, - PRIVATE_RSA_KEY_SIZE, - VIDEO_FILTERS, - ROUTE_CACHE_LIFETIME, - SORTABLE_COLUMNS, - JOB_TTL, - DEFAULT_THEME_NAME, - NSFW_POLICY_TYPES, - STATIC_MAX_AGE, - VIEWER_SYNC_REDIS, - STATIC_PATHS, - USER_EXPORT_MAX_ITEMS, - VIDEO_IMPORT_TIMEOUT, - VIDEO_PLAYLIST_TYPES, - MAX_LOGS_OUTPUT_CHARACTERS, - ACTIVITY_PUB, - ACTIVITY_PUB_ACTOR_TYPES, - THUMBNAILS_SIZE, - VIDEO_CATEGORIES, - MEMOIZE_LENGTH, - VIDEO_LANGUAGES, - VIDEO_PRIVACIES, - VIDEO_LICENCES, - VIDEO_STATES, - WORKER_THREADS, - VIDEO_RATE_TYPES, - JOB_PRIORITY, - VIDEO_TRANSCODING_FPS, - FFMPEG_NICE, - ABUSE_STATES, - USER_REGISTRATION_STATES, - LRU_CACHE, - REQUEST_TIMEOUTS, - RUNNER_JOBS, - MAX_LOCAL_VIEWER_WATCH_SECTIONS, - USER_PASSWORD_RESET_LIFETIME, - USER_PASSWORD_CREATE_LIFETIME, - MEMOIZE_TTL, - EMAIL_VERIFY_LIFETIME, - OVERVIEWS, - SCHEDULER_INTERVALS_MS, - REPEAT_JOBS, - DOWNLOAD_PATHS, - MIMETYPES, - CRAWL_REQUEST_CONCURRENCY, - DEFAULT_AUDIO_RESOLUTION, - BINARY_CONTENT_TYPES, - JOB_REMOVAL_OPTIONS, - HTTP_SIGNATURE, - VIDEO_IMPORT_STATES, - VIDEO_CHANNEL_SYNC_STATE, - VIEW_LIFETIME, - CONTACT_FORM_LIFETIME, - VIDEO_PLAYLIST_PRIVACIES, - PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME, - ASSETS_PATH, - FILES_CONTENT_HASH, - OTP, - loadLanguages, - buildLanguages, - generateContentHash -} - +// Private // --------------------------------------------------------------------------- function buildVideoMimetypeExt () { @@ -1480,12 +1366,6 @@ function buildMimetypesRegex (obj: { [id: string]: string | string[] }) { .join('|') } -async function loadLanguages () { - if (Object.keys(VIDEO_LANGUAGES).length !== 0) return - - Object.assign(VIDEO_LANGUAGES, await buildLanguages()) -} - async function buildLanguages () { const { iso6393 } = await import('iso-639-3') diff --git a/server/core/lib/live/live-manager.ts b/server/core/lib/live/live-manager.ts index 604772894..4b2e55382 100644 --- a/server/core/lib/live/live-manager.ts +++ b/server/core/lib/live/live-manager.ts @@ -341,10 +341,14 @@ class LiveManager { inputLocalUrl, inputPublicUrl, + fps, bitrate, ratio, + + inputResolution: resolution, allResolutions, + hasAudio, hasVideo, probe @@ -363,7 +367,10 @@ class LiveManager { fps: number bitrate: number ratio: number + + inputResolution: number allResolutions: number[] + hasAudio: boolean hasVideo: boolean probe: FfprobeData @@ -384,7 +391,18 @@ class LiveManager { videoLive, user, - ...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio', 'hasVideo', 'probe' ]) + ...pick(options, [ + 'inputLocalUrl', + 'inputPublicUrl', + 'inputResolution', + 'bitrate', + 'ratio', + 'fps', + 'allResolutions', + 'hasAudio', + 'hasVideo', + 'probe' + ]) }) muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, audioOnlyOutput, localLTags })) diff --git a/server/core/lib/live/shared/muxing-session.ts b/server/core/lib/live/shared/muxing-session.ts index 1b5b8f4bf..ca6743103 100644 --- a/server/core/lib/live/shared/muxing-session.ts +++ b/server/core/lib/live/shared/muxing-session.ts @@ -72,6 +72,8 @@ class MuxingSession extends EventEmitter { private readonly inputPublicUrl: string private readonly fps: number + + private readonly inputResolution: number private readonly allResolutions: number[] private readonly bitrate: number @@ -125,7 +127,10 @@ class MuxingSession extends EventEmitter { fps: number bitrate: number ratio: number + + inputResolution: number allResolutions: number[] + hasAudio: boolean hasVideo: boolean probe: FfprobeData @@ -149,6 +154,7 @@ class MuxingSession extends EventEmitter { this.hasVideo = options.hasVideo this.hasAudio = options.hasAudio + this.inputResolution = options.inputResolution this.allResolutions = options.allResolutions this.videoUUID = this.videoLive.Video.uuid @@ -547,7 +553,12 @@ class MuxingSession extends EventEmitter { } try { - toTranscodeFPS = computeOutputFPS({ inputFPS: this.fps, resolution }) + toTranscodeFPS = computeOutputFPS({ + inputFPS: this.fps, + resolution, + isOriginResolution: resolution === this.inputResolution, + type: 'live' + }) } catch (err) { err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM throw err diff --git a/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts b/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts index 29be6a3f8..062bd6de0 100644 --- a/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts +++ b/server/core/lib/transcoding/shared/job-builders/abstract-job-builder.ts @@ -3,7 +3,7 @@ import { VideoResolution } from '@peertube/peertube-models' import { computeOutputFPS } from '@server/helpers/ffmpeg/framerate.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { CONFIG } from '@server/initializers/config.js' -import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js' +import { DEFAULT_AUDIO_MERGE_RESOLUTION, DEFAULT_AUDIO_RESOLUTION } from '@server/initializers/constants.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' @@ -36,7 +36,7 @@ export abstract class AbstractJobBuilder

{ await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => { const probe = await ffprobePromise(videoFilePath) - const quickTranscode = await canDoQuickTranscode(videoFilePath, probe) + const quickTranscode = await canDoQuickTranscode(videoFilePath, CONFIG.TRANSCODING.FPS.MAX, probe) let inputFPS: number @@ -46,7 +46,8 @@ export abstract class AbstractJobBuilder

{ let hlsAudioAlreadyGenerated = false if (videoFile.isAudio()) { - inputFPS = maxFPS = VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value + // The first transcoding job will transcode to this FPS value + inputFPS = maxFPS = Math.min(DEFAULT_AUDIO_MERGE_RESOLUTION, CONFIG.TRANSCODING.FPS.MAX) maxResolution = DEFAULT_AUDIO_RESOLUTION mergeOrOptimizePayload = this.buildMergeAudioPayload({ @@ -59,7 +60,7 @@ export abstract class AbstractJobBuilder

{ } else { inputFPS = videoFile.fps maxResolution = buildOriginalFileResolution(videoFile.resolution) - maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution }) + maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution, isOriginResolution: true, type: 'vod' }) mergeOrOptimizePayload = this.buildOptimizePayload({ video, @@ -153,7 +154,7 @@ export abstract class AbstractJobBuilder

{ const inputFPS = video.getMaxFPS() const children = childrenResolutions.map(resolution => { - const fps = computeOutputFPS({ inputFPS, resolution }) + const fps = computeOutputFPS({ inputFPS, resolution, isOriginResolution: maxResolution === resolution, type: 'vod' }) if (transcodingType === 'hls') { return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio }) @@ -166,7 +167,7 @@ export abstract class AbstractJobBuilder

{ throw new Error('Unknown transcoding type') }) - const fps = computeOutputFPS({ inputFPS, resolution: maxResolution }) + const fps = computeOutputFPS({ inputFPS, resolution: maxResolution, isOriginResolution: true, type: 'vod' }) const parent = transcodingType === 'hls' ? this.buildHLSJobPayload({ video, resolution: maxResolution, fps, isNewVideo, separatedAudio }) @@ -199,7 +200,12 @@ export abstract class AbstractJobBuilder

{ const sequentialPayloads: P[][] = [] for (const resolution of resolutionsEnabled) { - const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution }) + const fps = computeOutputFPS({ + inputFPS: inputVideoFPS, + resolution, + isOriginResolution: resolution === inputVideoResolution, + type: 'vod' + }) let generateHLS = CONFIG.TRANSCODING.HLS.ENABLED if (resolution === VideoResolution.H_NOVIDEO && hlsAudioAlreadyGenerated) generateHLS = false diff --git a/server/core/lib/transcoding/transcoding-quick-transcode.ts b/server/core/lib/transcoding/transcoding-quick-transcode.ts index 1d054ece3..ba41afc21 100644 --- a/server/core/lib/transcoding/transcoding-quick-transcode.ts +++ b/server/core/lib/transcoding/transcoding-quick-transcode.ts @@ -2,11 +2,11 @@ import { FfprobeData } from 'fluent-ffmpeg' import { CONFIG } from '@server/initializers/config.js' import { canDoQuickAudioTranscode, canDoQuickVideoTranscode, ffprobePromise } from '@peertube/peertube-ffmpeg' -export async function canDoQuickTranscode (path: string, existingProbe?: FfprobeData): Promise { +export async function canDoQuickTranscode (path: string, maxFPS: number, existingProbe?: FfprobeData): Promise { if (CONFIG.TRANSCODING.PROFILE !== 'default') return false const probe = existingProbe || await ffprobePromise(path) - return await canDoQuickVideoTranscode(path, probe) && + return await canDoQuickVideoTranscode(path, maxFPS, probe) && await canDoQuickAudioTranscode(path, probe) } diff --git a/server/core/lib/transcoding/web-transcoding.ts b/server/core/lib/transcoding/web-transcoding.ts index 3ae837bbc..1df6a2ba6 100644 --- a/server/core/lib/transcoding/web-transcoding.ts +++ b/server/core/lib/transcoding/web-transcoding.ts @@ -50,7 +50,7 @@ export async function optimizeOriginalVideofile (options: { : 'video' const resolution = buildOriginalFileResolution(inputVideoFile.resolution) - const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution }) + const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution, isOriginResolution: true, type: 'vod' }) // Could be very long! await buildFFmpegVOD(job).transcode({ diff --git a/server/core/middlewares/validators/config.ts b/server/core/middlewares/validators/config.ts index f286cb0a3..257a0ac13 100644 --- a/server/core/middlewares/validators/config.ts +++ b/server/core/middlewares/validators/config.ts @@ -57,6 +57,7 @@ const customConfigUpdateValidator = [ body('transcoding.remoteRunners.enabled').isBoolean(), body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(), + body('transcoding.fps.max').custom(isIntOrNull), body('transcoding.webVideos.enabled').isBoolean(), body('transcoding.hls.enabled').isBoolean(), @@ -106,6 +107,7 @@ const customConfigUpdateValidator = [ body('live.transcoding.resolutions.1440p').isBoolean(), body('live.transcoding.resolutions.2160p').isBoolean(), body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean(), + body('live.transcoding.fps.max').custom(isIntOrNull), body('live.transcoding.remoteRunners.enabled').isBoolean(), body('search.remoteUri.users').isBoolean(),