diff options
Diffstat (limited to 'server/helpers/ffmpeg-utils.ts')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 140 |
1 files changed, 106 insertions, 34 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 132f4690e..76b744de8 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -1,11 +1,12 @@ | |||
1 | import * as ffmpeg from 'fluent-ffmpeg' | 1 | import * as ffmpeg from 'fluent-ffmpeg' |
2 | import { join } from 'path' | 2 | import { dirname, join } from 'path' |
3 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' | 3 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' |
4 | import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' | 4 | import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' |
5 | import { processImage } from './image-utils' | 5 | import { processImage } from './image-utils' |
6 | import { logger } from './logger' | 6 | import { logger } from './logger' |
7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | 7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' |
8 | import { remove } from 'fs-extra' | 8 | import { readFile, remove, writeFile } from 'fs-extra' |
9 | import { CONFIG } from '../initializers/config' | ||
9 | 10 | ||
10 | function computeResolutionsToTranscode (videoFileHeight: number) { | 11 | function computeResolutionsToTranscode (videoFileHeight: number) { |
11 | const resolutionsEnabled: number[] = [] | 12 | const resolutionsEnabled: number[] = [] |
@@ -29,12 +30,21 @@ function computeResolutionsToTranscode (videoFileHeight: number) { | |||
29 | return resolutionsEnabled | 30 | return resolutionsEnabled |
30 | } | 31 | } |
31 | 32 | ||
32 | async function getVideoFileResolution (path: string) { | 33 | async function getVideoFileSize (path: string) { |
33 | const videoStream = await getVideoFileStream(path) | 34 | const videoStream = await getVideoFileStream(path) |
34 | 35 | ||
35 | return { | 36 | return { |
36 | videoFileResolution: Math.min(videoStream.height, videoStream.width), | 37 | width: videoStream.width, |
37 | isPortraitMode: videoStream.height > videoStream.width | 38 | height: videoStream.height |
39 | } | ||
40 | } | ||
41 | |||
42 | async function getVideoFileResolution (path: string) { | ||
43 | const size = await getVideoFileSize(path) | ||
44 | |||
45 | return { | ||
46 | videoFileResolution: Math.min(size.height, size.width), | ||
47 | isPortraitMode: size.height > size.width | ||
38 | } | 48 | } |
39 | } | 49 | } |
40 | 50 | ||
@@ -95,7 +105,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima | |||
95 | }) | 105 | }) |
96 | 106 | ||
97 | const destination = join(folder, imageName) | 107 | const destination = join(folder, imageName) |
98 | await processImage({ path: pendingImagePath }, destination, size) | 108 | await processImage(pendingImagePath, destination, size) |
99 | } catch (err) { | 109 | } catch (err) { |
100 | logger.error('Cannot generate image from video %s.', fromPath, { err }) | 110 | logger.error('Cannot generate image from video %s.', fromPath, { err }) |
101 | 111 | ||
@@ -110,52 +120,41 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima | |||
110 | type TranscodeOptions = { | 120 | type TranscodeOptions = { |
111 | inputPath: string | 121 | inputPath: string |
112 | outputPath: string | 122 | outputPath: string |
113 | resolution?: VideoResolution | 123 | resolution: VideoResolution |
114 | isPortraitMode?: boolean | 124 | isPortraitMode?: boolean |
125 | |||
126 | hlsPlaylist?: { | ||
127 | videoFilename: string | ||
128 | } | ||
115 | } | 129 | } |
116 | 130 | ||
117 | function transcode (options: TranscodeOptions) { | 131 | function transcode (options: TranscodeOptions) { |
118 | return new Promise<void>(async (res, rej) => { | 132 | return new Promise<void>(async (res, rej) => { |
119 | try { | 133 | try { |
120 | let fps = await getVideoFileFPS(options.inputPath) | ||
121 | // On small/medium resolutions, limit FPS | ||
122 | if ( | ||
123 | options.resolution !== undefined && | ||
124 | options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | ||
125 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | ||
126 | ) { | ||
127 | fps = VIDEO_TRANSCODING_FPS.AVERAGE | ||
128 | } | ||
129 | |||
130 | let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) | 134 | let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) |
131 | .output(options.outputPath) | 135 | .output(options.outputPath) |
132 | command = await presetH264(command, options.resolution, fps) | 136 | |
137 | if (options.hlsPlaylist) { | ||
138 | command = await buildHLSCommand(command, options) | ||
139 | } else { | ||
140 | command = await buildx264Command(command, options) | ||
141 | } | ||
133 | 142 | ||
134 | if (CONFIG.TRANSCODING.THREADS > 0) { | 143 | if (CONFIG.TRANSCODING.THREADS > 0) { |
135 | // if we don't set any threads ffmpeg will chose automatically | 144 | // if we don't set any threads ffmpeg will chose automatically |
136 | command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) | 145 | command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) |
137 | } | 146 | } |
138 | 147 | ||
139 | if (options.resolution !== undefined) { | ||
140 | // '?x720' or '720x?' for example | ||
141 | const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` | ||
142 | command = command.size(size) | ||
143 | } | ||
144 | |||
145 | if (fps) { | ||
146 | // Hard FPS limits | ||
147 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX | ||
148 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN | ||
149 | |||
150 | command = command.withFPS(fps) | ||
151 | } | ||
152 | |||
153 | command | 148 | command |
154 | .on('error', (err, stdout, stderr) => { | 149 | .on('error', (err, stdout, stderr) => { |
155 | logger.error('Error in transcoding job.', { stdout, stderr }) | 150 | logger.error('Error in transcoding job.', { stdout, stderr }) |
156 | return rej(err) | 151 | return rej(err) |
157 | }) | 152 | }) |
158 | .on('end', res) | 153 | .on('end', () => { |
154 | return onTranscodingSuccess(options) | ||
155 | .then(() => res()) | ||
156 | .catch(err => rej(err)) | ||
157 | }) | ||
159 | .run() | 158 | .run() |
160 | } catch (err) { | 159 | } catch (err) { |
161 | return rej(err) | 160 | return rej(err) |
@@ -166,6 +165,7 @@ function transcode (options: TranscodeOptions) { | |||
166 | // --------------------------------------------------------------------------- | 165 | // --------------------------------------------------------------------------- |
167 | 166 | ||
168 | export { | 167 | export { |
168 | getVideoFileSize, | ||
169 | getVideoFileResolution, | 169 | getVideoFileResolution, |
170 | getDurationFromVideoFile, | 170 | getDurationFromVideoFile, |
171 | generateImageFromVideoFile, | 171 | generateImageFromVideoFile, |
@@ -178,6 +178,71 @@ export { | |||
178 | 178 | ||
179 | // --------------------------------------------------------------------------- | 179 | // --------------------------------------------------------------------------- |
180 | 180 | ||
181 | async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { | ||
182 | let fps = await getVideoFileFPS(options.inputPath) | ||
183 | // On small/medium resolutions, limit FPS | ||
184 | if ( | ||
185 | options.resolution !== undefined && | ||
186 | options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | ||
187 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | ||
188 | ) { | ||
189 | fps = VIDEO_TRANSCODING_FPS.AVERAGE | ||
190 | } | ||
191 | |||
192 | command = await presetH264(command, options.resolution, fps) | ||
193 | |||
194 | if (options.resolution !== undefined) { | ||
195 | // '?x720' or '720x?' for example | ||
196 | const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` | ||
197 | command = command.size(size) | ||
198 | } | ||
199 | |||
200 | if (fps) { | ||
201 | // Hard FPS limits | ||
202 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX | ||
203 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN | ||
204 | |||
205 | command = command.withFPS(fps) | ||
206 | } | ||
207 | |||
208 | return command | ||
209 | } | ||
210 | |||
211 | async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { | ||
212 | const videoPath = getHLSVideoPath(options) | ||
213 | |||
214 | command = await presetCopy(command) | ||
215 | |||
216 | command = command.outputOption('-hls_time 4') | ||
217 | .outputOption('-hls_list_size 0') | ||
218 | .outputOption('-hls_playlist_type vod') | ||
219 | .outputOption('-hls_segment_filename ' + videoPath) | ||
220 | .outputOption('-hls_segment_type fmp4') | ||
221 | .outputOption('-f hls') | ||
222 | .outputOption('-hls_flags single_file') | ||
223 | |||
224 | return command | ||
225 | } | ||
226 | |||
227 | function getHLSVideoPath (options: TranscodeOptions) { | ||
228 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
229 | } | ||
230 | |||
231 | async function onTranscodingSuccess (options: TranscodeOptions) { | ||
232 | if (!options.hlsPlaylist) return | ||
233 | |||
234 | // Fix wrong mapping with some ffmpeg versions | ||
235 | const fileContent = await readFile(options.outputPath) | ||
236 | |||
237 | const videoFileName = options.hlsPlaylist.videoFilename | ||
238 | const videoFilePath = getHLSVideoPath(options) | ||
239 | |||
240 | const newContent = fileContent.toString() | ||
241 | .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) | ||
242 | |||
243 | await writeFile(options.outputPath, newContent) | ||
244 | } | ||
245 | |||
181 | function getVideoFileStream (path: string) { | 246 | function getVideoFileStream (path: string) { |
182 | return new Promise<any>((res, rej) => { | 247 | return new Promise<any>((res, rej) => { |
183 | ffmpeg.ffprobe(path, (err, metadata) => { | 248 | ffmpeg.ffprobe(path, (err, metadata) => { |
@@ -348,3 +413,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol | |||
348 | 413 | ||
349 | return localCommand | 414 | return localCommand |
350 | } | 415 | } |
416 | |||
417 | async function presetCopy (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.FfmpegCommand> { | ||
418 | return command | ||
419 | .format('mp4') | ||
420 | .videoCodec('copy') | ||
421 | .audioCodec('copy') | ||
422 | } | ||