Support max FPS configuration

This commit is contained in:
Chocobozzz 2024-08-12 16:17:11 +02:00
parent 0bd2474fed
commit bbaf96d60d
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
37 changed files with 736 additions and 623 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ yarn-error.log
# Big fixtures generated/downloaded on-demand # Big fixtures generated/downloaded on-demand
/packages/tests/fixtures/video_high_bitrate_1080p.mp4 /packages/tests/fixtures/video_high_bitrate_1080p.mp4
/packages/tests/fixtures/video_59fps.mp4 /packages/tests/fixtures/video_59fps.mp4
/packages/tests/fixtures/video_50fps.mp4
/packages/tests/fixtures/transcription/models-v1/ /packages/tests/fixtures/transcription/models-v1/
# PeerTube # PeerTube

View File

@ -606,6 +606,11 @@ transcoding:
# Transcode and keep original resolution, even if it's above your maximum enabled resolution # Transcode and keep original resolution, even if it's above your maximum enabled resolution
always_transcode_original_resolution: true 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 # Generate videos in a web compatible format
# If you also enabled the hls format, it will multiply videos storage by 2 # If you also enabled the hls format, it will multiply videos storage by 2
# If disabled, breaks federation with PeerTube instances < 2.1 # 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 # Also transcode original resolution, even if it's above your maximum enabled resolution
always_transcode_original_resolution: true always_transcode_original_resolution: true
fps:
# Cap transcoded live FPS
# Max resolution stream still keeps the original FPS
max: 60
video_studio: video_studio:
# Enable video edition by users (cut, add intro/outro, add watermark etc) # Enable video edition by users (cut, add intro/outro, add watermark etc)
# If enabled, users can create transcoding tasks as they wish # If enabled, users can create transcoding tasks as they wish

View File

@ -616,6 +616,11 @@ transcoding:
# Transcode and keep original resolution, even if it's above your maximum enabled resolution # Transcode and keep original resolution, even if it's above your maximum enabled resolution
always_transcode_original_resolution: true 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 # Generate videos in a web compatible format
# If you also enabled the hls format, it will multiply videos storage by 2 # If you also enabled the hls format, it will multiply videos storage by 2
# If disabled, breaks federation with PeerTube instances < 2.1 # 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 # Also transcode original resolution, even if it's above your maximum enabled resolution
always_transcode_original_resolution: true always_transcode_original_resolution: true
fps:
# Cap transcoded live FPS
# Max resolution stream still keeps the original FPS
max: 60
video_studio: video_studio:
# Enable video edition by users (cut, add intro/outro, add watermark etc) # Enable video edition by users (cut, add intro/outro, add watermark etc)
# If enabled, users can create transcoding tasks as they wish # If enabled, users can create transcoding tasks as they wish

View File

@ -126,7 +126,7 @@ export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeDat
return true return true
} }
export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { export async function canDoQuickVideoTranscode (path: string, maxFPS: number, probe?: FfprobeData): Promise<boolean> {
const videoStream = await getVideoStream(path, probe) const videoStream = await getVideoStream(path, probe)
const fps = await getVideoStreamFPS(path, probe) const fps = await getVideoStreamFPS(path, probe)
const bitRate = await getVideoStreamBitrate(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) return false
if (videoStream['codec_name'] !== 'h264') return false if (videoStream['codec_name'] !== 'h264') return false
if (videoStream['pix_fmt'] !== 'yuv420p') 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 if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false
return true return true

View File

@ -138,6 +138,10 @@ export interface CustomConfig {
alwaysTranscodeOriginalResolution: boolean alwaysTranscodeOriginalResolution: boolean
fps: {
max: number
}
webVideos: { webVideos: {
enabled: boolean enabled: boolean
} }
@ -168,8 +172,13 @@ export interface CustomConfig {
} }
threads: number threads: number
profile: string profile: string
resolutions: ConfigResolutions resolutions: ConfigResolutions
alwaysTranscodeOriginalResolution: boolean alwaysTranscodeOriginalResolution: boolean
fps: {
max: number
}
} }
} }

View File

@ -1,3 +1,2 @@
export * from './video-transcoding-create.model.js' export * from './video-transcoding-create.model.js'
export * from './video-transcoding-fps.model.js'
export * from './video-transcoding.model.js' export * from './video-transcoding.model.js'

View File

@ -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
}

View File

