1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-04 10:19:35 +02:00

server/server -> server/core

This commit is contained in:
Chocobozzz 2023-10-04 15:13:25 +02:00
parent 114327d4ce
commit 5a3d0650c9
No known key found for this signature in database
GPG key ID: 583A612D890159BE
838 changed files with 111 additions and 111 deletions

View file

@ -0,0 +1,37 @@
import { CONFIG } from '@server/initializers/config.js'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { TranscodingJobQueueBuilder, TranscodingRunnerJobBuilder } from './shared/index.js'
export function createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
videoFileAlreadyLocked: boolean
}) {
return getJobBuilder().createOptimizeOrMergeAudioJobs(options)
}
// ---------------------------------------------------------------------------
export function createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
video: MVideoFullLight
resolutions: number[]
isNewVideo: boolean
user: MUserId
}) {
return getJobBuilder().createTranscodingJobs(options)
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function getJobBuilder () {
if (CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED === true) {
return new TranscodingRunnerJobBuilder()
}
return new TranscodingJobQueueBuilder()
}

View file

@ -0,0 +1,143 @@
import { logger } from '@server/helpers/logger.js'
import { FFmpegCommandWrapper, getDefaultAvailableEncoders } from '@peertube/peertube-ffmpeg'
import { AvailableEncoders, EncoderOptionsBuilder } from '@peertube/peertube-models'
// ---------------------------------------------------------------------------
// Profile manager to get and change default profiles
// ---------------------------------------------------------------------------
class VideoTranscodingProfilesManager {
private static instance: VideoTranscodingProfilesManager
// 1 === less priority
private readonly encodersPriorities = {
vod: this.buildDefaultEncodersPriorities(),
live: this.buildDefaultEncodersPriorities()
}
private readonly availableEncoders = getDefaultAvailableEncoders()
private availableProfiles = {
vod: [] as string[],
live: [] as string[]
}
private constructor () {
this.buildAvailableProfiles()
}
getAvailableEncoders (): AvailableEncoders {
return {
available: this.availableEncoders,
encodersToTry: {
vod: {
video: this.getEncodersByPriority('vod', 'video'),
audio: this.getEncodersByPriority('vod', 'audio')
},
live: {
video: this.getEncodersByPriority('live', 'video'),
audio: this.getEncodersByPriority('live', 'audio')
}
}
}
}
getAvailableProfiles (type: 'vod' | 'live') {
return this.availableProfiles[type]
}
addProfile (options: {
type: 'vod' | 'live'
encoder: string
profile: string
builder: EncoderOptionsBuilder
}) {
const { type, encoder, profile, builder } = options
const encoders = this.availableEncoders[type]
if (!encoders[encoder]) encoders[encoder] = {}
encoders[encoder][profile] = builder
this.buildAvailableProfiles()
}
removeProfile (options: {
type: 'vod' | 'live'
encoder: string
profile: string
}) {
const { type, encoder, profile } = options
delete this.availableEncoders[type][encoder][profile]
this.buildAvailableProfiles()
}
addEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
this.encodersPriorities[type][streamType].push({ name: encoder, priority })
FFmpegCommandWrapper.resetSupportedEncoders()
}
removeEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
this.encodersPriorities[type][streamType] = this.encodersPriorities[type][streamType]
.filter(o => o.name !== encoder && o.priority !== priority)
FFmpegCommandWrapper.resetSupportedEncoders()
}
private getEncodersByPriority (type: 'vod' | 'live', streamType: 'audio' | 'video') {
return this.encodersPriorities[type][streamType]
.sort((e1, e2) => {
if (e1.priority > e2.priority) return -1
else if (e1.priority === e2.priority) return 0
return 1
})
.map(e => e.name)
}
private buildAvailableProfiles () {
for (const type of [ 'vod', 'live' ]) {
const result = new Set()
const encoders = this.availableEncoders[type]
for (const encoderName of Object.keys(encoders)) {
for (const profile of Object.keys(encoders[encoderName])) {
result.add(profile)
}
}
this.availableProfiles[type] = Array.from(result)
}
logger.debug('Available transcoding profiles built.', { availableProfiles: this.availableProfiles })
}
private buildDefaultEncodersPriorities () {
return {
video: [
{ name: 'libx264', priority: 100 }
],
// Try the first one, if not available try the second one etc
audio: [
// we favor VBR, if a good AAC encoder is available
{ name: 'libfdk_aac', priority: 200 },
{ name: 'aac', priority: 100 }
]
}
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
VideoTranscodingProfilesManager
}

