Init commit
This commit is contained in:
36
server/core/lib/transcoding/create-transcoding-job.ts
Normal file
36
server/core/lib/transcoding/create-transcoding-job.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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
|
||||
}) {
|
||||
return getJobBuilder().createOptimizeOrMergeAudioJobs(options)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createTranscodingJobs (options: {
|
||||
transcodingType: 'hls' | 'web-video'
|
||||
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()
|
||||
}
|
||||
150
server/core/lib/transcoding/default-transcoding-profiles.ts
Normal file
150
server/core/lib/transcoding/default-transcoding-profiles.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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, isDefault: false })
|
||||
|
||||
FFmpegCommandWrapper.resetSupportedEncoders()
|
||||
}
|
||||
|
||||
removeEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
|
||||
this.encodersPriorities[type][streamType] = this.encodersPriorities[type][streamType]
|
||||
.filter(o => {
|
||||
// Don't remove default encoders
|
||||
if (o.isDefault) return true
|
||||
// Don't include this encoder anymore
|
||||
if (o.name === encoder && o.priority === priority) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
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, isDefault: true }
|
||||
],
|
||||
|
||||
// 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, isDefault: true },
|
||||
{ name: 'aac', priority: 100, isDefault: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
VideoTranscodingProfilesManager
|
||||
}
|
||||
28
server/core/lib/transcoding/ended-transcoding.ts
Normal file
28
server/core/lib/transcoding/ended-transcoding.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { FileStorage } from '@peertube/peertube-models'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { JobQueue } from '../job-queue/job-queue.js'
|
||||
import { hasVideoResourcesToBeMoved } from '../move-storage/shared/move-video.js'
|
||||
import { buildMoveVideoJob } from '../video-jobs.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) {
|
||||
const changedState = await retryTransactionWrapper(moveToNextState, { video, isNewVideo })
|
||||
|
||||
// Still send the transcoded file to external storage if needed
|
||||
if (!changedState && CONFIG.OBJECT_STORAGE.ENABLED && await hasVideoResourcesToBeMoved(video, FileStorage.OBJECT_STORAGE)) {
|
||||
await JobQueue.Instance.createJob(await buildMoveVideoJob({ type: 'move-to-object-storage', video }))
|
||||
}
|
||||
}
|
||||
}
|
||||
219
server/core/lib/transcoding/hls-transcoding.ts
Normal file
219
server/core/lib/transcoding/hls-transcoding.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { canCopyForHLS, getVideoStreamDuration, HLSFromTSTranscodeOptions, HLSTranscodeOptions } from '@peertube/peertube-ffmpeg'
|
||||
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
|
||||
import { sequelizeTypescript } from '@server/initializers/database.js'
|
||||
import { createTorrentAndSetInfoHash } from '@server/lib/webtorrent.js'
|
||||
import { MVideo } from '@server/types/models/index.js'
|
||||
import { MutexInterface } from 'async-mutex'
|
||||
import { Job } from 'bullmq'
|
||||
import { ensureDir, move } from 'fs-extra/esm'
|
||||
import { join } from 'path'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { VideoFileModel } from '../../models/video/video-file.js'
|
||||
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist.js'
|
||||
import { renameVideoFileInPlaylist, updateM3U8AndShaPlaylist } from '../hls.js'
|
||||
import { generateHLSVideoFilename, getHLSResolutionPlaylistFilename } from '../paths.js'
|
||||
import { createAllCaptionPlaylistsOnFSIfNeeded } from '../video-captions.js'
|
||||
import { buildNewFile } 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',
|
||||
|
||||
videoInputPath: 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
|
||||
separatedAudioInputPath: string
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
separatedAudio: boolean
|
||||
job?: Job
|
||||
}) {
|
||||
return generateHlsPlaylistCommon({
|
||||
type: 'hls' as 'hls',
|
||||
|
||||
...pick(options, [
|
||||
'videoInputPath',
|
||||
'separatedAudioInputPath',
|
||||
'video',
|
||||
'resolution',
|
||||
'fps',
|
||||
'separatedAudio',
|
||||
'inputFileMutexReleaser',
|
||||
'job'
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
export async function onHLSVideoFileTranscoding (options: {
|
||||
video: MVideo
|
||||
videoOutputPath: string
|
||||
m3u8OutputPath: string
|
||||
filesLockedInParent?: boolean // default false
|
||||
}) {
|
||||
const { video, videoOutputPath, m3u8OutputPath, filesLockedInParent = false } = options
|
||||
|
||||
// Create or update the playlist
|
||||
const { playlist, generated: playlistGenerated } = await retryTransactionWrapper(() => {
|
||||
return sequelizeTypescript.transaction(async transaction => {
|
||||
return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
|
||||
})
|
||||
})
|
||||
|
||||
const newVideoFile = await buildNewFile({ mode: 'hls', path: videoOutputPath })
|
||||
newVideoFile.videoStreamingPlaylistId = playlist.id
|
||||
|
||||
const mutexReleaser = !filesLockedInParent
|
||||
? await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||
: null
|
||||
|
||||
try {
|
||||
await video.reload()
|
||||
|
||||
const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile)
|
||||
await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video))
|
||||
|
||||
// Move playlist file
|
||||
const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(
|
||||
video,
|
||||
getHLSResolutionPlaylistFilename(newVideoFile.filename)
|
||||
)
|
||||
await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
|
||||
|
||||
// Move video file
|
||||
await move(videoOutputPath, videoFilePath, { overwrite: true })
|
||||
|
||||
await renameVideoFileInPlaylist(resolutionPlaylistPath, newVideoFile.filename)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
await createTorrentAndSetInfoHash(playlist, newVideoFile)
|
||||
|
||||
const oldFile = await VideoFileModel.loadHLSFile({
|
||||
playlistId: playlist.id,
|
||||
fps: newVideoFile.fps,
|
||||
resolution: newVideoFile.resolution
|
||||
})
|
||||
|
||||
if (oldFile) {
|
||||
await video.removeStreamingPlaylistVideoFile(playlist, oldFile)
|
||||
await oldFile.destroy()
|
||||
}
|
||||
|
||||
const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
|
||||
|
||||
if (playlistGenerated) {
|
||||
await createAllCaptionPlaylistsOnFSIfNeeded(video)
|
||||
}
|
||||
|
||||
await updateM3U8AndShaPlaylist(video, playlist)
|
||||
|
||||
return { resolutionPlaylistPath, videoFile: savedVideoFile }
|
||||
} finally {
|
||||
if (mutexReleaser) mutexReleaser()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function generateHlsPlaylistCommon (options: {
|
||||
type: 'hls' | 'hls-from-ts'
|
||||
video: MVideo
|
||||
|
||||
videoInputPath: string
|
||||
separatedAudioInputPath?: string
|
||||
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
inputFileMutexReleaser: MutexInterface.Releaser
|
||||
|
||||
separatedAudio?: boolean
|
||||
|
||||
isAAC?: boolean
|
||||
|
||||
job?: Job
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
video,
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
resolution,
|
||||
fps,
|
||||
separatedAudio,
|
||||
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: HLSTranscodeOptions | HLSFromTSTranscodeOptions = {
|
||||
type,
|
||||
|
||||
videoInputPath,
|
||||
separatedAudioInputPath,
|
||||
|
||||
outputPath: m3u8OutputPath,
|
||||
|
||||
resolution,
|
||||
fps,
|
||||
|
||||
copyCodecs: !separatedAudioInputPath && await canCopyForHLS({ fps, resolution, path: videoInputPath }),
|
||||
|
||||
separatedAudio,
|
||||
|
||||
isAAC,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
||||
hlsPlaylist: {
|
||||
videoFilename
|
||||
}
|
||||
}
|
||||
|
||||
await buildFFmpegVOD(job).transcode(transcodeOptions)
|
||||
|
||||
await onHLSVideoFileTranscoding({
|
||||
video,
|
||||
videoOutputPath,
|
||||
m3u8OutputPath,
|
||||
filesLockedInParent: !inputFileMutexReleaser
|
||||
})
|
||||
}
|
||||
18
server/core/lib/transcoding/shared/ffmpeg-builder.ts
Normal file
18
server/core/lib/transcoding/shared/ffmpeg-builder.ts
Normal 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 }))
|
||||
}
|
||||
})
|
||||
}
|
||||
2
server/core/lib/transcoding/shared/index.ts
Normal file
2
server/core/lib/transcoding/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './job-builders/index.js'
|
||||
export * from './ffmpeg-builder.js'
|
||||
@@ -0,0 +1,364 @@
|
||||
import { VideoFileStreamType, VideoResolution } from '@peertube/peertube-models'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/framerate.js'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { DEFAULT_AUDIO_MERGE_RESOLUTION, DEFAULT_AUDIO_RESOLUTION } from '@server/initializers/constants.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions.js'
|
||||
|
||||
const lTags = loggerTagsFactory('transcoding')
|
||||
|
||||
export type TranscodingPriorityType = 'required' | 'optional'
|
||||
|
||||
export abstract class AbstractJobBuilder<P extends { transcodingPriority: TranscodingPriorityType }> {
|
||||
async createOptimizeOrMergeAudioJobs (options: {
|
||||
video: MVideoFullLight
|
||||
videoFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
user: MUserId
|
||||
}) {
|
||||
const { video, videoFile, isNewVideo, user } = options
|
||||
|
||||
let mergeOrOptimizePayload: P
|
||||
let children: P[][] = []
|
||||
|
||||
const inputStreams = video.getStreamTypes()
|
||||
const transcodingRequestAt = new Date().toISOString()
|
||||
|
||||
let maxFPS: number
|
||||
let maxResolution: number
|
||||
|
||||
let hlsAudioAlreadyGenerated = false
|
||||
|
||||
if (videoFile.isAudio()) {
|
||||
// The first transcoding job will transcode to this FPS value
|
||||
maxFPS = Math.min(DEFAULT_AUDIO_MERGE_RESOLUTION, CONFIG.TRANSCODING.FPS.MAX)
|
||||
maxResolution = DEFAULT_AUDIO_RESOLUTION
|
||||
|
||||
mergeOrOptimizePayload = this.buildMergeAudioPayload({
|
||||
video,
|
||||
isNewVideo,
|
||||
inputFile: videoFile,
|
||||
resolution: maxResolution,
|
||||
fps: maxFPS,
|
||||
|
||||
transcodingPriority: 'required',
|
||||
canMoveVideoState: null // Will be set below
|
||||
})
|
||||
} else {
|
||||
const inputFPS = videoFile.fps
|
||||
maxResolution = buildOriginalFileResolution(videoFile.resolution)
|
||||
maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution, isOriginResolution: true, type: 'vod' })
|
||||
|
||||
mergeOrOptimizePayload = this.buildOptimizePayload({
|
||||
video,
|
||||
isNewVideo,
|
||||
inputFile: videoFile,
|
||||
resolution: maxResolution,
|
||||
fps: maxFPS,
|
||||
|
||||
transcodingPriority: 'required',
|
||||
canMoveVideoState: null // Will be set below
|
||||
})
|
||||
}
|
||||
|
||||
// HLS version of max resolution
|
||||
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
|
||||
const hasSplitAudioTranscoding = CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO && videoFile.hasAudio()
|
||||
|
||||
children.push([
|
||||
this.buildHLSJobPayload({
|
||||
deleteWebVideoFiles: !CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED,
|
||||
|
||||
separatedAudio: hasSplitAudioTranscoding,
|
||||
|
||||
resolution: maxResolution,
|
||||
fps: maxFPS,
|
||||
video,
|
||||
isNewVideo,
|
||||
|
||||
inputStreams,
|
||||
transcodingRequestAt,
|
||||
|
||||
transcodingPriority: 'required',
|
||||
canMoveVideoState: true
|
||||
})
|
||||
])
|
||||
|
||||
if (hasSplitAudioTranscoding) {
|
||||
hlsAudioAlreadyGenerated = true
|
||||
|
||||
children.push([
|
||||
this.buildHLSJobPayload({
|
||||
deleteWebVideoFiles: !CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED,
|
||||
separatedAudio: hasSplitAudioTranscoding,
|
||||
|
||||
resolution: 0,
|
||||
fps: 0,
|
||||
video,
|
||||
isNewVideo,
|
||||
|
||||
inputStreams,
|
||||
transcodingRequestAt,
|
||||
|
||||
transcodingPriority: 'required',
|
||||
canMoveVideoState: true
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({
|
||||
video,
|
||||
inputStreams,
|
||||
transcodingRequestAt,
|
||||
inputVideoResolution: maxResolution,
|
||||
inputVideoFPS: maxFPS,
|
||||
hasAudio: videoFile.hasAudio(),
|
||||
isNewVideo,
|
||||
hlsAudioAlreadyGenerated
|
||||
})
|
||||
|
||||
children = children.concat(lowerResolutionJobPayloads)
|
||||
|
||||
this.reassignCanMoveVideoState(mergeOrOptimizePayload, children.length === 0)
|
||||
|
||||
await this.createJobs({
|
||||
payloads: {
|
||||
parent: mergeOrOptimizePayload,
|
||||
children
|
||||
},
|
||||
user,
|
||||
video
|
||||
})
|
||||
}
|
||||
|
||||
async createTranscodingJobs (options: {
|
||||
transcodingType: 'hls' | 'web-video'
|
||||
video: MVideoFullLight
|
||||
resolutions: number[]
|
||||
isNewVideo: boolean
|
||||
user: MUserId | null
|
||||
}) {
|
||||
const { video, transcodingType, resolutions, isNewVideo } = options
|
||||
const separatedAudio = CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO
|
||||
const transcodingRequestAt = new Date().toISOString()
|
||||
|
||||
const inputStreams = video.getStreamTypes()
|
||||
const maxResolution = Math.max(...resolutions)
|
||||
|
||||
logger.info(`Manually creating transcoding jobs for ${transcodingType}`, { resolutions, maxResolution, ...lTags(video.uuid) })
|
||||
|
||||
const inputFPS = video.getMaxFPS()
|
||||
|
||||
const children = resolutions
|
||||
.map(resolution => {
|
||||
const fps = computeOutputFPS({ inputFPS, resolution, isOriginResolution: maxResolution === resolution, type: 'vod' })
|
||||
|
||||
if (transcodingType === 'hls') {
|
||||
// We'll generate audio resolution in a parent job
|
||||
if (resolution === VideoResolution.H_NOVIDEO && separatedAudio) return undefined
|
||||
|
||||
return [
|
||||
this.buildHLSJobPayload({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
separatedAudio,
|
||||
canMoveVideoState: true,
|
||||
inputStreams,
|
||||
transcodingRequestAt,
|
||||
transcodingPriority: 'optional'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
if (transcodingType === 'web-video') {
|
||||
return [
|
||||
this.buildWebVideoJobPayload({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
canMoveVideoState: true,
|
||||
transcodingPriority: 'optional'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
throw new Error('Unknown transcoding type')
|
||||
})
|
||||
.filter(r => !!r)
|
||||
|
||||
const fps = computeOutputFPS({ inputFPS, resolution: maxResolution, isOriginResolution: true, type: 'vod' })
|
||||
|
||||
// Process audio first to not override the max resolution where the audio stream will be removed
|
||||
const parent = transcodingType === 'hls' && separatedAudio
|
||||
? this.buildHLSJobPayload({
|
||||
video,
|
||||
resolution: VideoResolution.H_NOVIDEO,
|
||||
fps,
|
||||
isNewVideo,
|
||||
separatedAudio,
|
||||
canMoveVideoState: true,
|
||||
transcodingRequestAt,
|
||||
inputStreams,
|
||||
transcodingPriority: 'optional'
|
||||
})
|
||||
: undefined
|
||||
|
||||
await this.createJobs({ video, payloads: { parent, children }, user: null })
|
||||
}
|
||||
|
||||
private async buildLowerResolutionJobPayloads (options: {
|
||||
video: MVideoFullLight
|
||||
inputVideoResolution: number
|
||||
inputVideoFPS: number
|
||||
inputStreams: VideoFileStreamType[]
|
||||
hasAudio: boolean
|
||||
isNewVideo: boolean
|
||||
hlsAudioAlreadyGenerated: boolean
|
||||
transcodingRequestAt: string
|
||||
}) {
|
||||
const {
|
||||
video,
|
||||
inputVideoResolution,
|
||||
inputVideoFPS,
|
||||
inputStreams,
|
||||
isNewVideo,
|
||||
hlsAudioAlreadyGenerated,
|
||||
hasAudio,
|
||||
transcodingRequestAt
|
||||
} = 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 built for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) })
|
||||
|
||||
const sequentialPayloads: P[][] = []
|
||||
|
||||
for (const resolution of resolutionsEnabled) {
|
||||
const fps = computeOutputFPS({
|
||||
inputFPS: inputVideoFPS,
|
||||
resolution,
|
||||
isOriginResolution: resolution === inputVideoResolution,
|
||||
type: 'vod'
|
||||
})
|
||||
|
||||
let generateHLS = CONFIG.TRANSCODING.HLS.ENABLED
|
||||
if (resolution === VideoResolution.H_NOVIDEO && hlsAudioAlreadyGenerated) generateHLS = false
|
||||
|
||||
const parallelPayloads: P[] = []
|
||||
|
||||
if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
|
||||
parallelPayloads.push(
|
||||
this.buildWebVideoJobPayload({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
canMoveVideoState: true,
|
||||
transcodingPriority: 'optional'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Create a subsequent job to create HLS resolution that will just copy web video codecs
|
||||
if (generateHLS) {
|
||||
parallelPayloads.push(
|
||||
this.buildHLSJobPayload({
|
||||
video,
|
||||
resolution,
|
||||
fps,
|
||||
isNewVideo,
|
||||
separatedAudio: hasAudio && CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO,
|
||||
canMoveVideoState: true,
|
||||
transcodingPriority: 'optional',
|
||||
transcodingRequestAt,
|
||||
inputStreams
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (parallelPayloads.length !== 0) {
|
||||
sequentialPayloads.push(parallelPayloads)
|
||||
}
|
||||
}
|
||||
|
||||
return sequentialPayloads
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected abstract createJobs (options: {
|
||||
video: MVideoFullLight
|
||||
payloads: {
|
||||
parent: P
|
||||
|
||||
// Parallel of sequential jobs to execute
|
||||
// [ [ Parallel1 ], [ Parallel2 ], ... ]
|
||||
// [ [ Seq1, Seq2, ... ], [ Seq1, ... ]
|
||||
children: P[][]
|
||||
}
|
||||
user: MUserId | null
|
||||
}): Promise<void>
|
||||
|
||||
protected abstract buildMergeAudioPayload (options: {
|
||||
video: MVideoFullLight
|
||||
inputFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): P
|
||||
|
||||
protected abstract buildOptimizePayload (options: {
|
||||
video: MVideoFullLight
|
||||
isNewVideo: boolean
|
||||
inputFile: MVideoFile
|
||||
resolution: number
|
||||
fps: number
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): P
|
||||
|
||||
protected abstract buildHLSJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
separatedAudio: boolean
|
||||
|
||||
deleteWebVideoFiles?: boolean // default false
|
||||
|
||||
inputStreams: VideoFileStreamType[]
|
||||
canMoveVideoState: boolean
|
||||
transcodingRequestAt: string
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
}): P
|
||||
|
||||
protected abstract buildWebVideoJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): P
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected abstract reassignCanMoveVideoState (payload: P, canMoveVideoState: boolean): void
|
||||
}
|
||||
2
server/core/lib/transcoding/shared/job-builders/index.ts
Normal file
2
server/core/lib/transcoding/shared/job-builders/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './transcoding-job-queue-builder.js'
|
||||
export * from './transcoding-runner-job-builder.js'
|
||||
@@ -0,0 +1,204 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
HLSTranscodingPayload,
|
||||
MergeAudioTranscodingPayload,
|
||||
NewWebVideoResolutionTranscodingPayload,
|
||||
OptimizeTranscodingPayload,
|
||||
VideoFileStreamType,
|
||||
VideoTranscodingPayload
|
||||
} from '@peertube/peertube-models'
|
||||
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/index.js'
|
||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
|
||||
import { MUserId, MVideo } from '@server/types/models/index.js'
|
||||
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
|
||||
import { AbstractJobBuilder, TranscodingPriorityType } from './abstract-job-builder.js'
|
||||
|
||||
type BasePayload =
|
||||
| MergeAudioTranscodingPayload
|
||||
| OptimizeTranscodingPayload
|
||||
| NewWebVideoResolutionTranscodingPayload
|
||||
| HLSTranscodingPayload
|
||||
|
||||
type FullPayload = BasePayload & { transcodingPriority: TranscodingPriorityType }
|
||||
|
||||
export class TranscodingJobQueueBuilder extends AbstractJobBuilder<FullPayload> {
|
||||
protected async createJobs (options: {
|
||||
video: MVideo
|
||||
payloads: {
|
||||
parent: FullPayload | null
|
||||
children: FullPayload[][]
|
||||
}
|
||||
user: MUserId | null
|
||||
}): Promise<void> {
|
||||
const { video, payloads: { parent, children }, user } = options
|
||||
|
||||
const requiredPriority = await getTranscodingJobPriority({ user, type: 'vod-required' })
|
||||
const optionalPriority = await getTranscodingJobPriority({ user, type: 'vod-optional' })
|
||||
|
||||
const nextTranscodingSequentialJobs = children.map(p => {
|
||||
return p.map(payload => {
|
||||
return this.buildTranscodingJob({
|
||||
payload,
|
||||
|
||||
priority: payload.transcodingPriority === 'required'
|
||||
? requiredPriority
|
||||
: optionalPriority
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const transcodingJobBuilderJob: CreateJobArgument = {
|
||||
type: 'transcoding-job-builder',
|
||||
payload: {
|
||||
videoUUID: video.uuid,
|
||||
sequentialJobs: nextTranscodingSequentialJobs
|
||||
}
|
||||
}
|
||||
|
||||
const parentJob = parent
|
||||
? this.buildTranscodingJob({
|
||||
payload: parent,
|
||||
|
||||
priority: parent.transcodingPriority === 'required'
|
||||
? requiredPriority
|
||||
: optionalPriority
|
||||
})
|
||||
: undefined
|
||||
|
||||
await JobQueue.Instance.createSequentialJobFlow(parentJob, transcodingJobBuilderJob)
|
||||
|
||||
// transcoding-job-builder job will increase pendingTranscode
|
||||
if (parentJob) {
|
||||
await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
|
||||
}
|
||||
}
|
||||
|
||||
private buildTranscodingJob (options: {
|
||||
payload: VideoTranscodingPayload
|
||||
priority: number
|
||||
}) {
|
||||
const { priority, payload } = options
|
||||
|
||||
return {
|
||||
type: 'video-transcoding' as 'video-transcoding',
|
||||
priority,
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected buildHLSJobPayload (options: {
|
||||
video: MVideo
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
separatedAudio: boolean
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
|
||||
transcodingRequestAt: string
|
||||
canMoveVideoState: boolean
|
||||
inputStreams: VideoFileStreamType[]
|
||||
|
||||
deleteWebVideoFiles?: boolean // default false
|
||||
}): HLSTranscodingPayload & { transcodingPriority: TranscodingPriorityType } {
|
||||
const { video, deleteWebVideoFiles = false } = options
|
||||
|
||||
return {
|
||||
type: 'new-resolution-to-hls',
|
||||
videoUUID: video.uuid,
|
||||
|
||||
...pick(options, [
|
||||
'resolution',
|
||||
'fps',
|
||||
'isNewVideo',
|
||||
'separatedAudio',
|
||||
'canMoveVideoState',
|
||||
'transcodingPriority',
|
||||
'transcodingRequestAt',
|
||||
'inputStreams'
|
||||
]),
|
||||
|
||||
deleteWebVideoFiles
|
||||
}
|
||||
}
|
||||
|
||||
protected buildWebVideoJobPayload (options: {
|
||||
video: MVideo
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): NewWebVideoResolutionTranscodingPayload & { transcodingPriority: TranscodingPriorityType } {
|
||||
const { video } = options
|
||||
|
||||
return {
|
||||
type: 'new-resolution-to-web-video',
|
||||
videoUUID: video.uuid,
|
||||
|
||||
...pick(options, [
|
||||
'isNewVideo',
|
||||
'resolution',
|
||||
'fps',
|
||||
'transcodingPriority',
|
||||
'canMoveVideoState'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
protected buildMergeAudioPayload (options: {
|
||||
video: MVideo
|
||||
isNewVideo: boolean
|
||||
fps: number
|
||||
resolution: number
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): MergeAudioTranscodingPayload & { transcodingPriority: TranscodingPriorityType } {
|
||||
const { video } = options
|
||||
|
||||
return {
|
||||
type: 'merge-audio-to-web-video',
|
||||
videoUUID: video.uuid,
|
||||
|
||||
...pick(options, [
|
||||
'resolution',
|
||||
'fps',
|
||||
'isNewVideo',
|
||||
'transcodingPriority',
|
||||
'canMoveVideoState'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
protected buildOptimizePayload (options: {
|
||||
video: MVideo
|
||||
isNewVideo: boolean
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): OptimizeTranscodingPayload & { transcodingPriority: TranscodingPriorityType } {
|
||||
const { video } = options
|
||||
|
||||
return {
|
||||
type: 'optimize-to-web-video',
|
||||
|
||||
videoUUID: video.uuid,
|
||||
|
||||
...pick(options, [
|
||||
'isNewVideo',
|
||||
'transcodingPriority',
|
||||
'canMoveVideoState'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected reassignCanMoveVideoState (payload: FullPayload, canMoveVideoState: boolean): void {
|
||||
payload.canMoveVideoState = canMoveVideoState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { VideoFileStreamType } from '@peertube/peertube-models'
|
||||
import {
|
||||
VODAudioMergeTranscodingJobHandler,
|
||||
VODHLSTranscodingJobHandler,
|
||||
VODWebVideoTranscodingJobHandler
|
||||
} from '@server/lib/runners/job-handlers/index.js'
|
||||
import { MUserId, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { MRunnerJob } from '@server/types/models/runners/runner-job.js'
|
||||
import { getTranscodingJobPriority } from '../../transcoding-priority.js'
|
||||
import { AbstractJobBuilder, TranscodingPriorityType } from './abstract-job-builder.js'
|
||||
|
||||
/**
|
||||
* Class to build transcoding job in the local job queue
|
||||
*/
|
||||
|
||||
type BasePayload = {
|
||||
Builder: new() => VODHLSTranscodingJobHandler
|
||||
options: Omit<Parameters<VODHLSTranscodingJobHandler['create']>[0], 'priority'>
|
||||
} | {
|
||||
Builder: new() => VODAudioMergeTranscodingJobHandler
|
||||
options: Omit<Parameters<VODAudioMergeTranscodingJobHandler['create']>[0], 'priority'>
|
||||
} | {
|
||||
Builder: new() => VODWebVideoTranscodingJobHandler
|
||||
options: Omit<Parameters<VODWebVideoTranscodingJobHandler['create']>[0], 'priority'>
|
||||
}
|
||||
|
||||
type FullPayload = BasePayload & { transcodingPriority: TranscodingPriorityType }
|
||||
|
||||
export class TranscodingRunnerJobBuilder extends AbstractJobBuilder<FullPayload> {
|
||||
protected async createJobs (options: {
|
||||
video: MVideo
|
||||
payloads: {
|
||||
parent: FullPayload | null
|
||||
children: FullPayload[][]
|
||||
}
|
||||
user: MUserId | null
|
||||
}): Promise<void> {
|
||||
const { payloads: { parent, children }, user } = options
|
||||
|
||||
const requiredPriority = await getTranscodingJobPriority({ user, type: 'vod-required' })
|
||||
const optionalPriority = await getTranscodingJobPriority({ user, type: 'vod-optional' })
|
||||
|
||||
const parentJob = parent
|
||||
? await this.createJob({
|
||||
payload: parent,
|
||||
|
||||
priority: parent.transcodingPriority === 'required'
|
||||
? requiredPriority
|
||||
: optionalPriority
|
||||
})
|
||||
: undefined
|
||||
|
||||
for (const parallelPayloads of children) {
|
||||
let lastJob = parentJob
|
||||
|
||||
for (const sequentialPayload of parallelPayloads) {
|
||||
lastJob = await this.createJob({
|
||||
payload: sequentialPayload,
|
||||
|
||||
priority: sequentialPayload.transcodingPriority === 'required'
|
||||
? requiredPriority
|
||||
: optionalPriority,
|
||||
|
||||
dependsOnRunnerJob: lastJob
|
||||
})
|
||||
}
|
||||
|
||||
lastJob = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private createJob (options: {
|
||||
payload: FullPayload
|
||||
priority: number
|
||||
dependsOnRunnerJob?: MRunnerJob
|
||||
}) {
|
||||
const { dependsOnRunnerJob, payload, priority } = options
|
||||
|
||||
const builder = new payload.Builder()
|
||||
|
||||
return builder.create({
|
||||
...(payload.options as any), // FIXME: typings
|
||||
|
||||
dependsOnRunnerJob,
|
||||
priority
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected buildHLSJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
separatedAudio: boolean
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
|
||||
canMoveVideoState: boolean
|
||||
inputStreams: VideoFileStreamType[]
|
||||
transcodingRequestAt: string
|
||||
|
||||
deleteWebVideoFiles?: boolean // default false
|
||||
}): FullPayload {
|
||||
const { deleteWebVideoFiles = false } = options
|
||||
|
||||
return {
|
||||
Builder: VODHLSTranscodingJobHandler,
|
||||
|
||||
options: {
|
||||
...pick(options, [
|
||||
'video',
|
||||
'resolution',
|
||||
'fps',
|
||||
'isNewVideo',
|
||||
'separatedAudio',
|
||||
'canMoveVideoState',
|
||||
'transcodingRequestAt',
|
||||
'inputStreams'
|
||||
]),
|
||||
|
||||
deleteWebVideoFiles
|
||||
},
|
||||
|
||||
...pick(options, [ 'transcodingPriority' ])
|
||||
}
|
||||
}
|
||||
|
||||
protected buildWebVideoJobPayload (options: {
|
||||
video: MVideoFullLight
|
||||
resolution: number
|
||||
fps: number
|
||||
isNewVideo: boolean
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): FullPayload {
|
||||
return {
|
||||
Builder: VODWebVideoTranscodingJobHandler,
|
||||
|
||||
options: {
|
||||
...pick(options, [
|
||||
'video',
|
||||
'resolution',
|
||||
'fps',
|
||||
'isNewVideo',
|
||||
'canMoveVideoState',
|
||||
'transcodingPriority',
|
||||
'canMoveVideoState'
|
||||
]),
|
||||
|
||||
deleteInputFileId: null
|
||||
},
|
||||
|
||||
...pick(options, [ 'transcodingPriority' ])
|
||||
}
|
||||
}
|
||||
|
||||
protected buildMergeAudioPayload (options: {
|
||||
video: MVideoFullLight
|
||||
inputFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
fps: number
|
||||
resolution: number
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): FullPayload {
|
||||
const { inputFile } = options
|
||||
|
||||
return {
|
||||
Builder: VODAudioMergeTranscodingJobHandler,
|
||||
|
||||
options: {
|
||||
...pick(options, [
|
||||
'video',
|
||||
'resolution',
|
||||
'fps',
|
||||
'isNewVideo',
|
||||
'canMoveVideoState'
|
||||
]),
|
||||
|
||||
deleteInputFileId: inputFile.id
|
||||
},
|
||||
|
||||
...pick(options, [ 'transcodingPriority' ])
|
||||
}
|
||||
}
|
||||
|
||||
protected buildOptimizePayload (options: {
|
||||
video: MVideoFullLight
|
||||
inputFile: MVideoFile
|
||||
isNewVideo: boolean
|
||||
fps: number
|
||||
resolution: number
|
||||
|
||||
transcodingPriority: TranscodingPriorityType
|
||||
canMoveVideoState: boolean
|
||||
}): FullPayload {
|
||||
const { inputFile } = options
|
||||
|
||||
return {
|
||||
Builder: VODWebVideoTranscodingJobHandler,
|
||||
|
||||
options: {
|
||||
...pick(options, [
|
||||
'video',
|
||||
'resolution',
|
||||
'fps',
|
||||
'isNewVideo',
|
||||
'canMoveVideoState'
|
||||
]),
|
||||
|
||||
deleteInputFileId: inputFile.id
|
||||
},
|
||||
|
||||
...pick(options, [ 'transcodingPriority' ])
|
||||
}
|
||||
}
|
||||
|
||||
protected reassignCanMoveVideoState (payload: FullPayload, canMoveVideoState: boolean) {
|
||||
payload.options.canMoveVideoState = canMoveVideoState
|
||||
}
|
||||
}
|
||||
27
server/core/lib/transcoding/transcoding-priority.ts
Normal file
27
server/core/lib/transcoding/transcoding-priority.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
type: 'vod-required' | 'vod-optional' | 'studio'
|
||||
}) {
|
||||
const { user, type } = options
|
||||
|
||||
const priorityMap = {
|
||||
'vod-required': JOB_PRIORITY.REQUIRED_TRANSCODING,
|
||||
'vod-optional': JOB_PRIORITY.OPTIONAL_TRANSCODING,
|
||||
'studio': JOB_PRIORITY.VIDEO_STUDIO
|
||||
}
|
||||
|
||||
const priority = priorityMap[type]
|
||||
|
||||
if (!user) return priority
|
||||
|
||||
const now = new Date()
|
||||
const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
||||
|
||||
const videoUploadedByUser = await VideoModel.countVideosUploadedByUserSince(user.id, lastWeek)
|
||||
|
||||
return priority + videoUploadedByUser
|
||||
}
|
||||
12
server/core/lib/transcoding/transcoding-quick-transcode.ts
Normal file
12
server/core/lib/transcoding/transcoding-quick-transcode.ts
Normal 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, maxFPS: number, existingProbe?: FfprobeData): Promise<boolean> {
|
||||
if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
|
||||
|
||||
const probe = existingProbe || await ffprobePromise(path)
|
||||
|
||||
return await canDoQuickVideoTranscode(path, maxFPS, probe) &&
|
||||
await canDoQuickAudioTranscode(path, probe)
|
||||
}
|
||||
76
server/core/lib/transcoding/transcoding-resolutions.ts
Normal file
76
server/core/lib/transcoding/transcoding-resolutions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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 ||
|
||||
(resolutions.length === 1 && resolutions[0] === VideoResolution.H_NOVIDEO)
|
||||
) {
|
||||
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 resolution 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)
|
||||
}
|
||||
238
server/core/lib/transcoding/web-transcoding.ts
Normal file
238
server/core/lib/transcoding/web-transcoding.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { buildAspectRatio } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
MergeAudioTranscodeOptions,
|
||||
TranscodeVODOptionsType,
|
||||
VideoTranscodeOptions,
|
||||
getVideoStreamDuration
|
||||
} from '@peertube/peertube-ffmpeg'
|
||||
import { VideoFileStream } from '@peertube/peertube-models'
|
||||
import { computeOutputFPS } from '@server/helpers/ffmpeg/index.js'
|
||||
import { createTorrentAndSetInfoHash } from '@server/lib/webtorrent.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import { Job } from 'bullmq'
|
||||
import { move, remove } from 'fs-extra/esm'
|
||||
import { copyFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { CONFIG } from '../../initializers/config.js'
|
||||
import { VideoFileModel } from '../../models/video/video-file.js'
|
||||
import { generateWebVideoFilename } from '../paths.js'
|
||||
import { buildNewFile, saveNewOriginalFileIfNeeded } from '../video-file.js'
|
||||
import { addLocalOrRemoteStoryboardJobIfNeeded } from '../video-jobs.js'
|
||||
import { VideoPathManager } from '../video-path-manager.js'
|
||||
import { buildFFmpegVOD } from './shared/index.js'
|
||||
import { buildOriginalFileResolution } from './transcoding-resolutions.js'
|
||||
import { canDoQuickTranscode } from './transcoding-quick-transcode.js'
|
||||
|
||||
// Optimize the original video file and replace it. The resolution is not changed.
|
||||
export async function optimizeOriginalVideofile (options: {
|
||||
video: MVideoFullLight
|
||||
job: Job
|
||||
}) {
|
||||
const { 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(options.video.uuid)
|
||||
|
||||
try {
|
||||
const video = await VideoModel.loadFull(options.video.id)
|
||||
const inputVideoFile = video.getMaxQualityFile(VideoFileStream.VIDEO)
|
||||
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile, async videoInputPath => {
|
||||
const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||
|
||||
const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath, CONFIG.TRANSCODING.FPS.MAX)
|
||||
? 'quick-transcode'
|
||||
: 'video'
|
||||
|
||||
const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
|
||||
const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution, isOriginResolution: true, type: 'vod' })
|
||||
|
||||
// Could be very long!
|
||||
await buildFFmpegVOD(job).transcode({
|
||||
type: transcodeType,
|
||||
|
||||
videoInputPath,
|
||||
outputPath: videoOutputPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
||||
resolution,
|
||||
fps
|
||||
})
|
||||
|
||||
const { videoFile } = await onWebVideoFileTranscoding({ video, videoOutputPath, deleteWebInputVideoFile: inputVideoFile })
|
||||
|
||||
return { transcodeType, videoFile }
|
||||
})
|
||||
|
||||
return result
|
||||
} finally {
|
||||
inputFileMutexReleaser()
|
||||
}
|
||||
}
|
||||
|
||||
// Transcode the original/old/source 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 result = await VideoPathManager.Instance.makeAvailableMaxQualityFiles(video, async ({ videoPath, separatedAudioPath }) => {
|
||||
const filename = generateWebVideoFilename(resolution, newExtname)
|
||||
const videoOutputPath = join(transcodeDirectory, filename)
|
||||
|
||||
const transcodeOptions: VideoTranscodeOptions = {
|
||||
type: 'video',
|
||||
|
||||
videoInputPath: videoPath,
|
||||
separatedAudioInputPath: separatedAudioPath,
|
||||
|
||||
outputPath: videoOutputPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
||||
resolution,
|
||||
fps
|
||||
}
|
||||
|
||||
await buildFFmpegVOD(job).transcode(transcodeOptions)
|
||||
|
||||
return onWebVideoFileTranscoding({ video, 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.getMaxQualityFile(VideoFileStream.AUDIO)
|
||||
|
||||
const result = await VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile, 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: MergeAudioTranscodeOptions = {
|
||||
type: 'merge-audio',
|
||||
|
||||
videoInputPath: tmpPreviewPath,
|
||||
audioPath: audioInputPath,
|
||||
|
||||
outputPath: videoOutputPath,
|
||||
|
||||
inputFileMutexReleaser,
|
||||
|
||||
resolution,
|
||||
fps
|
||||
}
|
||||
|
||||
try {
|
||||
await buildFFmpegVOD(job).transcode(transcodeOptions)
|
||||
|
||||
await remove(tmpPreviewPath)
|
||||
} catch (err) {
|
||||
await remove(tmpPreviewPath)
|
||||
throw err
|
||||
}
|
||||
|
||||
await onWebVideoFileTranscoding({
|
||||
video,
|
||||
videoOutputPath,
|
||||
deleteWebInputVideoFile: inputVideoFile,
|
||||
wasAudioFile: true
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
} finally {
|
||||
inputFileMutexReleaser()
|
||||
}
|
||||
}
|
||||
|
||||
export async function onWebVideoFileTranscoding (options: {
|
||||
video: MVideoFullLight
|
||||
videoOutputPath: string
|
||||
wasAudioFile?: boolean // default false
|
||||
deleteWebInputVideoFile?: MVideoFile
|
||||
}) {
|
||||
const { video, videoOutputPath, wasAudioFile, deleteWebInputVideoFile } = options
|
||||
|
||||
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||
|
||||
const videoFile = await buildNewFile({ mode: 'web-video', path: videoOutputPath })
|
||||
videoFile.videoId = video.id
|
||||
|
||||
try {
|
||||
await video.reload()
|
||||
|
||||
// ffmpeg generated a new video file, so update the video duration
|
||||
// See https://trac.ffmpeg.org/ticket/5456
|
||||
if (wasAudioFile) {
|
||||
video.duration = await getVideoStreamDuration(videoOutputPath)
|
||||
video.aspectRatio = buildAspectRatio({ width: videoFile.width, height: videoFile.height })
|
||||
await video.save()
|
||||
}
|
||||
|
||||
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
|
||||
|
||||
await move(videoOutputPath, outputPath, { overwrite: true })
|
||||
|
||||
await createTorrentAndSetInfoHash(video, videoFile)
|
||||
|
||||
if (deleteWebInputVideoFile) {
|
||||
await saveNewOriginalFileIfNeeded(video, deleteWebInputVideoFile)
|
||||
|
||||
await video.removeWebVideoFile(deleteWebInputVideoFile)
|
||||
await deleteWebInputVideoFile.destroy()
|
||||
}
|
||||
|
||||
const existingFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
|
||||
if (existingFile) await video.removeWebVideoFile(existingFile)
|
||||
|
||||
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
|
||||
video.VideoFiles = await video.$get('VideoFiles')
|
||||
|
||||
if (wasAudioFile) {
|
||||
await addLocalOrRemoteStoryboardJobIfNeeded({ video, federate: false })
|
||||
}
|
||||
|
||||
return { video, videoFile }
|
||||
} finally {
|
||||
mutexReleaser()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user