@ -265,17 +265,42 @@ export class ConfigCommand extends AbstractCommand {
}) })
} }
enableTranscoding (options: { async enableTranscoding (options: {
webVideo?: boolean // default true webVideo?: boolean
hls?: boolean // default true hls?: boolean
keepOriginal?: boolean // default false keepOriginal?: boolean
splitAudioAndVideo?: boolean // default false 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<typeof ConfigCommand.getCustomConfigResolutions>
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({ return this.updateExistingConfig({
newConfig: { newConfig: {
@ -288,9 +313,9 @@ export class ConfigCommand extends AbstractCommand {
allowAudioFiles: true, allowAudioFiles: true,
allowAdditionalExtensions: true, allowAdditionalExtensions: true,
resolutions: Array.isArray(resolutions) resolutions,
? ConfigCommand.getCustomConfigResolutions(resolutions)
: ConfigCommand.getConfigResolutions(resolutions === 'max', with0p), alwaysTranscodeOriginalResolution,
webVideos: { webVideos: {
enabled: webVideo enabled: webVideo
@ -298,6 +323,9 @@ export class ConfigCommand extends AbstractCommand {
hls: { hls: {
enabled: hls, enabled: hls,
splitAudioAndVideo splitAudioAndVideo
},
fps: {
max: maxFPS
} }
} }
} }

View File

@ -75,7 +75,7 @@ describe('Object storage for video import', function () {
describe('With transcoding', async function () { describe('With transcoding', async function () {
before(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 () { it('Should import a video and have sent it to object storage', async function () {

View File

@ -41,7 +41,7 @@ describe('Test runner common actions', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ 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() await server.config.enableRemoteTranscoding()
}) })

View File

@ -85,7 +85,7 @@ describe('Test runner VOD transcoding', function () {
before(async function () { before(async function () {
this.timeout(60000) 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 () { it('Should error a transcoding job', async function () {

View File

@ -81,6 +81,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.transcoding.resolutions['1440p']).to.be.true expect(data.transcoding.resolutions['1440p']).to.be.true
expect(data.transcoding.resolutions['2160p']).to.be.true expect(data.transcoding.resolutions['2160p']).to.be.true
expect(data.transcoding.alwaysTranscodeOriginalResolution).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.webVideos.enabled).to.be.true
expect(data.transcoding.hls.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true
expect(data.transcoding.hls.splitAudioAndVideo).to.be.false 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['1440p']).to.be.false
expect(data.live.transcoding.resolutions['2160p']).to.be.false expect(data.live.transcoding.resolutions['2160p']).to.be.false
expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.true 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.enabled).to.be.false
expect(data.videoStudio.remoteRunners.enabled).to.be.false expect(data.videoStudio.remoteRunners.enabled).to.be.false
@ -255,6 +257,9 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
'2160p': false '2160p': false
}, },
alwaysTranscodeOriginalResolution: false, alwaysTranscodeOriginalResolution: false,
fps: {
max: 120
},
webVideos: { webVideos: {
enabled: true enabled: true
}, },
@ -290,7 +295,10 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
'1440p': true, '1440p': true,
'2160p': true '2160p': true
}, },
alwaysTranscodeOriginalResolution: false alwaysTranscodeOriginalResolution: false,
fps: {
max: 144
}
} }
}, },
videoStudio: { videoStudio: {

View File

@ -77,7 +77,7 @@ function runTests (options: {
const video = await servers[0].videos.get({ id: videoUUID }) const video = await servers[0].videos.get({ id: videoUUID })
publishedAt = video.publishedAt as string 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) await servers[0].config.setTranscodingConcurrency(concurrency)
}) })

View File

@ -2,6 +2,7 @@ export * from './audio-only.js'
export * from './create-transcoding.js' export * from './create-transcoding.js'
export * from './hls.js' export * from './hls.js'
export * from './split-audio-and-video.js' export * from './split-audio-and-video.js'
export * from './transcoder-limits.js'
export * from './transcoder.js' export * from './transcoder.js'
export * from './update-while-transcoding.js' export * from './update-while-transcoding.js'
export * from './video-studio.js' export * from './video-studio.js'

View File

@ -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)
})
})

View File

@ -1,12 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* 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 { import {
ffprobePromise, ffprobePromise,
getAudioStream, getAudioStream,
getVideoStreamBitrate,
getVideoStreamDimensionsInfo,
getVideoStreamFPS,
hasAudioStream hasAudioStream
} from '@peertube/peertube-ffmpeg' } from '@peertube/peertube-ffmpeg'
import { HttpStatusCode, VideoFileMetadata, VideoState } from '@peertube/peertube-models' import { HttpStatusCode, VideoFileMetadata, VideoState } from '@peertube/peertube-models'
@ -21,35 +18,9 @@ import {
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { canDoQuickTranscode } from '@peertube/peertube-server/core/lib/transcoding/transcoding-quick-transcode.js' 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 { checkWebTorrentWorks } from '@tests/shared/webtorrent.js'
import { expect } from 'chai' 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 () { describe('Test video transcoding', function () {
let servers: PeerTubeServer[] = [] let servers: PeerTubeServer[] = []
let video4k: string let video4k: string
@ -64,10 +35,16 @@ describe('Test video transcoding', function () {
await doubleFollow(servers[0], servers[1]) 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 () { it('Should not transcode video on server 1', async function () {
this.timeout(60_000) 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 () { describe('FFprobe', function () {
it('Should provide valid ffprobe data', async function () { it('Should provide valid ffprobe data', async function () {
this.timeout(160_000) this.timeout(160_000)
await servers[1].config.enableTranscoding({ resolutions: 'max' })
const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid
await waitJobs(servers) await waitJobs(servers)
@ -667,8 +469,8 @@ describe('Test video transcoding', function () {
it('Should correctly detect if quick transcode is possible', async function () { it('Should correctly detect if quick transcode is possible', async function () {
this.timeout(10_000) this.timeout(10_000)
expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'), 60)).to.be.true
expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false 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 () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)
}) })

View File

@ -51,7 +51,7 @@ describe('Test generate download', function () {
const resolutions = [ VideoResolution.H_NOVIDEO, VideoResolution.H_144P ] 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 server.videos.quickUpload({ name: 'common-' + seed })
await waitJobs(servers) await waitJobs(servers)
} }

View File

@ -25,7 +25,7 @@ describe('Test videos files', function () {
await doubleFollow(servers[0], servers[1]) 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 () { describe('When deleting all files', function () {

View File

@ -247,7 +247,7 @@ describe('Test video source management', function () {
const previousPaths: string[] = [] 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 uploadFixture = 'video_short_720p.mp4'
const { uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'fs with transcoding', fixture: uploadFixture }) 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[] = [] 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 fixture1 = 'video_short_360p.mp4'
const { uuid: videoUUID } = await servers[0].videos.quickUpload({ const { uuid: videoUUID } = await servers[0].videos.quickUpload({

View File

@ -56,7 +56,7 @@ describe('Test syndication feeds', () => {
await doubleFollow(servers[0], servers[1]) await doubleFollow(servers[0], servers[1])
await servers[0].config.enableLive({ allowReplay: false, transcoding: false }) 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() const user = await servers[0].users.getMyInfo()

View File

@ -70,6 +70,64 @@ describe('Test Live transcoding in peertube-runner program', function () {
await servers[0].videos.remove({ id: video.id }) 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 () { it('Should transcode audio only RTMP stream', async function () {
this.timeout(120000) this.timeout(120000)

View File

@ -255,6 +255,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
await doubleFollow(servers[0], servers[1]) await doubleFollow(servers[0], servers[1])
await servers[0].config.enableTranscoding({ resolutions: 'max' })
await servers[0].config.enableRemoteTranscoding() await servers[0].config.enableRemoteTranscoding()
const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken() 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 () { describe('Web video & HLS enabled', function () {
before(async 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 }) runSpecificSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage })
@ -317,29 +318,33 @@ describe('Test VOD transcoding in peertube-runner program', function () {
describe('Common', 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 () { it('Should not generate an upper resolution than original file', async function () {
this.timeout(120_000) this.timeout(120_000)
await servers[0].config.updateExistingConfig({ await servers[0].config.enableTranscoding({
newConfig: { maxFPS: 60,
transcoding: { resolutions: [ 240, 480 ],
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 alwaysTranscodeOriginalResolution: false
}
}
}) })
const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' }) 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(video.files).to.have.lengthOf(2)
expect(hlsFiles).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)
const resolutions = getAllFiles(video).map(f => f.resolution.id).sort() expect(resolutions).to.have.members([ 240, 240, 480, 480 ])
expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ])
}) })
}) })
}) })

View File

@ -285,7 +285,7 @@ describe('Test plugin helpers', function () {
before(async function () { before(async function () {
this.timeout(240000) 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' }) const res = await servers[0].videos.quickUpload({ name: 'video1' })
videoUUID = res.uuid videoUUID = res.uuid

View File

@ -47,7 +47,7 @@ async function generateHighBitrateVideo () {
return tempFixturePath return tempFixturePath
} }
async function generateVideoWithFramerate (fps = 60) { async function generateVideoWithFramerate (fps = 120, size = '1280x720') {
const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true) const tempFixturePath = buildAbsoluteFixturePath(`video_${fps}fps.mp4`, true)
await ensureDir(dirname(tempFixturePath)) await ensureDir(dirname(tempFixturePath))
@ -60,8 +60,8 @@ async function generateVideoWithFramerate (fps = 60) {
return new Promise<string>((res, rej) => { return new Promise<string>((res, rej) => {
ffmpeg() ffmpeg()
.outputOptions([ '-f rawvideo', '-video_size 1280x720', '-i /dev/urandom' ]) .outputOptions([ '-f rawvideo', '-video_size ' + size, '-i /dev/urandom' ])
.outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 5' ])
.outputOptions([ `-r ${fps}` ]) .outputOptions([ `-r ${fps}` ])
.output(tempFixturePath) .output(tempFixturePath)
.on('error', rej) .on('error', rej)

View File

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* 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 { LiveVideo, VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { sha1 } from '@peertube/peertube-node-utils' import { sha1 } from '@peertube/peertube-node-utils'
import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands' import { ObjectStorageCommand, PeerTubeServer } from '@peertube/peertube-server-commands'
@ -50,7 +51,10 @@ async function testLiveVideoResolutions (options: {
servers: PeerTubeServer[] servers: PeerTubeServer[]
liveVideoId: string liveVideoId: string
resolutions: number[] resolutions: number[]
framerates?: { [id: number]: number }
transcoded: boolean transcoded: boolean
hasAudio?: boolean hasAudio?: boolean
@ -65,6 +69,7 @@ async function testLiveVideoResolutions (options: {
servers, servers,
liveVideoId, liveVideoId,
transcoded, transcoded,
framerates,
objectStorage, objectStorage,
hasAudio = true, hasAudio = true,
hasVideo = true, hasVideo = true,
@ -102,6 +107,7 @@ async function testLiveVideoResolutions (options: {
server, server,
playlistUrl: hlsPlaylist.playlistUrl, playlistUrl: hlsPlaylist.playlistUrl,
resolutions, resolutions,
framerates,
transcoded, transcoded,
splittedAudio, splittedAudio,
hasAudio, hasAudio,
@ -125,6 +131,16 @@ async function testLiveVideoResolutions (options: {
objectStorageBaseUrl 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 const baseUrl = objectStorage
? join(objectStorageBaseUrl, 'hls') ? join(objectStorageBaseUrl, 'hls')
: originServer.url + '/static/streaming-playlists/hls' : originServer.url + '/static/streaming-playlists/hls'

View File

@ -90,6 +90,7 @@ export async function checkResolutionsInMasterPlaylist (options: {
server: PeerTubeServer server: PeerTubeServer
playlistUrl: string playlistUrl: string
resolutions: number[] resolutions: number[]
framerates?: { [id: number]: number }
token?: string token?: string
transcoded?: boolean // default true transcoded?: boolean // default true
withRetry?: boolean // default false withRetry?: boolean // default false
@ -101,6 +102,7 @@ export async function checkResolutionsInMasterPlaylist (options: {
server, server,
playlistUrl, playlistUrl,
resolutions, resolutions,
framerates,
token, token,
hasAudio = true, hasAudio = true,
hasVideo = true, hasVideo = true,
@ -136,7 +138,13 @@ export async function checkResolutionsInMasterPlaylist (options: {
: '' : ''
if (transcoded) { 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}`)) expect(masterPlaylist).to.match(new RegExp(`${regexp}`))

View File

@ -343,6 +343,9 @@ function customConfig (): CustomConfig {
'2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p'] '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p']
}, },
alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION, alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
fps: {
max: CONFIG.TRANSCODING.FPS.MAX
},
webVideos: { webVideos: {
enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
}, },
@ -378,7 +381,10 @@ function customConfig (): CustomConfig {
'1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'], '1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'],
'2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p'] '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: { videoStudio: {

View File

@ -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: { export function computeOutputFPS (options: {
inputFPS: number inputFPS: number
isOriginResolution: boolean
resolution: number 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 let fps = options.inputFPS
if ( if (
// On small/medium resolutions, limit FPS // On small/medium transcoded resolutions, limit FPS
!isOriginResolution &&
resolution !== undefined && resolution !== undefined &&
resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && resolution < settings.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
fps > VIDEO_TRANSCODING_FPS.AVERAGE fps > settings.AVERAGE
) { ) {
// Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value // 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) { if (fps < settings.HARD_MIN) {
throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${VIDEO_TRANSCODING_FPS.HARD_MIN}`) throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${settings.HARD_MIN}`)
} }
// Cap min FPS // 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 // 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 return fps
} }
@ -34,12 +47,44 @@ export function computeOutputFPS (options: {
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getClosestFramerateStandard (options: { function buildTranscodingFPSOptions (maxFPS: number) {
fps: number const STANDARD = [ 24, 25, 30 ].filter(v => v <= maxFPS)
type: 'HD_STANDARD' | 'STANDARD' if (STANDARD.length === 0) STANDARD.push(maxFPS)
}) {
const { fps, type } = options
return VIDEO_TRANSCODING_FPS[type].slice(0) const HD_STANDARD = [ 50, 60, maxFPS ].filter(v => v <= maxFPS)
.sort((a, b) => fps % a - fps % b)[0]
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<typeof buildTranscodingFPSOptions>
type: Extract<keyof ReturnType<typeof buildTranscodingFPSOptions>, '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]
} }

View File

@ -37,6 +37,7 @@ function checkMissedConfig () {
'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', '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.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled', 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
'transcoding.fps.max',
'video_studio.enabled', 'video_studio.remote_runners.enabled', 'video_studio.enabled', 'video_studio.remote_runners.enabled',
'video_file.update.enabled', 'video_file.update.enabled',
'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live', '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.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p',
'live.transcoding.resolutions.480p', 'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', '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.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' 'storyboards.enabled'
] ]

View File

@ -448,6 +448,9 @@ const CONFIG = {
get '1440p' () { return config.get<boolean>('transcoding.resolutions.1440p') }, get '1440p' () { return config.get<boolean>('transcoding.resolutions.1440p') },
get '2160p' () { return config.get<boolean>('transcoding.resolutions.2160p') } get '2160p' () { return config.get<boolean>('transcoding.resolutions.2160p') }
}, },
FPS: {
get MAX () { return config.get<number>('transcoding.fps.max') }
},
HLS: { HLS: {
get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') }, get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') },
get SPLIT_AUDIO_AND_VIDEO () { return config.get<boolean>('transcoding.hls.split_audio_and_video') } get SPLIT_AUDIO_AND_VIDEO () { return config.get<boolean>('transcoding.hls.split_audio_and_video') }
@ -506,6 +509,11 @@ const CONFIG = {
get '1440p' () { return config.get<boolean>('live.transcoding.resolutions.1440p') }, get '1440p' () { return config.get<boolean>('live.transcoding.resolutions.1440p') },
get '2160p' () { return config.get<boolean>('live.transcoding.resolutions.2160p') } get '2160p' () { return config.get<boolean>('live.transcoding.resolutions.2160p') }
}, },
FPS: {
get MAX () { return config.get<number>('live.transcoding.fps.max') }
},
REMOTE_RUNNERS: { REMOTE_RUNNERS: {
get ENABLED () { return config.get<boolean>('live.transcoding.remote_runners.enabled') } get ENABLED () { return config.get<boolean>('live.transcoding.remote_runners.enabled') }
} }

View File

@ -31,8 +31,7 @@ import {
VideoRateType, VideoRateType,
VideoResolution, VideoResolution,
VideoState, VideoState,
VideoStateType, VideoStateType
VideoTranscodingFPS
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { isTestInstance, isTestOrDevInstance, root } from '@peertube/peertube-node-utils' import { isTestInstance, isTestOrDevInstance, root } from '@peertube/peertube-node-utils'
import { RepeatOptions } from 'bullmq' import { RepeatOptions } from 'bullmq'
@ -41,20 +40,20 @@ import { readJsonSync } from 'fs-extra/esm'
import invert from 'lodash-es/invert.js' import invert from 'lodash-es/invert.js'
import { join } from 'path' import { join } from 'path'
// Do not use barrels, remain constants as independent as possible // Do not use barrels, remain constants as independent as possible
import { cpus } from 'os'
import { parseDurationToMs, sanitizeHost, sanitizeUrl } from '../helpers/core-utils.js' import { parseDurationToMs, sanitizeHost, sanitizeUrl } from '../helpers/core-utils.js'
import { CONFIG, registerConfigChangedHandler } from './config.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' export const API_VERSION = 'v1'
const PEERTUBE_VERSION: string = readJsonSync(join(root(), 'package.json')).version export const PEERTUBE_VERSION: string = readJsonSync(join(root(), 'package.json')).version
const PAGINATION = { export const PAGINATION = {
GLOBAL: { GLOBAL: {
COUNT: { COUNT: {
DEFAULT: 15, DEFAULT: 15,
@ -68,7 +67,7 @@ const PAGINATION = {
} }
} }
const WEBSERVER = { export const WEBSERVER = {
URL: '', URL: '',
HOST: '', HOST: '',
SCHEME: '', SCHEME: '',
@ -84,7 +83,7 @@ const WEBSERVER = {
} }
// Sortable columns per schema // Sortable columns per schema
const SORTABLE_COLUMNS = { export const SORTABLE_COLUMNS = {
ADMIN_USERS: [ 'id', 'username', 'videoQuotaUsed', 'createdAt', 'lastLoginDate', 'role' ], ADMIN_USERS: [ 'id', 'username', 'videoQuotaUsed', 'createdAt', 'lastLoginDate', 'role' ],
USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ], USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ],
ACCOUNTS: [ 'createdAt' ], ACCOUNTS: [ 'createdAt' ],
@ -149,7 +148,7 @@ const SORTABLE_COLUMNS = {
VIDEO_REDUNDANCIES: [ 'name' ] VIDEO_REDUNDANCIES: [ 'name' ]
} }
const ROUTE_CACHE_LIFETIME = { export const ROUTE_CACHE_LIFETIME = {
FEEDS: '15 minutes', FEEDS: '15 minutes',
ROBOTS: '2 hours', ROBOTS: '2 hours',
SITEMAP: '1 day', SITEMAP: '1 day',
@ -166,27 +165,27 @@ const ROUTE_CACHE_LIFETIME = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Number of points we add/remove after a successful/bad request // Number of points we add/remove after a successful/bad request
const ACTOR_FOLLOW_SCORE = { export const ACTOR_FOLLOW_SCORE = {
PENALTY: -10, PENALTY: -10,
BONUS: 10, BONUS: 10,
BASE: 1000, BASE: 1000,
MAX: 10000 MAX: 10000
} }
const FOLLOW_STATES: { [ id: string ]: FollowState } = { export const FOLLOW_STATES: { [ id: string ]: FollowState } = {
PENDING: 'pending', PENDING: 'pending',
ACCEPTED: 'accepted', ACCEPTED: 'accepted',
REJECTED: 'rejected' REJECTED: 'rejected'
} }
const REMOTE_SCHEME = { export const REMOTE_SCHEME = {
HTTP: 'https', HTTP: 'https',
WS: 'wss' WS: 'wss'
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const JOB_ATTEMPTS: { [id in JobType]: number } = { export const JOB_ATTEMPTS: { [id in JobType]: number } = {
'activitypub-http-broadcast': 1, 'activitypub-http-broadcast': 1,
'activitypub-http-broadcast-parallel': 1, 'activitypub-http-broadcast-parallel': 1,
'activitypub-http-unicast': 1, 'activitypub-http-unicast': 1,
@ -217,7 +216,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
'video-transcription': 2 'video-transcription': 2
} }
// Excluded keys are jobs that can be configured by admins // Excluded keys are jobs that can be configured by admins
const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = { export const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = {
'activitypub-http-broadcast': 1, 'activitypub-http-broadcast': 1,
'activitypub-http-broadcast-parallel': 30, 'activitypub-http-broadcast-parallel': 30,
'activitypub-http-unicast': 30, 'activitypub-http-unicast': 30,
@ -245,7 +244,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
'import-user-archive': 1, 'import-user-archive': 1,
'video-transcription': 1 'video-transcription': 1
} }
const JOB_TTL: { [id in JobType]: number } = { export const JOB_TTL: { [id in JobType]: number } = {
'activitypub-http-broadcast': 60000 * 10, // 10 minutes 'activitypub-http-broadcast': 60000 * 10, // 10 minutes
'activitypub-http-broadcast-parallel': 60000 * 10, // 10 minutes 'activitypub-http-broadcast-parallel': 60000 * 10, // 10 minutes
'activitypub-http-unicast': 60000 * 10, // 10 minutes 'activitypub-http-unicast': 60000 * 10, // 10 minutes
@ -275,7 +274,7 @@ const JOB_TTL: { [id in JobType]: number } = {
'import-user-archive': 60000 * 60 * 24, // 24 hours 'import-user-archive': 60000 * 60 * 24, // 24 hours
'video-transcription': 1000 * 3600 * 6 // 6 hours 'video-transcription': 1000 * 3600 * 6 // 6 hours
} }
const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = { export const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = {
'videos-views-stats': { 'videos-views-stats': {
pattern: randomInt(1, 20) + ' * * * *' // Between 1-20 minutes past the hour pattern: randomInt(1, 20) + ' * * * *' // Between 1-20 minutes past the hour
}, },
@ -283,13 +282,13 @@ const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = {
pattern: '30 5 * * ' + randomInt(0, 7) // 1 time per week (random day) at 5:30 AM pattern: '30 5 * * ' + randomInt(0, 7) // 1 time per week (random day) at 5:30 AM
} }
} }
const JOB_PRIORITY = { export const JOB_PRIORITY = {
TRANSCODING: 100, TRANSCODING: 100,
VIDEO_STUDIO: 150, VIDEO_STUDIO: 150,
TRANSCRIPTION: 200 TRANSCRIPTION: 200
} }
const JOB_REMOVAL_OPTIONS = { export const JOB_REMOVAL_OPTIONS = {
COUNT: 10000, // Max jobs to store COUNT: 10000, // Max jobs to store
SUCCESS: { // Success jobs SUCCESS: { // Success jobs
@ -306,32 +305,32 @@ const JOB_REMOVAL_OPTIONS = {
} }
} }
const VIDEO_IMPORT_TIMEOUT = Math.floor(JOB_TTL['video-import'] * 0.9) export const VIDEO_IMPORT_TIMEOUT = Math.floor(JOB_TTL['video-import'] * 0.9)
const RUNNER_JOBS = { export const RUNNER_JOBS = {
MAX_FAILURES: 5, MAX_FAILURES: 5,
LAST_CONTACT_UPDATE_INTERVAL: 30000 LAST_CONTACT_UPDATE_INTERVAL: 30000
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BROADCAST_CONCURRENCY = 30 // How many requests in parallel we do in activitypub-http-broadcast job export const BROADCAST_CONCURRENCY = 30 // How many requests in parallel we do in activitypub-http-broadcast job
const CRAWL_REQUEST_CONCURRENCY = 1 // How many requests in parallel to fetch remote data (likes, shares...) export const CRAWL_REQUEST_CONCURRENCY = 1 // How many requests in parallel to fetch remote data (likes, shares...)
const AP_CLEANER = { export const AP_CLEANER = {
CONCURRENCY: 10, // How many requests in parallel we do in activitypub-cleaner job CONCURRENCY: 10, // How many requests in parallel we do in activitypub-cleaner job
UNAVAILABLE_TRESHOLD: 3, // How many attempts we do before removing an unavailable remote resource UNAVAILABLE_TRESHOLD: 3, // How many attempts we do before removing an unavailable remote resource
PERIOD: parseDurationToMs('1 week') // /!\ Has to be sync with REPEAT_JOBS PERIOD: parseDurationToMs('1 week') // /!\ Has to be sync with REPEAT_JOBS
} }
const REQUEST_TIMEOUTS = { export const REQUEST_TIMEOUTS = {
DEFAULT: 7000, // 7 seconds DEFAULT: 7000, // 7 seconds
FILE: 30000, // 30 seconds FILE: 30000, // 30 seconds
VIDEO_FILE: 60000, // 1 minute VIDEO_FILE: 60000, // 1 minute
REDUNDANCY: JOB_TTL['video-redundancy'] REDUNDANCY: JOB_TTL['video-redundancy']
} }
const SCHEDULER_INTERVALS_MS = { export const SCHEDULER_INTERVALS_MS = {
RUNNER_JOB_WATCH_DOG: Math.min(CONFIG.REMOTE_RUNNERS.STALLED_JOBS.VOD, CONFIG.REMOTE_RUNNERS.STALLED_JOBS.LIVE), RUNNER_JOB_WATCH_DOG: Math.min(CONFIG.REMOTE_RUNNERS.STALLED_JOBS.VOD, CONFIG.REMOTE_RUNNERS.STALLED_JOBS.LIVE),
ACTOR_FOLLOW_SCORES: 60000 * 60, // 1 hour ACTOR_FOLLOW_SCORES: 60000 * 60, // 1 hour
REMOVE_OLD_JOBS: 60000 * 60, // 1 hour REMOVE_OLD_JOBS: 60000 * 60, // 1 hour
@ -352,7 +351,7 @@ const SCHEDULER_INTERVALS_MS = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const CONSTRAINTS_FIELDS = { export const CONSTRAINTS_FIELDS = {
USERS: { USERS: {
NAME: { min: 1, max: 120 }, // Length NAME: { min: 1, max: 120 }, // Length
DESCRIPTION: { min: 3, max: 1000 }, // Length DESCRIPTION: { min: 3, max: 1000 }, // Length
@ -515,40 +514,30 @@ const CONSTRAINTS_FIELDS = {
} }
} }
const VIEW_LIFETIME = { export const VIEW_LIFETIME = {
VIEW: CONFIG.VIEWS.VIDEOS.VIEW_EXPIRATION, VIEW: CONFIG.VIEWS.VIDEOS.VIEW_EXPIRATION,
VIEWER_COUNTER: 60000 * 2, // 2 minutes VIEWER_COUNTER: 60000 * 2, // 2 minutes
VIEWER_STATS: 60000 * 60 // 1 hour VIEWER_STATS: 60000 * 60 // 1 hour
} }
let VIEWER_SYNC_REDIS = 30000 // Sync viewer into redis export let VIEWER_SYNC_REDIS = 30000 // Sync viewer into redis
const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100 export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100
let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { export const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P
HARD_MIN: 0.1, export const DEFAULT_AUDIO_MERGE_RESOLUTION = 25
SOFT_MIN: 1,
STANDARD: [ 24, 25, 30 ],
HD_STANDARD: [ 50, 60 ],
AUDIO_MERGE: 25,
AVERAGE: 30,
SOFT_MAX: 60,
KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
}
const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P export const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = {
const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = {
LIKE: 'like', LIKE: 'like',
DISLIKE: 'dislike' DISLIKE: 'dislike'
} }
const USER_IMPORT = { export const USER_IMPORT = {
MAX_PLAYLIST_ELEMENTS: 1000 MAX_PLAYLIST_ELEMENTS: 1000
} }
const FFMPEG_NICE = { export const FFMPEG_NICE = {
// parent process defaults to niceness = 0 // parent process defaults to niceness = 0
// reminder: lower = higher priority, max value is 19, lowest is -20 // reminder: lower = higher priority, max value is 19, lowest is -20
LIVE: 5, // prioritize over VOD and THUMBNAIL LIVE: 5, // prioritize over VOD and THUMBNAIL
@ -556,7 +545,7 @@ const FFMPEG_NICE = {
VOD: 15 VOD: 15
} }
const VIDEO_CATEGORIES = { export const VIDEO_CATEGORIES = {
1: 'Music', 1: 'Music',
2: 'Films', 2: 'Films',
3: 'Vehicles', 3: 'Vehicles',
@ -578,7 +567,7 @@ const VIDEO_CATEGORIES = {
} }
// See https://creativecommons.org/licenses/?lang=en // See https://creativecommons.org/licenses/?lang=en
const VIDEO_LICENCES = { export const VIDEO_LICENCES = {
1: 'Attribution', 1: 'Attribution',
2: 'Attribution - Share Alike', 2: 'Attribution - Share Alike',
3: 'Attribution - No Derivatives', 3: 'Attribution - No Derivatives',
@ -588,9 +577,9 @@ const VIDEO_LICENCES = {
7: 'Public Domain Dedication' 7: 'Public Domain Dedication'
} }
const VIDEO_LANGUAGES: { [id: string]: string } = {} export const VIDEO_LANGUAGES: { [id: string]: string } = {}
const VIDEO_PRIVACIES: { [ id in VideoPrivacyType ]: string } = { export const VIDEO_PRIVACIES: { [ id in VideoPrivacyType ]: string } = {
[VideoPrivacy.PUBLIC]: 'Public', [VideoPrivacy.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.PRIVATE]: 'Private',
@ -598,7 +587,7 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacyType ]: string } = {
[VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected' [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
} }
const VIDEO_STATES: { [ id in VideoStateType ]: string } = { export const VIDEO_STATES: { [ id in VideoStateType ]: string } = {
[VideoState.PUBLISHED]: 'Published', [VideoState.PUBLISHED]: 'Published',
[VideoState.TO_TRANSCODE]: 'To transcode', [VideoState.TO_TRANSCODE]: 'To transcode',
[VideoState.TO_IMPORT]: 'To import', [VideoState.TO_IMPORT]: 'To import',
@ -612,7 +601,7 @@ const VIDEO_STATES: { [ id in VideoStateType ]: string } = {
[VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED]: 'Move to file system failed' [VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED]: 'Move to file system failed'
} }
const VIDEO_IMPORT_STATES: { [ id in VideoImportStateType ]: string } = { export const VIDEO_IMPORT_STATES: { [ id in VideoImportStateType ]: string } = {
[VideoImportState.FAILED]: 'Failed', [VideoImportState.FAILED]: 'Failed',
[VideoImportState.PENDING]: 'Pending', [VideoImportState.PENDING]: 'Pending',
[VideoImportState.SUCCESS]: 'Success', [VideoImportState.SUCCESS]: 'Success',
@ -621,37 +610,37 @@ const VIDEO_IMPORT_STATES: { [ id in VideoImportStateType ]: string } = {
[VideoImportState.PROCESSING]: 'Processing' [VideoImportState.PROCESSING]: 'Processing'
} }
const VIDEO_CHANNEL_SYNC_STATE: { [ id in VideoChannelSyncStateType ]: string } = { export const VIDEO_CHANNEL_SYNC_STATE: { [ id in VideoChannelSyncStateType ]: string } = {
[VideoChannelSyncState.FAILED]: 'Failed', [VideoChannelSyncState.FAILED]: 'Failed',
[VideoChannelSyncState.SYNCED]: 'Synchronized', [VideoChannelSyncState.SYNCED]: 'Synchronized',
[VideoChannelSyncState.PROCESSING]: 'Processing', [VideoChannelSyncState.PROCESSING]: 'Processing',
[VideoChannelSyncState.WAITING_FIRST_RUN]: 'Waiting first run' [VideoChannelSyncState.WAITING_FIRST_RUN]: 'Waiting first run'
} }
const ABUSE_STATES: { [ id in AbuseStateType ]: string } = { export const ABUSE_STATES: { [ id in AbuseStateType ]: string } = {
[AbuseState.PENDING]: 'Pending', [AbuseState.PENDING]: 'Pending',
[AbuseState.REJECTED]: 'Rejected', [AbuseState.REJECTED]: 'Rejected',
[AbuseState.ACCEPTED]: 'Accepted' [AbuseState.ACCEPTED]: 'Accepted'
} }
const USER_REGISTRATION_STATES: { [ id in UserRegistrationStateType ]: string } = { export const USER_REGISTRATION_STATES: { [ id in UserRegistrationStateType ]: string } = {
[UserRegistrationState.PENDING]: 'Pending', [UserRegistrationState.PENDING]: 'Pending',
[UserRegistrationState.REJECTED]: 'Rejected', [UserRegistrationState.REJECTED]: 'Rejected',
[UserRegistrationState.ACCEPTED]: 'Accepted' [UserRegistrationState.ACCEPTED]: 'Accepted'
} }
const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacyType ]: string } = { export const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacyType ]: string } = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public', [VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
[VideoPlaylistPrivacy.PRIVATE]: 'Private' [VideoPlaylistPrivacy.PRIVATE]: 'Private'
} }
const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType_Type ]: string } = { export const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType_Type ]: string } = {
[VideoPlaylistType.REGULAR]: 'Regular', [VideoPlaylistType.REGULAR]: 'Regular',
[VideoPlaylistType.WATCH_LATER]: 'Watch later' [VideoPlaylistType.WATCH_LATER]: 'Watch later'
} }
const RUNNER_JOB_STATES: { [ id in RunnerJobStateType ]: string } = { export const RUNNER_JOB_STATES: { [ id in RunnerJobStateType ]: string } = {
[RunnerJobState.PROCESSING]: 'Processing', [RunnerJobState.PROCESSING]: 'Processing',
[RunnerJobState.COMPLETED]: 'Completed', [RunnerJobState.COMPLETED]: 'Completed',
[RunnerJobState.COMPLETING]: 'Completing', [RunnerJobState.COMPLETING]: 'Completing',
@ -663,27 +652,27 @@ const RUNNER_JOB_STATES: { [ id in RunnerJobStateType ]: string } = {
[RunnerJobState.PARENT_CANCELLED]: 'Parent job cancelled' [RunnerJobState.PARENT_CANCELLED]: 'Parent job cancelled'
} }
const USER_EXPORT_STATES: { [ id in UserExportStateType ]: string } = { export const USER_EXPORT_STATES: { [ id in UserExportStateType ]: string } = {
[UserExportState.PENDING]: 'Pending', [UserExportState.PENDING]: 'Pending',
[UserExportState.PROCESSING]: 'Processing', [UserExportState.PROCESSING]: 'Processing',
[UserExportState.COMPLETED]: 'Completed', [UserExportState.COMPLETED]: 'Completed',
[UserExportState.ERRORED]: 'Failed' [UserExportState.ERRORED]: 'Failed'
} }
const USER_IMPORT_STATES: { [ id in UserImportStateType ]: string } = { export const USER_IMPORT_STATES: { [ id in UserImportStateType ]: string } = {
[UserImportState.PENDING]: 'Pending', [UserImportState.PENDING]: 'Pending',
[UserImportState.PROCESSING]: 'Processing', [UserImportState.PROCESSING]: 'Processing',
[UserImportState.COMPLETED]: 'Completed', [UserImportState.COMPLETED]: 'Completed',
[UserImportState.ERRORED]: 'Failed' [UserImportState.ERRORED]: 'Failed'
} }
const VIDEO_COMMENTS_POLICY: { [ id in VideoCommentPolicyType ]: string } = { export const VIDEO_COMMENTS_POLICY: { [ id in VideoCommentPolicyType ]: string } = {
[VideoCommentPolicy.DISABLED]: 'Disabled', [VideoCommentPolicy.DISABLED]: 'Disabled',
[VideoCommentPolicy.ENABLED]: 'Enabled', [VideoCommentPolicy.ENABLED]: 'Enabled',
[VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval' [VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval'
} }
const MIMETYPES = { export const MIMETYPES = {
AUDIO: { AUDIO: {
MIMETYPE_EXT: { MIMETYPE_EXT: {
'audio/mpeg': '.mp3', 'audio/mpeg': '.mp3',
@ -769,7 +758,7 @@ MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT) MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT)
MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
const BINARY_CONTENT_TYPES = new Set([ export const BINARY_CONTENT_TYPES = new Set([
'binary/octet-stream', 'binary/octet-stream',
'application/octet-stream', 'application/octet-stream',
'application/x-binary' 'application/x-binary'
@ -777,7 +766,7 @@ const BINARY_CONTENT_TYPES = new Set([
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const OVERVIEWS = { export const OVERVIEWS = {
VIDEOS: { VIDEOS: {
SAMPLE_THRESHOLD: 6, SAMPLE_THRESHOLD: 6,
SAMPLES_COUNT: 20 SAMPLES_COUNT: 20
@ -786,9 +775,9 @@ const OVERVIEWS = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube' export const SERVER_ACTOR_NAME = 'peertube'
const ACTIVITY_PUB = { export const ACTIVITY_PUB = {
POTENTIAL_ACCEPT_HEADERS: [ POTENTIAL_ACCEPT_HEADERS: [
'application/activity+json', 'application/activity+json',
'application/ld+json', 'application/ld+json',
@ -803,7 +792,7 @@ const ACTIVITY_PUB = {
VIDEO_PLAYLIST_REFRESH_INTERVAL: 3600 * 24 * 1000 * 2 // 2 days VIDEO_PLAYLIST_REFRESH_INTERVAL: 3600 * 24 * 1000 * 2 // 2 days
} }
const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = { export const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = {
GROUP: 'Group', GROUP: 'Group',
PERSON: 'Person', PERSON: 'Person',
APPLICATION: 'Application', APPLICATION: 'Application',
@ -811,7 +800,7 @@ const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = {
SERVICE: 'Service' SERVICE: 'Service'
} }
const HTTP_SIGNATURE = { export const HTTP_SIGNATURE = {
HEADER_NAME: 'signature', HEADER_NAME: 'signature',
ALGORITHM: 'rsa-sha256', ALGORITHM: 'rsa-sha256',
HEADERS_TO_SIGN_WITH_PAYLOAD: [ '(request-target)', 'host', 'date', 'digest' ], HEADERS_TO_SIGN_WITH_PAYLOAD: [ '(request-target)', 'host', 'date', 'digest' ],
@ -821,27 +810,27 @@ const HTTP_SIGNATURE = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let PRIVATE_RSA_KEY_SIZE = 2048 export let PRIVATE_RSA_KEY_SIZE = 2048
// Password encryption // Password encryption
const BCRYPT_SALT_SIZE = 10 export const BCRYPT_SALT_SIZE = 10
const ENCRYPTION = { export const ENCRYPTION = {
ALGORITHM: 'aes-256-cbc', ALGORITHM: 'aes-256-cbc',
IV: 16, IV: 16,
SALT: 'peertube', SALT: 'peertube',
ENCODING: 'hex' as Encoding ENCODING: 'hex' as Encoding
} }
const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes export const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days export const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes export const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
let JWT_TOKEN_USER_EXPORT_FILE_LIFETIME = '15 minutes' export let JWT_TOKEN_USER_EXPORT_FILE_LIFETIME = '15 minutes'
const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes export const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { export const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
DO_NOT_LIST: 'do_not_list', DO_NOT_LIST: 'do_not_list',
BLUR: 'blur', BLUR: 'blur',
DISPLAY: 'display' DISPLAY: 'display'
@ -849,13 +838,13 @@ const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const USER_EXPORT_MAX_ITEMS = 1000 export const USER_EXPORT_MAX_ITEMS = 1000
const USER_EXPORT_FILE_PREFIX = 'user-export-' export const USER_EXPORT_FILE_PREFIX = 'user-export-'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Express static paths (router) // Express static paths (router)
const STATIC_PATHS = { export const STATIC_PATHS = {
// TODO: deprecated in v6, to remove // TODO: deprecated in v6, to remove
THUMBNAILS: '/static/thumbnails/', THUMBNAILS: '/static/thumbnails/',
@ -874,7 +863,7 @@ const STATIC_PATHS = {
PRIVATE_HLS: '/static/streaming-playlists/hls/private/' PRIVATE_HLS: '/static/streaming-playlists/hls/private/'
} }
} }
const DOWNLOAD_PATHS = { export const DOWNLOAD_PATHS = {
TORRENTS: '/download/torrents/', TORRENTS: '/download/torrents/',
GENERATE_VIDEO: '/download/videos/generate/', GENERATE_VIDEO: '/download/videos/generate/',
WEB_VIDEOS: '/download/web-videos/', WEB_VIDEOS: '/download/web-videos/',
@ -882,7 +871,7 @@ const DOWNLOAD_PATHS = {
USER_EXPORTS: '/download/user-exports/', USER_EXPORTS: '/download/user-exports/',
ORIGINAL_VIDEO_FILE: '/download/original-video-files/' ORIGINAL_VIDEO_FILE: '/download/original-video-files/'
} }
const LAZY_STATIC_PATHS = { export const LAZY_STATIC_PATHS = {
THUMBNAILS: '/lazy-static/thumbnails/', THUMBNAILS: '/lazy-static/thumbnails/',
BANNERS: '/lazy-static/banners/', BANNERS: '/lazy-static/banners/',
AVATARS: '/lazy-static/avatars/', AVATARS: '/lazy-static/avatars/',
@ -891,7 +880,7 @@ const LAZY_STATIC_PATHS = {
TORRENTS: '/lazy-static/torrents/', TORRENTS: '/lazy-static/torrents/',
STORYBOARDS: '/lazy-static/storyboards/' STORYBOARDS: '/lazy-static/storyboards/'
} }
const OBJECT_STORAGE_PROXY_PATHS = { export const OBJECT_STORAGE_PROXY_PATHS = {
// Need to keep this legacy path for previously generated torrents // Need to keep this legacy path for previously generated torrents
LEGACY_PRIVATE_WEB_VIDEOS: '/object-storage-proxy/webseed/private/', LEGACY_PRIVATE_WEB_VIDEOS: '/object-storage-proxy/webseed/private/',
PRIVATE_WEB_VIDEOS: '/object-storage-proxy/web-videos/private/', PRIVATE_WEB_VIDEOS: '/object-storage-proxy/web-videos/private/',
@ -902,24 +891,24 @@ const OBJECT_STORAGE_PROXY_PATHS = {
} }
// Cache control // Cache control
const STATIC_MAX_AGE = { export const STATIC_MAX_AGE = {
SERVER: '2h', SERVER: '2h',
LAZY_SERVER: '2d', LAZY_SERVER: '2d',
CLIENT: '30d' CLIENT: '30d'
} }
// Videos thumbnail size // Videos thumbnail size
const THUMBNAILS_SIZE = { export const THUMBNAILS_SIZE = {
width: minBy(CONFIG.THUMBNAILS.SIZES, 'width').width, width: minBy(CONFIG.THUMBNAILS.SIZES, 'width').width,
height: minBy(CONFIG.THUMBNAILS.SIZES, 'width').height, height: minBy(CONFIG.THUMBNAILS.SIZES, 'width').height,
minRemoteWidth: 150 minRemoteWidth: 150
} }
const PREVIEWS_SIZE = { export const PREVIEWS_SIZE = {
width: maxBy(CONFIG.THUMBNAILS.SIZES, 'width').width, width: maxBy(CONFIG.THUMBNAILS.SIZES, 'width').width,
height: maxBy(CONFIG.THUMBNAILS.SIZES, 'width').height, height: maxBy(CONFIG.THUMBNAILS.SIZES, 'width').height,
minRemoteWidth: 400 minRemoteWidth: 400
} }
const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number, height: number }[] } = { export const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number, height: number }[] } = {
[ActorImageType.AVATAR]: [ // 1/1 ratio [ActorImageType.AVATAR]: [ // 1/1 ratio
{ {
width: 1500, width: 1500,
@ -950,18 +939,18 @@ const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number, height
] ]
} }
const STORYBOARD = { export const STORYBOARD = {
SPRITE_MAX_SIZE: 192, SPRITE_MAX_SIZE: 192,
SPRITES_MAX_EDGE_COUNT: 10 SPRITES_MAX_EDGE_COUNT: 10
} }
const EMBED_SIZE = { export const EMBED_SIZE = {
width: 560, width: 560,
height: 315 height: 315
} }
// Sub folders of cache directory // Sub folders of cache directory
const FILES_CACHE = { export const FILES_CACHE = {
PREVIEWS: { PREVIEWS: {
DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'previews'), DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
MAX_AGE: 1000 * 3600 * 3 // 3 hours MAX_AGE: 1000 * 3600 * 3 // 3 hours
@ -980,7 +969,7 @@ const FILES_CACHE = {
} }
} }
const LRU_CACHE = { export const LRU_CACHE = {
USER_TOKENS: { USER_TOKENS: {
MAX_SIZE: 1000 MAX_SIZE: 1000
}, },
@ -1004,7 +993,7 @@ const LRU_CACHE = {
} }
} }
const DIRECTORIES = { export const DIRECTORIES = {
RESUMABLE_UPLOAD: join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads'), RESUMABLE_UPLOAD: join(CONFIG.STORAGE.TMP_DIR, 'resumable-uploads'),
HLS_STREAMING_PLAYLIST: { HLS_STREAMING_PLAYLIST: {
@ -1024,9 +1013,9 @@ const DIRECTORIES = {
LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip') LOCAL_PIP_DIRECTORY: join(CONFIG.STORAGE.BIN_DIR, 'pip')
} }
const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS export const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS
const VIDEO_LIVE = { export const VIDEO_LIVE = {
EXTENSION: '.ts', EXTENSION: '.ts',
CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
SEGMENT_TIME_SECONDS: { SEGMENT_TIME_SECONDS: {
@ -1046,7 +1035,7 @@ const VIDEO_LIVE = {
} }
} }
const MEMOIZE_TTL = { export const MEMOIZE_TTL = {
OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
INFO_HASH_EXISTS: 1000 * 60, // 1 minute INFO_HASH_EXISTS: 1000 * 60, // 1 minute
VIDEO_DURATION: 1000 * 10, // 10 seconds VIDEO_DURATION: 1000 * 10, // 10 seconds
@ -1056,14 +1045,14 @@ const MEMOIZE_TTL = {
EMBED_HTML: 1000 * 10 // 10 seconds EMBED_HTML: 1000 * 10 // 10 seconds
} }
const MEMOIZE_LENGTH = { export const MEMOIZE_LENGTH = {
INFO_HASH_EXISTS: 200, INFO_HASH_EXISTS: 200,
VIDEO_DURATION: 200 VIDEO_DURATION: 200
} }
const totalCPUs = Math.max(cpus().length, 1) export const totalCPUs = Math.max(cpus().length, 1)
const WORKER_THREADS = { export const WORKER_THREADS = {
DOWNLOAD_IMAGE: { DOWNLOAD_IMAGE: {
CONCURRENCY: 3, CONCURRENCY: 3,
MAX_THREADS: 1 MAX_THREADS: 1
@ -1086,26 +1075,26 @@ const WORKER_THREADS = {
} }
} }
const REDUNDANCY = { export const REDUNDANCY = {
VIDEOS: { VIDEOS: {
RANDOMIZED_FACTOR: 5 RANDOMIZED_FACTOR: 5
} }
} }
const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) export const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
const OTP = { export const OTP = {
HEADER_NAME: 'x-peertube-otp', HEADER_NAME: 'x-peertube-otp',
HEADER_REQUIRED_VALUE: 'required; app' HEADER_REQUIRED_VALUE: 'required; app'
} }
const ASSETS_PATH = { export const ASSETS_PATH = {
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'core', 'assets', 'default-audio-background.jpg'), DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'core', 'assets', 'default-audio-background.jpg'),
DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'core', 'assets', 'default-live-background.jpg') DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'core', 'assets', 'default-live-background.jpg')
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const CUSTOM_HTML_TAG_COMMENTS = { export const CUSTOM_HTML_TAG_COMMENTS = {
TITLE: '<!-- title tag -->', TITLE: '<!-- title tag -->',
DESCRIPTION: '<!-- description tag -->', DESCRIPTION: '<!-- description tag -->',
CUSTOM_CSS: '<!-- custom css tag -->', CUSTOM_CSS: '<!-- custom css tag -->',
@ -1113,34 +1102,34 @@ const CUSTOM_HTML_TAG_COMMENTS = {
SERVER_CONFIG: '<!-- server config -->' SERVER_CONFIG: '<!-- server config -->'
} }
const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000 export const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000
const LOG_FILENAME = 'peertube.log' export const LOG_FILENAME = 'peertube.log'
const AUDIT_LOG_FILENAME = 'peertube-audit.log' export const AUDIT_LOG_FILENAME = 'peertube-audit.log'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TRACKER_RATE_LIMITS = { export const TRACKER_RATE_LIMITS = {
INTERVAL: 60000 * 5, // 5 minutes INTERVAL: 60000 * 5, // 5 minutes
ANNOUNCES_PER_IP_PER_INFOHASH: 15, // maximum announces per torrent in the interval 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 ANNOUNCES_PER_IP: 30, // maximum announces for all our torrents in the interval
BLOCK_IP_LIFETIME: parseDurationToMs('3 minutes') 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' export 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_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' export const DEFAULT_THEME_NAME = 'default'
const DEFAULT_USER_THEME_NAME = 'instance-default' export const DEFAULT_USER_THEME_NAME = 'instance-default'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SEARCH_INDEX = { export const SEARCH_INDEX = {
ROUTES: { ROUTES: {
VIDEOS: '/api/v1/search/videos', VIDEOS: '/api/v1/search/videos',
VIDEO_CHANNELS: '/api/v1/search/video-channels' 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 MAX_DAYS: 365 * 10 // Around 10 years
} }
@ -1231,9 +1220,15 @@ registerConfigChangedHandler(() => {
updateWebserverConfig() 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(), MANIFEST: generateContentHash(),
FAVICON: generateContentHash(), FAVICON: generateContentHash(),
LOGO: generateContentHash() LOGO: generateContentHash()
@ -1241,7 +1236,7 @@ const FILES_CONTENT_HASH = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const VIDEO_FILTERS = { export const VIDEO_FILTERS = {
WATERMARK: { WATERMARK: {
SIZE_RATIO: 1 / 10, SIZE_RATIO: 1 / 10,
HORIZONTAL_MARGIN_RATIO: 1 / 20, HORIZONTAL_MARGIN_RATIO: 1 / 20,
@ -1250,116 +1245,7 @@ const VIDEO_FILTERS = {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private
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
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildVideoMimetypeExt () { function buildVideoMimetypeExt () {
@ -1480,12 +1366,6 @@ function buildMimetypesRegex (obj: { [id: string]: string | string[] }) {
.join('|') .join('|')
} }
async function loadLanguages () {
if (Object.keys(VIDEO_LANGUAGES).length !== 0) return
Object.assign(VIDEO_LANGUAGES, await buildLanguages())
}
async function buildLanguages () { async function buildLanguages () {
const { iso6393 } = await import('iso-639-3') const { iso6393 } = await import('iso-639-3')

View File

@ -341,10 +341,14 @@ class LiveManager {
inputLocalUrl, inputLocalUrl,
inputPublicUrl, inputPublicUrl,
fps, fps,
bitrate, bitrate,
ratio, ratio,
inputResolution: resolution,
allResolutions, allResolutions,
hasAudio, hasAudio,
hasVideo, hasVideo,
probe probe
@ -363,7 +367,10 @@ class LiveManager {
fps: number fps: number
bitrate: number bitrate: number
ratio: number ratio: number
inputResolution: number
allResolutions: number[] allResolutions: number[]
hasAudio: boolean hasAudio: boolean
hasVideo: boolean hasVideo: boolean
probe: FfprobeData probe: FfprobeData
@ -384,7 +391,18 @@ class LiveManager {
videoLive, videoLive,
user, 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 })) muxingSession.on('live-ready', () => this.publishAndFederateLive({ live: videoLive, ratio, audioOnlyOutput, localLTags }))

View File

@ -72,6 +72,8 @@ class MuxingSession extends EventEmitter {
private readonly inputPublicUrl: string private readonly inputPublicUrl: string
private readonly fps: number private readonly fps: number
private readonly inputResolution: number
private readonly allResolutions: number[] private readonly allResolutions: number[]
private readonly bitrate: number private readonly bitrate: number
@ -125,7 +127,10 @@ class MuxingSession extends EventEmitter {
fps: number fps: number
bitrate: number bitrate: number
ratio: number ratio: number
inputResolution: number
allResolutions: number[] allResolutions: number[]
hasAudio: boolean hasAudio: boolean
hasVideo: boolean hasVideo: boolean
probe: FfprobeData probe: FfprobeData
@ -149,6 +154,7 @@ class MuxingSession extends EventEmitter {
this.hasVideo = options.hasVideo this.hasVideo = options.hasVideo
this.hasAudio = options.hasAudio this.hasAudio = options.hasAudio
this.inputResolution = options.inputResolution
this.allResolutions = options.allResolutions this.allResolutions = options.allResolutions
this.videoUUID = this.videoLive.Video.uuid this.videoUUID = this.videoLive.Video.uuid
@ -547,7 +553,12 @@ class MuxingSession extends EventEmitter {
} }
try { try {
toTranscodeFPS = computeOutputFPS({ inputFPS: this.fps, resolution }) toTranscodeFPS = computeOutputFPS({
inputFPS: this.fps,
resolution,
isOriginResolution: resolution === this.inputResolution,
type: 'live'
})
} catch (err) { } catch (err) {
err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM err.liveVideoErrorCode = LiveVideoError.INVALID_INPUT_VIDEO_STREAM
throw err throw err

View File

@ -3,7 +3,7 @@ import { VideoResolution } from '@peertube/peertube-models'
import { computeOutputFPS } from '@server/helpers/ffmpeg/framerate.js' import { computeOutputFPS } from '@server/helpers/ffmpeg/framerate.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.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 { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js' import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
@ -36,7 +36,7 @@ export abstract class AbstractJobBuilder <P> {
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => { await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
const probe = await ffprobePromise(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 let inputFPS: number
@ -46,7 +46,8 @@ export abstract class AbstractJobBuilder <P> {
let hlsAudioAlreadyGenerated = false let hlsAudioAlreadyGenerated = false
if (videoFile.isAudio()) { 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 maxResolution = DEFAULT_AUDIO_RESOLUTION
mergeOrOptimizePayload = this.buildMergeAudioPayload({ mergeOrOptimizePayload = this.buildMergeAudioPayload({
@ -59,7 +60,7 @@ export abstract class AbstractJobBuilder <P> {
} else { } else {
inputFPS = videoFile.fps inputFPS = videoFile.fps
maxResolution = buildOriginalFileResolution(videoFile.resolution) maxResolution = buildOriginalFileResolution(videoFile.resolution)
maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution }) maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution, isOriginResolution: true, type: 'vod' })
mergeOrOptimizePayload = this.buildOptimizePayload({ mergeOrOptimizePayload = this.buildOptimizePayload({
video, video,
@ -153,7 +154,7 @@ export abstract class AbstractJobBuilder <P> {
const inputFPS = video.getMaxFPS() const inputFPS = video.getMaxFPS()
const children = childrenResolutions.map(resolution => { const children = childrenResolutions.map(resolution => {
const fps = computeOutputFPS({ inputFPS, resolution }) const fps = computeOutputFPS({ inputFPS, resolution, isOriginResolution: maxResolution === resolution, type: 'vod' })
if (transcodingType === 'hls') { if (transcodingType === 'hls') {
return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio }) return this.buildHLSJobPayload({ video, resolution, fps, isNewVideo, separatedAudio })
@ -166,7 +167,7 @@ export abstract class AbstractJobBuilder <P> {
throw new Error('Unknown transcoding type') 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' const parent = transcodingType === 'hls'
? this.buildHLSJobPayload({ video, resolution: maxResolution, fps, isNewVideo, separatedAudio }) ? this.buildHLSJobPayload({ video, resolution: maxResolution, fps, isNewVideo, separatedAudio })
@ -199,7 +200,12 @@ export abstract class AbstractJobBuilder <P> {
const sequentialPayloads: P[][] = [] const sequentialPayloads: P[][] = []
for (const resolution of resolutionsEnabled) { 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 let generateHLS = CONFIG.TRANSCODING.HLS.ENABLED
if (resolution === VideoResolution.H_NOVIDEO && hlsAudioAlreadyGenerated) generateHLS = false if (resolution === VideoResolution.H_NOVIDEO && hlsAudioAlreadyGenerated) generateHLS = false

View File

@ -2,11 +2,11 @@ import { FfprobeData } from 'fluent-ffmpeg'
import { CONFIG } from '@server/initializers/config.js' import { CONFIG } from '@server/initializers/config.js'
import { canDoQuickAudioTranscode, canDoQuickVideoTranscode, ffprobePromise } from '@peertube/peertube-ffmpeg' import { canDoQuickAudioTranscode, canDoQuickVideoTranscode, ffprobePromise } from '@peertube/peertube-ffmpeg'
export async function canDoQuickTranscode (path: string, existingProbe?: FfprobeData): Promise<boolean> { export async function canDoQuickTranscode (path: string, maxFPS: number, existingProbe?: FfprobeData): Promise<boolean> {
if (CONFIG.TRANSCODING.PROFILE !== 'default') return false if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
const probe = existingProbe || await ffprobePromise(path) const probe = existingProbe || await ffprobePromise(path)
return await canDoQuickVideoTranscode(path, probe) && return await canDoQuickVideoTranscode(path, maxFPS, probe) &&
await canDoQuickAudioTranscode(path, probe) await canDoQuickAudioTranscode(path, probe)
} }

View File

@ -50,7 +50,7 @@ export async function optimizeOriginalVideofile (options: {
: 'video' : 'video'
const resolution = buildOriginalFileResolution(inputVideoFile.resolution) 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! // Could be very long!
await buildFFmpegVOD(job).transcode({ await buildFFmpegVOD(job).transcode({

View File

@ -57,6 +57,7 @@ const customConfigUpdateValidator = [
body('transcoding.remoteRunners.enabled').isBoolean(), body('transcoding.remoteRunners.enabled').isBoolean(),
body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(), body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(),
body('transcoding.fps.max').custom(isIntOrNull),
body('transcoding.webVideos.enabled').isBoolean(), body('transcoding.webVideos.enabled').isBoolean(),
body('transcoding.hls.enabled').isBoolean(), body('transcoding.hls.enabled').isBoolean(),
@ -106,6 +107,7 @@ const customConfigUpdateValidator = [
body('live.transcoding.resolutions.1440p').isBoolean(), body('live.transcoding.resolutions.1440p').isBoolean(),
body('live.transcoding.resolutions.2160p').isBoolean(), body('live.transcoding.resolutions.2160p').isBoolean(),
body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean(), body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean(),
body('live.transcoding.fps.max').custom(isIntOrNull),
body('live.transcoding.remoteRunners.enabled').isBoolean(), body('live.transcoding.remoteRunners.enabled').isBoolean(),
body('search.remoteUri.users').isBoolean(), body('search.remoteUri.users').isBoolean(),