aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/helpers
diff options
context:
space:
mode:
Diffstat (limited to 'server/helpers')
-rw-r--r--server/helpers/custom-validators/accounts.ts11
-rw-r--r--server/helpers/custom-validators/activitypub/video-comments.ts3
-rw-r--r--server/helpers/custom-validators/video-channels.ts9
-rw-r--r--server/helpers/express-utils.ts13
-rw-r--r--server/helpers/ffmpeg-utils.ts180
5 files changed, 136 insertions, 80 deletions
diff --git a/server/helpers/custom-validators/accounts.ts b/server/helpers/custom-validators/accounts.ts
index 146c7708e..31a2de5ca 100644
--- a/server/helpers/custom-validators/accounts.ts
+++ b/server/helpers/custom-validators/accounts.ts
@@ -1,7 +1,6 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { Response } from 'express' 2import { Response } from 'express'
3import 'express-validator' 3import 'express-validator'
4import * as validator from 'validator'
5import { AccountModel } from '../../models/account/account' 4import { AccountModel } from '../../models/account/account'
6import { isUserDescriptionValid, isUserUsernameValid } from './users' 5import { isUserDescriptionValid, isUserUsernameValid } from './users'
7import { exists } from './misc' 6import { exists } from './misc'
@@ -18,14 +17,8 @@ function isAccountDescriptionValid (value: string) {
18 return isUserDescriptionValid(value) 17 return isUserDescriptionValid(value)
19} 18}
20 19
21function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { 20function doesAccountIdExist (id: number, res: Response, sendNotFound = true) {
22 let promise: Bluebird<AccountModel> 21 const promise = AccountModel.load(id)
23
24 if (validator.isInt('' + id)) {
25 promise = AccountModel.load(+id)
26 } else { // UUID
27 promise = AccountModel.loadByUUID('' + id)
28 }
29 22
30 return doesAccountExist(promise, res, sendNotFound) 23 return doesAccountExist(promise, res, sendNotFound)
31} 24}
diff --git a/server/helpers/custom-validators/activitypub/video-comments.ts b/server/helpers/custom-validators/activitypub/video-comments.ts
index 26c8c4cc6..e04c5388f 100644
--- a/server/helpers/custom-validators/activitypub/video-comments.ts
+++ b/server/helpers/custom-validators/activitypub/video-comments.ts
@@ -36,7 +36,8 @@ function normalizeComment (comment: any) {
36 if (!comment) return 36 if (!comment) return
37 37
38 if (typeof comment.url !== 'string') { 38 if (typeof comment.url !== 'string') {
39 comment.url = comment.url.href || comment.url.url 39 if (typeof comment.url === 'object') comment.url = comment.url.href || comment.url.url
40 else comment.url = comment.id
40 } 41 }
41 42
42 return 43 return
diff --git a/server/helpers/custom-validators/video-channels.ts b/server/helpers/custom-validators/video-channels.ts
index fd56b9a70..f818ce8f1 100644
--- a/server/helpers/custom-validators/video-channels.ts
+++ b/server/helpers/custom-validators/video-channels.ts
@@ -26,13 +26,8 @@ async function doesLocalVideoChannelNameExist (name: string, res: express.Respon
26 return processVideoChannelExist(videoChannel, res) 26 return processVideoChannelExist(videoChannel, res)
27} 27}
28 28
29async function doesVideoChannelIdExist (id: number | string, res: express.Response) { 29async function doesVideoChannelIdExist (id: number, res: express.Response) {
30 let videoChannel: VideoChannelModel 30 const videoChannel = await VideoChannelModel.loadAndPopulateAccount(+id)
31 if (validator.isInt('' + id)) {
32 videoChannel = await VideoChannelModel.loadAndPopulateAccount(+id)
33 } else { // UUID
34 videoChannel = await VideoChannelModel.loadByUUIDAndPopulateAccount('' + id)
35 }
36 31
37 return processVideoChannelExist(videoChannel, res) 32 return processVideoChannelExist(videoChannel, res)
38} 33}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index e0a1d56a5..00f3f198b 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -74,7 +74,18 @@ function createReqFiles (
74 }, 74 },
75 75
76 filename: async (req, file, cb) => { 76 filename: async (req, file, cb) => {
77 const extension = mimeTypes[ file.mimetype ] || extname(file.originalname) 77 let extension: string
78 const fileExtension = extname(file.originalname)
79 const extensionFromMimetype = mimeTypes[ file.mimetype ]
80
81 // Take the file extension if we don't understand the mime type
82 // We have the OGG/OGV exception too because firefox sends a bad mime type when sending an OGG file
83 if (fileExtension === '.ogg' || fileExtension === '.ogv' || !extensionFromMimetype) {
84 extension = fileExtension
85 } else {
86 extension = extensionFromMimetype
87 }
88
78 let randomString = '' 89 let randomString = ''
79 90
80 try { 91 try {
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 76b744de8..8041e7b3b 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,6 +1,6 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { dirname, join } from 'path' 2import { dirname, join } from 'path'
3import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' 3import { getTargetBitrate, getMaxBitrate, VideoResolution } from '../../shared/models/videos'
4import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' 4import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
5import { processImage } from './image-utils' 5import { processImage } from './image-utils'
6import { logger } from './logger' 6import { logger } from './logger'
@@ -18,7 +18,8 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
18 VideoResolution.H_360P, 18 VideoResolution.H_360P,
19 VideoResolution.H_720P, 19 VideoResolution.H_720P,
20 VideoResolution.H_240P, 20 VideoResolution.H_240P,
21 VideoResolution.H_1080P 21 VideoResolution.H_1080P,
22 VideoResolution.H_4K
22 ] 23 ]
23 24
24 for (const resolution of resolutions) { 25 for (const resolution of resolutions) {
@@ -31,7 +32,7 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
31} 32}
32 33
33async function getVideoFileSize (path: string) { 34async function getVideoFileSize (path: string) {
34 const videoStream = await getVideoFileStream(path) 35 const videoStream = await getVideoStreamFromFile(path)
35 36
36 return { 37 return {
37 width: videoStream.width, 38 width: videoStream.width,
@@ -49,7 +50,7 @@ async function getVideoFileResolution (path: string) {
49} 50}
50 51
51async function getVideoFileFPS (path: string) { 52async function getVideoFileFPS (path: string) {
52 const videoStream = await getVideoFileStream(path) 53 const videoStream = await getVideoStreamFromFile(path)
53 54
54 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { 55 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
55 const valuesText: string = videoStream[key] 56 const valuesText: string = videoStream[key]
@@ -117,25 +118,50 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
117 } 118 }
118} 119}
119 120
120type TranscodeOptions = { 121type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio'
122
123interface BaseTranscodeOptions {
124 type: TranscodeOptionsType
121 inputPath: string 125 inputPath: string
122 outputPath: string 126 outputPath: string
123 resolution: VideoResolution 127 resolution: VideoResolution
124 isPortraitMode?: boolean 128 isPortraitMode?: boolean
129}
125 130
126 hlsPlaylist?: { 131interface HLSTranscodeOptions extends BaseTranscodeOptions {
132 type: 'hls'
133 hlsPlaylist: {
127 videoFilename: string 134 videoFilename: string
128 } 135 }
129} 136}
130 137
138interface QuickTranscodeOptions extends BaseTranscodeOptions {
139 type: 'quick-transcode'
140}
141
142interface VideoTranscodeOptions extends BaseTranscodeOptions {
143 type: 'video'
144}
145
146interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
147 type: 'merge-audio'
148 audioPath: string
149}
150
151type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions
152
131function transcode (options: TranscodeOptions) { 153function transcode (options: TranscodeOptions) {
132 return new Promise<void>(async (res, rej) => { 154 return new Promise<void>(async (res, rej) => {
133 try { 155 try {
134 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) 156 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
135 .output(options.outputPath) 157 .output(options.outputPath)
136 158
137 if (options.hlsPlaylist) { 159 if (options.type === 'quick-transcode') {
160 command = await buildQuickTranscodeCommand(command)
161 } else if (options.type === 'hls') {
138 command = await buildHLSCommand(command, options) 162 command = await buildHLSCommand(command, options)
163 } else if (options.type === 'merge-audio') {
164 command = await buildAudioMergeCommand(command, options)
139 } else { 165 } else {
140 command = await buildx264Command(command, options) 166 command = await buildx264Command(command, options)
141 } 167 }
@@ -151,7 +177,7 @@ function transcode (options: TranscodeOptions) {
151 return rej(err) 177 return rej(err)
152 }) 178 })
153 .on('end', () => { 179 .on('end', () => {
154 return onTranscodingSuccess(options) 180 return fixHLSPlaylistIfNeeded(options)
155 .then(() => res()) 181 .then(() => res())
156 .catch(err => rej(err)) 182 .catch(err => rej(err))
157 }) 183 })
@@ -162,6 +188,30 @@ function transcode (options: TranscodeOptions) {
162 }) 188 })
163} 189}
164 190
191async function canDoQuickTranscode (path: string): Promise<boolean> {
192 // NOTE: This could be optimized by running ffprobe only once (but it runs fast anyway)
193 const videoStream = await getVideoStreamFromFile(path)
194 const parsedAudio = await audio.get(path)
195 const fps = await getVideoFileFPS(path)
196 const bitRate = await getVideoFileBitrate(path)
197 const resolution = await getVideoFileResolution(path)
198
199 // check video params
200 if (videoStream[ 'codec_name' ] !== 'h264') return false
201 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
202 if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false
203
204 // check audio params (if audio stream exists)
205 if (parsedAudio.audioStream) {
206 if (parsedAudio.audioStream[ 'codec_name' ] !== 'aac') return false
207
208 const maxAudioBitrate = audio.bitrate[ 'aac' ](parsedAudio.audioStream[ 'bit_rate' ])
209 if (maxAudioBitrate !== -1 && parsedAudio.audioStream[ 'bit_rate' ] > maxAudioBitrate) return false
210 }
211
212 return true
213}
214
165// --------------------------------------------------------------------------- 215// ---------------------------------------------------------------------------
166 216
167export { 217export {
@@ -169,16 +219,19 @@ export {
169 getVideoFileResolution, 219 getVideoFileResolution,
170 getDurationFromVideoFile, 220 getDurationFromVideoFile,
171 generateImageFromVideoFile, 221 generateImageFromVideoFile,
222 TranscodeOptions,
223 TranscodeOptionsType,
172 transcode, 224 transcode,
173 getVideoFileFPS, 225 getVideoFileFPS,
174 computeResolutionsToTranscode, 226 computeResolutionsToTranscode,
175 audio, 227 audio,
176 getVideoFileBitrate 228 getVideoFileBitrate,
229 canDoQuickTranscode
177} 230}
178 231
179// --------------------------------------------------------------------------- 232// ---------------------------------------------------------------------------
180 233
181async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 234async function buildx264Command (command: ffmpeg.FfmpegCommand, options: VideoTranscodeOptions) {
182 let fps = await getVideoFileFPS(options.inputPath) 235 let fps = await getVideoFileFPS(options.inputPath)
183 // On small/medium resolutions, limit FPS 236 // On small/medium resolutions, limit FPS
184 if ( 237 if (
@@ -189,7 +242,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
189 fps = VIDEO_TRANSCODING_FPS.AVERAGE 242 fps = VIDEO_TRANSCODING_FPS.AVERAGE
190 } 243 }
191 244
192 command = await presetH264(command, options.resolution, fps) 245 command = await presetH264(command, options.inputPath, options.resolution, fps)
193 246
194 if (options.resolution !== undefined) { 247 if (options.resolution !== undefined) {
195 // '?x720' or '720x?' for example 248 // '?x720' or '720x?' for example
@@ -208,7 +261,29 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
208 return command 261 return command
209} 262}
210 263
211async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 264async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
265 command = command.loop(undefined)
266
267 command = await presetH264VeryFast(command, options.audioPath, options.resolution)
268
269 command = command.input(options.audioPath)
270 .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
271 .outputOption('-tune stillimage')
272 .outputOption('-shortest')
273
274 return command
275}
276
277async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
278 command = await presetCopy(command)
279
280 command = command.outputOption('-map_metadata -1') // strip all metadata
281 .outputOption('-movflags faststart')
282
283 return command
284}
285
286async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
212 const videoPath = getHLSVideoPath(options) 287 const videoPath = getHLSVideoPath(options)
213 288
214 command = await presetCopy(command) 289 command = await presetCopy(command)
@@ -224,26 +299,26 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod
224 return command 299 return command
225} 300}
226 301
227function getHLSVideoPath (options: TranscodeOptions) { 302function getHLSVideoPath (options: HLSTranscodeOptions) {
228 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` 303 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
229} 304}
230 305
231async function onTranscodingSuccess (options: TranscodeOptions) { 306async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
232 if (!options.hlsPlaylist) return 307 if (options.type !== 'hls') return
233 308
234 // Fix wrong mapping with some ffmpeg versions
235 const fileContent = await readFile(options.outputPath) 309 const fileContent = await readFile(options.outputPath)
236 310
237 const videoFileName = options.hlsPlaylist.videoFilename 311 const videoFileName = options.hlsPlaylist.videoFilename
238 const videoFilePath = getHLSVideoPath(options) 312 const videoFilePath = getHLSVideoPath(options)
239 313
314 // Fix wrong mapping with some ffmpeg versions
240 const newContent = fileContent.toString() 315 const newContent = fileContent.toString()
241 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) 316 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
242 317
243 await writeFile(options.outputPath, newContent) 318 await writeFile(options.outputPath, newContent)
244} 319}
245 320
246function getVideoFileStream (path: string) { 321function getVideoStreamFromFile (path: string) {
247 return new Promise<any>((res, rej) => { 322 return new Promise<any>((res, rej) => {
248 ffmpeg.ffprobe(path, (err, metadata) => { 323 ffmpeg.ffprobe(path, (err, metadata) => {
249 if (err) return rej(err) 324 if (err) return rej(err)
@@ -263,44 +338,27 @@ function getVideoFileStream (path: string) {
263 * and quality. Superfast and ultrafast will give you better 338 * and quality. Superfast and ultrafast will give you better
264 * performance, but then quality is noticeably worse. 339 * performance, but then quality is noticeably worse.
265 */ 340 */
266async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { 341async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
267 let localCommand = await presetH264(command, resolution, fps) 342 let localCommand = await presetH264(command, input, resolution, fps)
343
268 localCommand = localCommand.outputOption('-preset:v veryfast') 344 localCommand = localCommand.outputOption('-preset:v veryfast')
269 .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) 345
270 /* 346 /*
271 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html 347 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
272 Our target situation is closer to a livestream than a stream, 348 Our target situation is closer to a livestream than a stream,
273 since we want to reduce as much a possible the encoding burden, 349 since we want to reduce as much a possible the encoding burden,
274 altough not to the point of a livestream where there is a hard 350 although not to the point of a livestream where there is a hard
275 constraint on the frames per second to be encoded. 351 constraint on the frames per second to be encoded.
276
277 why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
278 Make up for most of the loss of grain and macroblocking
279 with less computing power.
280 */ 352 */
281 353
282 return localCommand 354 return localCommand
283} 355}
284 356
285/** 357/**
286 * A preset optimised for a stillimage audio video
287 */
288async function presetStillImageWithAudio (
289 command: ffmpeg.FfmpegCommand,
290 resolution: VideoResolution,
291 fps: number
292): Promise<ffmpeg.FfmpegCommand> {
293 let localCommand = await presetH264VeryFast(command, resolution, fps)
294 localCommand = localCommand.outputOption('-tune stillimage')
295
296 return localCommand
297}
298
299/**
300 * A toolbox to play with audio 358 * A toolbox to play with audio
301 */ 359 */
302namespace audio { 360namespace audio {
303 export const get = (option: ffmpeg.FfmpegCommand | string) => { 361 export const get = (option: string) => {
304 // without position, ffprobe considers the last input only 362 // without position, ffprobe considers the last input only
305 // we make it consider the first input only 363 // we make it consider the first input only
306 // if you pass a file path to pos, then ffprobe acts on that file directly 364 // if you pass a file path to pos, then ffprobe acts on that file directly
@@ -322,11 +380,7 @@ namespace audio {
322 return res({ absolutePath: data.format.filename }) 380 return res({ absolutePath: data.format.filename })
323 } 381 }
324 382
325 if (typeof option === 'string') { 383 return ffmpeg.ffprobe(option, parseFfprobe)
326 return ffmpeg.ffprobe(option, parseFfprobe)
327 }
328
329 return option.ffprobe(parseFfprobe)
330 }) 384 })
331 } 385 }
332 386
@@ -368,7 +422,7 @@ namespace audio {
368 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel 422 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
369 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr 423 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
370 */ 424 */
371async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { 425async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
372 let localCommand = command 426 let localCommand = command
373 .format('mp4') 427 .format('mp4')
374 .videoCodec('libx264') 428 .videoCodec('libx264')
@@ -379,7 +433,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
379 .outputOption('-map_metadata -1') // strip all metadata 433 .outputOption('-map_metadata -1') // strip all metadata
380 .outputOption('-movflags faststart') 434 .outputOption('-movflags faststart')
381 435
382 const parsedAudio = await audio.get(localCommand) 436 const parsedAudio = await audio.get(input)
383 437
384 if (!parsedAudio.audioStream) { 438 if (!parsedAudio.audioStream) {
385 localCommand = localCommand.noAudio() 439 localCommand = localCommand.noAudio()
@@ -388,28 +442,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
388 .audioCodec('libfdk_aac') 442 .audioCodec('libfdk_aac')
389 .audioQuality(5) 443 .audioQuality(5)
390 } else { 444 } else {
391 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates 445 // we try to reduce the ceiling bitrate by making rough matches of bitrates
392 // of course this is far from perfect, but it might save some space in the end 446 // of course this is far from perfect, but it might save some space in the end
447 localCommand = localCommand.audioCodec('aac')
448
393 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ] 449 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
394 let bitrate: number
395 if (audio.bitrate[ audioCodecName ]) {
396 localCommand = localCommand.audioCodec('aac')
397 450
398 bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) 451 if (audio.bitrate[ audioCodecName ]) {
452 const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
399 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) 453 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
400 } 454 }
401 } 455 }
402 456
403 // Constrained Encoding (VBV) 457 if (fps) {
404 // https://slhck.info/video/2017/03/01/rate-control.html 458 // Constrained Encoding (VBV)
405 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate 459 // https://slhck.info/video/2017/03/01/rate-control.html
406 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) 460 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
407 localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) 461 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
408 462 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
409 // Keyframe interval of 2 seconds for faster seeking and resolution switching. 463
410 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html 464 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
411 // https://superuser.com/a/908325 465 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
412 localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) 466 // https://superuser.com/a/908325
467 localCommand = localCommand.outputOption(`-g ${fps * 2}`)
468 }
413 469
414 return localCommand 470 return localCommand
415} 471}