View file

@ -0,0 +1,18 @@
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MVideo } from '@server/types/models/index.js'
import { moveToNextState } from '../video-state.js'
export async function onTranscodingEnded (options: {
video: MVideo
isNewVideo: boolean
moveVideoToNextState: boolean
}) {
const { video, isNewVideo, moveVideoToNextState } = options
await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
if (moveVideoToNextState) {
await retryTransactionWrapper(moveToNextState, { video, isNewVideo })
}
}

View file

@ -0,0 +1,189 @@
import { MutexInterface } from 'async-mutex'
import { Job } from 'bullmq'
import { ensureDir, move } from 'fs-extra/esm'
import { stat } from 'fs/promises'
import { basename, extname as extnameUtil, join } from 'path'
import { pick } from '@peertube/peertube-core-utils'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { MVideo, MVideoFile } from '@server/types/models/index.js'
import { getVideoStreamDuration, getVideoStreamFPS } from '@peertube/peertube-ffmpeg'
import { CONFIG } from '../../initializers/config.js'
import { VideoFileModel } from '../../models/video/video-file.js'
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js'
import { updatePlaylistAfterFileChange } from '../hls.js'
import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths.js'
import { buildFileMetadata } from '../video-file.js'
import { VideoPathManager } from '../video-path-manager.js'
import { buildFFmpegVOD } from './shared/index.js'
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
export async function generateHlsPlaylistResolutionFromTS (options: {
video: MVideo
concatenatedTsFilePath: string
resolution: number
fps: number
isAAC: boolean
inputFileMutexReleaser: MutexInterface.Releaser
}) {
return generateHlsPlaylistCommon({
type: 'hls-from-ts' as 'hls-from-ts',
inputPath: options.concatenatedTsFilePath,
...pick(options, [ 'video', 'resolution', 'fps', 'inputFileMutexReleaser', 'isAAC' ])
})
}
// Generate an HLS playlist from an input file, and update the master playlist
export function generateHlsPlaylistResolution (options: {
video: MVideo
videoInputPath: string
resolution: number
fps: number
copyCodecs: boolean
inputFileMutexReleaser: MutexInterface.Releaser
job?: Job
}) {
return generateHlsPlaylistCommon({
type: 'hls' as 'hls',
inputPath: options.videoInputPath,
...pick(options, [ 'video', 'resolution', 'fps', 'copyCodecs', 'inputFileMutexReleaser', 'job' ])
})
}
export async function onHLSVideoFileTranscoding (options: {
video: MVideo
videoFile: MVideoFile
videoOutputPath: string
m3u8OutputPath: string
filesLockedInParent?: boolean // default false
}) {
const { video, videoFile, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options
// Create or update the playlist
const playlist = await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
})
})
videoFile.videoStreamingPlaylistId = playlist.id
const mutexReleaser = !filesLockedInParent
? await VideoPathManager.Instance.lockFiles(video.uuid)
: null
try {
await video.reload()
const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, videoFile)
await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video))
// Move playlist file
const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, basename(m3u8OutputPath))
await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
// Move video file
await move(videoOutputPath, videoFilePath, { overwrite: true })
// Update video duration if it was not set (in case of a live for example)
if (!video.duration) {
video.duration = await getVideoStreamDuration(videoFilePath)
await video.save()
}
const stats = await stat(videoFilePath)
videoFile.size = stats.size
videoFile.fps = await getVideoStreamFPS(videoFilePath)
videoFile.metadata = await buildFileMetadata(videoFilePath)
await createTorrentAndSetInfoHash(playlist, videoFile)
const oldFile = await VideoFileModel.loadHLSFile({
playlistId: playlist.id,
fps: videoFile.fps,
resolution: videoFile.resolution
})
if (oldFile) {
await video.removeStreamingPlaylistVideoFile(playlist, oldFile)
await oldFile.destroy()
}
const savedVideoFile = await VideoFileModel.customUpsert(videoFile, 'streaming-playlist', undefined)
await updatePlaylistAfterFileChange(video, playlist)
return { resolutionPlaylistPath, videoFile: savedVideoFile }
} finally {
if (mutexReleaser) mutexReleaser()
}
}
// ---------------------------------------------------------------------------
async function generateHlsPlaylistCommon (options: {
type: 'hls' | 'hls-from-ts'
video: MVideo
inputPath: string
resolution: number
fps: number
inputFileMutexReleaser: MutexInterface.Releaser
copyCodecs?: boolean
isAAC?: boolean
job?: Job
}) {
const { type, video, inputPath, resolution, fps, copyCodecs, isAAC, job, inputFileMutexReleaser } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const videoTranscodedBasePath = join(transcodeDirectory, type)
await ensureDir(videoTranscodedBasePath)
const videoFilename = generateHLSVideoFilename(resolution)
const videoOutputPath = join(videoTranscodedBasePath, videoFilename)
const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
const transcodeOptions = {
type,
inputPath,
outputPath: m3u8OutputPath,
resolution,
fps,
copyCodecs,
isAAC,
inputFileMutexReleaser,
hlsPlaylist: {
videoFilename
}
}
await buildFFmpegVOD(job).transcode(transcodeOptions)
const newVideoFile = new VideoFileModel({
resolution,
extname: extnameUtil(videoFilename),
size: 0,
filename: videoFilename,
fps: -1
})
await onHLSVideoFileTranscoding({
video,
videoFile: newVideoFile,
videoOutputPath,
m3u8OutputPath,
filesLockedInParent: !inputFileMutexReleaser
})
}

