aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/transcoding
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-04-21 14:55:10 +0200
committerChocobozzz <chocobozzz@cpy.re>2023-05-09 08:57:34 +0200
commit0c9668f77901e7540e2c7045eb0f2974a4842a69 (patch)
tree226d3dd1565b0bb56588897af3b8530e6216e96b /server/lib/transcoding
parent6bcb854cdea8688a32240bc5719c7d139806e00b (diff)
downloadPeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.tar.gz
PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.tar.zst
PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.zip
Implement remote runner jobs in server
Move ffmpeg functions to @shared
Diffstat (limited to 'server/lib/transcoding')
-rw-r--r--server/lib/transcoding/create-transcoding-job.ts36
-rw-r--r--server/lib/transcoding/default-transcoding-profiles.ts16
-rw-r--r--server/lib/transcoding/ended-transcoding.ts18
-rw-r--r--server/lib/transcoding/hls-transcoding.ts181
-rw-r--r--server/lib/transcoding/shared/ffmpeg-builder.ts18
-rw-r--r--server/lib/transcoding/shared/index.ts2
-rw-r--r--server/lib/transcoding/shared/job-builders/abstract-job-builder.ts38
-rw-r--r--server/lib/transcoding/shared/job-builders/index.ts2
-rw-r--r--server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts308
-rw-r--r--server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts189
-rw-r--r--server/lib/transcoding/transcoding-quick-transcode.ts61
-rw-r--r--server/lib/transcoding/transcoding-resolutions.ts52
-rw-r--r--server/lib/transcoding/transcoding.ts465
-rw-r--r--server/lib/transcoding/web-transcoding.ts273
14 files changed, 1183 insertions, 476 deletions
diff --git a/server/lib/transcoding/create-transcoding-job.ts b/server/lib/transcoding/create-transcoding-job.ts
new file mode 100644
index 000000000..46831a912
--- /dev/null
+++ b/server/lib/transcoding/create-transcoding-job.ts
@@ -0,0 +1,36 @@
1import { CONFIG } from '@server/initializers/config'
2import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
3import { TranscodingJobQueueBuilder, TranscodingRunnerJobBuilder } from './shared'
4
5export function createOptimizeOrMergeAudioJobs (options: {
6 video: MVideoFullLight
7 videoFile: MVideoFile
8 isNewVideo: boolean
9 user: MUserId
10}) {
11 return getJobBuilder().createOptimizeOrMergeAudioJobs(options)
12}
13
14// ---------------------------------------------------------------------------
15
16export function createTranscodingJobs (options: {
17 transcodingType: 'hls' | 'webtorrent'
18 video: MVideoFullLight
19 resolutions: number[]
20 isNewVideo: boolean
21 user: MUserId
22}) {
23 return getJobBuilder().createTranscodingJobs(options)
24}
25
26// ---------------------------------------------------------------------------
27// Private
28// ---------------------------------------------------------------------------
29
30function getJobBuilder () {
31 if (CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED === true) {
32 return new TranscodingRunnerJobBuilder()
33 }
34
35 return new TranscodingJobQueueBuilder()
36}
diff --git a/server/lib/transcoding/default-transcoding-profiles.ts b/server/lib/transcoding/default-transcoding-profiles.ts
index f47718819..5251784ac 100644
--- a/server/lib/transcoding/default-transcoding-profiles.ts
+++ b/server/lib/transcoding/default-transcoding-profiles.ts
@@ -1,15 +1,9 @@
1 1
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils' 3import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
4import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos' 4import { buildStreamSuffix, FFmpegCommandWrapper, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '@shared/ffmpeg'
5import { 5import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '@shared/models'
6 buildStreamSuffix, 6import { canDoQuickAudioTranscode } from './transcoding-quick-transcode'
7 canDoQuickAudioTranscode,
8 ffprobePromise,
9 getAudioStream,
10 getMaxAudioBitrate,
11 resetSupportedEncoders
12} from '../../helpers/ffmpeg'
13 7
14/** 8/**
15 * 9 *
@@ -184,14 +178,14 @@ class VideoTranscodingProfilesManager {
184 addEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) { 178 addEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
185 this.encodersPriorities[type][streamType].push({ name: encoder, priority }) 179 this.encodersPriorities[type][streamType].push({ name: encoder, priority })
186 180
187 resetSupportedEncoders() 181 FFmpegCommandWrapper.resetSupportedEncoders()
188 } 182 }
189 183
190 removeEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) { 184 removeEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
191 this.encodersPriorities[type][streamType] = this.encodersPriorities[type][streamType] 185 this.encodersPriorities[type][streamType] = this.encodersPriorities[type][streamType]
192 .filter(o => o.name !== encoder && o.priority !== priority) 186 .filter(o => o.name !== encoder && o.priority !== priority)
193 187
194 resetSupportedEncoders() 188 FFmpegCommandWrapper.resetSupportedEncoders()
195 } 189 }
196 190
197 private getEncodersByPriority (type: 'vod' | 'live', streamType: 'audio' | 'video') { 191 private getEncodersByPriority (type: 'vod' | 'live', streamType: 'audio' | 'video') {
diff --git a/server/lib/transcoding/ended-transcoding.ts b/server/lib/transcoding/ended-transcoding.ts
new file mode 100644
index 000000000..d31674ede
--- /dev/null
+++ b/server/lib/transcoding/ended-transcoding.ts
@@ -0,0 +1,18 @@
1import { retryTransactionWrapper } from '@server/helpers/database-utils'
2import { VideoJobInfoModel } from '@server/models/video/video-job-info'
3import { MVideo } from '@server/types/models'
4import { moveToNextState } from '../video-state'
5
6export async function onTranscodingEnded (options: {
7 video: MVideo
8 isNewVideo: boolean
9 moveVideoToNextState: boolean
10}) {
11 const { video, isNewVideo, moveVideoToNextState } = options
12
13 await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
14
15 if (moveVideoToNextState) {
16 await retryTransactionWrapper(moveToNextState, { video, isNewVideo })
17 }
18}
diff --git a/server/lib/transcoding/hls-transcoding.ts b/server/lib/transcoding/hls-transcoding.ts
new file mode 100644
index 000000000..cffa859c7
--- /dev/null
+++ b/server/lib/transcoding/hls-transcoding.ts
@@ -0,0 +1,181 @@
1import { MutexInterface } from 'async-mutex'
2import { Job } from 'bullmq'
3import { ensureDir, move, stat } from 'fs-extra'
4import { basename, extname as extnameUtil, join } from 'path'
5import { retryTransactionWrapper } from '@server/helpers/database-utils'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
7import { sequelizeTypescript } from '@server/initializers/database'
8import { MVideo, MVideoFile } from '@server/types/models'
9import { pick } from '@shared/core-utils'
10import { getVideoStreamDuration, getVideoStreamFPS } from '@shared/ffmpeg'
11import { VideoResolution } from '@shared/models'
12import { CONFIG } from '../../initializers/config'
13import { VideoFileModel } from '../../models/video/video-file'
14import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
15import { updatePlaylistAfterFileChange } from '../hls'
16import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths'
17import { buildFileMetadata } from '../video-file'
18import { VideoPathManager } from '../video-path-manager'
19import { buildFFmpegVOD } from './shared'
20
21// Concat TS segments from a live video to a fragmented mp4 HLS playlist
22export async function generateHlsPlaylistResolutionFromTS (options: {
23 video: MVideo
24 concatenatedTsFilePath: string
25 resolution: VideoResolution
26 fps: number
27 isAAC: boolean
28 inputFileMutexReleaser: MutexInterface.Releaser
29}) {
30 return generateHlsPlaylistCommon({
31 type: 'hls-from-ts' as 'hls-from-ts',
32 inputPath: options.concatenatedTsFilePath,
33
34 ...pick(options, [ 'video', 'resolution', 'fps', 'inputFileMutexReleaser', 'isAAC' ])
35 })
36}
37
38// Generate an HLS playlist from an input file, and update the master playlist
39export function generateHlsPlaylistResolution (options: {
40 video: MVideo
41 videoInputPath: string
42 resolution: VideoResolution
43 fps: number
44 copyCodecs: boolean
45 inputFileMutexReleaser: MutexInterface.Releaser
46 job?: Job
47}) {
48 return generateHlsPlaylistCommon({
49 type: 'hls' as 'hls',
50 inputPath: options.videoInputPath,
51
52 ...pick(options, [ 'video', 'resolution', 'fps', 'copyCodecs', 'inputFileMutexReleaser', 'job' ])
53 })
54}
55
56export async function onHLSVideoFileTranscoding (options: {
57 video: MVideo
58 videoFile: MVideoFile
59 videoOutputPath: string
60 m3u8OutputPath: string
61}) {
62 const { video, videoFile, videoOutputPath, m3u8OutputPath } = options
63
64 // Create or update the playlist
65 const playlist = await retryTransactionWrapper(() => {
66 return sequelizeTypescript.transaction(async transaction => {
67 return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
68 })
69 })
70 videoFile.videoStreamingPlaylistId = playlist.id
71
72 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
73
74 try {
75 // VOD transcoding is a long task, refresh video attributes
76 await video.reload()
77
78 const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, videoFile)
79 await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video))
80
81 // Move playlist file
82 const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, basename(m3u8OutputPath))
83 await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
84 // Move video file
85 await move(videoOutputPath, videoFilePath, { overwrite: true })
86
87 // Update video duration if it was not set (in case of a live for example)
88 if (!video.duration) {
89 video.duration = await getVideoStreamDuration(videoFilePath)
90 await video.save()
91 }
92
93 const stats = await stat(videoFilePath)
94
95 videoFile.size = stats.size
96 videoFile.fps = await getVideoStreamFPS(videoFilePath)
97 videoFile.metadata = await buildFileMetadata(videoFilePath)
98
99 await createTorrentAndSetInfoHash(playlist, videoFile)
100
101 const oldFile = await VideoFileModel.loadHLSFile({
102 playlistId: playlist.id,
103 fps: videoFile.fps,
104 resolution: videoFile.resolution
105 })
106
107 if (oldFile) {
108 await video.removeStreamingPlaylistVideoFile(playlist, oldFile)
109 await oldFile.destroy()
110 }
111
112 const savedVideoFile = await VideoFileModel.customUpsert(videoFile, 'streaming-playlist', undefined)
113
114 await updatePlaylistAfterFileChange(video, playlist)
115
116 return { resolutionPlaylistPath, videoFile: savedVideoFile }
117 } finally {
118 mutexReleaser()
119 }
120}
121
122// ---------------------------------------------------------------------------
123
124async function generateHlsPlaylistCommon (options: {
125 type: 'hls' | 'hls-from-ts'
126 video: MVideo
127 inputPath: string
128
129 resolution: VideoResolution
130 fps: number
131
132 inputFileMutexReleaser: MutexInterface.Releaser
133
134 copyCodecs?: boolean
135 isAAC?: boolean
136
137 job?: Job
138}) {
139 const { type, video, inputPath, resolution, fps, copyCodecs, isAAC, job, inputFileMutexReleaser } = options
140 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
141
142 const videoTranscodedBasePath = join(transcodeDirectory, type)
143 await ensureDir(videoTranscodedBasePath)
144
145 const videoFilename = generateHLSVideoFilename(resolution)
146 const videoOutputPath = join(videoTranscodedBasePath, videoFilename)
147
148 const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
149 const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
150
151 const transcodeOptions = {
152 type,
153
154 inputPath,
155 outputPath: m3u8OutputPath,
156
157 resolution,
158 fps,
159 copyCodecs,
160
161 isAAC,
162
163 inputFileMutexReleaser,
164
165 hlsPlaylist: {
166 videoFilename
167 }
168 }
169
170 await buildFFmpegVOD(job).transcode(transcodeOptions)
171
172 const newVideoFile = new VideoFileModel({
173 resolution,
174 extname: extnameUtil(videoFilename),
175 size: 0,
176 filename: videoFilename,
177 fps: -1
178 })
179
180 await onHLSVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath, m3u8OutputPath })
181}
diff --git a/server/lib/transcoding/shared/ffmpeg-builder.ts b/server/lib/transcoding/shared/ffmpeg-builder.ts
new file mode 100644
index 000000000..441445ec4
--- /dev/null
+++ b/server/lib/transcoding/shared/ffmpeg-builder.ts
@@ -0,0 +1,18 @@
1import { Job } from 'bullmq'
2import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
3import { logger } from '@server/helpers/logger'
4import { FFmpegVOD } from '@shared/ffmpeg'
5import { VideoTranscodingProfilesManager } from '../default-transcoding-profiles'
6
7export function buildFFmpegVOD (job?: Job) {
8 return new FFmpegVOD({
9 ...getFFmpegCommandWrapperOptions('vod', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()),
10
11 updateJobProgress: progress => {
12 if (!job) return
13
14 job.updateProgress(progress)
15 .catch(err => logger.error('Cannot update ffmpeg job progress', { err }))
16 }
17 })
18}
diff --git a/server/lib/transcoding/shared/index.ts b/server/lib/transcoding/shared/index.ts
new file mode 100644
index 000000000..f0b45bcbb
--- /dev/null
+++ b/server/lib/transcoding/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './job-builders'
2export * from './ffmpeg-builder'
diff --git a/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts b/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
new file mode 100644
index 000000000..f1e9efdcf
--- /dev/null
+++ b/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
@@ -0,0 +1,38 @@
1
2import { JOB_PRIORITY } from '@server/initializers/constants'
3import { VideoModel } from '@server/models/video/video'
4import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
5
6export abstract class AbstractJobBuilder {
7
8 abstract createOptimizeOrMergeAudioJobs (options: {
9 video: MVideoFullLight
10 videoFile: MVideoFile
11 isNewVideo: boolean
12 user: MUserId
13 }): Promise<any>
14
15 abstract createTranscodingJobs (options: {
16 transcodingType: 'hls' | 'webtorrent'
17 video: MVideoFullLight
18 resolutions: number[]
19 isNewVideo: boolean
20 user: MUserId | null
21 }): Promise<any>
22
23 protected async getTranscodingJobPriority (options: {
24 user: MUserId
25 fallback: number
26 }) {
27 const { user, fallback } = options
28
29 if (!user) return fallback
30
31 const now = new Date()
32 const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
33
34 const videoUploadedByUser = await VideoModel.countVideosUploadedByUserSince(user.id, lastWeek)
35
36 return JOB_PRIORITY.TRANSCODING + videoUploadedByUser
37 }
38}
diff --git a/server/lib/transcoding/shared/job-builders/index.ts b/server/lib/transcoding/shared/job-builders/index.ts
new file mode 100644
index 000000000..9b1c82adf
--- /dev/null
+++ b/server/lib/transcoding/shared/job-builders/index.ts
@@ -0,0 +1,2 @@
1export * from './transcoding-job-queue-builder'
2export * from './transcoding-runner-job-builder'
diff --git a/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts b/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
new file mode 100644
index 000000000..7c892718b
--- /dev/null
+++ b/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
@@ -0,0 +1,308 @@
1import Bluebird from 'bluebird'
2import { computeOutputFPS } from '@server/helpers/ffmpeg'
3import { logger } from '@server/helpers/logger'
4import { CONFIG } from '@server/initializers/config'
5import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
6import { CreateJobArgument, JobQueue } from '@server/lib/job-queue'
7import { Hooks } from '@server/lib/plugins/hooks'
8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
11import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
12import {
13 HLSTranscodingPayload,
14 MergeAudioTranscodingPayload,
15 NewWebTorrentResolutionTranscodingPayload,
16 OptimizeTranscodingPayload,
17 VideoTranscodingPayload
18} from '@shared/models'
19import { canDoQuickTranscode } from '../../transcoding-quick-transcode'
20import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
21import { AbstractJobBuilder } from './abstract-job-builder'
22
23export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
24
25 async createOptimizeOrMergeAudioJobs (options: {
26 video: MVideoFullLight
27 videoFile: MVideoFile
28 isNewVideo: boolean
29 user: MUserId
30 }) {
31 const { video, videoFile, isNewVideo, user } = options
32
33 let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload
34 let nextTranscodingSequentialJobPayloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
35
36 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
37
38 try {
39 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
40 const probe = await ffprobePromise(videoFilePath)
41
42 const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
43 const hasAudio = await hasAudioStream(videoFilePath, probe)
44 const quickTranscode = await canDoQuickTranscode(videoFilePath, probe)
45 const inputFPS = videoFile.isAudio()
46 ? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
47 : await getVideoStreamFPS(videoFilePath, probe)
48
49 const maxResolution = await isAudioFile(videoFilePath, probe)
50 ? DEFAULT_AUDIO_RESOLUTION
51 : resolution
52
53 if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
54 nextTranscodingSequentialJobPayloads.push([
55 this.buildHLSJobPayload({
56 deleteWebTorrentFiles: CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false,
57
58 // We had some issues with a web video quick transcoded while producing a HLS version of it
59 copyCodecs: !quickTranscode,
60
61 resolution: maxResolution,
62 fps: computeOutputFPS({ inputFPS, resolution: maxResolution }),
63 videoUUID: video.uuid,
64 isNewVideo
65 })
66 ])
67 }
68
69 const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({
70 video,
71 inputVideoResolution: maxResolution,
72 inputVideoFPS: inputFPS,
73 hasAudio,
74 isNewVideo
75 })
76
77 nextTranscodingSequentialJobPayloads = [ ...nextTranscodingSequentialJobPayloads, ...lowerResolutionJobPayloads ]
78
79 mergeOrOptimizePayload = videoFile.isAudio()
80 ? this.buildMergeAudioPayload({ videoUUID: video.uuid, isNewVideo })
81 : this.buildOptimizePayload({ videoUUID: video.uuid, isNewVideo, quickTranscode })
82 })
83 } finally {
84 mutexReleaser()
85 }
86
87 const nextTranscodingSequentialJobs = await Bluebird.mapSeries(nextTranscodingSequentialJobPayloads, payloads => {
88 return Bluebird.mapSeries(payloads, payload => {
89 return this.buildTranscodingJob({ payload, user })
90 })
91 })
92
93 const transcodingJobBuilderJob: CreateJobArgument = {
94 type: 'transcoding-job-builder',
95 payload: {
96 videoUUID: video.uuid,
97 sequentialJobs: nextTranscodingSequentialJobs
98 }
99 }
100
101 const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: mergeOrOptimizePayload, user })
102
103 return JobQueue.Instance.createSequentialJobFlow(...[ mergeOrOptimizeJob, transcodingJobBuilderJob ])
104 }
105
106 // ---------------------------------------------------------------------------
107
108 async createTranscodingJobs (options: {
109 transcodingType: 'hls' | 'webtorrent'
110 video: MVideoFullLight
111 resolutions: number[]
112 isNewVideo: boolean
113 user: MUserId | null
114 }) {
115 const { video, transcodingType, resolutions, isNewVideo } = options
116
117 const maxResolution = Math.max(...resolutions)
118 const childrenResolutions = resolutions.filter(r => r !== maxResolution)
119
120 logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
121
122 const { fps: inputFPS } = await video.probeMaxQualityFile()
123
124 const children = childrenResolutions.map(resolution => {
125 const fps = computeOutputFPS({ inputFPS, resolution })
126
127 if (transcodingType === 'hls') {
128 return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
129 }
130
131 if (transcodingType === 'webtorrent') {
132 return this.buildWebTorrentJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
133 }
134
135 throw new Error('Unknown transcoding type')
136 })
137
138 const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
139
140 const parent = transcodingType === 'hls'
141 ? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
142 : this.buildWebTorrentJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
143
144 // Process the last resolution after the other ones to prevent concurrency issue
145 // Because low resolutions use the biggest one as ffmpeg input
146 await this.createTranscodingJobsWithChildren({ videoUUID: video.uuid, parent, children, user: null })
147 }
148
149 // ---------------------------------------------------------------------------
150
151 private async createTranscodingJobsWithChildren (options: {
152 videoUUID: string
153 parent: (HLSTranscodingPayload | NewWebTorrentResolutionTranscodingPayload)
154 children: (HLSTranscodingPayload | NewWebTorrentResolutionTranscodingPayload)[]
155 user: MUserId | null
156 }) {
157 const { videoUUID, parent, children, user } = options
158
159 const parentJob = await this.buildTranscodingJob({ payload: parent, user })
160 const childrenJobs = await Bluebird.mapSeries(children, c => this.buildTranscodingJob({ payload: c, user }))
161
162 await JobQueue.Instance.createJobWithChildren(parentJob, childrenJobs)
163
164 await VideoJobInfoModel.increaseOrCreate(videoUUID, 'pendingTranscode', 1 + children.length)
165 }
166
167 private async buildTranscodingJob (options: {
168 payload: VideoTranscodingPayload
169 user: MUserId | null // null means we don't want priority
170 }) {
171 const { user, payload } = options
172
173 return {
174 type: 'video-transcoding' as 'video-transcoding',
175 priority: await this.getTranscodingJobPriority({ user, fallback: undefined }),
176 payload
177 }
178 }
179
180 private async buildLowerResolutionJobPayloads (options: {
181 video: MVideoWithFileThumbnail
182 inputVideoResolution: number
183 inputVideoFPS: number
184 hasAudio: boolean
185 isNewVideo: boolean
186 }) {
187 const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio } = options
188
189 // Create transcoding jobs if there are enabled resolutions
190 const resolutionsEnabled = await Hooks.wrapObject(
191 computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
192 'filter:transcoding.auto.resolutions-to-transcode.result',
193 options
194 )
195
196 const sequentialPayloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
197
198 for (const resolution of resolutionsEnabled) {
199 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
200
201 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED) {
202 const payloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[] = [
203 this.buildWebTorrentJobPayload({
204 videoUUID: video.uuid,
205 resolution,
206 fps,
207 isNewVideo
208 })
209 ]
210
211 // Create a subsequent job to create HLS resolution that will just copy web video codecs
212 if (CONFIG.TRANSCODING.HLS.ENABLED) {
213 payloads.push(
214 this.buildHLSJobPayload({
215 videoUUID: video.uuid,
216 resolution,
217 fps,
218 isNewVideo,
219 copyCodecs: true
220 })
221 )
222 }
223
224 sequentialPayloads.push(payloads)
225 } else if (CONFIG.TRANSCODING.HLS.ENABLED) {
226 sequentialPayloads.push([
227 this.buildHLSJobPayload({
228 videoUUID: video.uuid,
229 resolution,
230 fps,
231 copyCodecs: false,
232 isNewVideo
233 })
234 ])
235 }
236 }
237
238 return sequentialPayloads
239 }
240
241 private buildHLSJobPayload (options: {
242 videoUUID: string
243 resolution: number
244 fps: number
245 isNewVideo: boolean
246 deleteWebTorrentFiles?: boolean // default false
247 copyCodecs?: boolean // default false
248 }): HLSTranscodingPayload {
249 const { videoUUID, resolution, fps, isNewVideo, deleteWebTorrentFiles = false, copyCodecs = false } = options
250
251 return {
252 type: 'new-resolution-to-hls',
253 videoUUID,
254 resolution,
255 fps,
256 copyCodecs,
257 isNewVideo,
258 deleteWebTorrentFiles
259 }
260 }
261
262 private buildWebTorrentJobPayload (options: {
263 videoUUID: string
264 resolution: number
265 fps: number
266 isNewVideo: boolean
267 }): NewWebTorrentResolutionTranscodingPayload {
268 const { videoUUID, resolution, fps, isNewVideo } = options
269
270 return {
271 type: 'new-resolution-to-webtorrent',
272 videoUUID,
273 isNewVideo,
274 resolution,
275 fps
276 }
277 }
278
279 private buildMergeAudioPayload (options: {
280 videoUUID: string
281 isNewVideo: boolean
282 }): MergeAudioTranscodingPayload {
283 const { videoUUID, isNewVideo } = options
284
285 return {
286 type: 'merge-audio-to-webtorrent',
287 resolution: DEFAULT_AUDIO_RESOLUTION,
288 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
289 videoUUID,
290 isNewVideo
291 }
292 }
293
294 private buildOptimizePayload (options: {
295 videoUUID: string
296 quickTranscode: boolean
297 isNewVideo: boolean
298 }): OptimizeTranscodingPayload {
299 const { videoUUID, quickTranscode, isNewVideo } = options
300
301 return {
302 type: 'optimize-to-webtorrent',
303 videoUUID,
304 isNewVideo,
305 quickTranscode
306 }
307 }
308}
diff --git a/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts b/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
new file mode 100644
index 000000000..c7a63d2e2
--- /dev/null
+++ b/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
@@ -0,0 +1,189 @@
1import { computeOutputFPS } from '@server/helpers/ffmpeg'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
5import { Hooks } from '@server/lib/plugins/hooks'
6import { VODAudioMergeTranscodingJobHandler, VODHLSTranscodingJobHandler, VODWebVideoTranscodingJobHandler } from '@server/lib/runners'
7import { VideoPathManager } from '@server/lib/video-path-manager'
8import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
9import { MRunnerJob } from '@server/types/models/runners'
10import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
11import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
12import { AbstractJobBuilder } from './abstract-job-builder'
13
14/**
15 *
16 * Class to build transcoding job in the local job queue
17 *
18 */
19
20const lTags = loggerTagsFactory('transcoding')
21
22export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
23
24 async createOptimizeOrMergeAudioJobs (options: {
25 video: MVideoFullLight
26 videoFile: MVideoFile
27 isNewVideo: boolean
28 user: MUserId
29 }) {
30 const { video, videoFile, isNewVideo, user } = options
31
32 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
33
34 try {
35 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
36 const probe = await ffprobePromise(videoFilePath)
37
38 const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
39 const hasAudio = await hasAudioStream(videoFilePath, probe)
40 const inputFPS = videoFile.isAudio()
41 ? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
42 : await getVideoStreamFPS(videoFilePath, probe)
43
44 const maxResolution = await isAudioFile(videoFilePath, probe)
45 ? DEFAULT_AUDIO_RESOLUTION
46 : resolution
47
48 const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
49 const priority = await this.getTranscodingJobPriority({ user, fallback: 0 })
50
51 const mainRunnerJob = videoFile.isAudio()
52 ? await new VODAudioMergeTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
53 : await new VODWebVideoTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
54
55 if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
56 await new VODHLSTranscodingJobHandler().create({
57 video,
58 deleteWebVideoFiles: CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false,
59 resolution: maxResolution,
60 fps,
61 isNewVideo,
62 dependsOnRunnerJob: mainRunnerJob,
63 priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
64 })
65 }
66
67 await this.buildLowerResolutionJobPayloads({
68 video,
69 inputVideoResolution: maxResolution,
70 inputVideoFPS: inputFPS,
71 hasAudio,
72 isNewVideo,
73 mainRunnerJob,
74 user
75 })
76 })
77 } finally {
78 mutexReleaser()
79 }
80 }
81
82 // ---------------------------------------------------------------------------
83
84 async createTranscodingJobs (options: {
85 transcodingType: 'hls' | 'webtorrent'
86 video: MVideoFullLight
87 resolutions: number[]
88 isNewVideo: boolean
89 user: MUserId | null
90 }) {
91 const { video, transcodingType, resolutions, isNewVideo, user } = options
92
93 const maxResolution = Math.max(...resolutions)
94 const { fps: inputFPS } = await video.probeMaxQualityFile()
95 const maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution })
96 const priority = await this.getTranscodingJobPriority({ user, fallback: 0 })
97
98 const childrenResolutions = resolutions.filter(r => r !== maxResolution)
99
100 logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
101
102 // Process the last resolution before the other ones to prevent concurrency issue
103 // Because low resolutions use the biggest one as ffmpeg input
104 const mainJob = transcodingType === 'hls'
105 // eslint-disable-next-line max-len
106 ? await new VODHLSTranscodingJobHandler().create({ video, resolution: maxResolution, fps: maxFPS, isNewVideo, deleteWebVideoFiles: false, priority })
107 : await new VODWebVideoTranscodingJobHandler().create({ video, resolution: maxResolution, fps: maxFPS, isNewVideo, priority })
108
109 for (const resolution of childrenResolutions) {
110 const dependsOnRunnerJob = mainJob
111 const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
112
113 if (transcodingType === 'hls') {
114 await new VODHLSTranscodingJobHandler().create({
115 video,
116 resolution,
117 fps,
118 isNewVideo,
119 deleteWebVideoFiles: false,
120 dependsOnRunnerJob,
121 priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
122 })
123 continue
124 }
125
126 if (transcodingType === 'webtorrent') {
127 await new VODWebVideoTranscodingJobHandler().create({
128 video,
129 resolution,
130 fps,
131 isNewVideo,
132 dependsOnRunnerJob,
133 priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
134 })
135 continue
136 }
137
138 throw new Error('Unknown transcoding type')
139 }
140 }
141
142 private async buildLowerResolutionJobPayloads (options: {
143 mainRunnerJob: MRunnerJob
144 video: MVideoWithFileThumbnail
145 inputVideoResolution: number
146 inputVideoFPS: number
147 hasAudio: boolean
148 isNewVideo: boolean
149 user: MUserId
150 }) {
151 const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio, mainRunnerJob, user } = options
152
153 // Create transcoding jobs if there are enabled resolutions
154 const resolutionsEnabled = await Hooks.wrapObject(
155 computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
156 'filter:transcoding.auto.resolutions-to-transcode.result',
157 options
158 )
159
160 logger.debug('Lower resolutions build for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) })
161
162 for (const resolution of resolutionsEnabled) {
163 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
164
165 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED) {
166 await new VODWebVideoTranscodingJobHandler().create({
167 video,
168 resolution,
169 fps,
170 isNewVideo,
171 dependsOnRunnerJob: mainRunnerJob,
172 priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
173 })
174 }
175
176 if (CONFIG.TRANSCODING.HLS.ENABLED) {
177 await new VODHLSTranscodingJobHandler().create({
178 video,
179 resolution,
180 fps,
181 isNewVideo,
182 deleteWebVideoFiles: false,
183 dependsOnRunnerJob: mainRunnerJob,
184 priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
185 })
186 }
187 }
188 }
189}
diff --git a/server/lib/transcoding/transcoding-quick-transcode.ts b/server/lib/transcoding/transcoding-quick-transcode.ts
new file mode 100644
index 000000000..b7f921890
--- /dev/null
+++ b/server/lib/transcoding/transcoding-quick-transcode.ts
@@ -0,0 +1,61 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { CONFIG } from '@server/initializers/config'
3import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
4import { getMaxBitrate } from '@shared/core-utils'
5import {
6 ffprobePromise,
7 getAudioStream,
8 getMaxAudioBitrate,
9 getVideoStream,
10 getVideoStreamBitrate,
11 getVideoStreamDimensionsInfo,
12 getVideoStreamFPS
13} from '@shared/ffmpeg'
14
15export async function canDoQuickTranscode (path: string, existingProbe?: FfprobeData): Promise<boolean> {
16 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
17
18 const probe = existingProbe || await ffprobePromise(path)
19
20 return await canDoQuickVideoTranscode(path, probe) &&
21 await canDoQuickAudioTranscode(path, probe)
22}
23
24export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
25 const parsedAudio = await getAudioStream(path, probe)
26
27 if (!parsedAudio.audioStream) return true
28
29 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
30
31 const audioBitrate = parsedAudio.bitrate
32 if (!audioBitrate) return false
33
34 const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
35 if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
36
37 const channelLayout = parsedAudio.audioStream['channel_layout']
38 // Causes playback issues with Chrome
39 if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false
40
41 return true
42}
43
44export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
45 const videoStream = await getVideoStream(path, probe)
46 const fps = await getVideoStreamFPS(path, probe)
47 const bitRate = await getVideoStreamBitrate(path, probe)
48 const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
49
50 // If ffprobe did not manage to guess the bitrate
51 if (!bitRate) return false
52
53 // check video params
54 if (!videoStream) return false
55 if (videoStream['codec_name'] !== 'h264') return false
56 if (videoStream['pix_fmt'] !== 'yuv420p') return false
57 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
58 if (bitRate > getMaxBitrate({ ...resolutionData, fps })) return false
59
60 return true
61}
diff --git a/server/lib/transcoding/transcoding-resolutions.ts b/server/lib/transcoding/transcoding-resolutions.ts
new file mode 100644
index 000000000..91f4d18d8
--- /dev/null
+++ b/server/lib/transcoding/transcoding-resolutions.ts
@@ -0,0 +1,52 @@
1import { CONFIG } from '@server/initializers/config'
2import { toEven } from '@shared/core-utils'
3import { VideoResolution } from '@shared/models'
4
5export function computeResolutionsToTranscode (options: {
6 input: number
7 type: 'vod' | 'live'
8 includeInput: boolean
9 strictLower: boolean
10 hasAudio: boolean
11}) {
12 const { input, type, includeInput, strictLower, hasAudio } = options
13
14 const configResolutions = type === 'vod'
15 ? CONFIG.TRANSCODING.RESOLUTIONS
16 : CONFIG.LIVE.TRANSCODING.RESOLUTIONS
17
18 const resolutionsEnabled = new Set<number>()
19
20 // Put in the order we want to proceed jobs
21 const availableResolutions: VideoResolution[] = [
22 VideoResolution.H_NOVIDEO,
23 VideoResolution.H_480P,
24 VideoResolution.H_360P,
25 VideoResolution.H_720P,
26 VideoResolution.H_240P,
27 VideoResolution.H_144P,
28 VideoResolution.H_1080P,
29 VideoResolution.H_1440P,
30 VideoResolution.H_4K
31 ]
32
33 for (const resolution of availableResolutions) {
34 // Resolution not enabled
35 if (configResolutions[resolution + 'p'] !== true) continue
36 // Too big resolution for input file
37 if (input < resolution) continue
38 // We only want lower resolutions than input file
39 if (strictLower && input === resolution) continue
40 // Audio resolutio but no audio in the video
41 if (resolution === VideoResolution.H_NOVIDEO && !hasAudio) continue
42
43 resolutionsEnabled.add(resolution)
44 }
45
46 if (includeInput) {
47 // Always use an even resolution to avoid issues with ffmpeg
48 resolutionsEnabled.add(toEven(input))
49 }
50
51 return Array.from(resolutionsEnabled)
52}
diff --git a/server/lib/transcoding/transcoding.ts b/server/lib/transcoding/transcoding.ts
deleted file mode 100644
index c7b61e9ba..000000000
--- a/server/lib/transcoding/transcoding.ts
+++ /dev/null
@@ -1,465 +0,0 @@
1import { MutexInterface } from 'async-mutex'
2import { Job } from 'bullmq'
3import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
4import { basename, extname as extnameUtil, join } from 'path'
5import { toEven } from '@server/helpers/core-utils'
6import { retryTransactionWrapper } from '@server/helpers/database-utils'
7import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
8import { sequelizeTypescript } from '@server/initializers/database'
9import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
10import { pick } from '@shared/core-utils'
11import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
12import {
13 buildFileMetadata,
14 canDoQuickTranscode,
15 computeResolutionsToTranscode,
16 ffprobePromise,
17 getVideoStreamDuration,
18 getVideoStreamFPS,
19 transcodeVOD,
20 TranscodeVODOptions,
21 TranscodeVODOptionsType
22} from '../../helpers/ffmpeg'
23import { CONFIG } from '../../initializers/config'
24import { VideoFileModel } from '../../models/video/video-file'
25import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
26import { updatePlaylistAfterFileChange } from '../hls'
27import { generateHLSVideoFilename, generateWebTorrentVideoFilename, getHlsResolutionPlaylistFilename } from '../paths'
28import { VideoPathManager } from '../video-path-manager'
29import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
30
31/**
32 *
33 * Functions that run transcoding functions, update the database, cleanup files, create torrent files...
34 * Mainly called by the job queue
35 *
36 */
37
38// Optimize the original video file and replace it. The resolution is not changed.
39async function optimizeOriginalVideofile (options: {
40 video: MVideoFullLight
41 inputVideoFile: MVideoFile
42 job: Job
43}) {
44 const { video, inputVideoFile, job } = options
45
46 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
47 const newExtname = '.mp4'
48
49 // Will be released by our transcodeVOD function once ffmpeg is ran
50 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
51
52 try {
53 await video.reload()
54
55 const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
56
57 const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
58 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
59
60 const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
61 ? 'quick-transcode'
62 : 'video'
63
64 const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
65
66 const transcodeOptions: TranscodeVODOptions = {
67 type: transcodeType,
68
69 inputPath: videoInputPath,
70 outputPath: videoTranscodedPath,
71
72 inputFileMutexReleaser,
73
74 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
75 profile: CONFIG.TRANSCODING.PROFILE,
76
77 resolution,
78
79 job
80 }
81
82 // Could be very long!
83 await transcodeVOD(transcodeOptions)
84
85 // Important to do this before getVideoFilename() to take in account the new filename
86 inputVideoFile.resolution = resolution
87 inputVideoFile.extname = newExtname
88 inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname)
89 inputVideoFile.storage = VideoStorage.FILE_SYSTEM
90
91 const { videoFile } = await onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, inputVideoFile)
92 await remove(videoInputPath)
93
94 return { transcodeType, videoFile }
95 })
96
97 return result
98 } finally {
99 inputFileMutexReleaser()
100 }
101}
102
103// Transcode the original video file to a lower resolution compatible with WebTorrent
104async function transcodeNewWebTorrentResolution (options: {
105 video: MVideoFullLight
106 resolution: VideoResolution
107 job: Job
108}) {
109 const { video, resolution, job } = options
110
111 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
112 const newExtname = '.mp4'
113
114 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
115
116 try {
117 await video.reload()
118
119 const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
120
121 const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
122 const newVideoFile = new VideoFileModel({
123 resolution,
124 extname: newExtname,
125 filename: generateWebTorrentVideoFilename(resolution, newExtname),
126 size: 0,
127 videoId: video.id
128 })
129
130 const videoTranscodedPath = join(transcodeDirectory, newVideoFile.filename)
131
132 const transcodeOptions = resolution === VideoResolution.H_NOVIDEO
133 ? {
134 type: 'only-audio' as 'only-audio',
135
136 inputPath: videoInputPath,
137 outputPath: videoTranscodedPath,
138
139 inputFileMutexReleaser,
140
141 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
142 profile: CONFIG.TRANSCODING.PROFILE,
143
144 resolution,
145
146 job
147 }
148 : {
149 type: 'video' as 'video',
150 inputPath: videoInputPath,
151 outputPath: videoTranscodedPath,
152
153 inputFileMutexReleaser,
154
155 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
156 profile: CONFIG.TRANSCODING.PROFILE,
157
158 resolution,
159
160 job
161 }
162
163 await transcodeVOD(transcodeOptions)
164
165 return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, newVideoFile)
166 })
167
168 return result
169 } finally {
170 inputFileMutexReleaser()
171 }
172}
173
174// Merge an image with an audio file to create a video
175async function mergeAudioVideofile (options: {
176 video: MVideoFullLight
177 resolution: VideoResolution
178 job: Job
179}) {
180 const { video, resolution, job } = options
181
182 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
183 const newExtname = '.mp4'
184
185 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
186
187 try {
188 await video.reload()
189
190 const inputVideoFile = video.getMinQualityFile()
191
192 const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
193
194 const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
195 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
196
197 // If the user updates the video preview during transcoding
198 const previewPath = video.getPreview().getPath()
199 const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath))
200 await copyFile(previewPath, tmpPreviewPath)
201
202 const transcodeOptions = {
203 type: 'merge-audio' as 'merge-audio',
204
205 inputPath: tmpPreviewPath,
206 outputPath: videoTranscodedPath,
207
208 inputFileMutexReleaser,
209
210 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
211 profile: CONFIG.TRANSCODING.PROFILE,
212
213 audioPath: audioInputPath,
214 resolution,
215
216 job
217 }
218
219 try {
220 await transcodeVOD(transcodeOptions)
221
222 await remove(audioInputPath)
223 await remove(tmpPreviewPath)
224 } catch (err) {
225 await remove(tmpPreviewPath)
226 throw err
227 }
228
229 // Important to do this before getVideoFilename() to take in account the new file extension
230 inputVideoFile.extname = newExtname
231 inputVideoFile.resolution = resolution
232 inputVideoFile.filename = generateWebTorrentVideoFilename(inputVideoFile.resolution, newExtname)
233
234 // ffmpeg generated a new video file, so update the video duration
235 // See https://trac.ffmpeg.org/ticket/5456
236 video.duration = await getVideoStreamDuration(videoTranscodedPath)
237 await video.save()
238
239 return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, inputVideoFile)
240 })
241
242 return result
243 } finally {
244 inputFileMutexReleaser()
245 }
246}
247
248// Concat TS segments from a live video to a fragmented mp4 HLS playlist
249async function generateHlsPlaylistResolutionFromTS (options: {
250 video: MVideo
251 concatenatedTsFilePath: string
252 resolution: VideoResolution
253 isAAC: boolean
254 inputFileMutexReleaser: MutexInterface.Releaser
255}) {
256 return generateHlsPlaylistCommon({
257 type: 'hls-from-ts' as 'hls-from-ts',
258 inputPath: options.concatenatedTsFilePath,
259
260 ...pick(options, [ 'video', 'resolution', 'inputFileMutexReleaser', 'isAAC' ])
261 })
262}
263
264// Generate an HLS playlist from an input file, and update the master playlist
265function generateHlsPlaylistResolution (options: {
266 video: MVideo
267 videoInputPath: string
268 resolution: VideoResolution
269 copyCodecs: boolean
270 inputFileMutexReleaser: MutexInterface.Releaser
271 job?: Job
272}) {
273 return generateHlsPlaylistCommon({
274 type: 'hls' as 'hls',
275 inputPath: options.videoInputPath,
276
277 ...pick(options, [ 'video', 'resolution', 'copyCodecs', 'inputFileMutexReleaser', 'job' ])
278 })
279}
280
281// ---------------------------------------------------------------------------
282
283export {
284 generateHlsPlaylistResolution,
285 generateHlsPlaylistResolutionFromTS,
286 optimizeOriginalVideofile,
287 transcodeNewWebTorrentResolution,
288 mergeAudioVideofile
289}
290
291// ---------------------------------------------------------------------------
292
293async function onWebTorrentVideoFileTranscoding (
294 video: MVideoFullLight,
295 videoFile: MVideoFile,
296 transcodingPath: string,
297 newVideoFile: MVideoFile
298) {
299 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
300
301 try {
302 await video.reload()
303
304 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile)
305
306 const stats = await stat(transcodingPath)
307
308 const probe = await ffprobePromise(transcodingPath)
309 const fps = await getVideoStreamFPS(transcodingPath, probe)
310 const metadata = await buildFileMetadata(transcodingPath, probe)
311
312 await move(transcodingPath, outputPath, { overwrite: true })
313
314 videoFile.size = stats.size
315 videoFile.fps = fps
316 videoFile.metadata = metadata
317
318 await createTorrentAndSetInfoHash(video, videoFile)
319
320 const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
321 if (oldFile) await video.removeWebTorrentFile(oldFile)
322
323 await VideoFileModel.customUpsert(videoFile, 'video', undefined)
324 video.VideoFiles = await video.$get('VideoFiles')
325
326 return { video, videoFile }
327 } finally {
328 mutexReleaser()
329 }
330}
331
332async function generateHlsPlaylistCommon (options: {
333 type: 'hls' | 'hls-from-ts'
334 video: MVideo
335 inputPath: string
336 resolution: VideoResolution
337
338 inputFileMutexReleaser: MutexInterface.Releaser
339
340 copyCodecs?: boolean
341 isAAC?: boolean
342
343 job?: Job
344}) {
345 const { type, video, inputPath, resolution, copyCodecs, isAAC, job, inputFileMutexReleaser } = options
346 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
347
348 const videoTranscodedBasePath = join(transcodeDirectory, type)
349 await ensureDir(videoTranscodedBasePath)
350
351 const videoFilename = generateHLSVideoFilename(resolution)
352 const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
353 const resolutionPlaylistFileTranscodePath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
354
355 const transcodeOptions = {
356 type,
357
358 inputPath,
359 outputPath: resolutionPlaylistFileTranscodePath,
360
361 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
362 profile: CONFIG.TRANSCODING.PROFILE,
363
364 resolution,
365 copyCodecs,
366
367 isAAC,
368
369 inputFileMutexReleaser,
370
371 hlsPlaylist: {
372 videoFilename
373 },
374
375 job
376 }
377
378 await transcodeVOD(transcodeOptions)
379
380 // Create or update the playlist
381 const playlist = await retryTransactionWrapper(() => {
382 return sequelizeTypescript.transaction(async transaction => {
383 return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
384 })
385 })
386
387 const newVideoFile = new VideoFileModel({
388 resolution,
389 extname: extnameUtil(videoFilename),
390 size: 0,
391 filename: videoFilename,
392 fps: -1,
393 videoStreamingPlaylistId: playlist.id
394 })
395
396 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
397
398 try {
399 // VOD transcoding is a long task, refresh video attributes
400 await video.reload()
401
402 const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile)
403 await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video))
404
405 // Move playlist file
406 const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, resolutionPlaylistFilename)
407 await move(resolutionPlaylistFileTranscodePath, resolutionPlaylistPath, { overwrite: true })
408 // Move video file
409 await move(join(videoTranscodedBasePath, videoFilename), videoFilePath, { overwrite: true })
410
411 // Update video duration if it was not set (in case of a live for example)
412 if (!video.duration) {
413 video.duration = await getVideoStreamDuration(videoFilePath)
414 await video.save()
415 }
416
417 const stats = await stat(videoFilePath)
418
419 newVideoFile.size = stats.size
420 newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
421 newVideoFile.metadata = await buildFileMetadata(videoFilePath)
422
423 await createTorrentAndSetInfoHash(playlist, newVideoFile)
424
425 const oldFile = await VideoFileModel.loadHLSFile({
426 playlistId: playlist.id,
427 fps: newVideoFile.fps,
428 resolution: newVideoFile.resolution
429 })
430
431 if (oldFile) {
432 await video.removeStreamingPlaylistVideoFile(playlist, oldFile)
433 await oldFile.destroy()
434 }
435
436 const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
437
438 await updatePlaylistAfterFileChange(video, playlist)
439
440 return { resolutionPlaylistPath, videoFile: savedVideoFile }
441 } finally {
442 mutexReleaser()
443 }
444}
445
446function buildOriginalFileResolution (inputResolution: number) {
447 if (CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION === true) {
448 return toEven(inputResolution)
449 }
450
451 const resolutions = computeResolutionsToTranscode({
452 input: inputResolution,
453 type: 'vod',
454 includeInput: false,
455 strictLower: false,
456 // We don't really care about the audio resolution in this context
457 hasAudio: true
458 })
459
460 if (resolutions.length === 0) {
461 return toEven(inputResolution)
462 }
463
464 return Math.max(...resolutions)
465}
diff --git a/server/lib/transcoding/web-transcoding.ts b/server/lib/transcoding/web-transcoding.ts
new file mode 100644
index 000000000..d43d03b2a
--- /dev/null
+++ b/server/lib/transcoding/web-transcoding.ts
@@ -0,0 +1,273 @@
1import { Job } from 'bullmq'
2import { copyFile, move, remove, stat } from 'fs-extra'
3import { basename, join } from 'path'
4import { computeOutputFPS } from '@server/helpers/ffmpeg'
5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6import { MVideoFile, MVideoFullLight } from '@server/types/models'
7import { toEven } from '@shared/core-utils'
8import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVODOptionsType } from '@shared/ffmpeg'
9import { VideoResolution, VideoStorage } from '@shared/models'
10import { CONFIG } from '../../initializers/config'
11import { VideoFileModel } from '../../models/video/video-file'
12import { generateWebTorrentVideoFilename } from '../paths'
13import { buildFileMetadata } from '../video-file'
14import { VideoPathManager } from '../video-path-manager'
15import { buildFFmpegVOD } from './shared'
16import { computeResolutionsToTranscode } from './transcoding-resolutions'
17
18// Optimize the original video file and replace it. The resolution is not changed.
19export async function optimizeOriginalVideofile (options: {
20 video: MVideoFullLight
21 inputVideoFile: MVideoFile
22 quickTranscode: boolean
23 job: Job
24}) {
25 const { video, inputVideoFile, quickTranscode, job } = options
26
27 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
28 const newExtname = '.mp4'
29
30 // Will be released by our transcodeVOD function once ffmpeg is ran
31 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
32
33 try {
34 await video.reload()
35
36 const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
37
38 const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
39 const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
40
41 const transcodeType: TranscodeVODOptionsType = quickTranscode
42 ? 'quick-transcode'
43 : 'video'
44
45 const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
46 const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution })
47
48 // Could be very long!
49 await buildFFmpegVOD(job).transcode({
50 type: transcodeType,
51
52 inputPath: videoInputPath,
53 outputPath: videoOutputPath,
54
55 inputFileMutexReleaser,
56
57 resolution,
58 fps
59 })
60
61 // Important to do this before getVideoFilename() to take in account the new filename
62 inputVideoFile.resolution = resolution
63 inputVideoFile.extname = newExtname
64 inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname)
65 inputVideoFile.storage = VideoStorage.FILE_SYSTEM
66
67 const { videoFile } = await onWebTorrentVideoFileTranscoding({
68 video,
69 videoFile: inputVideoFile,
70 videoOutputPath
71 })
72
73 await remove(videoInputPath)
74
75 return { transcodeType, videoFile }
76 })
77
78 return result
79 } finally {
80 inputFileMutexReleaser()
81 }
82}
83
84// Transcode the original video file to a lower resolution compatible with WebTorrent
85export async function transcodeNewWebTorrentResolution (options: {
86 video: MVideoFullLight
87 resolution: VideoResolution
88 fps: number
89 job: Job
90}) {
91 const { video, resolution, fps, job } = options
92
93 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
94 const newExtname = '.mp4'
95
96 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
97
98 try {
99 await video.reload()
100
101 const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
102
103 const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
104 const newVideoFile = new VideoFileModel({
105 resolution,
106 extname: newExtname,
107 filename: generateWebTorrentVideoFilename(resolution, newExtname),
108 size: 0,
109 videoId: video.id
110 })
111
112 const videoOutputPath = join(transcodeDirectory, newVideoFile.filename)
113
114 const transcodeOptions = {
115 type: 'video' as 'video',
116
117 inputPath: videoInputPath,
118 outputPath: videoOutputPath,
119
120 inputFileMutexReleaser,
121
122 resolution,
123 fps
124 }
125
126 await buildFFmpegVOD(job).transcode(transcodeOptions)
127
128 return onWebTorrentVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath })
129 })
130
131 return result
132 } finally {
133 inputFileMutexReleaser()
134 }
135}
136
137// Merge an image with an audio file to create a video
138export async function mergeAudioVideofile (options: {
139 video: MVideoFullLight
140 resolution: VideoResolution
141 fps: number
142 job: Job
143}) {
144 const { video, resolution, fps, job } = options
145
146 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
147 const newExtname = '.mp4'
148
149 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
150
151 try {
152 await video.reload()
153
154 const inputVideoFile = video.getMinQualityFile()
155
156 const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
157
158 const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
159 const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
160
161 // If the user updates the video preview during transcoding
162 const previewPath = video.getPreview().getPath()
163 const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath))
164 await copyFile(previewPath, tmpPreviewPath)
165
166 const transcodeOptions = {
167 type: 'merge-audio' as 'merge-audio',
168
169 inputPath: tmpPreviewPath,
170 outputPath: videoOutputPath,
171
172 inputFileMutexReleaser,
173
174 audioPath: audioInputPath,
175 resolution,
176 fps
177 }
178
179 try {
180 await buildFFmpegVOD(job).transcode(transcodeOptions)
181
182 await remove(audioInputPath)
183 await remove(tmpPreviewPath)
184 } catch (err) {
185 await remove(tmpPreviewPath)
186 throw err
187 }
188
189 // Important to do this before getVideoFilename() to take in account the new file extension
190 inputVideoFile.extname = newExtname
191 inputVideoFile.resolution = resolution
192 inputVideoFile.filename = generateWebTorrentVideoFilename(inputVideoFile.resolution, newExtname)
193
194 // ffmpeg generated a new video file, so update the video duration
195 // See https://trac.ffmpeg.org/ticket/5456
196 video.duration = await getVideoStreamDuration(videoOutputPath)
197 await video.save()
198
199 return onWebTorrentVideoFileTranscoding({
200 video,
201 videoFile: inputVideoFile,
202 videoOutputPath
203 })
204 })
205
206 return result
207 } finally {
208 inputFileMutexReleaser()
209 }
210}
211
212export async function onWebTorrentVideoFileTranscoding (options: {
213 video: MVideoFullLight
214 videoFile: MVideoFile
215 videoOutputPath: string
216}) {
217 const { video, videoFile, videoOutputPath } = options
218
219 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
220
221 try {
222 await video.reload()
223
224 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
225
226 const stats = await stat(videoOutputPath)
227
228 const probe = await ffprobePromise(videoOutputPath)
229 const fps = await getVideoStreamFPS(videoOutputPath, probe)
230 const metadata = await buildFileMetadata(videoOutputPath, probe)
231
232 await move(videoOutputPath, outputPath, { overwrite: true })
233
234 videoFile.size = stats.size
235 videoFile.fps = fps
236 videoFile.metadata = metadata
237
238 await createTorrentAndSetInfoHash(video, videoFile)
239
240 const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
241 if (oldFile) await video.removeWebTorrentFile(oldFile)
242
243 await VideoFileModel.customUpsert(videoFile, 'video', undefined)
244 video.VideoFiles = await video.$get('VideoFiles')
245
246 return { video, videoFile }
247 } finally {
248 mutexReleaser()
249 }
250}
251
252// ---------------------------------------------------------------------------
253
254function buildOriginalFileResolution (inputResolution: number) {
255 if (CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION === true) {
256 return toEven(inputResolution)
257 }
258
259 const resolutions = computeResolutionsToTranscode({
260 input: inputResolution,
261 type: 'vod',
262 includeInput: false,
263 strictLower: false,
264 // We don't really care about the audio resolution in this context
265 hasAudio: true
266 })
267
268 if (resolutions.length === 0) {
269 return toEven(inputResolution)
270 }
271
272 return Math.max(...resolutions)
273}