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

Implement remote runner jobs in server

Move ffmpeg functions to @shared
This commit is contained in:
Chocobozzz 2023-04-21 14:55:10 +02:00 committed by Chocobozzz
parent 6bcb854cde
commit 0c9668f779
168 changed files with 6907 additions and 2803 deletions

View file

@ -0,0 +1,36 @@
import { CONFIG } from '@server/initializers/config'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
import { TranscodingJobQueueBuilder, TranscodingRunnerJobBuilder } from './shared'
export function createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
}) {
return getJobBuilder().createOptimizeOrMergeAudioJobs(options)
}
// ---------------------------------------------------------------------------
export function createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent'
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

@ -1,15 +1,9 @@
import { logger } from '@server/helpers/logger'
import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
import {
buildStreamSuffix,
canDoQuickAudioTranscode,
ffprobePromise,
getAudioStream,
getMaxAudioBitrate,
resetSupportedEncoders
} from '../../helpers/ffmpeg'
import { buildStreamSuffix, FFmpegCommandWrapper, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '@shared/ffmpeg'
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '@shared/models'
import { canDoQuickAudioTranscode } from './transcoding-quick-transcode'
/**
*
@ -184,14 +178,14 @@ class VideoTranscodingProfilesManager {
addEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
this.encodersPriorities[type][streamType].push({ name: encoder, priority })
resetSupportedEncoders()
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)
resetSupportedEncoders()
FFmpegCommandWrapper.resetSupportedEncoders()
}
private getEncodersByPriority (type: 'vod' | 'live', streamType: 'audio' | 'video') {

View file

@ -0,0 +1,18 @@
import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
import { MVideo } from '@server/types/models'
import { moveToNextState } from '../video-state'
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,181 @@
import { MutexInterface } from 'async-mutex'
import { Job } from 'bullmq'
import { ensureDir, move, stat } from 'fs-extra'
import { basename, extname as extnameUtil, join } from 'path'
import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { sequelizeTypescript } from '@server/initializers/database'
import { MVideo, MVideoFile } from '@server/types/models'
import { pick } from '@shared/core-utils'
import { getVideoStreamDuration, getVideoStreamFPS } from '@shared/ffmpeg'
import { VideoResolution } from '@shared/models'
import { CONFIG } from '../../initializers/config'
import { VideoFileModel } from '../../models/video/video-file'
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
import { updatePlaylistAfterFileChange } from '../hls'
import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths'
import { buildFileMetadata } from '../video-file'
import { VideoPathManager } from '../video-path-manager'
import { buildFFmpegVOD } from './shared'
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
export async function generateHlsPlaylistResolutionFromTS (options: {
video: MVideo
concatenatedTsFilePath: string
resolution: VideoResolution
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: VideoResolution
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
}) {
const { video, videoFile, videoOutputPath, m3u8OutputPath } = 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 = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
// VOD transcoding is a long task, refresh video attributes
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 {
mutexReleaser()
}
}
// ---------------------------------------------------------------------------
async function generateHlsPlaylistCommon (options: {
type: 'hls' | 'hls-from-ts'
video: MVideo
inputPath: string
resolution: VideoResolution
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 })
}

View file

@ -0,0 +1,18 @@
import { Job } from 'bullmq'
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
import { logger } from '@server/helpers/logger'
import { FFmpegVOD } from '@shared/ffmpeg'
import { VideoTranscodingProfilesManager } from '../default-transcoding-profiles'
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'
export * from './ffmpeg-builder'

View file

@ -0,0 +1,38 @@
import { JOB_PRIORITY } from '@server/initializers/constants'
import { VideoModel } from '@server/models/video/video'
import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
export abstract class AbstractJobBuilder {
abstract createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
}): Promise<any>
abstract createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent'
video: MVideoFullLight
resolutions: number[]
isNewVideo: boolean
user: MUserId | null
}): Promise<any>
protected async getTranscodingJobPriority (options: {
user: MUserId
fallback: number
}) {
const { user, fallback } = 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)
return JOB_PRIORITY.TRANSCODING + videoUploadedByUser
}
}

View file

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

View file

@ -0,0 +1,308 @@
import Bluebird from 'bluebird'
import { computeOutputFPS } from '@server/helpers/ffmpeg'
import { logger } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config'
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue'
import { Hooks } from '@server/lib/plugins/hooks'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
import {
HLSTranscodingPayload,
MergeAudioTranscodingPayload,
NewWebTorrentResolutionTranscodingPayload,
OptimizeTranscodingPayload,
VideoTranscodingPayload
} from '@shared/models'
import { canDoQuickTranscode } from '../../transcoding-quick-transcode'
import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
import { AbstractJobBuilder } from './abstract-job-builder'
export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
async createOptimizeOrMergeAudioJobs (options: {
video: MVideoFullLight
videoFile: MVideoFile
isNewVideo: boolean
user: MUserId
}) {
const { video, videoFile, isNewVideo, user } = options
let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload
let nextTranscodingSequentialJobPayloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
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
: resolution
if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
nextTranscodingSequentialJobPayloads.push([
this.buildHLSJobPayload({
deleteWebTorrentFiles: CONFIG.TRANSCODING.WEBTORRENT.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 ]
mergeOrOptimizePayload = videoFile.isAudio()
? this.buildMergeAudioPayload({ videoUUID: video.uuid, isNewVideo })
: this.buildOptimizePayload({ videoUUID: video.uuid, isNewVideo, quickTranscode })
})
} 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 })
return JobQueue.Instance.createSequentialJobFlow(...[ mergeOrOptimizeJob, transcodingJobBuilderJob ])
}
// ---------------------------------------------------------------------------
async createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent'
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') {
return this.buildWebTorrentJobPayload({ 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.buildWebTorrentJobPayload({ 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 | NewWebTorrentResolutionTranscodingPayload)
children: (HLSTranscodingPayload | NewWebTorrentResolutionTranscodingPayload)[]
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 this.getTranscodingJobPriority({ user, 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: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
for (const resolution of resolutionsEnabled) {
const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED) {
const payloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[] = [
this.buildWebTorrentJobPayload({
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
deleteWebTorrentFiles?: boolean // default false
copyCodecs?: boolean // default false
}): HLSTranscodingPayload {
const { videoUUID, resolution, fps, isNewVideo, deleteWebTorrentFiles = false, copyCodecs = false } = options
return {
type: 'new-resolution-to-hls',
videoUUID,
resolution,
fps,
copyCodecs,
isNewVideo,
deleteWebTorrentFiles
}
}
private buildWebTorrentJobPayload (options: {
videoUUID: string
resolution: number
fps: number
isNewVideo: boolean
}): NewWebTorrentResolutionTranscodingPayload {
const { videoUUID, resolution, fps, isNewVideo } = options
return {
type: 'new-resolution-to-webtorrent',
videoUUID,
isNewVideo,
resolution,
fps
}
}
private buildMergeAudioPayload (options: {
videoUUID: string
isNewVideo: boolean
}): MergeAudioTranscodingPayload {
const { videoUUID, isNewVideo } = options
return {
type: 'merge-audio-to-webtorrent',
resolution: DEFAULT_AUDIO_RESOLUTION,
fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
videoUUID,
isNewVideo
}
}
private buildOptimizePayload (options: {
videoUUID: string
quickTranscode: boolean
isNewVideo: boolean
}): OptimizeTranscodingPayload {
const { videoUUID, quickTranscode, isNewVideo } = options
return {
type: 'optimize-to-webtorrent',
videoUUID,
isNewVideo,
quickTranscode
}
}
}

View file

@ -0,0 +1,189 @@
import { computeOutputFPS } from '@server/helpers/ffmpeg'
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config'
import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
import { Hooks } from '@server/lib/plugins/hooks'
import { VODAudioMergeTranscodingJobHandler, VODHLSTranscodingJobHandler, VODWebVideoTranscodingJobHandler } from '@server/lib/runners'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
import { MRunnerJob } from '@server/types/models/runners'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
import { AbstractJobBuilder } from './abstract-job-builder'
/**
*
* 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
}) {
const { video, videoFile, isNewVideo, user } = options
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
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 this.getTranscodingJobPriority({ user, 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.WEBTORRENT.ENABLED === false,
resolution: maxResolution,
fps,
isNewVideo,
dependsOnRunnerJob: mainRunnerJob,
priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
})
}
await this.buildLowerResolutionJobPayloads({
video,
inputVideoResolution: maxResolution,
inputVideoFPS: inputFPS,
hasAudio,
isNewVideo,
mainRunnerJob,
user
})
})
} finally {
mutexReleaser()
}
}
// ---------------------------------------------------------------------------
async createTranscodingJobs (options: {
transcodingType: 'hls' | 'webtorrent'
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 this.getTranscodingJobPriority({ user, 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: maxResolution })
if (transcodingType === 'hls') {
await new VODHLSTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
deleteWebVideoFiles: false,
dependsOnRunnerJob,
priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
})
continue
}
if (transcodingType === 'webtorrent') {
await new VODWebVideoTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
dependsOnRunnerJob,
priority: await this.getTranscodingJobPriority({ user, 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.WEBTORRENT.ENABLED) {
await new VODWebVideoTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
dependsOnRunnerJob: mainRunnerJob,
priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
})
}
if (CONFIG.TRANSCODING.HLS.ENABLED) {
await new VODHLSTranscodingJobHandler().create({
video,
resolution,
fps,
isNewVideo,
deleteWebVideoFiles: false,
dependsOnRunnerJob: mainRunnerJob,
priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
})
}
}
}
}

View file

@ -0,0 +1,61 @@
import { FfprobeData } from 'fluent-ffmpeg'
import { CONFIG } from '@server/initializers/config'
import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
import { getMaxBitrate } from '@shared/core-utils'
import {
ffprobePromise,
getAudioStream,
getMaxAudioBitrate,
getVideoStream,
getVideoStreamBitrate,
getVideoStreamDimensionsInfo,
getVideoStreamFPS
} from '@shared/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)
}
export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
const parsedAudio = await getAudioStream(path, probe)
if (!parsedAudio.audioStream) return true
if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
const audioBitrate = parsedAudio.bitrate
if (!audioBitrate) return false
const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
const channelLayout = parsedAudio.audioStream['channel_layout']
// Causes playback issues with Chrome
if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false
return true
}
export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
const videoStream = await getVideoStream(path, probe)
const fps = await getVideoStreamFPS(path, probe)
const bitRate = await getVideoStreamBitrate(path, probe)
const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
// If ffprobe did not manage to guess the bitrate
if (!bitRate) return false
// check video params
if (!videoStream) return false
if (videoStream['codec_name'] !== 'h264') return false
if (videoStream['pix_fmt'] !== 'yuv420p') return false
if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
if (bitRate > getMaxBitrate({ ...resolutionData, fps })) return false
return true
}

View file

@ -0,0 +1,52 @@
import { CONFIG } from '@server/initializers/config'
import { toEven } from '@shared/core-utils'
import { VideoResolution } from '@shared/models'
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: VideoResolution[] = [
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

@ -1,465 +0,0 @@
import { MutexInterface } from 'async-mutex'
import { Job } from 'bullmq'
import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
import { basename, extname as extnameUtil, join } from 'path'
import { toEven } from '@server/helpers/core-utils'
import { retryTransactionWrapper } from '@server/helpers/database-utils'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { sequelizeTypescript } from '@server/initializers/database'
import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
import { pick } from '@shared/core-utils'
import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
import {
buildFileMetadata,
canDoQuickTranscode,
computeResolutionsToTranscode,
ffprobePromise,
getVideoStreamDuration,
getVideoStreamFPS,
transcodeVOD,
TranscodeVODOptions,
TranscodeVODOptionsType
} from '../../helpers/ffmpeg'
import { CONFIG } from '../../initializers/config'
import { VideoFileModel } from '../../models/video/video-file'
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
import { updatePlaylistAfterFileChange } from '../hls'
import { generateHLSVideoFilename, generateWebTorrentVideoFilename, getHlsResolutionPlaylistFilename } from '../paths'
import { VideoPathManager } from '../video-path-manager'
import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
/**
*
* Functions that run transcoding functions, update the database, cleanup files, create torrent files...
* Mainly called by the job queue
*
*/
// Optimize the original video file and replace it. The resolution is not changed.
async function optimizeOriginalVideofile (options: {
video: MVideoFullLight
inputVideoFile: MVideoFile
job: Job
}) {
const { video, inputVideoFile, 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()
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
? 'quick-transcode'
: 'video'
const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
const transcodeOptions: TranscodeVODOptions = {
type: transcodeType,
inputPath: videoInputPath,
outputPath: videoTranscodedPath,
inputFileMutexReleaser,
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE,
resolution,
job
}
// Could be very long!
await transcodeVOD(transcodeOptions)
// Important to do this before getVideoFilename() to take in account the new filename
inputVideoFile.resolution = resolution
inputVideoFile.extname = newExtname
inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname)
inputVideoFile.storage = VideoStorage.FILE_SYSTEM
const { videoFile } = await onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, inputVideoFile)
await remove(videoInputPath)
return { transcodeType, videoFile }
})
return result
} finally {
inputFileMutexReleaser()
}
}
// Transcode the original video file to a lower resolution compatible with WebTorrent
async function transcodeNewWebTorrentResolution (options: {
video: MVideoFullLight
resolution: VideoResolution
job: Job
}) {
const { video, resolution, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
const newVideoFile = new VideoFileModel({
resolution,
extname: newExtname,
filename: generateWebTorrentVideoFilename(resolution, newExtname),
size: 0,
videoId: video.id
})
const videoTranscodedPath = join(transcodeDirectory, newVideoFile.filename)
const transcodeOptions = resolution === VideoResolution.H_NOVIDEO
? {
type: 'only-audio' as 'only-audio',
inputPath: videoInputPath,
outputPath: videoTranscodedPath,
inputFileMutexReleaser,
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE,
resolution,
job
}
: {
type: 'video' as 'video',
inputPath: videoInputPath,
outputPath: videoTranscodedPath,
inputFileMutexReleaser,
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE,
resolution,
job
}
await transcodeVOD(transcodeOptions)
return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, newVideoFile)
})
return result
} finally {
inputFileMutexReleaser()
}
}
// Merge an image with an audio file to create a video
async function mergeAudioVideofile (options: {
video: MVideoFullLight
resolution: VideoResolution
job: Job
}) {
const { video, resolution, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
const inputVideoFile = video.getMinQualityFile()
const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
const videoTranscodedPath = 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: videoTranscodedPath,
inputFileMutexReleaser,
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE,
audioPath: audioInputPath,
resolution,
job
}
try {
await transcodeVOD(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 = generateWebTorrentVideoFilename(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(videoTranscodedPath)
await video.save()
return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, inputVideoFile)
})
return result
} finally {
inputFileMutexReleaser()
}
}
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
async function generateHlsPlaylistResolutionFromTS (options: {
video: MVideo
concatenatedTsFilePath: string
resolution: VideoResolution
isAAC: boolean
inputFileMutexReleaser: MutexInterface.Releaser
}) {
return generateHlsPlaylistCommon({
type: 'hls-from-ts' as 'hls-from-ts',
inputPath: options.concatenatedTsFilePath,
...pick(options, [ 'video', 'resolution', 'inputFileMutexReleaser', 'isAAC' ])
})
}
// Generate an HLS playlist from an input file, and update the master playlist
function generateHlsPlaylistResolution (options: {
video: MVideo
videoInputPath: string
resolution: VideoResolution
copyCodecs: boolean
inputFileMutexReleaser: MutexInterface.Releaser
job?: Job
}) {
return generateHlsPlaylistCommon({
type: 'hls' as 'hls',
inputPath: options.videoInputPath,
...pick(options, [ 'video', 'resolution', 'copyCodecs', 'inputFileMutexReleaser', 'job' ])
})
}
// ---------------------------------------------------------------------------
export {
generateHlsPlaylistResolution,
generateHlsPlaylistResolutionFromTS,
optimizeOriginalVideofile,
transcodeNewWebTorrentResolution,
mergeAudioVideofile
}
// ---------------------------------------------------------------------------
async function onWebTorrentVideoFileTranscoding (
video: MVideoFullLight,
videoFile: MVideoFile,
transcodingPath: string,
newVideoFile: MVideoFile
) {
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile)
const stats = await stat(transcodingPath)
const probe = await ffprobePromise(transcodingPath)
const fps = await getVideoStreamFPS(transcodingPath, probe)
const metadata = await buildFileMetadata(transcodingPath, probe)
await move(transcodingPath, outputPath, { overwrite: true })
videoFile.size = stats.size
videoFile.fps = fps
videoFile.metadata = metadata
await createTorrentAndSetInfoHash(video, videoFile)
const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
if (oldFile) await video.removeWebTorrentFile(oldFile)
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
video.VideoFiles = await video.$get('VideoFiles')
return { video, videoFile }
} finally {
mutexReleaser()
}
}
async function generateHlsPlaylistCommon (options: {
type: 'hls' | 'hls-from-ts'
video: MVideo
inputPath: string
resolution: VideoResolution
inputFileMutexReleaser: MutexInterface.Releaser
copyCodecs?: boolean
isAAC?: boolean
job?: Job
}) {
const { type, video, inputPath, resolution, copyCodecs, isAAC, job, inputFileMutexReleaser } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const videoTranscodedBasePath = join(transcodeDirectory, type)
await ensureDir(videoTranscodedBasePath)
const videoFilename = generateHLSVideoFilename(resolution)
const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
const resolutionPlaylistFileTranscodePath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
const transcodeOptions = {
type,
inputPath,
outputPath: resolutionPlaylistFileTranscodePath,
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE,
resolution,
copyCodecs,
isAAC,
inputFileMutexReleaser,
hlsPlaylist: {
videoFilename
},
job
}
await transcodeVOD(transcodeOptions)
// Create or update the playlist
const playlist = await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
})
})
const newVideoFile = new VideoFileModel({
resolution,
extname: extnameUtil(videoFilename),
size: 0,
filename: videoFilename,
fps: -1,
videoStreamingPlaylistId: playlist.id
})
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
// VOD transcoding is a long task, refresh video attributes
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, resolutionPlaylistFilename)
await move(resolutionPlaylistFileTranscodePath, resolutionPlaylistPath, { overwrite: true })
// Move video file
await move(join(videoTranscodedBasePath, videoFilename), 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)
newVideoFile.size = stats.size
newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
newVideoFile.metadata = await buildFileMetadata(videoFilePath)
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)
await updatePlaylistAfterFileChange(video, playlist)
return { resolutionPlaylistPath, videoFile: savedVideoFile }
} finally {
mutexReleaser()
}
}
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)
}

