diff options
Diffstat (limited to 'server/helpers')
21 files changed, 1414 insertions, 929 deletions
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index fe721cbac..cbba2f51c 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts | |||
@@ -38,6 +38,9 @@ function getContextData (type: ContextType) { | |||
38 | sensitive: 'as:sensitive', | 38 | sensitive: 'as:sensitive', |
39 | language: 'sc:inLanguage', | 39 | language: 'sc:inLanguage', |
40 | 40 | ||
41 | // TODO: remove in a few versions, introduced in 4.2 | ||
42 | icons: 'as:icon', | ||
43 | |||
41 | isLiveBroadcast: 'sc:isLiveBroadcast', | 44 | isLiveBroadcast: 'sc:isLiveBroadcast', |
42 | liveSaveReplay: { | 45 | liveSaveReplay: { |
43 | '@type': 'sc:Boolean', | 46 | '@type': 'sc:Boolean', |
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 | ||
2 | import { UploadFilesForCheck } from 'express' | ||
2 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | 3 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' |
3 | import { isFileValid } from './misc' | 4 | import { 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('|') |
8 | const imageMimeTypesRegex = `image/(${imageMimeTypes})` | 9 | const imageMimeTypesRegex = `image/(${imageMimeTypes})` |
9 | function 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) | 11 | function 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 | ||
64 | function isFileFieldValid ( | 64 | function 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 | ||
85 | function 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 | |||
108 | function 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 | |||
99 | function 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 @@ | |||
1 | import { getFileSize } from '@shared/extra-utils' | 1 | import { UploadFilesForCheck } from 'express' |
2 | import { readFile } from 'fs-extra' | 2 | import { readFile } from 'fs-extra' |
3 | import { getFileSize } from '@shared/extra-utils' | ||
3 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' |
4 | import { exists, isFileValid } from './misc' | 5 | import { 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('|') |
14 | function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { | 15 | function 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 | ||
18 | async function isVTTFileValid (filePath: string) { | 24 | async 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 @@ | |||
1 | import validator from 'validator' | ||
2 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' | ||
3 | import { buildTaskFileFieldname } from '@server/lib/video-editor' | ||
4 | import { VideoEditorTask } from '@shared/models' | ||
5 | import { isArray } from './misc' | ||
6 | import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos' | ||
7 | |||
8 | function 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 | |||
15 | function 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 | |||
30 | function 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 | |||
37 | function 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 | |||
46 | export { | ||
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 @@ | |||
1 | import 'multer' | 1 | import 'multer' |
2 | import { UploadFilesForCheck } from 'express' | ||
2 | import validator from 'validator' | 3 | import validator from 'validator' |
3 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' | 4 | import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' |
4 | import { exists, isFileValid } from './misc' | 5 | import { 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('|') |
28 | function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { | 29 | function 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' |
16 | import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' | 16 | import { exists, isArray, isDateValid, isFileValid } from './misc' |
17 | 17 | ||
18 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS | 18 | const 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 | ||
69 | function isVideoTagsValid (tags: string[]) { | 69 | function 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 | ||
89 | function isVideoFileMimeTypeValid (files: UploadFilesForCheck) { | 89 | function 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 | ||
93 | const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME | 98 | const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME |
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME | |||
95 | .join('|') | 100 | .join('|') |
96 | const videoImageTypesRegex = `image/(${videoImageTypes})` | 101 | const videoImageTypesRegex = `image/(${videoImageTypes})` |
97 | 102 | ||
98 | function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { | 103 | function 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 | ||
102 | function isVideoPrivacyValid (value: number) { | 113 | function 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..82dd4c178 100644 --- a/server/helpers/express-utils.ts +++ b/server/helpers/express-utils.ts | |||
@@ -1,9 +1,9 @@ | |||
1 | import express, { RequestHandler } from 'express' | 1 | import express, { RequestHandler } from 'express' |
2 | import multer, { diskStorage } from 'multer' | 2 | import multer, { diskStorage } from 'multer' |
3 | import { getLowercaseExtension } from '@shared/core-utils' | ||
3 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' | 4 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' |
4 | import { CONFIG } from '../initializers/config' | 5 | import { CONFIG } from '../initializers/config' |
5 | import { REMOTE_SCHEME } from '../initializers/constants' | 6 | import { REMOTE_SCHEME } from '../initializers/constants' |
6 | import { getLowercaseExtension } from '@shared/core-utils' | ||
7 | import { isArray } from './custom-validators/misc' | 7 | import { isArray } from './custom-validators/misc' |
8 | import { logger } from './logger' | 8 | import { logger } from './logger' |
9 | import { deleteFileAndCatch, generateRandomString } from './utils' | 9 | import { deleteFileAndCatch, generateRandomString } from './utils' |
@@ -68,36 +68,15 @@ function badRequest (_req: express.Request, res: express.Response) { | |||
68 | function createReqFiles ( | 68 | function createReqFiles ( |
69 | fieldNames: string[], | 69 | fieldNames: string[], |
70 | mimeTypes: { [id: string]: string | string[] }, | 70 | mimeTypes: { [id: string]: string | string[] }, |
71 | destinations: { [fieldName: string]: string } | 71 | destination = CONFIG.STORAGE.TMP_DIR |
72 | ): RequestHandler { | 72 | ): RequestHandler { |
73 | const storage = diskStorage({ | 73 | const storage = diskStorage({ |
74 | destination: (req, file, cb) => { | 74 | destination: (req, file, cb) => { |
75 | cb(null, destinations[file.fieldname]) | 75 | cb(null, destination) |
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,23 @@ function createReqFiles ( | |||
112 | return multer({ storage }).fields(fields) | 91 | return multer({ storage }).fields(fields) |
113 | } | 92 | } |
114 | 93 | ||
94 | function createAnyReqFiles ( | ||
95 | mimeTypes: { [id: string]: string | string[] }, | ||
96 | fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void | ||
97 | ): RequestHandler { | ||
98 | const storage = diskStorage({ | ||
99 | destination: (req, file, cb) => { | ||
100 | cb(null, CONFIG.STORAGE.TMP_DIR) | ||
101 | }, | ||
102 | |||
103 | filename: (req, file, cb) => { | ||
104 | return generateReqFilename(file, mimeTypes, cb) | ||
105 | } | ||
106 | }) | ||
107 | |||
108 | return multer({ storage, fileFilter }).any() | ||
109 | } | ||
110 | |||
115 | function isUserAbleToSearchRemoteURI (res: express.Response) { | 111 | function isUserAbleToSearchRemoteURI (res: express.Response) { |
116 | const user = res.locals.oauth ? res.locals.oauth.token.User : undefined | 112 | const user = res.locals.oauth ? res.locals.oauth.token.User : undefined |
117 | 113 | ||
@@ -128,9 +124,41 @@ function getCountVideos (req: express.Request) { | |||
128 | export { | 124 | export { |
129 | buildNSFWFilter, | 125 | buildNSFWFilter, |
130 | getHostWithPort, | 126 | getHostWithPort, |
127 | createAnyReqFiles, | ||
131 | isUserAbleToSearchRemoteURI, | 128 | isUserAbleToSearchRemoteURI, |
132 | badRequest, | 129 | badRequest, |
133 | createReqFiles, | 130 | createReqFiles, |
134 | cleanUpReqFiles, | 131 | cleanUpReqFiles, |
135 | getCountVideos | 132 | getCountVideos |
136 | } | 133 | } |
134 | |||
135 | // --------------------------------------------------------------------------- | ||
136 | |||
137 | async function generateReqFilename ( | ||
138 | file: Express.Multer.File, | ||
139 | mimeTypes: { [id: string]: string | string[] }, | ||
140 | cb: (err: Error, name: string) => void | ||
141 | ) { | ||
142 | let extension: string | ||
143 | const fileExtension = getLowercaseExtension(file.originalname) | ||
144 | const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype) | ||
145 | |||
146 | // Take the file extension if we don't understand the mime type | ||
147 | if (!extensionFromMimetype) { | ||
148 | extension = fileExtension | ||
149 | } else { | ||
150 | // Take the first available extension for this mimetype | ||
151 | extension = extensionFromMimetype | ||
152 | } | ||
153 | |||
154 | let randomString = '' | ||
155 | |||
156 | try { | ||
157 | randomString = await generateRandomString(16) | ||
158 | } catch (err) { | ||
159 | logger.error('Cannot generate random string for file name.', { err }) | ||
160 | randomString = 'fake-random-string' | ||
161 | } | ||
162 | |||
163 | cb(null, randomString + extension) | ||
164 | } | ||
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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg' | ||
3 | import { readFile, remove, writeFile } from 'fs-extra' | ||
4 | import { dirname, join } from 'path' | ||
5 | import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants' | ||
6 | import { pick } from '@shared/core-utils' | ||
7 | import { | ||
8 | AvailableEncoders, | ||
9 | EncoderOptions, | ||
10 | EncoderOptionsBuilder, | ||
11 | EncoderOptionsBuilderParams, | ||
12 | EncoderProfile, | ||
13 | VideoResolution | ||
14 | } from '../../shared/models/videos' | ||
15 | import { CONFIG } from '../initializers/config' | ||
16 | import { execPromise, promisify0 } from './core-utils' | ||
17 | import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils' | ||
18 | import { processImage } from './image-utils' | ||
19 | import { logger, loggerTagsFactory } from './logger' | ||
20 | |||
21 | const 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 | |||
34 | type StreamType = 'audio' | 'video' | ||
35 | |||
36 | // --------------------------------------------------------------------------- | ||
37 | // Encoders support | ||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | // Detect supported encoders by ffmpeg | ||
41 | let supportedEncoders: Map<string, boolean> | ||
42 | async 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 | |||
70 | function resetSupportedEncoders () { | ||
71 | supportedEncoders = undefined | ||
72 | } | ||
73 | |||
74 | // --------------------------------------------------------------------------- | ||
75 | // Image manipulation | ||
76 | // --------------------------------------------------------------------------- | ||
77 | |||
78 | function 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 | |||
85 | function 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 | |||
98 | async 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 | |||
134 | type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
135 | |||
136 | interface 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 | |||
152 | interface HLSTranscodeOptions extends BaseTranscodeOptions { | ||
153 | type: 'hls' | ||
154 | copyCodecs: boolean | ||
155 | hlsPlaylist: { | ||
156 | videoFilename: string | ||
157 | } | ||
158 | } | ||
159 | |||
160 | interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions { | ||
161 | type: 'hls-from-ts' | ||
162 | |||
163 | isAAC: boolean | ||
164 | |||
165 | hlsPlaylist: { | ||
166 | videoFilename: string | ||
167 | } | ||
168 | } | ||
169 | |||
170 | interface QuickTranscodeOptions extends BaseTranscodeOptions { | ||
171 | type: 'quick-transcode' | ||
172 | } | ||
173 | |||
174 | interface VideoTranscodeOptions extends BaseTranscodeOptions { | ||
175 | type: 'video' | ||
176 | } | ||
177 | |||
178 | interface MergeAudioTranscodeOptions extends BaseTranscodeOptions { | ||
179 | type: 'merge-audio' | ||
180 | audioPath: string | ||
181 | } | ||
182 | |||
183 | interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions { | ||
184 | type: 'only-audio' | ||
185 | } | ||
186 | |||
187 | type TranscodeOptions = | ||
188 | HLSTranscodeOptions | ||
189 | | HLSFromTSTranscodeOptions | ||
190 | | VideoTranscodeOptions | ||
191 | | MergeAudioTranscodeOptions | ||
192 | | OnlyAudioTranscodeOptions | ||
193 | | QuickTranscodeOptions | ||
194 | |||
195 | const 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 | |||
206 | async 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 | |||
223 | async 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 | |||
337 | function 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 | |||
350 | function 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 | |||
362 | function 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 | |||
375 | function 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 | |||
396 | function 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 | |||
411 | async 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 | |||
428 | async 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 | |||
443 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
444 | command = presetOnlyAudio(command) | ||
445 | |||
446 | return command | ||
447 | } | ||
448 | |||
449 | function 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 | |||
458 | function 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 | |||
468 | async 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 | |||
480 | function 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 | |||
496 | async 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 | |||
511 | function 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 | ||
522 | async 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 | |||
576 | async 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 | |||
647 | function presetCopy (command: FfmpegCommand): FfmpegCommand { | ||
648 | return command | ||
649 | .format('mp4') | ||
650 | .videoCodec('copy') | ||
651 | .audioCodec('copy') | ||
652 | } | ||
653 | |||
654 | function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand { | ||
655 | return command | ||
656 | .format('mp4') | ||
657 | .audioCodec('copy') | ||
658 | .noVideo() | ||
659 | } | ||
660 | |||
661 | function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand { | ||
662 | return command | ||
663 | .inputOptions(options.inputOptions ?? []) | ||
664 | .outputOptions(options.outputOptions ?? []) | ||
665 | } | ||
666 | |||
667 | function getScaleFilter (options: EncoderOptions): string { | ||
668 | if (options.scaleFilter) return options.scaleFilter.name | ||
669 | |||
670 | return 'scale' | ||
671 | } | ||
672 | |||
673 | // --------------------------------------------------------------------------- | ||
674 | // Utils | ||
675 | // --------------------------------------------------------------------------- | ||
676 | |||
677 | function 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 | |||
696 | function 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 | |||
720 | async 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 | ||
758 | function getScaleCleanerValue () { | ||
759 | return 'trunc(iw/2)*2:trunc(ih/2)*2' | ||
760 | } | ||
761 | |||
762 | // --------------------------------------------------------------------------- | ||
763 | |||
764 | export { | ||
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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { execPromise } from '@server/helpers/core-utils' | ||
4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
7 | import { EncoderOptions } from '@shared/models' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | type StreamType = 'audio' | 'video' | ||
12 | |||
13 | function 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 | |||
32 | function 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 | |||
56 | async 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 | |||
93 | function buildStreamSuffix (base: string, streamNum?: number) { | ||
94 | if (streamNum !== undefined) { | ||
95 | return `${base}:${streamNum}` | ||
96 | } | ||
97 | |||
98 | return base | ||
99 | } | ||
100 | |||
101 | function getScaleFilter (options: EncoderOptions): string { | ||
102 | if (options.scaleFilter) return options.scaleFilter.name | ||
103 | |||
104 | return 'scale' | ||
105 | } | ||
106 | |||
107 | export { | ||
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 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { VIDEO_FILTERS } from '@server/initializers/constants' | ||
3 | import { AvailableEncoders } from '@shared/models' | ||
4 | import { logger, loggerTagsFactory } from '../logger' | ||
5 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
6 | import { presetCopy, presetVOD } from './ffmpeg-presets' | ||
7 | import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | async 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 | |||
37 | async 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 | |||
95 | async 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 | |||
238 | export { | ||
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 @@ | |||
1 | import { getAvailableEncoders } from 'fluent-ffmpeg' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models' | ||
4 | import { promisify0 } from '../core-utils' | ||
5 | import { logger, loggerTagsFactory } from '../logger' | ||
6 | |||
7 | const lTags = loggerTagsFactory('ffmpeg') | ||
8 | |||
9 | // Detect supported encoders by ffmpeg | ||
10 | let supportedEncoders: Map<string, boolean> | ||
11 | async 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 | |||
39 | function 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 | ||
46 | async 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 | |||
111 | export { | ||
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 @@ | |||
1 | import ffmpeg from 'fluent-ffmpeg' | ||
2 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
3 | import { runCommand } from './ffmpeg-commons' | ||
4 | |||
5 | function 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 | |||
12 | function 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 | |||
25 | async 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 | |||
42 | export { | ||
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 @@ | |||
1 | import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { join } from 'path' | ||
3 | import { VIDEO_LIVE } from '@server/initializers/constants' | ||
4 | import { AvailableEncoders } from '@shared/models' | ||
5 | import { logger, loggerTagsFactory } from '../logger' | ||
6 | import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons' | ||
7 | import { getEncoderBuilderResult } from './ffmpeg-encoders' | ||
8 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets' | ||
9 | import { computeFPS } from './ffprobe-utils' | ||
10 | |||
11 | const lTags = loggerTagsFactory('ffmpeg') | ||
12 | |||
13 | async 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 | |||
130 | function 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 | |||
145 | export { | ||
146 | getLiveTranscodingCommand, | ||
147 | getLiveMuxingCommand | ||
148 | } | ||
149 | |||
150 | // --------------------------------------------------------------------------- | ||
151 | |||
152 | function 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 @@ | |||
1 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { pick } from 'lodash' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { AvailableEncoders, EncoderOptions } from '@shared/models' | ||
5 | import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons' | ||
6 | import { getEncoderBuilderResult } from './ffmpeg-encoders' | ||
7 | import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | // --------------------------------------------------------------------------- | ||
12 | |||
13 | function 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 | |||
22 | function 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 | |||
46 | async 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 | |||
125 | function presetCopy (command: FfmpegCommand): FfmpegCommand { | ||
126 | return command | ||
127 | .format('mp4') | ||
128 | .videoCodec('copy') | ||
129 | .audioCodec('copy') | ||
130 | } | ||
131 | |||
132 | function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand { | ||
133 | return command | ||
134 | .format('mp4') | ||
135 | .audioCodec('copy') | ||
136 | .noVideo() | ||
137 | } | ||
138 | |||
139 | function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand { | ||
140 | return command | ||
141 | .inputOptions(options.inputOptions ?? []) | ||
142 | .outputOptions(options.outputOptions ?? []) | ||
143 | } | ||
144 | |||
145 | // --------------------------------------------------------------------------- | ||
146 | |||
147 | export { | ||
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 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { readFile, writeFile } from 'fs-extra' | ||
4 | import { dirname } from 'path' | ||
5 | import { pick } from '@shared/core-utils' | ||
6 | import { AvailableEncoders, VideoResolution } from '@shared/models' | ||
7 | import { logger, loggerTagsFactory } from '../logger' | ||
8 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
9 | import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets' | ||
10 | import { computeFPS, getVideoStreamFPS } from './ffprobe-utils' | ||
11 | import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' | ||
12 | |||
13 | const lTags = loggerTagsFactory('ffmpeg') | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
18 | |||
19 | interface 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 | |||
35 | interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | ||
36 | type: 'hls' | ||
37 | copyCodecs: boolean | ||
38 | hlsPlaylist: { | ||
39 | videoFilename: string | ||
40 | } | ||
41 | } | ||
42 | |||
43 | interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | ||
44 | type: 'hls-from-ts' | ||
45 | |||
46 | isAAC: boolean | ||
47 | |||
48 | hlsPlaylist: { | ||
49 | videoFilename: string | ||
50 | } | ||
51 | } | ||
52 | |||
53 | interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | ||
54 | type: 'quick-transcode' | ||
55 | } | ||
56 | |||
57 | interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | ||
58 | type: 'video' | ||
59 | } | ||
60 | |||
61 | interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
62 | type: 'merge-audio' | ||
63 | audioPath: string | ||
64 | } | ||
65 | |||
66 | interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
67 | type: 'only-audio' | ||
68 | } | ||
69 | |||
70 | type TranscodeVODOptions = | ||
71 | HLSTranscodeOptions | ||
72 | | HLSFromTSTranscodeOptions | ||
73 | | VideoTranscodeOptions | ||
74 | | MergeAudioTranscodeOptions | ||
75 | | OnlyAudioTranscodeOptions | ||
76 | | QuickTranscodeOptions | ||
77 | |||
78 | // --------------------------------------------------------------------------- | ||
79 | |||
80 | const 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 | |||
91 | async 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 | |||
106 | export { | ||
107 | transcodeVOD, | ||
108 | |||
109 | buildVODCommand, | ||
110 | |||
111 | TranscodeVODOptions, | ||
112 | TranscodeVODOptionsType | ||
113 | } | ||
114 | |||
115 | // --------------------------------------------------------------------------- | ||
116 | |||
117 | async 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 | |||
143 | function 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 | |||
156 | async 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 | |||
180 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
181 | command = presetOnlyAudio(command) | ||
182 | |||
183 | return command | ||
184 | } | ||
185 | |||
186 | // --------------------------------------------------------------------------- | ||
187 | // HLS transcoding | ||
188 | // --------------------------------------------------------------------------- | ||
189 | |||
190 | async 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 | |||
202 | function 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 | |||
218 | function 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 | |||
228 | async 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 | |||
247 | function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { | ||
248 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
249 | } | ||
250 | |||
251 | // Avoid "height not divisible by 2" error | ||
252 | function 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 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | 1 | import { FfprobeData } from 'fluent-ffmpeg' |
2 | import { getMaxBitrate } from '@shared/core-utils' | 2 | import { getMaxBitrate } from '@shared/core-utils' |
3 | import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos' | ||
4 | import { CONFIG } from '../initializers/config' | ||
5 | import { VIDEO_TRANSCODING_FPS } from '../initializers/constants' | ||
6 | import { logger } from './logger' | ||
7 | import { | 3 | import { |
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' |
15 | import { VideoResolution, VideoTranscodingFPS } from '@shared/models' | ||
16 | import { CONFIG } from '../../initializers/config' | ||
17 | import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants' | ||
18 | import { logger } from '../logger' | ||
20 | 19 | ||
21 | /** | 20 | /** |
22 | * | 21 | * |
@@ -24,9 +23,12 @@ import { | |||
24 | * | 23 | * |
25 | */ | 24 | */ |
26 | 25 | ||
27 | async function getVideoStreamCodec (path: string) { | 26 | // --------------------------------------------------------------------------- |
28 | const videoStream = await getVideoStreamFromFile(path) | 27 | // Codecs |
28 | // --------------------------------------------------------------------------- | ||
29 | 29 | ||
30 | async 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 | |||
86 | function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { | 92 | function 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 | |||
115 | async function canDoQuickTranscode (path: string): Promise<boolean> { | 125 | async 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 | ||
134 | async 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 | |||
124 | async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { | 154 | async 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 | |||
143 | function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { | 177 | function 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 | ||
173 | export { | 207 | export { |
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 @@ | |||
1 | export * from './ffmpeg-commons' | ||
2 | export * from './ffmpeg-edition' | ||
3 | export * from './ffmpeg-encoders' | ||
4 | export * from './ffmpeg-images' | ||
5 | export * from './ffmpeg-live' | ||
6 | export * from './ffmpeg-presets' | ||
7 | export * from './ffmpeg-vod' | ||
8 | export * from './ffprobe-utils' | ||
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts index b174ae436..9d0c09051 100644 --- a/server/helpers/image-utils.ts +++ b/server/helpers/image-utils.ts | |||
@@ -1,9 +1,12 @@ | |||
1 | import { copy, readFile, remove, rename } from 'fs-extra' | 1 | import { copy, readFile, remove, rename } from 'fs-extra' |
2 | import Jimp, { read } from 'jimp' | 2 | import Jimp, { read as jimpRead } from 'jimp' |
3 | import { join } from 'path' | ||
3 | import { getLowercaseExtension } from '@shared/core-utils' | 4 | import { getLowercaseExtension } from '@shared/core-utils' |
4 | import { buildUUID } from '@shared/extra-utils' | 5 | import { buildUUID } from '@shared/extra-utils' |
5 | import { convertWebPToJPG, processGIF } from './ffmpeg-utils' | 6 | import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images' |
6 | import { logger } from './logger' | 7 | import { logger, loggerTagsFactory } from './logger' |
8 | |||
9 | const lTags = loggerTagsFactory('image-utils') | ||
7 | 10 | ||
8 | function generateImageFilename (extension = '.jpg') { | 11 | function generateImageFilename (extension = '.jpg') { |
9 | return buildUUID() + extension | 12 | return buildUUID() + extension |
@@ -33,11 +36,46 @@ async function processImage ( | |||
33 | if (keepOriginal !== true) await remove(path) | 36 | if (keepOriginal !== true) await remove(path) |
34 | } | 37 | } |
35 | 38 | ||
39 | async 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 | |||
59 | async function getImageSize (path: string) { | ||
60 | const inputBuffer = await readFile(path) | ||
61 | |||
62 | const image = await jimpRead(inputBuffer) | ||
63 | |||
64 | return { | ||
65 | width: image.getWidth(), | ||
66 | height: image.getHeight() | ||
67 | } | ||
68 | } | ||
69 | |||
36 | // --------------------------------------------------------------------------- | 70 | // --------------------------------------------------------------------------- |
37 | 71 | ||
38 | export { | 72 | export { |
39 | generateImageFilename, | 73 | generateImageFilename, |
40 | processImage | 74 | generateImageFromVideoFile, |
75 | |||
76 | processImage, | ||
77 | |||
78 | getImageSize | ||
41 | } | 79 | } |
42 | 80 | ||
43 | // --------------------------------------------------------------------------- | 81 | // --------------------------------------------------------------------------- |
@@ -47,7 +85,7 @@ async function jimpProcessor (path: string, destination: string, newSize: { widt | |||
47 | const inputBuffer = await readFile(path) | 85 | const inputBuffer = await readFile(path) |
48 | 86 | ||
49 | try { | 87 | try { |
50 | sourceImage = await read(inputBuffer) | 88 | sourceImage = await jimpRead(inputBuffer) |
51 | } catch (err) { | 89 | } catch (err) { |
52 | logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err }) | 90 | logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err }) |
53 | 91 | ||
@@ -55,7 +93,7 @@ async function jimpProcessor (path: string, destination: string, newSize: { widt | |||
55 | await convertWebPToJPG(path, newName) | 93 | await convertWebPToJPG(path, newName) |
56 | await rename(newName, path) | 94 | await rename(newName, path) |
57 | 95 | ||
58 | sourceImage = await read(path) | 96 | sourceImage = await jimpRead(path) |
59 | } | 97 | } |
60 | 98 | ||
61 | await remove(destination) | 99 | await remove(destination) |
diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts index 25685ec6d..41c1186ec 100644 --- a/server/helpers/markdown.ts +++ b/server/helpers/markdown.ts | |||
@@ -8,7 +8,7 @@ const markdownItEmoji = require('markdown-it-emoji/light') | |||
8 | const MarkdownItClass = require('markdown-it') | 8 | const MarkdownItClass = require('markdown-it') |
9 | 9 | ||
10 | const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) | 10 | const markdownItWithHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: true }) |
11 | const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: true, breaks: true, html: false }) | 11 | const markdownItWithoutHTML = new MarkdownItClass('default', { linkify: false, breaks: true, html: false }) |
12 | 12 | ||
13 | const toSafeHtml = (text: string) => { | 13 | const toSafeHtml = (text: string) => { |
14 | if (!text) return '' | 14 | if (!text) return '' |
@@ -66,7 +66,7 @@ function plainTextPlugin (markdownIt: any) { | |||
66 | 66 | ||
67 | if (token.type === 'list_item_close') { | 67 | if (token.type === 'list_item_close') { |
68 | lastSeparator = ', ' | 68 | lastSeparator = ', ' |
69 | } else if (/[a-zA-Z]+_close/.test(token.type)) { | 69 | } else if (token.type.endsWith('_close')) { |
70 | lastSeparator = ' ' | 70 | lastSeparator = ' ' |
71 | } else if (token.content) { | 71 | } else if (token.content) { |
72 | text += lastSeparator | 72 | text += lastSeparator |
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 | ||
93 | function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { | 93 | function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { |
94 | return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => { | ||
95 | return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath) | ||
96 | }) | ||
97 | } | ||
98 | |||
99 | async 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 | ||
124 | async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { | 132 | async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { |
@@ -177,7 +185,10 @@ function generateMagnetUri ( | |||
177 | export { | 185 | export { |
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 | } |