View file

@ -0,0 +1,18 @@
import { Job } from 'bullmq'
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
import { logger } from '@server/helpers/logger.js'
import { FFmpegVOD } from '@peertube/peertube-ffmpeg'
import { VideoTranscodingProfilesManager } from '../default-transcoding-profiles.js'
export function buildFFmpegVOD (job?: Job) {
return new FFmpegVOD({
...getFFmpegCommandWrapperOptions('vod', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()),
updateJobProgress: progress => {
if (!job) return
job.updateProgress(progress)
.catch(err => logger.error('Cannot update ffmpeg job progress', { err }))
}
})
}

View file

@ -0,0 +1,2 @@
export * from './job-builders/index.js'
export * from './ffmpeg-builder.js'

View file

@ -0,0 +1,21 @@
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
export abstract class AbstractJobBuilder {
abstract createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
videoFileAlreadyLocked: boolean
}): Promise<any>
abstract createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
video: MVideoFullLight
resolutions: number[]
isNewVideo: boolean
user: MUserId | null
}): Promise<any>
}

View file

@ -0,0 +1,2 @@
export * from './transcoding-job-queue-builder.js'
export * from './transcoding-runner-job-builder.js'

View file

@ -0,0 +1,322 @@
import Bluebird from 'bluebird'
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js'
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg'
import {
HLSTranscodingPayload,
MergeAudioTranscodingPayload,
NewWebVideoResolutionTranscodingPayload,
OptimizeTranscodingPayload,
VideoTranscodingPayload
} from '@peertube/peertube-models'
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
import { canDoQuickTranscode } from '../../transcoding-quick-transcode.js'
import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
import { AbstractJobBuilder } from './abstract-job-builder.js'
export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
async createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
videoFileAlreadyLocked: boolean
}) {
const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload
let nextTranscodingSequentialJobPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
const mutexReleaser = videoFileAlreadyLocked
? () => {}
: await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
await videoFile.reload()
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
const probe = await ffprobePromise(videoFilePath)
const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
const hasAudio = await hasAudioStream(videoFilePath, probe)
const quickTranscode = await canDoQuickTranscode(videoFilePath, probe)
const inputFPS = videoFile.isAudio()
? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
: await getVideoStreamFPS(videoFilePath, probe)
const maxResolution = await isAudioFile(videoFilePath, probe)
? DEFAULT_AUDIO_RESOLUTION
: buildOriginalFileResolution(resolution)
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
nextTranscodingSequentialJobPayloads.push([
this.buildHLSJobPayload({
deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
// We had some issues with a web video quick transcoded while producing a HLS version of it
copyCodecs: !quickTranscode,
resolution: maxResolution,
fps: computeOutputFPS({ inputFPS, resolution: maxResolution }),
videoUUID: video.uuid,
isNewVideo
})
])
}
const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({
video,
inputVideoResolution: maxResolution,
inputVideoFPS: inputFPS,
hasAudio,
isNewVideo
})
nextTranscodingSequentialJobPayloads = [ ...nextTranscodingSequentialJobPayloads, ...lowerResolutionJobPayloads ]
const hasChildren = nextTranscodingSequentialJobPayloads.length !== 0
mergeOrOptimizePayload = videoFile.isAudio()
? this.buildMergeAudioPayload({ videoUUID: video.uuid, isNewVideo, hasChildren })
: this.buildOptimizePayload({ videoUUID: video.uuid, isNewVideo, quickTranscode, hasChildren })
})
} finally {
mutexReleaser()
}
const nextTranscodingSequentialJobs = await Bluebird.mapSeries(nextTranscodingSequentialJobPayloads, payloads => {
return Bluebird.mapSeries(payloads, payload => {
return this.buildTranscodingJob({ payload, user })
})
})
const transcodingJobBuilderJob: CreateJobArgument = {
type: 'transcoding-job-builder',
payload: {
videoUUID: video.uuid,
sequentialJobs: nextTranscodingSequentialJobs
}
}
const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: mergeOrOptimizePayload, user })
await JobQueue.Instance.createSequentialJobFlow(...[ mergeOrOptimizeJob, transcodingJobBuilderJob ])
await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
}
// ---------------------------------------------------------------------------
async createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
video: MVideoFullLight
resolutions: number[]
isNewVideo: boolean
user: MUserId | null
}) {
const { video, transcodingType, resolutions, isNewVideo } = options
const maxResolution = Math.max(...resolutions)
const childrenResolutions = resolutions.filter(r => r !== maxResolution)
logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
const { fps: inputFPS } = await video.probeMaxQualityFile()
const children = childrenResolutions.map(resolution => {
const fps = computeOutputFPS({ inputFPS, resolution })
if (transcodingType === 'hls') {
return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
}
if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
return this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
}
throw new Error('Unknown transcoding type')
})
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
const parent = transcodingType === 'hls'
? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
: this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
// Process the last resolution after the other ones to prevent concurrency issue
// Because low resolutions use the biggest one as ffmpeg input
await this.createTranscodingJobsWithChildren({ videoUUID: video.uuid, parent, children, user: null })
}
// ---------------------------------------------------------------------------
private async createTranscodingJobsWithChildren (options: {
videoUUID: string
parent: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)
children: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)[]
user: MUserId | null
}) {
const { videoUUID, parent, children, user } = options
const parentJob = await this.buildTranscodingJob({ payload: parent, user })
const childrenJobs = await Bluebird.mapSeries(children, c => this.buildTranscodingJob({ payload: c, user }))
await JobQueue.Instance.createJobWithChildren(parentJob, childrenJobs)
await VideoJobInfoModel.increaseOrCreate(videoUUID, 'pendingTranscode', 1 + children.length)
}
private async buildTranscodingJob (options: {
payload: VideoTranscodingPayload
user: MUserId | null // null means we don't want priority
}) {
const { user, payload } = options
return {
type: 'video-transcoding' as 'video-transcoding',
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: undefined }),
payload
}
}
private async buildLowerResolutionJobPayloads (options: {
video: MVideoWithFileThumbnail
inputVideoResolution: number
inputVideoFPS: number
hasAudio: boolean
isNewVideo: boolean
}) {
const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio } = options
// Create transcoding jobs if there are enabled resolutions
const resolutionsEnabled = await Hooks.wrapObject(
computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
'filter:transcoding.auto.resolutions-to-transcode.result',
options
)
const sequentialPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
for (const resolution of resolutionsEnabled) {
const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
const payloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[] = [
this.buildWebVideoJobPayload({
videoUUID: video.uuid,
resolution,
fps,
isNewVideo
})
]
// Create a subsequent job to create HLS resolution that will just copy web video codecs
if (CONFIG.TRANSCODING.HLS.ENABLED) {
payloads.push(
this.buildHLSJobPayload({
videoUUID: video.uuid,
resolution,
fps,
isNewVideo,
copyCodecs: true
})
)
}
sequentialPayloads.push(payloads)
} else if (CONFIG.TRANSCODING.HLS.ENABLED) {
sequentialPayloads.push([
this.buildHLSJobPayload({
videoUUID: video.uuid,
resolution,
fps,
copyCodecs: false,
isNewVideo
})
])
}
}
return sequentialPayloads
}
private buildHLSJobPayload (options: {
videoUUID: string
resolution: number
fps: number
isNewVideo: boolean
deleteWebVideoFiles?: boolean // default false
copyCodecs?: boolean // default false
}): HLSTranscodingPayload {
const { videoUUID, resolution, fps, isNewVideo, deleteWebVideoFiles = false, copyCodecs = false } = options
return {
type: 'new-resolution-to-hls',
videoUUID,
resolution,
fps,
copyCodecs,
isNewVideo,
deleteWebVideoFiles
}
}
private buildWebVideoJobPayload (options: {
videoUUID: string
resolution: number
fps: number
isNewVideo: boolean
}): NewWebVideoResolutionTranscodingPayload {
const { videoUUID, resolution, fps, isNewVideo } = options
return {
type: 'new-resolution-to-web-video',
videoUUID,
isNewVideo,
resolution,
fps
}
}
private buildMergeAudioPayload (options: {
videoUUID: string
isNewVideo: boolean
hasChildren: boolean
}): MergeAudioTranscodingPayload {
const { videoUUID, isNewVideo, hasChildren } = options
return {
type: 'merge-audio-to-web-video',
resolution: DEFAULT_AUDIO_RESOLUTION,
fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
videoUUID,
isNewVideo,
hasChildren
}
}
private buildOptimizePayload (options: {
videoUUID: string
quickTranscode: boolean
isNewVideo: boolean
hasChildren: boolean
}): OptimizeTranscodingPayload {
const { videoUUID, quickTranscode, isNewVideo, hasChildren } = options
return {
type: 'optimize-to-web-video',
videoUUID,
isNewVideo,
hasChildren,
quickTranscode
}
}
}

