video-transcoding.ts 11.8 KB
Newer Older
Chocobozzz's avatar
Chocobozzz committed
1
import { Job } from 'bull'
Chocobozzz's avatar
Chocobozzz committed
2
import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
Chocobozzz's avatar
Chocobozzz committed
3
import { basename, extname as extnameUtil, join } from 'path'
4
import { toEven } from '@server/helpers/core-utils'
Chocobozzz's avatar
Chocobozzz committed
5
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
7
8
9
10
11
12
13
14
15
16
17
import { VideoResolution } from '../../../shared/models/videos'
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils'
import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../../initializers/constants'
import { VideoFileModel } from '../../models/video/video-file'
import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
import { updateMasterHLSPlaylist, updateSha256VODSegments } from '../hls'
import { generateVideoFilename, generateVideoStreamingPlaylistName, getVideoFilePath } from '../video-paths'
18
import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
19

Felix's avatar
Felix committed
20
/**
Chocobozzz's avatar
Chocobozzz committed
21
22
23
24
 *
 * Functions that run transcoding functions, update the database, cleanup files, create torrent files...
 * Mainly called by the job queue
 *
Felix's avatar
Felix committed
25
 */
Chocobozzz's avatar
Chocobozzz committed
26
27

// Optimize the original video file and replace it. The resolution is not changed.
28
async function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVideoFile, job?: Job) {
29
  const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
30
  const newExtname = '.mp4'
31

Chocobozzz's avatar
Chocobozzz committed
32
  const videoInputPath = getVideoFilePath(video, inputVideoFile)
33
  const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
34

Chocobozzz's avatar
Chocobozzz committed
35
36
37
  const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath)
    ? 'quick-transcode'
    : 'video'
38

39
40
  const resolution = toEven(inputVideoFile.resolution)

Chocobozzz's avatar
Chocobozzz committed
41
  const transcodeOptions: TranscodeOptions = {
Chocobozzz's avatar
Chocobozzz committed
42
    type: transcodeType,
43

44
    inputPath: videoInputPath,
Chocobozzz's avatar
Chocobozzz committed
45
    outputPath: videoTranscodedPath,
46

47
    availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
48
    profile: CONFIG.TRANSCODING.PROFILE,
49

50
    resolution,
Chocobozzz's avatar
Chocobozzz committed
51
52

    job
53
54
55
56
57
58
59
60
  }

  // Could be very long!
  await transcode(transcodeOptions)

  try {
    await remove(videoInputPath)

61
    // Important to do this before getVideoFilename() to take in account the new filename
Chocobozzz's avatar
Chocobozzz committed
62
    inputVideoFile.extname = newExtname
63
    inputVideoFile.filename = generateVideoFilename(video, false, resolution, newExtname)
64

Chocobozzz's avatar
Chocobozzz committed
65
    const videoOutputPath = getVideoFilePath(video, inputVideoFile)
66

Chocobozzz's avatar
Chocobozzz committed
67
    await onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
Chocobozzz's avatar
Chocobozzz committed
68
69

    return transcodeType
70
71
72
73
74
75
76
77
  } catch (err) {
    // Auto destruction...
    video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))

    throw err
  }
}

Chocobozzz's avatar
Chocobozzz committed
78
// Transcode the original video file to a lower resolution.
79
async function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: VideoResolution, isPortrait: boolean, job: Job) {
80
  const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
81
82
83
  const extname = '.mp4'

  // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
Chocobozzz's avatar
Chocobozzz committed
84
  const videoInputPath = getVideoFilePath(video, video.getMaxQualityFile())
85
86
87
88

  const newVideoFile = new VideoFileModel({
    resolution,
    extname,
89
    filename: generateVideoFilename(video, false, resolution, extname),
90
91
92
    size: 0,
    videoId: video.id
  })
93

Chocobozzz's avatar
Chocobozzz committed
94
  const videoOutputPath = getVideoFilePath(video, newVideoFile)
95
  const videoTranscodedPath = join(transcodeDirectory, newVideoFile.filename)
96

97
98
  const transcodeOptions = resolution === VideoResolution.H_NOVIDEO
    ? {
Chocobozzz's avatar
Chocobozzz committed
99
      type: 'only-audio' as 'only-audio',
100

Chocobozzz's avatar
Chocobozzz committed
101
102
      inputPath: videoInputPath,
      outputPath: videoTranscodedPath,
103

104
      availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
105
      profile: CONFIG.TRANSCODING.PROFILE,
106

Chocobozzz's avatar
Chocobozzz committed
107
108
109
      resolution,

      job
Chocobozzz's avatar
Chocobozzz committed
110
    }
111
    : {
Chocobozzz's avatar
Chocobozzz committed
112
113
114
      type: 'video' as 'video',
      inputPath: videoInputPath,
      outputPath: videoTranscodedPath,
115

116
      availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
117
      profile: CONFIG.TRANSCODING.PROFILE,
118

Chocobozzz's avatar
Chocobozzz committed
119
      resolution,
Chocobozzz's avatar
Chocobozzz committed
120
121
122
      isPortraitMode: isPortrait,

      job
Chocobozzz's avatar
Chocobozzz committed
123
    }
124
125
126

  await transcode(transcodeOptions)

Chocobozzz's avatar
Chocobozzz committed
127
  return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
Chocobozzz's avatar
Chocobozzz committed
128
129
}

