diff options
author | Chocobozzz <me@florianbigard.com> | 2022-02-11 10:51:33 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2022-02-28 10:42:19 +0100 |
commit | c729caf6cc34630877a0e5a1bda1719384cd0c8a (patch) | |
tree | 1d2e13722e518c73d2c9e6f0969615e29d51cf8c /server/helpers/ffmpeg/ffmpeg-edition.ts | |
parent | a24bf4dc659cebb65d887862bf21d7a35e9ec791 (diff) | |
download | PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.gz PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.zst PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.zip |
Add basic video editor support
Diffstat (limited to 'server/helpers/ffmpeg/ffmpeg-edition.ts')
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-edition.ts | 242 |
1 files changed, 242 insertions, 0 deletions
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 | } | ||