View file

@ -0,0 +1,200 @@
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import {
VODAudioMergeTranscodingJobHandler,
VODHLSTranscodingJobHandler,
VODWebVideoTranscodingJobHandler
} from '@server/lib/runners/index.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models/index.js'
import { MRunnerJob } from '@server/types/models/runners/index.js'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@peertube/peertube-ffmpeg'
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
import { computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
import { AbstractJobBuilder } from './abstract-job-builder.js'
/**
*
* Class to build transcoding job in the local job queue
*
*/
const lTags = loggerTagsFactory('transcoding')
export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
async createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
videoFileAlreadyLocked: boolean
}) {
const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
const mutexReleaser = videoFileAlreadyLocked
? () => {}
: await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
await videoFile.reload()
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
const probe = await ffprobePromise(videoFilePath)
const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
const hasAudio = await hasAudioStream(videoFilePath, probe)
const inputFPS = videoFile.isAudio()
? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
: await getVideoStreamFPS(videoFilePath, probe)
const maxResolution = await isAudioFile(videoFilePath, probe)
? DEFAULT_AUDIO_RESOLUTION
: resolution
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
const mainRunnerJob = videoFile.isAudio()
? await new VODAudioMergeTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
: await new VODWebVideoTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
await new VODHLSTranscodingJobHandler().create({
video,
deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
resolution: maxResolution,
fps,
isNewVideo,
dependsOnRunnerJob: mainRunnerJob,
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
})
}
await this.buildLowerResolutionJobPayloads({
video,
inputVideoResolution: maxResolution,
inputVideoFPS: inputFPS,
hasAudio,
isNewVideo,
mainRunnerJob,
user
})
})
} finally {
mutexReleaser()
}
}
// ---------------------------------------------------------------------------
async createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
video: MVideoFullLight
resolutions: number[]
isNewVideo: boolean
user: MUserId | null
}) {
const { video, transcodingType, resolutions, isNewVideo, user } = options
const maxResolution = Math.max(...resolutions)
const { fps: inputFPS } = await video.probeMaxQualityFile()
const maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution })
const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
const childrenResolutions = resolutions.filter(r => r !== maxResolution)
logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
// Process the last resolution before the other ones to prevent concurrency issue
// Because low resolutions use the biggest one as ffmpeg input
const mainJob = transcodingType === 'hls'
// eslint-disable-next-line max-len
? await new VODHLSTranscodingJobHandler().create({ video, resolution: maxResolution, fps: maxFPS, isNewVideo, deleteWebVideoFiles: false, priority })
: await new VODWebVideoTranscodingJobHandler().create({ video, resolution: maxResolution, fps: maxFPS, isNewVideo, priority })
for (const resolution of childrenResolutions) {
const dependsOnRunnerJob = mainJob
const fps = computeOutputFPS({ inputFPS, resolution })
if (transcodingType === 'hls') {
await new VODHLSTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
deleteWebVideoFiles: false,
dependsOnRunnerJob,
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
})
continue
}
if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
await new VODWebVideoTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
dependsOnRunnerJob,
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
})
continue
}
throw new Error('Unknown transcoding type')
}
}
private async buildLowerResolutionJobPayloads (options: {
mainRunnerJob: MRunnerJob
video: MVideoWithFileThumbnail
inputVideoResolution: number
inputVideoFPS: number
hasAudio: boolean
isNewVideo: boolean
user: MUserId
}) {
const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio, mainRunnerJob, user } = options
// Create transcoding jobs if there are enabled resolutions
const resolutionsEnabled = await Hooks.wrapObject(
computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
'filter:transcoding.auto.resolutions-to-transcode.result',
options
)
logger.debug('Lower resolutions build for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) })
for (const resolution of resolutionsEnabled) {
const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
await new VODWebVideoTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
dependsOnRunnerJob: mainRunnerJob,
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
})
}
if (CONFIG.TRANSCODING.HLS.ENABLED) {
await new VODHLSTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
deleteWebVideoFiles: false,
dependsOnRunnerJob: mainRunnerJob,
priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
})
}
}
}
}