Chocobozzz's avatar
Chocobozzz committed
130
// Merge an image with an audio file to create a video
131
async function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolution, job: Job) {
Chocobozzz's avatar
Chocobozzz committed
132
133
134
  const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
  const newExtname = '.mp4'

135
  const inputVideoFile = video.getMinQualityFile()
136

Chocobozzz's avatar
Chocobozzz committed
137
  const audioInputPath = getVideoFilePath(video, inputVideoFile)
Chocobozzz's avatar
Chocobozzz committed
138
  const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
139

140
141
142
143
144
  // 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)

Chocobozzz's avatar
Chocobozzz committed
145
146
  const transcodeOptions = {
    type: 'merge-audio' as 'merge-audio',
147

148
    inputPath: tmpPreviewPath,
Chocobozzz's avatar
Chocobozzz committed
149
    outputPath: videoTranscodedPath,
150

151
    availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
152
    profile: CONFIG.TRANSCODING.PROFILE,
153

Chocobozzz's avatar
Chocobozzz committed
154
    audioPath: audioInputPath,
Chocobozzz's avatar
Chocobozzz committed
155
156
157
    resolution,

    job
Chocobozzz's avatar
Chocobozzz committed
158
  }
159

160
161
  try {
    await transcode(transcodeOptions)
162

163
164
165
166
167
168
    await remove(audioInputPath)
    await remove(tmpPreviewPath)
  } catch (err) {
    await remove(tmpPreviewPath)
    throw err
  }
169

Chocobozzz's avatar
Chocobozzz committed
170
171
  // Important to do this before getVideoFilename() to take in account the new file extension
  inputVideoFile.extname = newExtname
Chocobozzz's avatar
Chocobozzz committed
172
  inputVideoFile.filename = generateVideoFilename(video, false, inputVideoFile.resolution, newExtname)
Chocobozzz's avatar
Chocobozzz committed
173

Chocobozzz's avatar
Chocobozzz committed
174
  const videoOutputPath = getVideoFilePath(video, inputVideoFile)
175
176
177
178
  // ffmpeg generated a new video file, so update the video duration
  // See https://trac.ffmpeg.org/ticket/5456
  video.duration = await getDurationFromVideoFile(videoTranscodedPath)
  await video.save()
Chocobozzz's avatar
Chocobozzz committed
179

Chocobozzz's avatar
Chocobozzz committed
180
  return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
181
182
}

Chocobozzz's avatar
Chocobozzz committed
183
// Concat TS segments from a live video to a fragmented mp4 HLS playlist
Chocobozzz's avatar
Chocobozzz committed
184
async function generateHlsPlaylistResolutionFromTS (options: {
185
  video: MVideoFullLight
Chocobozzz's avatar
Chocobozzz committed
186
  concatenatedTsFilePath: string
Chocobozzz's avatar
Chocobozzz committed
187
188
  resolution: VideoResolution
  isPortraitMode: boolean
189
  isAAC: boolean
Chocobozzz's avatar
Chocobozzz committed
190
}) {
Chocobozzz's avatar
Chocobozzz committed
191
192
193
194
195
  return generateHlsPlaylistCommon({
    video: options.video,
    resolution: options.resolution,
    isPortraitMode: options.isPortraitMode,
    inputPath: options.concatenatedTsFilePath,
196
197
    type: 'hls-from-ts' as 'hls-from-ts',
    isAAC: options.isAAC
Chocobozzz's avatar
Chocobozzz committed
198
  })
Chocobozzz's avatar
Chocobozzz committed
199
200
}