View file

@ -0,0 +1,273 @@
import { Job } from 'bullmq'
import { copyFile, move, remove, stat } from 'fs-extra'
import { basename, join } from 'path'
import { computeOutputFPS } from '@server/helpers/ffmpeg'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { MVideoFile, MVideoFullLight } from '@server/types/models'
import { toEven } from '@shared/core-utils'
import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVODOptionsType } from '@shared/ffmpeg'
import { VideoResolution, VideoStorage } from '@shared/models'
import { CONFIG } from '../../initializers/config'
import { VideoFileModel } from '../../models/video/video-file'
import { generateWebTorrentVideoFilename } from '../paths'
import { buildFileMetadata } from '../video-file'
import { VideoPathManager } from '../video-path-manager'
import { buildFFmpegVOD } from './shared'
import { computeResolutionsToTranscode } from './transcoding-resolutions'
// 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()
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 = generateWebTorrentVideoFilename(resolution, newExtname)
inputVideoFile.storage = VideoStorage.FILE_SYSTEM
const { videoFile } = await onWebTorrentVideoFileTranscoding({
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 WebTorrent
export async function transcodeNewWebTorrentResolution (options: {
video: MVideoFullLight
resolution: VideoResolution
fps: number
job: Job
}) {
const { video, resolution, fps, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
const newVideoFile = new VideoFileModel({
resolution,
extname: newExtname,
filename: generateWebTorrentVideoFilename(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 onWebTorrentVideoFileTranscoding({ 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: VideoResolution
fps: number
job: Job
}) {
const { video, resolution, fps, job } = options
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
const newExtname = '.mp4'
const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
try {
await video.reload()
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 = generateWebTorrentVideoFilename(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 onWebTorrentVideoFileTranscoding({
video,
videoFile: inputVideoFile,
videoOutputPath
})
})
return result
} finally {
inputFileMutexReleaser()
}
}
export async function onWebTorrentVideoFileTranscoding (options: {
video: MVideoFullLight
videoFile: MVideoFile
videoOutputPath: string
}) {
const { video, videoFile, videoOutputPath } = 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.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
if (oldFile) await video.removeWebTorrentFile(oldFile)
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
video.VideoFiles = await video.$get('VideoFiles')
return { video, videoFile }
} finally {
mutexReleaser()
}
}
// ---------------------------------------------------------------------------
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)
}