View file

@ -0,0 +1,24 @@
import { JOB_PRIORITY } from '@server/initializers/constants.js'
import { VideoModel } from '@server/models/video/video.js'
import { MUserId } from '@server/types/models/index.js'
export async function getTranscodingJobPriority (options: {
user: MUserId
fallback: number
type: 'vod' | 'studio'
}) {
const { user, fallback, type } = options
if (!user) return fallback
const now = new Date()
const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
const videoUploadedByUser = await VideoModel.countVideosUploadedByUserSince(user.id, lastWeek)
const base = type === 'vod'
? JOB_PRIORITY.TRANSCODING
: JOB_PRIORITY.VIDEO_STUDIO
return base + videoUploadedByUser
}

View file

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

View file

@ -0,0 +1,73 @@
import { toEven } from '@peertube/peertube-core-utils'
import { VideoResolution, VideoResolutionType } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js'
export function buildOriginalFileResolution (inputResolution: number) {
if (CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION === true) {
return toEven(inputResolution)
}
const resolutions = computeResolutionsToTranscode({
input: inputResolution,
type: 'vod',
includeInput: false,
strictLower: false,
// We don't really care about the audio resolution in this context
hasAudio: true
})
if (resolutions.length === 0) {
return toEven(inputResolution)
}
return Math.max(...resolutions)
}
export function computeResolutionsToTranscode (options: {
input: number
type: 'vod' | 'live'
includeInput: boolean
strictLower: boolean
hasAudio: boolean
}) {
const { input, type, includeInput, strictLower, hasAudio } = options
const configResolutions = type === 'vod'
? CONFIG.TRANSCODING.RESOLUTIONS
: CONFIG.LIVE.TRANSCODING.RESOLUTIONS
const resolutionsEnabled = new Set<number>()
// Put in the order we want to proceed jobs
const availableResolutions: VideoResolutionType[] = [
VideoResolution.H_NOVIDEO,
VideoResolution.H_480P,
VideoResolution.H_360P,
VideoResolution.H_720P,
VideoResolution.H_240P,
VideoResolution.H_144P,
VideoResolution.H_1080P,
VideoResolution.H_1440P,
VideoResolution.H_4K
]
for (const resolution of availableResolutions) {
// Resolution not enabled
if (configResolutions[resolution + 'p'] !== true) continue
// Too big resolution for input file
if (input < resolution) continue
// We only want lower resolutions than input file
if (strictLower && input === resolution) continue
// Audio resolutio but no audio in the video
if (resolution === VideoResolution.H_NOVIDEO && !hasAudio) continue
resolutionsEnabled.add(resolution)
}
if (includeInput) {
// Always use an even resolution to avoid issues with ffmpeg
resolutionsEnabled.add(toEven(input))
}
return Array.from(resolutionsEnabled)
}