Chocobozzz's avatar
Chocobozzz committed
201
// Generate an HLS playlist from an input file, and update the master playlist
Chocobozzz's avatar
Chocobozzz committed
202
function generateHlsPlaylistResolution (options: {
203
  video: MVideoFullLight
Chocobozzz's avatar
Chocobozzz committed
204
205
206
207
  videoInputPath: string
  resolution: VideoResolution
  copyCodecs: boolean
  isPortraitMode: boolean
Chocobozzz's avatar
Chocobozzz committed
208
  job?: Job
Chocobozzz's avatar
Chocobozzz committed
209
}) {
Chocobozzz's avatar
Chocobozzz committed
210
211
212
213
214
215
  return generateHlsPlaylistCommon({
    video: options.video,
    resolution: options.resolution,
    copyCodecs: options.copyCodecs,
    isPortraitMode: options.isPortraitMode,
    inputPath: options.videoInputPath,
Chocobozzz's avatar
Chocobozzz committed
216
217
    type: 'hls' as 'hls',
    job: options.job
Chocobozzz's avatar
Chocobozzz committed
218
219
220
221
222
223
  })
}

// ---------------------------------------------------------------------------

export {
Chocobozzz's avatar
Chocobozzz committed
224
225
  generateHlsPlaylistResolution,
  generateHlsPlaylistResolutionFromTS,
Chocobozzz's avatar
Chocobozzz committed
226
  optimizeOriginalVideofile,
Chocobozzz's avatar
Chocobozzz committed
227
  transcodeNewWebTorrentResolution,
Chocobozzz's avatar
Chocobozzz committed
228
  mergeAudioVideofile
Chocobozzz's avatar
Chocobozzz committed
229
230
231
232
}

// ---------------------------------------------------------------------------

Chocobozzz's avatar
Chocobozzz committed
233
async function onWebTorrentVideoFileTranscoding (
234
  video: MVideoFullLight,
Chocobozzz's avatar
Chocobozzz committed
235
236
237
238
  videoFile: MVideoFile,
  transcodingPath: string,
  outputPath: string
) {
Chocobozzz's avatar
Chocobozzz committed
239
240
241
242
243
244
245
246
247
248
  const stats = await stat(transcodingPath)
  const fps = await getVideoFileFPS(transcodingPath)
  const metadata = await getMetadataFromFile(transcodingPath)

  await move(transcodingPath, outputPath, { overwrite: true })

  videoFile.size = stats.size
  videoFile.fps = fps
  videoFile.metadata = metadata

Chocobozzz's avatar
Cleanup    
Chocobozzz committed
249
  await createTorrentAndSetInfoHash(video, videoFile)
Chocobozzz's avatar
Chocobozzz committed
250
251
252
253
254
255
256
257
258

  await VideoFileModel.customUpsert(videoFile, 'video', undefined)
  video.VideoFiles = await video.$get('VideoFiles')

  return video
}

async function generateHlsPlaylistCommon (options: {
  type: 'hls' | 'hls-from-ts'
259
  video: MVideoFullLight
Chocobozzz's avatar
Chocobozzz committed
260
261
262
  inputPath: string
  resolution: VideoResolution
  copyCodecs?: boolean
263
  isAAC?: boolean
Chocobozzz's avatar
Chocobozzz committed
264
  isPortraitMode: boolean
Chocobozzz's avatar
Chocobozzz committed
265
266

  job?: Job
Chocobozzz's avatar
Chocobozzz committed
267
}) {
Chocobozzz's avatar
Chocobozzz committed
268
  const { type, video, inputPath, resolution, copyCodecs, isPortraitMode, isAAC, job } = options
269
  const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
Chocobozzz's avatar
Chocobozzz committed
270

271
  const videoTranscodedBasePath = join(transcodeDirectory, type)
272
  await ensureDir(videoTranscodedBasePath)
Chocobozzz's avatar
Chocobozzz committed
273

Chocobozzz's avatar
Chocobozzz committed
274
  const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
275
276
  const playlistFilename = VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)
  const playlistFileTranscodePath = join(videoTranscodedBasePath, playlistFilename)
