aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/helpers
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-02-11 10:51:33 +0100
committerChocobozzz <chocobozzz@cpy.re>2022-02-28 10:42:19 +0100
commitc729caf6cc34630877a0e5a1bda1719384cd0c8a (patch)
tree1d2e13722e518c73d2c9e6f0969615e29d51cf8c /server/helpers
parenta24bf4dc659cebb65d887862bf21d7a35e9ec791 (diff)
downloadPeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.gz
PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.zst
PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.zip
Add basic video editor support
Diffstat (limited to 'server/helpers')
-rw-r--r--server/helpers/custom-validators/actor-images.ts11
-rw-r--r--server/helpers/custom-validators/misc.ts79
-rw-r--r--server/helpers/custom-validators/video-captions.ts12
-rw-r--r--server/helpers/custom-validators/video-editor.ts52
-rw-r--r--server/helpers/custom-validators/video-imports.ts11
-rw-r--r--server/helpers/custom-validators/videos.ts27
-rw-r--r--server/helpers/express-utils.ts77
-rw-r--r--server/helpers/ffmpeg-utils.ts781
-rw-r--r--server/helpers/ffmpeg/ffmpeg-commons.ts114
-rw-r--r--server/helpers/ffmpeg/ffmpeg-edition.ts242
-rw-r--r--server/helpers/ffmpeg/ffmpeg-encoders.ts116
-rw-r--r--server/helpers/ffmpeg/ffmpeg-images.ts46
-rw-r--r--server/helpers/ffmpeg/ffmpeg-live.ts161
-rw-r--r--server/helpers/ffmpeg/ffmpeg-presets.ts156
-rw-r--r--server/helpers/ffmpeg/ffmpeg-vod.ts254
-rw-r--r--server/helpers/ffmpeg/ffprobe-utils.ts (renamed from server/helpers/ffprobe-utils.ts)97
-rw-r--r--server/helpers/ffmpeg/index.ts8
-rw-r--r--server/helpers/image-utils.ts28
-rw-r--r--server/helpers/webtorrent.ts39
19 files changed, 1390 insertions, 921 deletions
diff --git a/server/helpers/custom-validators/actor-images.ts b/server/helpers/custom-validators/actor-images.ts
index 4fb0b7c70..89f5a2262 100644
--- a/server/helpers/custom-validators/actor-images.ts
+++ b/server/helpers/custom-validators/actor-images.ts
@@ -1,4 +1,5 @@
1 1
2import { UploadFilesForCheck } from 'express'
2import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 3import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
3import { isFileValid } from './misc' 4import { isFileValid } from './misc'
4 5
@@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
6 .map(v => v.replace('.', '')) 7 .map(v => v.replace('.', ''))
7 .join('|') 8 .join('|')
8const imageMimeTypesRegex = `image/(${imageMimeTypes})` 9const imageMimeTypesRegex = `image/(${imageMimeTypes})`
9function isActorImageFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], fieldname: string) { 10
10 return isFileValid(files, imageMimeTypesRegex, fieldname, CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max) 11function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
12 return isFileValid({
13 files,
14 mimeTypeRegex: imageMimeTypesRegex,
15 field: fieldname,
16 maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
17 })
11} 18}
12 19
13// --------------------------------------------------------------------------- 20// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 81a60ee66..c80c86193 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -61,75 +61,43 @@ function isIntOrNull (value: any) {
61 61
62// --------------------------------------------------------------------------- 62// ---------------------------------------------------------------------------
63 63
64function isFileFieldValid ( 64function isFileValid (options: {
65 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 65 files: UploadFilesForCheck
66 field: string,
67 optional = false
68) {
69 // Should have files
70 if (!files) return optional
71 if (isArray(files)) return optional
72 66
73 // Should have a file 67 maxSize: number | null
74 const fileArray = files[field] 68 mimeTypeRegex: string | null
75 if (!fileArray || fileArray.length === 0) {
76 return optional
77 }
78 69
79 // The file should exist 70 field?: string
80 const file = fileArray[0]
81 if (!file || !file.originalname) return false
82 return file
83}
84 71
85function isFileMimeTypeValid ( 72 optional?: boolean // Default false
86 files: UploadFilesForCheck, 73}) {
87 mimeTypeRegex: string, 74 const { files, mimeTypeRegex, field, maxSize, optional = false } = options
88 field: string,
89 optional = false
90) {
91 // Should have files
92 if (!files) return optional
93 if (isArray(files)) return optional
94 75
95 // Should have a file
96 const fileArray = files[field]
97 if (!fileArray || fileArray.length === 0) {
98 return optional
99 }
100
101 // The file should exist
102 const file = fileArray[0]
103 if (!file || !file.originalname) return false
104
105 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
106}
107
108function isFileValid (
109 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
110 mimeTypeRegex: string,
111 field: string,
112 maxSize: number | null,
113 optional = false
114) {
115 // Should have files 76 // Should have files
116 if (!files) return optional 77 if (!files) return optional
117 if (isArray(files)) return optional
118 78
119 // Should have a file 79 const fileArray = isArray(files)
120 const fileArray = files[field] 80 ? files
121 if (!fileArray || fileArray.length === 0) { 81 : files[field]
82
83 if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
122 return optional 84 return optional
123 } 85 }
124 86
125 // The file should exist 87 // The file exists
126 const file = fileArray[0] 88 const file = fileArray[0]
127 if (!file || !file.originalname) return false 89 if (!file || !file.originalname) return false
128 90
129 // Check size 91 // Check size
130 if ((maxSize !== null) && file.size > maxSize) return false 92 if ((maxSize !== null) && file.size > maxSize) return false
131 93
132 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype) 94 if (mimeTypeRegex === null) return true
95
96 return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
97}
98
99function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
100 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
133} 101}
134 102
135// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
@@ -204,7 +172,6 @@ export {
204 areUUIDsValid, 172 areUUIDsValid,
205 toArray, 173 toArray,
206 toIntArray, 174 toIntArray,
207 isFileFieldValid, 175 isFileValid,
208 isFileMimeTypeValid, 176 checkMimetypeRegex
209 isFileValid
210} 177}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 4cc7dcaf4..59ba005fe 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -1,5 +1,6 @@
1import { getFileSize } from '@shared/extra-utils' 1import { UploadFilesForCheck } from 'express'
2import { readFile } from 'fs-extra' 2import { readFile } from 'fs-extra'
3import { getFileSize } from '@shared/extra-utils'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
5 6
@@ -11,8 +12,13 @@ const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT
11 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
12 .map(m => `(${m})`) 13 .map(m => `(${m})`)
13 .join('|') 14 .join('|')
14function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { 15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
15 return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max) 16 return isFileValid({
17 files,
18 mimeTypeRegex: videoCaptionTypesRegex,
19 field,
20 maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
21 })
16} 22}
17 23
18async function isVTTFileValid (filePath: string) { 24async function isVTTFileValid (filePath: string) {
diff --git a/server/helpers/custom-validators/video-editor.ts b/server/helpers/custom-validators/video-editor.ts
new file mode 100644
index 000000000..09238675e
--- /dev/null
+++ b/server/helpers/custom-validators/video-editor.ts
@@ -0,0 +1,52 @@
1import validator from 'validator'
2import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
3import { buildTaskFileFieldname } from '@server/lib/video-editor'
4import { VideoEditorTask } from '@shared/models'
5import { isArray } from './misc'
6import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos'
7
8function isValidEditorTasksArray (tasks: any) {
9 if (!isArray(tasks)) return false
10
11 return tasks.length >= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.min &&
12 tasks.length <= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.max
13}
14
15function isEditorCutTaskValid (task: VideoEditorTask) {
16 if (task.name !== 'cut') return false
17 if (!task.options) return false
18
19 const { start, end } = task.options
20 if (!start && !end) return false
21
22 if (start && !validator.isInt(start + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
23 if (end && !validator.isInt(end + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
24
25 if (!start || !end) return true
26
27 return parseInt(start + '') < parseInt(end + '')
28}
29
30function isEditorTaskAddIntroOutroValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
31 const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
32
33 return (task.name === 'add-intro' || task.name === 'add-outro') &&
34 file && isVideoFileMimeTypeValid([ file ], null)
35}
36
37function isEditorTaskAddWatermarkValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
38 const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
39
40 return task.name === 'add-watermark' &&
41 file && isVideoImageValid([ file ], null, true)
42}
43
44// ---------------------------------------------------------------------------
45
46export {
47 isValidEditorTasksArray,
48
49 isEditorCutTaskValid,
50 isEditorTaskAddIntroOutroValid,
51 isEditorTaskAddWatermarkValid
52}
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index dbf6a3504..af93aea56 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -1,4 +1,5 @@
1import 'multer' 1import 'multer'
2import { UploadFilesForCheck } from 'express'
2import validator from 'validator' 3import validator from 'validator'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
@@ -25,8 +26,14 @@ const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
25 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
26 .map(m => `(${m})`) 27 .map(m => `(${m})`)
27 .join('|') 28 .join('|')
28function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 29function isVideoImportTorrentFile (files: UploadFilesForCheck) {
29 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true) 30 return isFileValid({
31 files,
32 mimeTypeRegex: videoTorrentImportRegex,
33 field: 'torrentfile',
34 maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
35 optional: true
36 })
30} 37}
31 38
32// --------------------------------------------------------------------------- 39// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index e526c4284..ca5f70fdc 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -13,7 +13,7 @@ import {
13 VIDEO_RATE_TYPES, 13 VIDEO_RATE_TYPES,
14 VIDEO_STATES 14 VIDEO_STATES
15} from '../../initializers/constants' 15} from '../../initializers/constants'
16import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' 16import { exists, isArray, isDateValid, isFileValid } from './misc'
17 17
18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
19 19
@@ -66,7 +66,7 @@ function isVideoTagValid (tag: string) {
66 return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG) 66 return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
67} 67}
68 68
69function isVideoTagsValid (tags: string[]) { 69function areVideoTagsValid (tags: string[]) {
70 return tags === null || ( 70 return tags === null || (
71 isArray(tags) && 71 isArray(tags) &&
72 validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) && 72 validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) &&
@@ -86,8 +86,13 @@ function isVideoFileExtnameValid (value: string) {
86 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined) 86 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
87} 87}
88 88
89function isVideoFileMimeTypeValid (files: UploadFilesForCheck) { 89function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
90 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile') 90 return isFileValid({
91 files,
92 mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
93 field,
94 maxSize: null
95 })
91} 96}
92 97
93const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME 98const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
95 .join('|') 100 .join('|')
96const videoImageTypesRegex = `image/(${videoImageTypes})` 101const videoImageTypesRegex = `image/(${videoImageTypes})`
97 102
98function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { 103function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
99 return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true) 104 return isFileValid({
105 files,
106 mimeTypeRegex: videoImageTypesRegex,
107 field,
108 maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
109 optional
110 })
100} 111}
101 112
102function isVideoPrivacyValid (value: number) { 113function isVideoPrivacyValid (value: number) {
@@ -144,7 +155,7 @@ export {
144 isVideoDescriptionValid, 155 isVideoDescriptionValid,
145 isVideoFileInfoHashValid, 156 isVideoFileInfoHashValid,
146 isVideoNameValid, 157 isVideoNameValid,
147 isVideoTagsValid, 158 areVideoTagsValid,
148 isVideoFPSResolutionValid, 159 isVideoFPSResolutionValid,
149 isScheduleVideoUpdatePrivacyValid, 160 isScheduleVideoUpdatePrivacyValid,
150 isVideoOriginallyPublishedAtValid, 161 isVideoOriginallyPublishedAtValid,
@@ -160,7 +171,7 @@ export {
160 isVideoPrivacyValid, 171 isVideoPrivacyValid,
161 isVideoFileResolutionValid, 172 isVideoFileResolutionValid,
162 isVideoFileSizeValid, 173 isVideoFileSizeValid,
163 isVideoImage, 174 isVideoImageValid,
164 isVideoSupportValid, 175 isVideoSupportValid,
165 isVideoFilterValid 176 isVideoFilterValid
166} 177}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 780fd6345..08f77966f 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -1,9 +1,9 @@
1import express, { RequestHandler } from 'express' 1import express, { RequestHandler } from 'express'
2import multer, { diskStorage } from 'multer' 2import multer, { diskStorage } from 'multer'
3import { getLowercaseExtension } from '@shared/core-utils'
3import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 4import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
4import { CONFIG } from '../initializers/config' 5import { CONFIG } from '../initializers/config'
5import { REMOTE_SCHEME } from '../initializers/constants' 6import { REMOTE_SCHEME } from '../initializers/constants'
6import { getLowercaseExtension } from '@shared/core-utils'
7import { isArray } from './custom-validators/misc' 7import { isArray } from './custom-validators/misc'
8import { logger } from './logger' 8import { logger } from './logger'
9import { deleteFileAndCatch, generateRandomString } from './utils' 9import { deleteFileAndCatch, generateRandomString } from './utils'
@@ -75,29 +75,8 @@ function createReqFiles (
75 cb(null, destinations[file.fieldname]) 75 cb(null, destinations[file.fieldname])
76 }, 76 },
77 77
78 filename: async (req, file, cb) => { 78 filename: (req, file, cb) => {
79 let extension: string 79 return generateReqFilename(file, mimeTypes, cb)
80 const fileExtension = getLowercaseExtension(file.originalname)
81 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
82
83 // Take the file extension if we don't understand the mime type
84 if (!extensionFromMimetype) {
85 extension = fileExtension
86 } else {
87 // Take the first available extension for this mimetype
88 extension = extensionFromMimetype
89 }
90
91 let randomString = ''
92
93 try {
94 randomString = await generateRandomString(16)
95 } catch (err) {
96 logger.error('Cannot generate random string for file name.', { err })
97 randomString = 'fake-random-string'
98 }
99
100 cb(null, randomString + extension)
101 } 80 }
102 }) 81 })
103 82
@@ -112,6 +91,24 @@ function createReqFiles (
112 return multer({ storage }).fields(fields) 91 return multer({ storage }).fields(fields)
113} 92}
114 93
94function createAnyReqFiles (
95 mimeTypes: { [id: string]: string | string[] },
96 destinationDirectory: string,
97 fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void
98): RequestHandler {
99 const storage = diskStorage({
100 destination: (req, file, cb) => {
101 cb(null, destinationDirectory)
102 },
103
104 filename: (req, file, cb) => {
105 return generateReqFilename(file, mimeTypes, cb)
106 }
107 })
108
109 return multer({ storage, fileFilter }).any()
110}
111
115function isUserAbleToSearchRemoteURI (res: express.Response) { 112function isUserAbleToSearchRemoteURI (res: express.Response) {
116 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 113 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
117 114
@@ -128,9 +125,41 @@ function getCountVideos (req: express.Request) {
128export { 125export {
129 buildNSFWFilter, 126 buildNSFWFilter,
130 getHostWithPort, 127 getHostWithPort,
128 createAnyReqFiles,
131 isUserAbleToSearchRemoteURI, 129 isUserAbleToSearchRemoteURI,
132 badRequest, 130 badRequest,
133 createReqFiles, 131 createReqFiles,
134 cleanUpReqFiles, 132 cleanUpReqFiles,
135 getCountVideos 133 getCountVideos
136} 134}
135
136// ---------------------------------------------------------------------------
137
138async function generateReqFilename (
139 file: Express.Multer.File,
140 mimeTypes: { [id: string]: string | string[] },
141 cb: (err: Error, name: string) => void
142) {
143 let extension: string
144 const fileExtension = getLowercaseExtension(file.originalname)
145 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
146
147 // Take the file extension if we don't understand the mime type
148 if (!extensionFromMimetype) {
149 extension = fileExtension
150 } else {
151 // Take the first available extension for this mimetype
152 extension = extensionFromMimetype
153 }
154
155 let randomString = ''
156
157 try {
158 randomString = await generateRandomString(16)
159 } catch (err) {
160 logger.error('Cannot generate random string for file name.', { err })
161 randomString = 'fake-random-string'
162 }
163
164 cb(null, randomString + extension)
165}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
deleted file mode 100644
index 78ee5fa7f..000000000
--- a/server/helpers/ffmpeg-utils.ts
+++ /dev/null
@@ -1,781 +0,0 @@
1import { Job } from 'bull'
2import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
3import { readFile, remove, writeFile } from 'fs-extra'
4import { dirname, join } from 'path'
5import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
6import { pick } from '@shared/core-utils'
7import {
8 AvailableEncoders,
9 EncoderOptions,
10 EncoderOptionsBuilder,
11 EncoderOptionsBuilderParams,
12 EncoderProfile,
13 VideoResolution
14} from '../../shared/models/videos'
15import { CONFIG } from '../initializers/config'
16import { execPromise, promisify0 } from './core-utils'
17import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
18import { processImage } from './image-utils'
19import { logger, loggerTagsFactory } from './logger'
20
21const lTags = loggerTagsFactory('ffmpeg')
22
23/**
24 *
25 * Functions that run transcoding/muxing ffmpeg processes
26 * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
27 *
28 */
29
30// ---------------------------------------------------------------------------
31// Encoder options
32// ---------------------------------------------------------------------------
33
34type StreamType = 'audio' | 'video'
35
36// ---------------------------------------------------------------------------
37// Encoders support
38// ---------------------------------------------------------------------------
39
40// Detect supported encoders by ffmpeg
41let supportedEncoders: Map<string, boolean>
42async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
43 if (supportedEncoders !== undefined) {
44 return supportedEncoders
45 }
46
47 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
48 const availableFFmpegEncoders = await getAvailableEncodersPromise()
49
50 const searchEncoders = new Set<string>()
51 for (const type of [ 'live', 'vod' ]) {
52 for (const streamType of [ 'audio', 'video' ]) {
53 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
54 searchEncoders.add(encoder)
55 }
56 }
57 }
58
59 supportedEncoders = new Map<string, boolean>()
60
61 for (const searchEncoder of searchEncoders) {
62 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
63 }
64
65 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
66
67 return supportedEncoders
68}
69
70function resetSupportedEncoders () {
71 supportedEncoders = undefined
72}
73
74// ---------------------------------------------------------------------------
75// Image manipulation
76// ---------------------------------------------------------------------------
77
78function convertWebPToJPG (path: string, destination: string): Promise<void> {
79 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
80 .output(destination)
81
82 return runCommand({ command, silent: true })
83}
84
85function processGIF (
86 path: string,
87 destination: string,
88 newSize: { width: number, height: number }
89): Promise<void> {
90 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
91 .fps(20)
92 .size(`${newSize.width}x${newSize.height}`)
93 .output(destination)
94
95 return runCommand({ command })
96}
97
98async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
99 const pendingImageName = 'pending-' + imageName
100
101 const options = {
102 filename: pendingImageName,
103 count: 1,
104 folder
105 }
106
107 const pendingImagePath = join(folder, pendingImageName)
108
109 try {
110 await new Promise<string>((res, rej) => {
111 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
112 .on('error', rej)
113 .on('end', () => res(imageName))
114 .thumbnail(options)
115 })
116
117 const destination = join(folder, imageName)
118 await processImage(pendingImagePath, destination, size)
119 } catch (err) {
120 logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
121
122 try {
123 await remove(pendingImagePath)
124 } catch (err) {
125 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
126 }
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Transcode meta function
132// ---------------------------------------------------------------------------
133
134type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
135
136interface BaseTranscodeOptions {
137 type: TranscodeOptionsType
138
139 inputPath: string
140 outputPath: string
141
142 availableEncoders: AvailableEncoders
143 profile: string
144
145 resolution: number
146
147 isPortraitMode?: boolean
148
149 job?: Job
150}
151
152interface HLSTranscodeOptions extends BaseTranscodeOptions {
153 type: 'hls'
154 copyCodecs: boolean
155 hlsPlaylist: {
156 videoFilename: string
157 }
158}
159
160interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
161 type: 'hls-from-ts'
162
163 isAAC: boolean
164
165 hlsPlaylist: {
166 videoFilename: string
167 }
168}
169
170interface QuickTranscodeOptions extends BaseTranscodeOptions {
171 type: 'quick-transcode'
172}
173
174interface VideoTranscodeOptions extends BaseTranscodeOptions {
175 type: 'video'
176}
177
178interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
179 type: 'merge-audio'
180 audioPath: string
181}
182
183interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
184 type: 'only-audio'
185}
186
187type TranscodeOptions =
188 HLSTranscodeOptions
189 | HLSFromTSTranscodeOptions
190 | VideoTranscodeOptions
191 | MergeAudioTranscodeOptions
192 | OnlyAudioTranscodeOptions
193 | QuickTranscodeOptions
194
195const builders: {
196 [ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand
197} = {
198 'quick-transcode': buildQuickTranscodeCommand,
199 'hls': buildHLSVODCommand,
200 'hls-from-ts': buildHLSVODFromTSCommand,
201 'merge-audio': buildAudioMergeCommand,
202 'only-audio': buildOnlyAudioCommand,
203 'video': buildx264VODCommand
204}
205
206async function transcode (options: TranscodeOptions) {
207 logger.debug('Will run transcode.', { options, ...lTags() })
208
209 let command = getFFmpeg(options.inputPath, 'vod')
210 .output(options.outputPath)
211
212 command = await builders[options.type](command, options)
213
214 await runCommand({ command, job: options.job })
215
216 await fixHLSPlaylistIfNeeded(options)
217}
218
219// ---------------------------------------------------------------------------
220// Live muxing/transcoding functions
221// ---------------------------------------------------------------------------
222
223async function getLiveTranscodingCommand (options: {
224 inputUrl: string
225
226 outPath: string
227 masterPlaylistName: string
228
229 resolutions: number[]
230
231 // Input information
232 fps: number
233 bitrate: number
234 ratio: number
235
236 availableEncoders: AvailableEncoders
237 profile: string
238}) {
239 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
240
241 const command = getFFmpeg(inputUrl, 'live')
242
243 const varStreamMap: string[] = []
244
245 const complexFilter: FilterSpecification[] = [
246 {
247 inputs: '[v:0]',
248 filter: 'split',
249 options: resolutions.length,
250 outputs: resolutions.map(r => `vtemp${r}`)
251 }
252 ]
253
254 command.outputOption('-sc_threshold 0')
255
256 addDefaultEncoderGlobalParams({ command })
257
258 for (let i = 0; i < resolutions.length; i++) {
259 const resolution = resolutions[i]
260 const resolutionFPS = computeFPS(fps, resolution)
261
262 const baseEncoderBuilderParams = {
263 input: inputUrl,
264
265 availableEncoders,
266 profile,
267
268 inputBitrate: bitrate,
269 inputRatio: ratio,
270
271 resolution,
272 fps: resolutionFPS,
273
274 streamNum: i,
275 videoType: 'live' as 'live'
276 }
277
278 {
279 const streamType: StreamType = 'video'
280 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
281 if (!builderResult) {
282 throw new Error('No available live video encoder found')
283 }
284
285 command.outputOption(`-map [vout${resolution}]`)
286
287 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
288
289 logger.debug(
290 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
291 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
292 )
293
294 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
295 applyEncoderOptions(command, builderResult.result)
296
297 complexFilter.push({
298 inputs: `vtemp${resolution}`,
299 filter: getScaleFilter(builderResult.result),
300 options: `w=-2:h=${resolution}`,
301 outputs: `vout${resolution}`
302 })
303 }
304
305 {
306 const streamType: StreamType = 'audio'
307 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
308 if (!builderResult) {
309 throw new Error('No available live audio encoder found')
310 }
311
312 command.outputOption('-map a:0')
313
314 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
315
316 logger.debug(
317 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
318 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
319 )
320
321 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
322 applyEncoderOptions(command, builderResult.result)
323 }
324
325 varStreamMap.push(`v:${i},a:${i}`)
326 }
327
328 command.complexFilter(complexFilter)
329
330 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
331
332 command.outputOption('-var_stream_map', varStreamMap.join(' '))
333
334 return command
335}
336
337function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
338 const command = getFFmpeg(inputUrl, 'live')
339
340 command.outputOption('-c:v copy')
341 command.outputOption('-c:a copy')
342 command.outputOption('-map 0:a?')
343 command.outputOption('-map 0:v?')
344
345 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
346
347 return command
348}
349
350function buildStreamSuffix (base: string, streamNum?: number) {
351 if (streamNum !== undefined) {
352 return `${base}:${streamNum}`
353 }
354
355 return base
356}
357
358// ---------------------------------------------------------------------------
359// Default options
360// ---------------------------------------------------------------------------
361
362function addDefaultEncoderGlobalParams (options: {
363 command: FfmpegCommand
364}) {
365 const { command } = options
366
367 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
368 command.outputOption('-max_muxing_queue_size 1024')
369 // strip all metadata
370 .outputOption('-map_metadata -1')
371 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
372 .outputOption('-pix_fmt yuv420p')
373}
374
375function addDefaultEncoderParams (options: {
376 command: FfmpegCommand
377 encoder: 'libx264' | string
378 streamNum?: number
379 fps?: number
380}) {
381 const { command, encoder, fps, streamNum } = options
382
383 if (encoder === 'libx264') {
384 // 3.1 is the minimal resource allocation for our highest supported resolution
385 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
386
387 if (fps) {
388 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
389 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
390 // https://superuser.com/a/908325
391 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
392 }
393 }
394}
395
396function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
397 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
398 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
399 command.outputOption('-hls_flags delete_segments+independent_segments')
400 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
401 command.outputOption('-master_pl_name ' + masterPlaylistName)
402 command.outputOption(`-f hls`)
403
404 command.output(join(outPath, '%v.m3u8'))
405}
406
407// ---------------------------------------------------------------------------
408// Transcode VOD command builders
409// ---------------------------------------------------------------------------
410
411async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) {
412 let fps = await getVideoFileFPS(options.inputPath)
413 fps = computeFPS(fps, options.resolution)
414
415 let scaleFilterValue: string
416
417 if (options.resolution !== undefined) {
418 scaleFilterValue = options.isPortraitMode === true
419 ? `w=${options.resolution}:h=-2`
420 : `w=-2:h=${options.resolution}`
421 }
422
423 command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue })
424
425 return command
426}
427
428async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
429 command = command.loop(undefined)
430
431 const scaleFilterValue = getScaleCleanerValue()
432 command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
433
434 command.outputOption('-preset:v veryfast')
435
436 command = command.input(options.audioPath)
437 .outputOption('-tune stillimage')
438 .outputOption('-shortest')
439
440 return command
441}
442
443function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
444 command = presetOnlyAudio(command)
445
446 return command
447}
448
449function buildQuickTranscodeCommand (command: FfmpegCommand) {
450 command = presetCopy(command)
451
452 command = command.outputOption('-map_metadata -1') // strip all metadata
453 .outputOption('-movflags faststart')
454
455 return command
456}
457
458function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
459 return command.outputOption('-hls_time 4')
460 .outputOption('-hls_list_size 0')
461 .outputOption('-hls_playlist_type vod')
462 .outputOption('-hls_segment_filename ' + outputPath)
463 .outputOption('-hls_segment_type fmp4')
464 .outputOption('-f hls')
465 .outputOption('-hls_flags single_file')
466}
467
468async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
469 const videoPath = getHLSVideoPath(options)
470
471 if (options.copyCodecs) command = presetCopy(command)
472 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
473 else command = await buildx264VODCommand(command, options)
474
475 addCommonHLSVODCommandOptions(command, videoPath)
476
477 return command
478}
479
480function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
481 const videoPath = getHLSVideoPath(options)
482
483 command.outputOption('-c copy')
484
485 if (options.isAAC) {
486 // Required for example when copying an AAC stream from an MPEG-TS
487 // Since it's a bitstream filter, we don't need to reencode the audio
488 command.outputOption('-bsf:a aac_adtstoasc')
489 }
490
491 addCommonHLSVODCommandOptions(command, videoPath)
492
493 return command
494}
495
496async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
497 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
498
499 const fileContent = await readFile(options.outputPath)
500
501 const videoFileName = options.hlsPlaylist.videoFilename
502 const videoFilePath = getHLSVideoPath(options)
503
504 // Fix wrong mapping with some ffmpeg versions
505 const newContent = fileContent.toString()
506 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
507
508 await writeFile(options.outputPath, newContent)
509}
510
511function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
512 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
513}
514
515// ---------------------------------------------------------------------------
516// Transcoding presets
517// ---------------------------------------------------------------------------
518
519// Run encoder builder depending on available encoders
520// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
521// If the default one does not exist, check the next encoder
522async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
523 streamType: 'video' | 'audio'
524 input: string
525
526 availableEncoders: AvailableEncoders
527 profile: string
528
529 videoType: 'vod' | 'live'
530}) {
531 const { availableEncoders, profile, streamType, videoType } = options
532
533 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
534 const encoders = availableEncoders.available[videoType]
535
536 for (const encoder of encodersToTry) {
537 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
538 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
539 continue
540 }
541
542 if (!encoders[encoder]) {
543 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
544 continue
545 }
546
547 // An object containing available profiles for this encoder
548 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
549 let builder = builderProfiles[profile]
550
551 if (!builder) {
552 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
553 builder = builderProfiles.default
554
555 if (!builder) {
556 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
557 continue
558 }
559 }
560
561 const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]))
562
563 return {
564 result,
565
566 // If we don't have output options, then copy the input stream
567 encoder: result.copy === true
568 ? 'copy'
569 : encoder
570 }
571 }
572
573 return null
574}
575
576async function presetVideo (options: {
577 command: FfmpegCommand
578 input: string
579 transcodeOptions: TranscodeOptions
580 fps?: number
581 scaleFilterValue?: string
582}) {
583 const { command, input, transcodeOptions, fps, scaleFilterValue } = options
584
585 let localCommand = command
586 .format('mp4')
587 .outputOption('-movflags faststart')
588
589 addDefaultEncoderGlobalParams({ command })
590
591 const probe = await ffprobePromise(input)
592
593 // Audio encoder
594 const parsedAudio = await getAudioStream(input, probe)
595 const bitrate = await getVideoFileBitrate(input, probe)
596 const { ratio } = await getVideoFileResolution(input, probe)
597
598 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
599
600 if (!parsedAudio.audioStream) {
601 localCommand = localCommand.noAudio()
602 streamsToProcess = [ 'video' ]
603 }
604
605 for (const streamType of streamsToProcess) {
606 const { profile, resolution, availableEncoders } = transcodeOptions
607
608 const builderResult = await getEncoderBuilderResult({
609 streamType,
610 input,
611 resolution,
612 availableEncoders,
613 profile,
614 fps,
615 inputBitrate: bitrate,
616 inputRatio: ratio,
617 videoType: 'vod' as 'vod'
618 })
619
620 if (!builderResult) {
621 throw new Error('No available encoder found for stream ' + streamType)
622 }
623
624 logger.debug(
625 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
626 builderResult.encoder, streamType, input, profile,
627 { builderResult, resolution, fps, ...lTags() }
628 )
629
630 if (streamType === 'video') {
631 localCommand.videoCodec(builderResult.encoder)
632
633 if (scaleFilterValue) {
634 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
635 }
636 } else if (streamType === 'audio') {
637 localCommand.audioCodec(builderResult.encoder)
638 }
639
640 applyEncoderOptions(localCommand, builderResult.result)
641 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
642 }
643
644 return localCommand
645}
646
647function presetCopy (command: FfmpegCommand): FfmpegCommand {
648 return command
649 .format('mp4')
650 .videoCodec('copy')
651 .audioCodec('copy')
652}
653
654function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
655 return command
656 .format('mp4')
657 .audioCodec('copy')
658 .noVideo()
659}
660
661function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
662 return command
663 .inputOptions(options.inputOptions ?? [])
664 .outputOptions(options.outputOptions ?? [])
665}
666
667function getScaleFilter (options: EncoderOptions): string {
668 if (options.scaleFilter) return options.scaleFilter.name
669
670 return 'scale'
671}
672
673// ---------------------------------------------------------------------------
674// Utils
675// ---------------------------------------------------------------------------
676
677function getFFmpeg (input: string, type: 'live' | 'vod') {
678 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
679 const command = ffmpeg(input, {
680 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
681 cwd: CONFIG.STORAGE.TMP_DIR
682 })
683
684 const threads = type === 'live'
685 ? CONFIG.LIVE.TRANSCODING.THREADS
686 : CONFIG.TRANSCODING.THREADS
687
688 if (threads > 0) {
689 // If we don't set any threads ffmpeg will chose automatically
690 command.outputOption('-threads ' + threads)
691 }
692
693 return command
694}
695
696function getFFmpegVersion () {
697 return new Promise<string>((res, rej) => {
698 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
699 if (err) return rej(err)
700 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
701
702 return execPromise(`${ffmpegPath} -version`)
703 .then(stdout => {
704 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
705 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
706
707 // Fix ffmpeg version that does not include patch version (4.4 for example)
708 let version = parsed[1]
709 if (version.match(/^\d+\.\d+$/)) {
710 version += '.0'
711 }
712
713 return res(version)
714 })
715 .catch(err => rej(err))
716 })
717 })
718}
719
720async function runCommand (options: {
721 command: FfmpegCommand
722 silent?: boolean // false
723 job?: Job
724}) {
725 const { command, silent = false, job } = options
726
727 return new Promise<void>((res, rej) => {
728 let shellCommand: string
729
730 command.on('start', cmdline => { shellCommand = cmdline })
731
732 command.on('error', (err, stdout, stderr) => {
733 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
734
735 rej(err)
736 })
737
738 command.on('end', (stdout, stderr) => {
739 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
740
741 res()
742 })
743
744 if (job) {
745 command.on('progress', progress => {
746 if (!progress.percent) return
747
748 job.progress(Math.round(progress.percent))
749 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
750 })
751 }
752
753 command.run()
754 })
755}
756
757// Avoid "height not divisible by 2" error
758function getScaleCleanerValue () {
759 return 'trunc(iw/2)*2:trunc(ih/2)*2'
760}
761
762// ---------------------------------------------------------------------------
763
764export {
765 getLiveTranscodingCommand,
766 getLiveMuxingCommand,
767 buildStreamSuffix,
768 convertWebPToJPG,
769 processGIF,
770 generateImageFromVideoFile,
771 TranscodeOptions,
772 TranscodeOptionsType,
773 transcode,
774 runCommand,
775 getFFmpegVersion,
776
777 resetSupportedEncoders,
778
779 // builders
780 buildx264VODCommand
781}
diff --git a/server/helpers/ffmpeg/ffmpeg-commons.ts b/server/helpers/ffmpeg/ffmpeg-commons.ts
new file mode 100644
index 000000000..ee338889c
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-commons.ts
@@ -0,0 +1,114 @@
1import { Job } from 'bull'
2import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
3import { execPromise } from '@server/helpers/core-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config'
6import { FFMPEG_NICE } from '@server/initializers/constants'
7import { EncoderOptions } from '@shared/models'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11type StreamType = 'audio' | 'video'
12
13function getFFmpeg (input: string, type: 'live' | 'vod') {
14 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
15 const command = ffmpeg(input, {
16 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
17 cwd: CONFIG.STORAGE.TMP_DIR
18 })
19
20 const threads = type === 'live'
21 ? CONFIG.LIVE.TRANSCODING.THREADS
22 : CONFIG.TRANSCODING.THREADS
23
24 if (threads > 0) {
25 // If we don't set any threads ffmpeg will chose automatically
26 command.outputOption('-threads ' + threads)
27 }
28
29 return command
30}
31
32function getFFmpegVersion () {
33 return new Promise<string>((res, rej) => {
34 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
35 if (err) return rej(err)
36 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
37
38 return execPromise(`${ffmpegPath} -version`)
39 .then(stdout => {
40 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
41 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
42
43 // Fix ffmpeg version that does not include patch version (4.4 for example)
44 let version = parsed[1]
45 if (version.match(/^\d+\.\d+$/)) {
46 version += '.0'
47 }
48
49 return res(version)
50 })
51 .catch(err => rej(err))
52 })
53 })
54}
55
56async function runCommand (options: {
57 command: FfmpegCommand
58 silent?: boolean // false by default
59 job?: Job
60}) {
61 const { command, silent = false, job } = options
62
63 return new Promise<void>((res, rej) => {
64 let shellCommand: string
65
66 command.on('start', cmdline => { shellCommand = cmdline })
67
68 command.on('error', (err, stdout, stderr) => {
69 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
70
71 rej(err)
72 })
73
74 command.on('end', (stdout, stderr) => {
75 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
76
77 res()
78 })
79
80 if (job) {
81 command.on('progress', progress => {
82 if (!progress.percent) return
83
84 job.progress(Math.round(progress.percent))
85 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
86 })
87 }
88
89 command.run()
90 })
91}
92
93function buildStreamSuffix (base: string, streamNum?: number) {
94 if (streamNum !== undefined) {
95 return `${base}:${streamNum}`
96 }
97
98 return base
99}
100
101function getScaleFilter (options: EncoderOptions): string {
102 if (options.scaleFilter) return options.scaleFilter.name
103
104 return 'scale'
105}
106
107export {
108 getFFmpeg,
109 getFFmpegVersion,
110 runCommand,
111 StreamType,
112 buildStreamSuffix,
113 getScaleFilter
114}
diff --git a/server/helpers/ffmpeg/ffmpeg-edition.ts b/server/helpers/ffmpeg/ffmpeg-edition.ts
new file mode 100644
index 000000000..a5baa7ef1
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-edition.ts
@@ -0,0 +1,242 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { VIDEO_FILTERS } from '@server/initializers/constants'
3import { AvailableEncoders } from '@shared/models'
4import { logger, loggerTagsFactory } from '../logger'
5import { getFFmpeg, runCommand } from './ffmpeg-commons'
6import { presetCopy, presetVOD } from './ffmpeg-presets'
7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11async function cutVideo (options: {
12 inputPath: string
13 outputPath: string
14 start?: number
15 end?: number
16}) {
17 const { inputPath, outputPath } = options
18
19 logger.debug('Will cut the video.', { options, ...lTags() })
20
21 let command = getFFmpeg(inputPath, 'vod')
22 .output(outputPath)
23
24 command = presetCopy(command)
25
26 if (options.start) command.inputOption('-ss ' + options.start)
27
28 if (options.end) {
29 const endSeeking = options.end - (options.start || 0)
30
31 command.outputOption('-to ' + endSeeking)
32 }
33
34 await runCommand({ command })
35}
36
37async function addWatermark (options: {
38 inputPath: string
39 watermarkPath: string
40 outputPath: string
41
42 availableEncoders: AvailableEncoders
43 profile: string
44}) {
45 const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options
46
47 logger.debug('Will add watermark to the video.', { options, ...lTags() })
48
49 const videoProbe = await ffprobePromise(inputPath)
50 const fps = await getVideoStreamFPS(inputPath, videoProbe)
51 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
52
53 let command = getFFmpeg(inputPath, 'vod')
54 .output(outputPath)
55 command.input(watermarkPath)
56
57 command = await presetVOD({
58 command,
59 input: inputPath,
60 availableEncoders,
61 profile,
62 resolution,
63 fps,
64 canCopyAudio: true,
65 canCopyVideo: false
66 })
67
68 const complexFilter: FilterSpecification[] = [
69 // Scale watermark
70 {
71 inputs: [ '[1]', '[0]' ],
72 filter: 'scale2ref',
73 options: {
74 w: 'oh*mdar',
75 h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}`
76 },
77 outputs: [ '[watermark]', '[video]' ]
78 },
79
80 {
81 inputs: [ '[video]', '[watermark]' ],
82 filter: 'overlay',
83 options: {
84 x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`,
85 y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}`
86 }
87 }
88 ]
89
90 command.complexFilter(complexFilter)
91
92 await runCommand({ command })
93}
94
95async function addIntroOutro (options: {
96 inputPath: string
97 introOutroPath: string
98 outputPath: string
99 type: 'intro' | 'outro'
100
101 availableEncoders: AvailableEncoders
102 profile: string
103}) {
104 const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options
105
106 logger.debug('Will add intro/outro to the video.', { options, ...lTags() })
107
108 const mainProbe = await ffprobePromise(inputPath)
109 const fps = await getVideoStreamFPS(inputPath, mainProbe)
110 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
111 const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
112
113 const introOutroProbe = await ffprobePromise(introOutroPath)
114 const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
115
116 let command = getFFmpeg(inputPath, 'vod')
117 .output(outputPath)
118
119 command.input(introOutroPath)
120
121 if (!introOutroHasAudio && mainHasAudio) {
122 const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
123
124 command.input('anullsrc')
125 command.withInputFormat('lavfi')
126 command.withInputOption('-t ' + duration)
127 }
128
129 command = await presetVOD({
130 command,
131 input: inputPath,
132 availableEncoders,
133 profile,
134 resolution,
135 fps,
136 canCopyAudio: false,
137 canCopyVideo: false
138 })
139
140 // Add black background to correctly scale intro/outro with padding
141 const complexFilter: FilterSpecification[] = [
142 {
143 inputs: [ '1', '0' ],
144 filter: 'scale2ref',
145 options: {
146 w: 'iw',
147 h: `ih`
148 },
149 outputs: [ 'intro-outro', 'main' ]
150 },
151 {
152 inputs: [ 'intro-outro', 'main' ],
153 filter: 'scale2ref',
154 options: {
155 w: 'iw',
156 h: `ih`
157 },
158 outputs: [ 'to-scale', 'main' ]
159 },
160 {
161 inputs: 'to-scale',
162 filter: 'drawbox',
163 options: {
164 t: 'fill'
165 },
166 outputs: [ 'to-scale-bg' ]
167 },
168 {
169 inputs: [ '1', 'to-scale-bg' ],
170 filter: 'scale2ref',
171 options: {
172 w: 'iw',
173 h: 'ih',
174 force_original_aspect_ratio: 'decrease',
175 flags: 'spline'
176 },
177 outputs: [ 'to-scale', 'to-scale-bg' ]
178 },
179 {
180 inputs: [ 'to-scale-bg', 'to-scale' ],
181 filter: 'overlay',
182 options: {
183 x: '(main_w - overlay_w)/2',
184 y: '(main_h - overlay_h)/2'
185 },
186 outputs: 'intro-outro-resized'
187 }
188 ]
189
190 const concatFilter = {
191 inputs: [],
192 filter: 'concat',
193 options: {
194 n: 2,
195 v: 1,
196 unsafe: 1
197 },
198 outputs: [ 'v' ]
199 }
200
201 const introOutroFilterInputs = [ 'intro-outro-resized' ]
202 const mainFilterInputs = [ 'main' ]
203
204 if (mainHasAudio) {
205 mainFilterInputs.push('0:a')
206
207 if (introOutroHasAudio) {
208 introOutroFilterInputs.push('1:a')
209 } else {
210 // Silent input
211 introOutroFilterInputs.push('2:a')
212 }
213 }
214
215 if (type === 'intro') {
216 concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
217 } else {
218 concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
219 }
220
221 if (mainHasAudio) {
222 concatFilter.options['a'] = 1
223 concatFilter.outputs.push('a')
224
225 command.outputOption('-map [a]')
226 }
227
228 command.outputOption('-map [v]')
229
230 complexFilter.push(concatFilter)
231 command.complexFilter(complexFilter)
232
233 await runCommand({ command })
234}
235
236// ---------------------------------------------------------------------------
237
238export {
239 cutVideo,
240 addIntroOutro,
241 addWatermark
242}
diff --git a/server/helpers/ffmpeg/ffmpeg-encoders.ts b/server/helpers/ffmpeg/ffmpeg-encoders.ts
new file mode 100644
index 000000000..5bd80ba05
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-encoders.ts
@@ -0,0 +1,116 @@
1import { getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4import { promisify0 } from '../core-utils'
5import { logger, loggerTagsFactory } from '../logger'
6
7const lTags = loggerTagsFactory('ffmpeg')
8
9// Detect supported encoders by ffmpeg
10let supportedEncoders: Map<string, boolean>
11async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
12 if (supportedEncoders !== undefined) {
13 return supportedEncoders
14 }
15
16 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
17 const availableFFmpegEncoders = await getAvailableEncodersPromise()
18
19 const searchEncoders = new Set<string>()
20 for (const type of [ 'live', 'vod' ]) {
21 for (const streamType of [ 'audio', 'video' ]) {
22 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
23 searchEncoders.add(encoder)
24 }
25 }
26 }
27
28 supportedEncoders = new Map<string, boolean>()
29
30 for (const searchEncoder of searchEncoders) {
31 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
32 }
33
34 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
35
36 return supportedEncoders
37}
38
39function resetSupportedEncoders () {
40 supportedEncoders = undefined
41}
42
43// Run encoder builder depending on available encoders
44// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
45// If the default one does not exist, check the next encoder
46async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
47 streamType: 'video' | 'audio'
48 input: string
49
50 availableEncoders: AvailableEncoders
51 profile: string
52
53 videoType: 'vod' | 'live'
54}) {
55 const { availableEncoders, profile, streamType, videoType } = options
56
57 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
58 const encoders = availableEncoders.available[videoType]
59
60 for (const encoder of encodersToTry) {
61 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
62 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
63 continue
64 }
65
66 if (!encoders[encoder]) {
67 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
68 continue
69 }
70
71 // An object containing available profiles for this encoder
72 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
73 let builder = builderProfiles[profile]
74
75 if (!builder) {
76 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
77 builder = builderProfiles.default
78
79 if (!builder) {
80 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
81 continue
82 }
83 }
84
85 const result = await builder(
86 pick(options, [
87 'input',
88 'canCopyAudio',
89 'canCopyVideo',
90 'resolution',
91 'inputBitrate',
92 'fps',
93 'inputRatio',
94 'streamNum'
95 ])
96 )
97
98 return {
99 result,
100
101 // If we don't have output options, then copy the input stream
102 encoder: result.copy === true
103 ? 'copy'
104 : encoder
105 }
106 }
107
108 return null
109}
110
111export {
112 checkFFmpegEncoders,
113 resetSupportedEncoders,
114
115 getEncoderBuilderResult
116}
diff --git a/server/helpers/ffmpeg/ffmpeg-images.ts b/server/helpers/ffmpeg/ffmpeg-images.ts
new file mode 100644
index 000000000..7f64c6d0a
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-images.ts
@@ -0,0 +1,46 @@
1import ffmpeg from 'fluent-ffmpeg'
2import { FFMPEG_NICE } from '@server/initializers/constants'
3import { runCommand } from './ffmpeg-commons'
4
5function convertWebPToJPG (path: string, destination: string): Promise<void> {
6 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
7 .output(destination)
8
9 return runCommand({ command, silent: true })
10}
11
12function processGIF (
13 path: string,
14 destination: string,
15 newSize: { width: number, height: number }
16): Promise<void> {
17 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
18 .fps(20)
19 .size(`${newSize.width}x${newSize.height}`)
20 .output(destination)
21
22 return runCommand({ command })
23}
24
25async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) {
26 const pendingImageName = 'pending-' + imageName
27
28 const options = {
29 filename: pendingImageName,
30 count: 1,
31 folder
32 }
33
34 return new Promise<string>((res, rej) => {
35 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
36 .on('error', rej)
37 .on('end', () => res(imageName))
38 .thumbnail(options)
39 })
40}
41
42export {
43 convertWebPToJPG,
44 processGIF,
45 generateThumbnailFromVideo
46}
diff --git a/server/helpers/ffmpeg/ffmpeg-live.ts b/server/helpers/ffmpeg/ffmpeg-live.ts
new file mode 100644
index 000000000..ff571626c
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-live.ts
@@ -0,0 +1,161 @@
1import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { VIDEO_LIVE } from '@server/initializers/constants'
4import { AvailableEncoders } from '@shared/models'
5import { logger, loggerTagsFactory } from '../logger'
6import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
7import { getEncoderBuilderResult } from './ffmpeg-encoders'
8import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
9import { computeFPS } from './ffprobe-utils'
10
11const lTags = loggerTagsFactory('ffmpeg')
12
13async function getLiveTranscodingCommand (options: {
14 inputUrl: string
15
16 outPath: string
17 masterPlaylistName: string
18
19 resolutions: number[]
20
21 // Input information
22 fps: number
23 bitrate: number
24 ratio: number
25
26 availableEncoders: AvailableEncoders
27 profile: string
28}) {
29 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
30
31 const command = getFFmpeg(inputUrl, 'live')
32
33 const varStreamMap: string[] = []
34
35 const complexFilter: FilterSpecification[] = [
36 {
37 inputs: '[v:0]',
38 filter: 'split',
39 options: resolutions.length,
40 outputs: resolutions.map(r => `vtemp${r}`)
41 }
42 ]
43
44 command.outputOption('-sc_threshold 0')
45
46 addDefaultEncoderGlobalParams(command)
47
48 for (let i = 0; i < resolutions.length; i++) {
49 const resolution = resolutions[i]
50 const resolutionFPS = computeFPS(fps, resolution)
51
52 const baseEncoderBuilderParams = {
53 input: inputUrl,
54
55 availableEncoders,
56 profile,
57
58 canCopyAudio: true,
59 canCopyVideo: true,
60
61 inputBitrate: bitrate,
62 inputRatio: ratio,
63
64 resolution,
65 fps: resolutionFPS,
66
67 streamNum: i,
68 videoType: 'live' as 'live'
69 }
70
71 {
72 const streamType: StreamType = 'video'
73 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
74 if (!builderResult) {
75 throw new Error('No available live video encoder found')
76 }
77
78 command.outputOption(`-map [vout${resolution}]`)
79
80 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
81
82 logger.debug(
83 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
84 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
85 )
86
87 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
88 applyEncoderOptions(command, builderResult.result)
89
90 complexFilter.push({
91 inputs: `vtemp${resolution}`,
92 filter: getScaleFilter(builderResult.result),
93 options: `w=-2:h=${resolution}`,
94 outputs: `vout${resolution}`
95 })
96 }
97
98 {
99 const streamType: StreamType = 'audio'
100 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
101 if (!builderResult) {
102 throw new Error('No available live audio encoder found')
103 }
104
105 command.outputOption('-map a:0')
106
107 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
108
109 logger.debug(
110 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
111 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
112 )
113
114 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
115 applyEncoderOptions(command, builderResult.result)
116 }
117
118 varStreamMap.push(`v:${i},a:${i}`)
119 }
120
121 command.complexFilter(complexFilter)
122
123 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
124
125 command.outputOption('-var_stream_map', varStreamMap.join(' '))
126
127 return command
128}
129
130function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
131 const command = getFFmpeg(inputUrl, 'live')
132
133 command.outputOption('-c:v copy')
134 command.outputOption('-c:a copy')
135 command.outputOption('-map 0:a?')
136 command.outputOption('-map 0:v?')
137
138 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
139
140 return command
141}
142
143// ---------------------------------------------------------------------------
144
145export {
146 getLiveTranscodingCommand,
147 getLiveMuxingCommand
148}
149
150// ---------------------------------------------------------------------------
151
152function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
153 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
154 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
155 command.outputOption('-hls_flags delete_segments+independent_segments')
156 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
157 command.outputOption('-master_pl_name ' + masterPlaylistName)
158 command.outputOption(`-f hls`)
159
160 command.output(join(outPath, '%v.m3u8'))
161}
diff --git a/server/helpers/ffmpeg/ffmpeg-presets.ts b/server/helpers/ffmpeg/ffmpeg-presets.ts
new file mode 100644
index 000000000..99b39f79a
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-presets.ts
@@ -0,0 +1,156 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { pick } from 'lodash'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { AvailableEncoders, EncoderOptions } from '@shared/models'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
6import { getEncoderBuilderResult } from './ffmpeg-encoders'
7import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11// ---------------------------------------------------------------------------
12
13function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
14 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
15 command.outputOption('-max_muxing_queue_size 1024')
16 // strip all metadata
17 .outputOption('-map_metadata -1')
18 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
19 .outputOption('-pix_fmt yuv420p')
20}
21
22function addDefaultEncoderParams (options: {
23 command: FfmpegCommand
24 encoder: 'libx264' | string
25 fps: number
26
27 streamNum?: number
28}) {
29 const { command, encoder, fps, streamNum } = options
30
31 if (encoder === 'libx264') {
32 // 3.1 is the minimal resource allocation for our highest supported resolution
33 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
34
35 if (fps) {
36 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
37 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
38 // https://superuser.com/a/908325
39 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
40 }
41 }
42}
43
44// ---------------------------------------------------------------------------
45
46async function presetVOD (options: {
47 command: FfmpegCommand
48 input: string
49
50 availableEncoders: AvailableEncoders
51 profile: string
52
53 canCopyAudio: boolean
54 canCopyVideo: boolean
55
56 resolution: number
57 fps: number
58
59 scaleFilterValue?: string
60}) {
61 const { command, input, profile, resolution, fps, scaleFilterValue } = options
62
63 let localCommand = command
64 .format('mp4')
65 .outputOption('-movflags faststart')
66
67 addDefaultEncoderGlobalParams(command)
68
69 const probe = await ffprobePromise(input)
70
71 // Audio encoder
72 const bitrate = await getVideoStreamBitrate(input, probe)
73 const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
74
75 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
76
77 if (!await hasAudioStream(input, probe)) {
78 localCommand = localCommand.noAudio()
79 streamsToProcess = [ 'video' ]
80 }
81
82 for (const streamType of streamsToProcess) {
83 const builderResult = await getEncoderBuilderResult({
84 ...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]),
85
86 input,
87 inputBitrate: bitrate,
88 inputRatio: videoStreamDimensions?.ratio || 0,
89
90 profile,
91 resolution,
92 fps,
93 streamType,
94
95 videoType: 'vod' as 'vod'
96 })
97
98 if (!builderResult) {
99 throw new Error('No available encoder found for stream ' + streamType)
100 }
101
102 logger.debug(
103 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
104 builderResult.encoder, streamType, input, profile,
105 { builderResult, resolution, fps, ...lTags() }
106 )
107
108 if (streamType === 'video') {
109 localCommand.videoCodec(builderResult.encoder)
110
111 if (scaleFilterValue) {
112 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
113 }
114 } else if (streamType === 'audio') {
115 localCommand.audioCodec(builderResult.encoder)
116 }
117
118 applyEncoderOptions(localCommand, builderResult.result)
119 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
120 }
121
122 return localCommand
123}
124
125function presetCopy (command: FfmpegCommand): FfmpegCommand {
126 return command
127 .format('mp4')
128 .videoCodec('copy')
129 .audioCodec('copy')
130}
131
132function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
133 return command
134 .format('mp4')
135 .audioCodec('copy')
136 .noVideo()
137}
138
139function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
140 return command
141 .inputOptions(options.inputOptions ?? [])
142 .outputOptions(options.outputOptions ?? [])
143}
144
145// ---------------------------------------------------------------------------
146
147export {
148 presetVOD,
149 presetCopy,
150 presetOnlyAudio,
151
152 addDefaultEncoderGlobalParams,
153 addDefaultEncoderParams,
154
155 applyEncoderOptions
156}
diff --git a/server/helpers/ffmpeg/ffmpeg-vod.ts b/server/helpers/ffmpeg/ffmpeg-vod.ts
new file mode 100644
index 000000000..c3622ceb1
--- /dev/null
+++ b/server/helpers/ffmpeg/ffmpeg-vod.ts
@@ -0,0 +1,254 @@
1import { Job } from 'bull'
2import { FfmpegCommand } from 'fluent-ffmpeg'
3import { readFile, writeFile } from 'fs-extra'
4import { dirname } from 'path'
5import { pick } from '@shared/core-utils'
6import { AvailableEncoders, VideoResolution } from '@shared/models'
7import { logger, loggerTagsFactory } from '../logger'
8import { getFFmpeg, runCommand } from './ffmpeg-commons'
9import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
10import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
11import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
12
13const lTags = loggerTagsFactory('ffmpeg')
14
15// ---------------------------------------------------------------------------
16
17type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
18
19interface BaseTranscodeVODOptions {
20 type: TranscodeVODOptionsType
21
22 inputPath: string
23 outputPath: string
24
25 availableEncoders: AvailableEncoders
26 profile: string
27
28 resolution: number
29
30 isPortraitMode?: boolean
31
32 job?: Job
33}
34
35interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
36 type: 'hls'
37 copyCodecs: boolean
38 hlsPlaylist: {
39 videoFilename: string
40 }
41}
42
43interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
44 type: 'hls-from-ts'
45
46 isAAC: boolean
47
48 hlsPlaylist: {
49 videoFilename: string
50 }
51}
52
53interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
54 type: 'quick-transcode'
55}
56
57interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
58 type: 'video'
59}
60
61interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
62 type: 'merge-audio'
63 audioPath: string
64}
65
66interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
67 type: 'only-audio'
68}
69
70type TranscodeVODOptions =
71 HLSTranscodeOptions
72 | HLSFromTSTranscodeOptions
73 | VideoTranscodeOptions
74 | MergeAudioTranscodeOptions
75 | OnlyAudioTranscodeOptions
76 | QuickTranscodeOptions
77
78// ---------------------------------------------------------------------------
79
80const builders: {
81 [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
82} = {
83 'quick-transcode': buildQuickTranscodeCommand,
84 'hls': buildHLSVODCommand,
85 'hls-from-ts': buildHLSVODFromTSCommand,
86 'merge-audio': buildAudioMergeCommand,
87 'only-audio': buildOnlyAudioCommand,
88 'video': buildVODCommand
89}
90
91async function transcodeVOD (options: TranscodeVODOptions) {
92 logger.debug('Will run transcode.', { options, ...lTags() })
93
94 let command = getFFmpeg(options.inputPath, 'vod')
95 .output(options.outputPath)
96
97 command = await builders[options.type](command, options)
98
99 await runCommand({ command, job: options.job })
100
101 await fixHLSPlaylistIfNeeded(options)
102}
103
104// ---------------------------------------------------------------------------
105
106export {
107 transcodeVOD,
108
109 buildVODCommand,
110
111 TranscodeVODOptions,
112 TranscodeVODOptionsType
113}
114
115// ---------------------------------------------------------------------------
116
117async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
118 let fps = await getVideoStreamFPS(options.inputPath)
119 fps = computeFPS(fps, options.resolution)
120
121 let scaleFilterValue: string
122
123 if (options.resolution !== undefined) {
124 scaleFilterValue = options.isPortraitMode === true
125 ? `w=${options.resolution}:h=-2`
126 : `w=-2:h=${options.resolution}`
127 }
128
129 command = await presetVOD({
130 ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
131
132 command,
133 input: options.inputPath,
134 canCopyAudio: true,
135 canCopyVideo: true,
136 fps,
137 scaleFilterValue
138 })
139
140 return command
141}
142
143function buildQuickTranscodeCommand (command: FfmpegCommand) {
144 command = presetCopy(command)
145
146 command = command.outputOption('-map_metadata -1') // strip all metadata
147 .outputOption('-movflags faststart')
148
149 return command
150}
151
152// ---------------------------------------------------------------------------
153// Audio transcoding
154// ---------------------------------------------------------------------------
155
156async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
157 command = command.loop(undefined)
158
159 const scaleFilterValue = getMergeAudioScaleFilterValue()
160 command = await presetVOD({
161 ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
162
163 command,
164 input: options.audioPath,
165 canCopyAudio: true,
166 canCopyVideo: true,
167 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
168 scaleFilterValue
169 })
170
171 command.outputOption('-preset:v veryfast')
172
173 command = command.input(options.audioPath)
174 .outputOption('-tune stillimage')
175 .outputOption('-shortest')
176
177 return command
178}
179
180function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
181 command = presetOnlyAudio(command)
182
183 return command
184}
185
186// ---------------------------------------------------------------------------
187// HLS transcoding
188// ---------------------------------------------------------------------------
189
190async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
191 const videoPath = getHLSVideoPath(options)
192
193 if (options.copyCodecs) command = presetCopy(command)
194 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
195 else command = await buildVODCommand(command, options)
196
197 addCommonHLSVODCommandOptions(command, videoPath)
198
199 return command
200}
201
202function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
203 const videoPath = getHLSVideoPath(options)
204
205 command.outputOption('-c copy')
206
207 if (options.isAAC) {
208 // Required for example when copying an AAC stream from an MPEG-TS
209 // Since it's a bitstream filter, we don't need to reencode the audio
210 command.outputOption('-bsf:a aac_adtstoasc')
211 }
212
213 addCommonHLSVODCommandOptions(command, videoPath)
214
215 return command
216}
217
218function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
219 return command.outputOption('-hls_time 4')
220 .outputOption('-hls_list_size 0')
221 .outputOption('-hls_playlist_type vod')
222 .outputOption('-hls_segment_filename ' + outputPath)
223 .outputOption('-hls_segment_type fmp4')
224 .outputOption('-f hls')
225 .outputOption('-hls_flags single_file')
226}
227
228async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
229 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
230
231 const fileContent = await readFile(options.outputPath)
232
233 const videoFileName = options.hlsPlaylist.videoFilename
234 const videoFilePath = getHLSVideoPath(options)
235
236 // Fix wrong mapping with some ffmpeg versions
237 const newContent = fileContent.toString()
238 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
239
240 await writeFile(options.outputPath, newContent)
241}
242
243// ---------------------------------------------------------------------------
244// Helpers
245// ---------------------------------------------------------------------------
246
247function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
248 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
249}
250
251// Avoid "height not divisible by 2" error
252function getMergeAudioScaleFilterValue () {
253 return 'trunc(iw/2)*2:trunc(ih/2)*2'
254}
diff --git a/server/helpers/ffprobe-utils.ts b/server/helpers/ffmpeg/ffprobe-utils.ts
index 595112bce..07bcf01f4 100644
--- a/server/helpers/ffprobe-utils.ts
+++ b/server/helpers/ffmpeg/ffprobe-utils.ts
@@ -1,22 +1,21 @@
1import { FfprobeData } from 'fluent-ffmpeg' 1import { FfprobeData } from 'fluent-ffmpeg'
2import { getMaxBitrate } from '@shared/core-utils' 2import { getMaxBitrate } from '@shared/core-utils'
3import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos'
4import { CONFIG } from '../initializers/config'
5import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
6import { logger } from './logger'
7import { 3import {
8 canDoQuickAudioTranscode,
9 ffprobePromise, 4 ffprobePromise,
10 getDurationFromVideoFile,
11 getAudioStream, 5 getAudioStream,
6 getVideoStreamDuration,
12 getMaxAudioBitrate, 7 getMaxAudioBitrate,
13 getMetadataFromFile, 8 buildFileMetadata,
14 getVideoFileBitrate, 9 getVideoStreamBitrate,
15 getVideoFileFPS, 10 getVideoStreamFPS,
16 getVideoFileResolution, 11 getVideoStream,
17 getVideoStreamFromFile, 12 getVideoStreamDimensionsInfo,
18 getVideoStreamSize 13 hasAudioStream
19} from '@shared/extra-utils/ffprobe' 14} from '@shared/extra-utils/ffprobe'
15import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
16import { CONFIG } from '../../initializers/config'
17import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
18import { logger } from '../logger'
20 19
21/** 20/**
22 * 21 *
@@ -24,9 +23,12 @@ import {
24 * 23 *
25 */ 24 */
26 25
27async function getVideoStreamCodec (path: string) { 26// ---------------------------------------------------------------------------
28 const videoStream = await getVideoStreamFromFile(path) 27// Codecs
28// ---------------------------------------------------------------------------
29 29
30async function getVideoStreamCodec (path: string) {
31 const videoStream = await getVideoStream(path)
30 if (!videoStream) return '' 32 if (!videoStream) return ''
31 33
32 const videoCodec = videoStream.codec_tag_string 34 const videoCodec = videoStream.codec_tag_string
@@ -83,6 +85,10 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
83 return 'mp4a.40.2' // Fallback 85 return 'mp4a.40.2' // Fallback
84} 86}
85 87
88// ---------------------------------------------------------------------------
89// Resolutions
90// ---------------------------------------------------------------------------
91
86function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { 92function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
87 const configResolutions = type === 'vod' 93 const configResolutions = type === 'vod'
88 ? CONFIG.TRANSCODING.RESOLUTIONS 94 ? CONFIG.TRANSCODING.RESOLUTIONS
@@ -112,6 +118,10 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
112 return resolutionsEnabled 118 return resolutionsEnabled
113} 119}
114 120
121// ---------------------------------------------------------------------------
122// Can quick transcode
123// ---------------------------------------------------------------------------
124
115async function canDoQuickTranscode (path: string): Promise<boolean> { 125async function canDoQuickTranscode (path: string): Promise<boolean> {
116 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false 126 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
117 127
@@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
121 await canDoQuickAudioTranscode(path, probe) 131 await canDoQuickAudioTranscode(path, probe)
122} 132}
123 133
134async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
135 const parsedAudio = await getAudioStream(path, probe)
136
137 if (!parsedAudio.audioStream) return true
138
139 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
140
141 const audioBitrate = parsedAudio.bitrate
142 if (!audioBitrate) return false
143
144 const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
145 if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
146
147 const channelLayout = parsedAudio.audioStream['channel_layout']
148 // Causes playback issues with Chrome
149 if (!channelLayout || channelLayout === 'unknown') return false
150
151 return true
152}
153
124async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { 154async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
125 const videoStream = await getVideoStreamFromFile(path, probe) 155 const videoStream = await getVideoStream(path, probe)
126 const fps = await getVideoFileFPS(path, probe) 156 const fps = await getVideoStreamFPS(path, probe)
127 const bitRate = await getVideoFileBitrate(path, probe) 157 const bitRate = await getVideoStreamBitrate(path, probe)
128 const resolutionData = await getVideoFileResolution(path, probe) 158 const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
129 159
130 // If ffprobe did not manage to guess the bitrate 160 // If ffprobe did not manage to guess the bitrate
131 if (!bitRate) return false 161 if (!bitRate) return false
132 162
133 // check video params 163 // check video params
134 if (videoStream == null) return false 164 if (!videoStream) return false
135 if (videoStream['codec_name'] !== 'h264') return false 165 if (videoStream['codec_name'] !== 'h264') return false
136 if (videoStream['pix_fmt'] !== 'yuv420p') return false 166 if (videoStream['pix_fmt'] !== 'yuv420p') return false
137 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false 167 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
@@ -140,6 +170,10 @@ async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Pro
140 return true 170 return true
141} 171}
142 172
173// ---------------------------------------------------------------------------
174// Framerate
175// ---------------------------------------------------------------------------
176
143function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { 177function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
144 return VIDEO_TRANSCODING_FPS[type].slice(0) 178 return VIDEO_TRANSCODING_FPS[type].slice(0)
145 .sort((a, b) => fps % a - fps % b)[0] 179 .sort((a, b) => fps % a - fps % b)[0]
@@ -171,21 +205,26 @@ function computeFPS (fpsArg: number, resolution: VideoResolution) {
171// --------------------------------------------------------------------------- 205// ---------------------------------------------------------------------------
172 206
173export { 207export {
174 getVideoStreamCodec, 208 // Re export ffprobe utils
175 getAudioStreamCodec, 209 getVideoStreamDimensionsInfo,
176 getVideoStreamSize, 210 buildFileMetadata,
177 getVideoFileResolution,
178 getMetadataFromFile,
179 getMaxAudioBitrate, 211 getMaxAudioBitrate,
180 getVideoStreamFromFile, 212 getVideoStream,
181 getDurationFromVideoFile, 213 getVideoStreamDuration,
182 getAudioStream, 214 getAudioStream,
183 computeFPS, 215 hasAudioStream,
184 getVideoFileFPS, 216 getVideoStreamFPS,
185 ffprobePromise, 217 ffprobePromise,
218 getVideoStreamBitrate,
219
220 getVideoStreamCodec,
221 getAudioStreamCodec,
222
223 computeFPS,
186 getClosestFramerateStandard, 224 getClosestFramerateStandard,
225
187 computeLowerResolutionsToTranscode, 226 computeLowerResolutionsToTranscode,
188 getVideoFileBitrate, 227
189 canDoQuickTranscode, 228 canDoQuickTranscode,
190 canDoQuickVideoTranscode, 229 canDoQuickVideoTranscode,
191 canDoQuickAudioTranscode 230 canDoQuickAudioTranscode
diff --git a/server/helpers/ffmpeg/index.ts b/server/helpers/ffmpeg/index.ts
new file mode 100644
index 000000000..e3bb2013f
--- /dev/null
+++ b/server/helpers/ffmpeg/index.ts
@@ -0,0 +1,8 @@
1export * from './ffmpeg-commons'
2export * from './ffmpeg-edition'
3export * from './ffmpeg-encoders'
4export * from './ffmpeg-images'
5export * from './ffmpeg-live'
6export * from './ffmpeg-presets'
7export * from './ffmpeg-vod'
8export * from './ffprobe-utils'
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index b174ae436..6e4a2b000 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -1,9 +1,12 @@
1import { copy, readFile, remove, rename } from 'fs-extra' 1import { copy, readFile, remove, rename } from 'fs-extra'
2import Jimp, { read } from 'jimp' 2import Jimp, { read } from 'jimp'
3import { join } from 'path'
3import { getLowercaseExtension } from '@shared/core-utils' 4import { getLowercaseExtension } from '@shared/core-utils'
4import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
5import { convertWebPToJPG, processGIF } from './ffmpeg-utils' 6import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
6import { logger } from './logger' 7import { logger, loggerTagsFactory } from './logger'
8
9const lTags = loggerTagsFactory('image-utils')
7 10
8function generateImageFilename (extension = '.jpg') { 11function generateImageFilename (extension = '.jpg') {
9 return buildUUID() + extension 12 return buildUUID() + extension
@@ -33,10 +36,31 @@ async function processImage (
33 if (keepOriginal !== true) await remove(path) 36 if (keepOriginal !== true) await remove(path)
34} 37}
35 38
39async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
40 const pendingImageName = 'pending-' + imageName
41 const pendingImagePath = join(folder, pendingImageName)
42
43 try {
44 await generateThumbnailFromVideo(fromPath, folder, imageName)
45
46 const destination = join(folder, imageName)
47 await processImage(pendingImagePath, destination, size)
48 } catch (err) {
49 logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
50
51 try {
52 await remove(pendingImagePath)
53 } catch (err) {
54 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
55 }
56 }
57}
58
36// --------------------------------------------------------------------------- 59// ---------------------------------------------------------------------------
37 60
38export { 61export {
39 generateImageFilename, 62 generateImageFilename,
63 generateImageFromVideoFile,
40 processImage 64 processImage
41} 65}
42 66
diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts
index 68d532c48..88bdb16b6 100644
--- a/server/helpers/webtorrent.ts
+++ b/server/helpers/webtorrent.ts
@@ -91,6 +91,16 @@ async function downloadWebTorrentVideo (target: { uri: string, torrentName?: str
91} 91}
92 92
93function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 93function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
94 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => {
95 return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath)
96 })
97}
98
99async function createTorrentAndSetInfoHashFromPath (
100 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
101 videoFile: MVideoFile,
102 filePath: string
103) {
94 const video = extractVideo(videoOrPlaylist) 104 const video = extractVideo(videoOrPlaylist)
95 105
96 const options = { 106 const options = {
@@ -101,24 +111,22 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
101 urlList: buildUrlList(video, videoFile) 111 urlList: buildUrlList(video, videoFile)
102 } 112 }
103 113
104 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), async videoPath => { 114 const torrentContent = await createTorrentPromise(filePath, options)
105 const torrentContent = await createTorrentPromise(videoPath, options)
106 115
107 const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution) 116 const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
108 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename) 117 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
109 logger.info('Creating torrent %s.', torrentPath) 118 logger.info('Creating torrent %s.', torrentPath)
110 119
111 await writeFile(torrentPath, torrentContent) 120 await writeFile(torrentPath, torrentContent)
112 121
113 // Remove old torrent file if it existed 122 // Remove old torrent file if it existed
114 if (videoFile.hasTorrent()) { 123 if (videoFile.hasTorrent()) {
115 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename)) 124 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
116 } 125 }
117 126
118 const parsedTorrent = parseTorrent(torrentContent) 127 const parsedTorrent = parseTorrent(torrentContent)
119 videoFile.infoHash = parsedTorrent.infoHash 128 videoFile.infoHash = parsedTorrent.infoHash
120 videoFile.torrentFilename = torrentFilename 129 videoFile.torrentFilename = torrentFilename
121 })
122} 130}
123 131
124async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 132async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
@@ -177,7 +185,10 @@ function generateMagnetUri (
177export { 185export {
178 createTorrentPromise, 186 createTorrentPromise,
179 updateTorrentMetadata, 187 updateTorrentMetadata,
188
180 createTorrentAndSetInfoHash, 189 createTorrentAndSetInfoHash,
190 createTorrentAndSetInfoHashFromPath,
191
181 generateMagnetUri, 192 generateMagnetUri,
182 downloadWebTorrentVideo 193 downloadWebTorrentVideo
183} 194}