View file

@ -0,0 +1,264 @@
import { Job } from 'bullmq'
import { move, remove } from 'fs-extra/esm'
import { copyFile, stat } from 'fs/promises'
import { basename, join } from 'path'
import { VideoStorage } from '@peertube/peertube-models'
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVODOptionsType } from '@peertube/peertube-ffmpeg'
import { CONFIG } from '../../initializers/config.js'
import { VideoFileModel } from '../../models/video/video-file.js'
import { JobQueue } from '../job-queue/index.js'
import { generateWebVideoFilename } from '../paths.js'
import { buildFileMetadata } from '../video-file.js'
import { VideoPathManager } from '../video-path-manager.js'
import { buildFFmpegVOD } from './shared/index.js'
import { buildOriginalFileResolution } from './transcoding-resolutions.js'
// Optimize the original video file and replace it. The resolution is not changed.
export async function optimizeOriginalVideofile (options: {
video: MVideoFullLight
inputVideoFile: MVideoFile
quickTranscode: boolean
job: Job
}) {
const { video, inputVideoFile, quickTranscode, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
// Will be released by our transcodeVOD function once ffmpeg is ran
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
await inputVideoFile.reload()
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
const transcodeType: TranscodeVODOptionsType = quickTranscode
? 'quick-transcode'
: 'video'
const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution })
// Could be very long!
await buildFFmpegVOD(job).transcode({
type: transcodeType,
inputPath: videoInputPath,
outputPath: videoOutputPath,
inputFileMutexReleaser,
resolution,
fps
})
// Important to do this before getVideoFilename() to take in account the new filename
inputVideoFile.resolution = resolution
inputVideoFile.extname = newExtname
inputVideoFile.filename = generateWebVideoFilename(resolution, newExtname)
inputVideoFile.storage = VideoStorage.FILE_SYSTEM
const { videoFile } = await onWebVideoFileTranscoding({
video,
videoFile: inputVideoFile,
videoOutputPath
})
await remove(videoInputPath)
return { transcodeType, videoFile }
})
return result
} finally {
inputFileMutexReleaser()
}
}
// Transcode the original video file to a lower resolution compatible with web browsers
export async function transcodeNewWebVideoResolution (options: {
video: MVideoFullLight
resolution: number
fps: number
job: Job
}) {
const { video: videoArg, resolution, fps, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
try {
const video = await VideoModel.loadFull(videoArg.uuid)
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
const newVideoFile = new VideoFileModel({
resolution,
extname: newExtname,
filename: generateWebVideoFilename(resolution, newExtname),
size: 0,
videoId: video.id
})
const videoOutputPath = join(transcodeDirectory, newVideoFile.filename)
const transcodeOptions = {
type: 'video' as 'video',
inputPath: videoInputPath,
outputPath: videoOutputPath,
inputFileMutexReleaser,
resolution,
fps
}
await buildFFmpegVOD(job).transcode(transcodeOptions)
return onWebVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath })
})
return result
} finally {
inputFileMutexReleaser()
}
}
// Merge an image with an audio file to create a video
export async function mergeAudioVideofile (options: {
video: MVideoFullLight
resolution: number
fps: number
job: Job
}) {
const { video: videoArg, resolution, fps, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
try {
const video = await VideoModel.loadFull(videoArg.uuid)
const inputVideoFile = video.getMinQualityFile()
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
// If the user updates the video preview during transcoding
const previewPath = video.getPreview().getPath()
const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath))
await copyFile(previewPath, tmpPreviewPath)
const transcodeOptions = {
type: 'merge-audio' as 'merge-audio',
inputPath: tmpPreviewPath,
outputPath: videoOutputPath,
inputFileMutexReleaser,
audioPath: audioInputPath,
resolution,
fps
}
try {
await buildFFmpegVOD(job).transcode(transcodeOptions)
await remove(audioInputPath)
await remove(tmpPreviewPath)
} catch (err) {
await remove(tmpPreviewPath)
throw err
}
// Important to do this before getVideoFilename() to take in account the new file extension
inputVideoFile.extname = newExtname
inputVideoFile.resolution = resolution
inputVideoFile.filename = generateWebVideoFilename(inputVideoFile.resolution, newExtname)
// ffmpeg generated a new video file, so update the video duration
// See https://trac.ffmpeg.org/ticket/5456
video.duration = await getVideoStreamDuration(videoOutputPath)
await video.save()
return onWebVideoFileTranscoding({
video,
videoFile: inputVideoFile,
videoOutputPath,
wasAudioFile: true
})
})
return result
} finally {
inputFileMutexReleaser()
}
}
export async function onWebVideoFileTranscoding (options: {
video: MVideoFullLight
videoFile: MVideoFile
videoOutputPath: string
wasAudioFile?: boolean // default false
}) {
const { video, videoFile, videoOutputPath, wasAudioFile } = options
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
const stats = await stat(videoOutputPath)
const probe = await ffprobePromise(videoOutputPath)
const fps = await getVideoStreamFPS(videoOutputPath, probe)
const metadata = await buildFileMetadata(videoOutputPath, probe)
await move(videoOutputPath, outputPath, { overwrite: true })
videoFile.size = stats.size
videoFile.fps = fps
videoFile.metadata = metadata
await createTorrentAndSetInfoHash(video, videoFile)
const oldFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
if (oldFile) await video.removeWebVideoFile(oldFile)
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
video.VideoFiles = await video.$get('VideoFiles')
if (wasAudioFile) {
await JobQueue.Instance.createJob({
type: 'generate-video-storyboard' as 'generate-video-storyboard',
payload: {
videoUUID: video.uuid,
// No need to federate, we process these jobs sequentially
federate: false
}
})
}
return { video, videoFile }
} finally {
mutexReleaser()
}
}