Chocobozzz's avatar
Chocobozzz committed
277
278

  const transcodeOptions = {
Chocobozzz's avatar
Chocobozzz committed
279
    type,
280

Chocobozzz's avatar
Chocobozzz committed
281
    inputPath,
282
    outputPath: playlistFileTranscodePath,
283

284
    availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
285
    profile: CONFIG.TRANSCODING.PROFILE,
286

Chocobozzz's avatar
Chocobozzz committed
287
    resolution,
Chocobozzz's avatar
Chocobozzz committed
288
    copyCodecs,
Chocobozzz's avatar
Chocobozzz committed
289
    isPortraitMode,
290

291
292
    isAAC,

293
    hlsPlaylist: {
Chocobozzz's avatar
Chocobozzz committed
294
      videoFilename
Chocobozzz's avatar
Chocobozzz committed
295
296
297
    },

    job
Chocobozzz's avatar
Chocobozzz committed
298
299
  }

Chocobozzz's avatar
Chocobozzz committed
300
  await transcode(transcodeOptions)
Chocobozzz's avatar
Chocobozzz committed
301

Chocobozzz's avatar
Chocobozzz committed
302
  const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
Chocobozzz's avatar
Chocobozzz committed
303

304
  // Create or update the playlist
Chocobozzz's avatar
Chocobozzz committed
305
  const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
Chocobozzz's avatar
Chocobozzz committed
306
307
    videoId: video.id,
    playlistUrl,
308
    segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
Chocobozzz's avatar
Chocobozzz committed
309
    p2pMediaLoaderInfohashes: [],
Chocobozzz's avatar
Chocobozzz committed
310
    p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
Chocobozzz's avatar
Chocobozzz committed
311
312

    type: VideoStreamingPlaylistType.HLS
Chocobozzz's avatar
Chocobozzz committed
313
314
315
  }, { returning: true }) as [ MStreamingPlaylistFilesVideo, boolean ]
  videoStreamingPlaylist.Video = video

316
  // Build the new playlist file
317
  const extname = extnameUtil(videoFilename)
Chocobozzz's avatar
Chocobozzz committed
318
319
  const newVideoFile = new VideoFileModel({
    resolution,
320
    extname,
Chocobozzz's avatar
Chocobozzz committed
321
    size: 0,
322
    filename: generateVideoFilename(video, true, resolution, extname),
Chocobozzz's avatar
Chocobozzz committed
323
324
    fps: -1,
    videoStreamingPlaylistId: videoStreamingPlaylist.id
Chocobozzz's avatar
Chocobozzz committed
325
  })
Chocobozzz's avatar
Chocobozzz committed
326
327

  const videoFilePath = getVideoFilePath(videoStreamingPlaylist, newVideoFile)
328
329
330
331
332
333
334

  // Move files from tmp transcoded directory to the appropriate place
  const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
  await ensureDir(baseHlsDirectory)

  // Move playlist file
  const playlistPath = join(baseHlsDirectory, playlistFilename)
335
  await move(playlistFileTranscodePath, playlistPath, { overwrite: true })
336
  // Move video file
337
  await move(join(videoTranscodedBasePath, videoFilename), videoFilePath, { overwrite: true })
338

Chocobozzz's avatar
Chocobozzz committed
339
340
341
342
  const stats = await stat(videoFilePath)

  newVideoFile.size = stats.size
  newVideoFile.fps = await getVideoFileFPS(videoFilePath)
343
  newVideoFile.metadata = await getMetadataFromFile(videoFilePath)
Chocobozzz's avatar
Chocobozzz committed
344

Chocobozzz's avatar
Cleanup    
Chocobozzz committed
345
  await createTorrentAndSetInfoHash(videoStreamingPlaylist, newVideoFile)
Chocobozzz's avatar
Chocobozzz committed
346

Chocobozzz's avatar
Chocobozzz committed
347
  await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
348
  videoStreamingPlaylist.VideoFiles = await videoStreamingPlaylist.$get('VideoFiles')
Chocobozzz's avatar
Chocobozzz committed
349

Chocobozzz's avatar
Chocobozzz committed
350
351
352
353
354
  videoStreamingPlaylist.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(
    playlistUrl, videoStreamingPlaylist.VideoFiles
  )
  await videoStreamingPlaylist.save()

Chocobozzz's avatar
Chocobozzz committed
355
356
357
  video.setHLSPlaylist(videoStreamingPlaylist)

  await updateMasterHLSPlaylist(video)
358
  await updateSha256VODSegments(video)
Chocobozzz's avatar
Chocobozzz committed
359

360
  return playlistPath
361
}