]> jfr.im git - yt-dlp.git/blame - yt_dlp/postprocessor/ffmpeg.py
[CBC] Fix URL regex
[yt-dlp.git] / yt_dlp / postprocessor / ffmpeg.py
CommitLineData
3aa578ca
PH
1from __future__ import unicode_literals
2
e9fade72 3import io
7dde84f3 4import itertools
496c1923
PH
5import os
6import subprocess
496c1923 7import time
fa2a36d9 8import re
06167fbb 9import json
496c1923 10
496c1923
PH
11from .common import AudioConversionError, PostProcessor
12
b11d2101 13from ..compat import compat_str
8c25f81b 14from ..utils import (
7a340e0d 15 dfxp2srt,
f07b74fc 16 encodeArgument,
496c1923 17 encodeFilename,
165efb82 18 float_or_none,
9af98e17 19 _get_exe_version_output,
20 detect_exe_version,
48844745 21 is_outdated_version,
7a340e0d
NA
22 ISO639Utils,
23 orderedSet,
d3c93ec2 24 Popen,
496c1923
PH
25 PostProcessingError,
26 prepend_extension,
06167fbb 27 replace_extension,
7a340e0d 28 shell_quote,
324ad820 29 traverse_obj,
6606817a 30 variadic,
dac5df5a 31 write_json_file,
496c1923
PH
32)
33
34
a755f825 35EXT_TO_OUT_FORMATS = {
21bfcd3d
PH
36 'aac': 'adts',
37 'flac': 'flac',
38 'm4a': 'ipod',
39 'mka': 'matroska',
40 'mkv': 'matroska',
41 'mpg': 'mpeg',
42 'ogv': 'ogg',
43 'ts': 'mpegts',
44 'wma': 'asf',
45 'wmv': 'asf',
abad8000 46 'vtt': 'webvtt',
21bfcd3d
PH
47}
48ACODECS = {
49 'mp3': 'libmp3lame',
50 'aac': 'aac',
51 'flac': 'flac',
52 'm4a': 'aac',
d2ae7e24 53 'opus': 'libopus',
21bfcd3d
PH
54 'vorbis': 'libvorbis',
55 'wav': None,
467b6b83 56 'alac': None,
a755f825 57}
58
59
496c1923
PH
60class FFmpegPostProcessorError(PostProcessingError):
61 pass
62
d799b47b 63
496c1923 64class FFmpegPostProcessor(PostProcessor):
d47aeb22 65 def __init__(self, downloader=None):
496c1923 66 PostProcessor.__init__(self, downloader)
73fac4e9 67 self._determine_executables()
496c1923 68
48844745 69 def check_version(self):
f740fae2 70 if not self.available:
beb4b92a 71 raise FFmpegPostProcessorError('ffmpeg not found. Please install or provide the path using --ffmpeg-location')
48844745 72
65bf37ef 73 required_version = '10-0' if self.basename == 'avconv' else '1.0'
48844745 74 if is_outdated_version(
73fac4e9 75 self._versions[self.basename], required_version):
3aa578ca 76 warning = 'Your copy of %s is outdated, update %s to version %s or newer if you encounter any errors.' % (
73fac4e9 77 self.basename, self.basename, required_version)
f446cc66 78 self.report_warning(warning)
48844745 79
8913ef74 80 @staticmethod
81 def get_versions_and_features(downloader=None):
82 pp = FFmpegPostProcessor(downloader)
83 return pp._versions, pp._features
84
496c1923 85 @staticmethod
73fac4e9 86 def get_versions(downloader=None):
8913ef74 87 return FFmpegPostProcessor.get_version_and_features(downloader)[0]
6271f1ca 88
73fac4e9
PH
89 def _determine_executables(self):
90 programs = ['avprobe', 'avconv', 'ffmpeg', 'ffprobe']
73fac4e9 91
9af98e17 92 def get_ffmpeg_version(path, prog):
93 out = _get_exe_version_output(path, ['-bsfs'])
94 ver = detect_exe_version(out) if out else False
a64646e4
RA
95 if ver:
96 regexs = [
cbdc688c 97 r'(?:\d+:)?([0-9.]+)-[0-9]+ubuntu[0-9.]+$', # Ubuntu, see [1]
5caa531a 98 r'n([0-9.]+)$', # Arch Linux
cbdc688c 99 # 1. http://www.ducea.com/2006/06/17/ubuntu-package-version-naming-explanation/
a64646e4
RA
100 ]
101 for regex in regexs:
102 mobj = re.match(regex, ver)
103 if mobj:
104 ver = mobj.group(1)
9af98e17 105 self._versions[prog] = ver
106 if prog != 'ffmpeg' or not out:
107 return
108
8913ef74 109 mobj = re.search(r'(?m)^\s+libavformat\s+(?:[0-9. ]+)\s+/\s+(?P<runtime>[0-9. ]+)', out)
110 lavf_runtime_version = mobj.group('runtime').replace(' ', '') if mobj else None
832e9000 111 self._features = {
112 'fdk': '--enable-libfdk-aac' in out,
113 'setts': 'setts' in out.splitlines(),
8913ef74 114 'needs_adtstoasc': is_outdated_version(lavf_runtime_version, '57.56.100', False),
832e9000 115 }
a64646e4 116
73fac4e9
PH
117 self.basename = None
118 self.probe_basename = None
73fac4e9
PH
119 self._paths = None
120 self._versions = None
9af98e17 121 self._features = {}
122
123 prefer_ffmpeg = self.get_param('prefer_ffmpeg', True)
124 location = self.get_param('ffmpeg_location')
125 if location is None:
126 self._paths = {p: p for p in programs}
127 else:
128 if not os.path.exists(location):
129 self.report_warning(
130 'ffmpeg-location %s does not exist! '
131 'Continuing without ffmpeg.' % (location))
132 self._versions = {}
133 return
134 elif os.path.isdir(location):
135 dirname, basename = location, None
136 else:
137 basename = os.path.splitext(os.path.basename(location))[0]
138 basename = next((p for p in programs if basename.startswith(p)), 'ffmpeg')
139 dirname = os.path.dirname(os.path.abspath(location))
140 if basename in ('ffmpeg', 'ffprobe'):
141 prefer_ffmpeg = True
142
143 self._paths = dict(
144 (p, os.path.join(dirname, p)) for p in programs)
145 if basename:
146 self._paths[basename] = location
147
148 self._versions = {}
149 for p in programs:
150 get_ffmpeg_version(self._paths[p], p)
73fac4e9 151
d4a24f40 152 if prefer_ffmpeg is False:
d28b5171 153 prefs = ('avconv', 'ffmpeg')
d4a24f40
S
154 else:
155 prefs = ('ffmpeg', 'avconv')
d28b5171
PH
156 for p in prefs:
157 if self._versions[p]:
73fac4e9
PH
158 self.basename = p
159 break
76b1bd67 160
d4a24f40 161 if prefer_ffmpeg is False:
1a253e13 162 prefs = ('avprobe', 'ffprobe')
d4a24f40
S
163 else:
164 prefs = ('ffprobe', 'avprobe')
1a253e13
PH
165 for p in prefs:
166 if self._versions[p]:
73fac4e9
PH
167 self.probe_basename = p
168 break
169
ee8dd27a 170 if self.basename == 'avconv':
171 self.deprecation_warning(
172 'Support for avconv is deprecated and may be removed in a future version. Use ffmpeg instead')
173 if self.probe_basename == 'avprobe':
174 self.deprecation_warning(
175 'Support for avprobe is deprecated and may be removed in a future version. Use ffprobe instead')
176
f740fae2 177 @property
73fac4e9
PH
178 def available(self):
179 return self.basename is not None
1a253e13 180
73fac4e9
PH
181 @property
182 def executable(self):
183 return self._paths[self.basename]
184
3da4b313
JMF
185 @property
186 def probe_available(self):
187 return self.probe_basename is not None
188
73fac4e9
PH
189 @property
190 def probe_executable(self):
191 return self._paths[self.probe_basename]
76b1bd67 192
30d9e209 193 def get_audio_codec(self, path):
eb35b163 194 if not self.probe_available and not self.available:
beb4b92a 195 raise PostProcessingError('ffprobe and ffmpeg not found. Please install or provide the path using --ffmpeg-location')
30d9e209 196 try:
eb35b163
RA
197 if self.probe_available:
198 cmd = [
199 encodeFilename(self.probe_executable, True),
200 encodeArgument('-show_streams')]
201 else:
202 cmd = [
203 encodeFilename(self.executable, True),
204 encodeArgument('-i')]
205 cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
f446cc66 206 self.write_debug('%s command line: %s' % (self.basename, shell_quote(cmd)))
d3c93ec2 207 handle = Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
208 stdout_data, stderr_data = handle.communicate_or_kill()
eb35b163
RA
209 expected_ret = 0 if self.probe_available else 1
210 if handle.wait() != expected_ret:
30d9e209
RA
211 return None
212 except (IOError, OSError):
213 return None
eb35b163
RA
214 output = (stdout_data if self.probe_available else stderr_data).decode('ascii', 'ignore')
215 if self.probe_available:
216 audio_codec = None
217 for line in output.split('\n'):
218 if line.startswith('codec_name='):
219 audio_codec = line.split('=')[1].strip()
220 elif line.strip() == 'codec_type=audio' and audio_codec is not None:
221 return audio_codec
222 else:
223 # Stream #FILE_INDEX:STREAM_INDEX[STREAM_ID](LANGUAGE): CODEC_TYPE: CODEC_NAME
224 mobj = re.search(
225 r'Stream\s*#\d+:\d+(?:\[0x[0-9a-f]+\])?(?:\([a-z]{3}\))?:\s*Audio:\s*([0-9a-z]+)',
226 output)
227 if mobj:
228 return mobj.group(1)
30d9e209
RA
229 return None
230
06167fbb 231 def get_metadata_object(self, path, opts=[]):
232 if self.probe_basename != 'ffprobe':
233 if self.probe_available:
234 self.report_warning('Only ffprobe is supported for metadata extraction')
beb4b92a 235 raise PostProcessingError('ffprobe not found. Please install or provide the path using --ffmpeg-location')
06167fbb 236 self.check_version()
237
238 cmd = [
239 encodeFilename(self.probe_executable, True),
240 encodeArgument('-hide_banner'),
241 encodeArgument('-show_format'),
242 encodeArgument('-show_streams'),
243 encodeArgument('-print_format'),
244 encodeArgument('json'),
245 ]
246
247 cmd += opts
248 cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
06869367 249 self.write_debug('ffprobe command line: %s' % shell_quote(cmd))
d3c93ec2 250 p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
06167fbb 251 stdout, stderr = p.communicate()
252 return json.loads(stdout.decode('utf-8', 'replace'))
253
254 def get_stream_number(self, path, keys, value):
255 streams = self.get_metadata_object(path)['streams']
256 num = next(
324ad820 257 (i for i, stream in enumerate(streams) if traverse_obj(stream, keys, casesense=False) == value),
06167fbb 258 None)
259 return num, len(streams)
260
5ce1d13e 261 def _get_real_video_duration(self, filepath, fatal=True):
165efb82 262 try:
5ce1d13e 263 duration = float_or_none(
264 traverse_obj(self.get_metadata_object(filepath), ('format', 'duration')))
265 if not duration:
165efb82 266 raise PostProcessingError('ffprobe returned empty duration')
5ce1d13e 267 return duration
165efb82 268 except PostProcessingError as e:
269 if fatal:
5ce1d13e 270 raise PostProcessingError(f'Unable to determine video duration: {e.msg}')
165efb82 271
272 def _duration_mismatch(self, d1, d2):
273 if not d1 or not d2:
274 return None
5ce1d13e 275 # The duration is often only known to nearest second. So there can be <1sec disparity natually.
276 # Further excuse an additional <1sec difference.
277 return abs(d1 - d2) > 2
165efb82 278
00034c14 279 def run_ffmpeg_multiple_files(self, input_paths, out_path, opts, **kwargs):
e92caff5 280 return self.real_run_ffmpeg(
281 [(path, []) for path in input_paths],
00034c14 282 [(out_path, opts)], **kwargs)
e92caff5 283
00034c14 284 def real_run_ffmpeg(self, input_path_opts, output_path_opts, *, expected_retcodes=(0,)):
48844745 285 self.check_version()
496c1923 286
52afb2ac 287 oldest_mtime = min(
7dde84f3 288 os.stat(encodeFilename(path)).st_mtime for path, _ in input_path_opts if path)
43bc8890 289
91b6c884 290 cmd = [encodeFilename(self.executable, True), encodeArgument('-y')]
ce52c7c1
S
291 # avconv does not have repeat option
292 if self.basename == 'ffmpeg':
293 cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')]
5b1ecbb3 294
e92caff5 295 def make_args(file, args, name, number):
296 keys = ['_%s%d' % (name, number), '_%s' % name]
ca5db158 297 if name == 'o':
298 args += ['-movflags', '+faststart']
299 elif number == 1:
e92caff5 300 keys.append('')
301 args += self._configuration_args(self.basename, keys)
302 if name == 'i':
303 args.append('-i')
5b1ecbb3 304 return (
e92caff5 305 [encodeArgument(arg) for arg in args]
5b1ecbb3 306 + [encodeFilename(self._ffmpeg_filename_argument(file), True)])
307
e92caff5 308 for arg_type, path_opts in (('i', input_path_opts), ('o', output_path_opts)):
7dde84f3 309 cmd += itertools.chain.from_iterable(
310 make_args(path, list(opts), arg_type, i + 1)
311 for i, (path, opts) in enumerate(path_opts) if path)
496c1923 312
f446cc66 313 self.write_debug('ffmpeg command line: %s' % shell_quote(cmd))
d3c93ec2 314 p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
315 stdout, stderr = p.communicate_or_kill()
00034c14 316 if p.returncode not in variadic(expected_retcodes):
06167fbb 317 stderr = stderr.decode('utf-8', 'replace').strip()
b1940459 318 self.write_debug(stderr)
06167fbb 319 raise FFmpegPostProcessorError(stderr.split('\n')[-1])
e92caff5 320 for out_path, _ in output_path_opts:
7dde84f3 321 if out_path:
322 self.try_utime(out_path, oldest_mtime, oldest_mtime)
06167fbb 323 return stderr.decode('utf-8', 'replace')
cc55d088 324
00034c14 325 def run_ffmpeg(self, path, out_path, opts, **kwargs):
326 return self.run_ffmpeg_multiple_files([path], out_path, opts, **kwargs)
496c1923 327
7a340e0d
NA
328 @staticmethod
329 def _ffmpeg_filename_argument(fn):
8a7bbd16
JMF
330 # Always use 'file:' because the filename may contain ':' (ffmpeg
331 # interprets that as a protocol) or can start with '-' (-- is broken in
332 # ffmpeg, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details)
b9f2fdd3 333 # Also leave '-' intact in order not to break streaming to stdout.
06167fbb 334 if fn.startswith(('http://', 'https://')):
335 return fn
d868f43c 336 return 'file:' + fn if fn != '-' else fn
496c1923 337
7a340e0d
NA
338 @staticmethod
339 def _quote_for_ffmpeg(string):
340 # See https://ffmpeg.org/ffmpeg-utils.html#toc-Quoting-and-escaping
341 # A sequence of '' produces '\'''\'';
342 # final replace removes the empty '' between \' \'.
343 string = string.replace("'", r"'\''").replace("'''", "'")
344 # Handle potential ' at string boundaries.
345 string = string[1:] if string[0] == "'" else "'" + string
346 return string[:-1] if string[-1] == "'" else string + "'"
347
348 def force_keyframes(self, filename, timestamps):
349 timestamps = orderedSet(timestamps)
350 if timestamps[0] == 0:
351 timestamps = timestamps[1:]
352 keyframe_file = prepend_extension(filename, 'keyframes.temp')
353 self.to_screen(f'Re-encoding "{filename}" with appropriate keyframes')
354 self.run_ffmpeg(filename, keyframe_file, ['-force_key_frames', ','.join(
355 f'{t:.6f}' for t in timestamps)])
356 return keyframe_file
357
358 def concat_files(self, in_files, out_file, concat_opts=None):
359 """
360 Use concat demuxer to concatenate multiple files having identical streams.
361
362 Only inpoint, outpoint, and duration concat options are supported.
363 See https://ffmpeg.org/ffmpeg-formats.html#concat-1 for details
364 """
365 concat_file = f'{out_file}.concat'
366 self.write_debug(f'Writing concat spec to {concat_file}')
367 with open(concat_file, 'wt', encoding='utf-8') as f:
368 f.writelines(self._concat_spec(in_files, concat_opts))
369
370 out_flags = ['-c', 'copy']
371 if out_file.rpartition('.')[-1] in ('mp4', 'mov'):
372 # For some reason, '-c copy' is not enough to copy subtitles
ca5db158 373 out_flags.extend(['-c:s', 'mov_text'])
7a340e0d
NA
374
375 try:
376 self.real_run_ffmpeg(
377 [(concat_file, ['-hide_banner', '-nostdin', '-f', 'concat', '-safe', '0'])],
378 [(out_file, out_flags)])
379 finally:
380 os.remove(concat_file)
381
382 @classmethod
383 def _concat_spec(cls, in_files, concat_opts=None):
384 if concat_opts is None:
385 concat_opts = [{}] * len(in_files)
386 yield 'ffconcat version 1.0\n'
387 for file, opts in zip(in_files, concat_opts):
388 yield f'file {cls._quote_for_ffmpeg(cls._ffmpeg_filename_argument(file))}\n'
389 # Iterate explicitly to yield the following directives in order, ignoring the rest.
390 for directive in 'inpoint', 'outpoint', 'duration':
391 if directive in opts:
392 yield f'{directive} {opts[directive]}\n'
393
496c1923
PH
394
395class FFmpegExtractAudioPP(FFmpegPostProcessor):
81a23040 396 COMMON_AUDIO_EXTS = ('wav', 'flac', 'm4a', 'aiff', 'mp3', 'ogg', 'mka', 'opus', 'wma')
467b6b83 397 SUPPORTED_EXTS = ('best', 'aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav', 'alac')
1de75fa1 398
496c1923
PH
399 def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
400 FFmpegPostProcessor.__init__(self, downloader)
81a23040 401 self._preferredcodec = preferredcodec or 'best'
31c49255 402 self._preferredquality = float_or_none(preferredquality)
496c1923
PH
403 self._nopostoverwrites = nopostoverwrites
404
31c49255 405 def _quality_args(self, codec):
406 if self._preferredquality is None:
407 return []
408 elif self._preferredquality > 10:
409 return ['-b:a', f'{self._preferredquality}k']
410
411 limits = {
412 'libmp3lame': (10, 0),
467b6b83 413 'libvorbis': (0, 10),
9af98e17 414 # FFmpeg's AAC encoder does not have an upper limit for the value of -q:a.
415 # Experimentally, with values over 4, bitrate changes were minimal or non-existent
416 'aac': (0.1, 4),
673c0057 417 'libfdk_aac': (1, 5),
39c04074 418 }.get(codec)
31c49255 419 if not limits:
420 return []
421
422 q = limits[1] + (limits[0] - limits[1]) * (self._preferredquality / 10)
673c0057
C
423 if codec == 'libfdk_aac':
424 return ['-vbr', f'{int(q)}']
31c49255 425 return ['-q:a', f'{q}']
426
496c1923 427 def run_ffmpeg(self, path, out_path, codec, more_opts):
496c1923
PH
428 if codec is None:
429 acodec_opts = []
430 else:
431 acodec_opts = ['-acodec', codec]
432 opts = ['-vn'] + acodec_opts + more_opts
433 try:
434 FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
435 except FFmpegPostProcessorError as err:
436 raise AudioConversionError(err.msg)
437
8326b00a 438 @PostProcessor._restrict_to(images=False)
496c1923 439 def run(self, information):
467b6b83 440 orig_path = path = information['filepath']
1de75fa1 441 orig_ext = information['ext']
442
81a23040 443 if self._preferredcodec == 'best' and orig_ext in self.COMMON_AUDIO_EXTS:
1de75fa1 444 self.to_screen('Skipping audio extraction since the file is already in a common audio format')
55b53b33 445 return [], information
496c1923
PH
446
447 filecodec = self.get_audio_codec(path)
448 if filecodec is None:
3aa578ca 449 raise PostProcessingError('WARNING: unable to obtain file audio codec with ffprobe')
496c1923
PH
450
451 more_opts = []
452 if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
453 if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
454 # Lossless, but in another container
455 acodec = 'copy'
456 extension = 'm4a'
467d3c9a 457 more_opts = ['-bsf:a', 'aac_adtstoasc']
21bfcd3d 458 elif filecodec in ['aac', 'flac', 'mp3', 'vorbis', 'opus']:
496c1923
PH
459 # Lossless if possible
460 acodec = 'copy'
461 extension = filecodec
462 if filecodec == 'aac':
463 more_opts = ['-f', 'adts']
464 if filecodec == 'vorbis':
465 extension = 'ogg'
467b6b83 466 elif filecodec == 'alac':
467 acodec = None
468 extension = 'm4a'
469 more_opts += ['-acodec', 'alac']
496c1923
PH
470 else:
471 # MP3 otherwise.
472 acodec = 'libmp3lame'
473 extension = 'mp3'
31c49255 474 more_opts = self._quality_args(acodec)
496c1923 475 else:
21bfcd3d
PH
476 # We convert the audio (lossy if codec is lossy)
477 acodec = ACODECS[self._preferredcodec]
673c0057
C
478 if acodec == 'aac' and self._features.get('fdk'):
479 acodec = 'libfdk_aac'
496c1923 480 extension = self._preferredcodec
31c49255 481 more_opts = self._quality_args(acodec)
496c1923
PH
482 if self._preferredcodec == 'aac':
483 more_opts += ['-f', 'adts']
467b6b83 484 elif self._preferredcodec == 'm4a':
467d3c9a 485 more_opts += ['-bsf:a', 'aac_adtstoasc']
467b6b83 486 elif self._preferredcodec == 'vorbis':
496c1923 487 extension = 'ogg'
467b6b83 488 elif self._preferredcodec == 'wav':
496c1923
PH
489 extension = 'wav'
490 more_opts += ['-f', 'wav']
467b6b83 491 elif self._preferredcodec == 'alac':
492 extension = 'm4a'
493 more_opts += ['-acodec', 'alac']
496c1923 494
3aa578ca 495 prefix, sep, ext = path.rpartition('.') # not os.path.splitext, since the latter does not work on unicode in all setups
467b6b83 496 temp_path = new_path = prefix + sep + extension
496c1923 497
467b6b83 498 if new_path == path:
499 orig_path = prepend_extension(path, 'orig')
500 temp_path = prepend_extension(path, 'temp')
501 if (self._nopostoverwrites and os.path.exists(encodeFilename(new_path))
502 and os.path.exists(encodeFilename(orig_path))):
1b77b347 503 self.to_screen('Post-process file %s exists, skipping' % new_path)
592e97e8 504 return [], information
496c1923
PH
505
506 try:
467b6b83 507 self.to_screen(f'Destination: {new_path}')
508 self.run_ffmpeg(path, temp_path, acodec, more_opts)
70a1165b
JMF
509 except AudioConversionError as e:
510 raise PostProcessingError(
511 'audio conversion failed: ' + e.msg)
512 except Exception:
513 raise PostProcessingError('error running ' + self.basename)
496c1923 514
467b6b83 515 os.replace(path, orig_path)
516 os.replace(temp_path, new_path)
517 information['filepath'] = new_path
518 information['ext'] = extension
519
496c1923
PH
520 # Try to update the date time for extracted audio file.
521 if information.get('filetime') is not None:
dd29eb7f
S
522 self.try_utime(
523 new_path, time.time(), information['filetime'],
524 errnote='Cannot update utime of audio file')
496c1923 525
467b6b83 526 return [orig_path], information
496c1923
PH
527
528
857f6313 529class FFmpegVideoConvertorPP(FFmpegPostProcessor):
81a23040 530 SUPPORTED_EXTS = ('mp4', 'mkv', 'flv', 'webm', 'mov', 'avi', 'mp3', 'mka', 'm4a', 'ogg', 'opus')
531 FORMAT_RE = re.compile(r'{0}(?:/{0})*$'.format(r'(?:\w+>)?(?:%s)' % '|'.join(SUPPORTED_EXTS)))
e6f21b3d 532 _ACTION = 'converting'
857f6313 533
efe87a10 534 def __init__(self, downloader=None, preferedformat=None):
857f6313 535 super(FFmpegVideoConvertorPP, self).__init__(downloader)
06167fbb 536 self._preferedformats = preferedformat.lower().split('/')
efe87a10 537
857f6313 538 def _target_ext(self, source_ext):
06167fbb 539 for pair in self._preferedformats:
540 kv = pair.split('>')
857f6313 541 if len(kv) == 1 or kv[0].strip() == source_ext:
542 return kv[-1].strip()
06167fbb 543
857f6313 544 @staticmethod
545 def _options(target_ext):
546 if target_ext == 'avi':
547 return ['-c:v', 'libxvid', '-vtag', 'XVID']
548 return []
549
8326b00a 550 @PostProcessor._restrict_to(images=False)
e6f21b3d 551 def run(self, info):
552 filename, source_ext = info['filepath'], info['ext'].lower()
81a23040 553 target_ext = self._target_ext(source_ext)
06167fbb 554 _skip_msg = (
e6f21b3d 555 f'could not find a mapping for {source_ext}' if not target_ext
556 else f'already is in target format {source_ext}' if source_ext == target_ext
06167fbb 557 else None)
558 if _skip_msg:
e6f21b3d 559 self.to_screen(f'Not {self._ACTION} media file {filename!r}; {_skip_msg}')
560 return [], info
06167fbb 561
e6f21b3d 562 outpath = replace_extension(filename, target_ext, source_ext)
563 self.to_screen(f'{self._ACTION.title()} video from {source_ext} to {target_ext}; Destination: {outpath}')
564 self.run_ffmpeg(filename, outpath, self._options(target_ext))
857f6313 565
e6f21b3d 566 info['filepath'] = outpath
567 info['format'] = info['ext'] = target_ext
568 return [filename], info
efe87a10
FS
569
570
857f6313 571class FFmpegVideoRemuxerPP(FFmpegVideoConvertorPP):
e6f21b3d 572 _ACTION = 'remuxing'
496c1923 573
857f6313 574 @staticmethod
575 def _options(target_ext):
ca5db158 576 return ['-c', 'copy', '-map', '0', '-dn']
496c1923
PH
577
578
579class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
cffab0ee 580 def __init__(self, downloader=None, already_have_subtitle=False):
581 super(FFmpegEmbedSubtitlePP, self).__init__(downloader)
582 self._already_have_subtitle = already_have_subtitle
583
8326b00a 584 @PostProcessor._restrict_to(images=False)
5ce1d13e 585 def run(self, info):
586 if info['ext'] not in ('mp4', 'webm', 'mkv'):
1b77b347 587 self.to_screen('Subtitles can only be embedded in mp4, webm or mkv files')
5ce1d13e 588 return [], info
589 subtitles = info.get('requested_subtitles')
c84dd8a9 590 if not subtitles:
1b77b347 591 self.to_screen('There aren\'t any subtitles to embed')
5ce1d13e 592 return [], info
496c1923 593
5ce1d13e 594 filename = info['filepath']
9bdd99cf 595
596 # Disabled temporarily. There needs to be a way to overide this
597 # in case of duration actually mismatching in extractor
598 # See: https://github.com/yt-dlp/yt-dlp/issues/1870, https://github.com/yt-dlp/yt-dlp/issues/1385
599 '''
5ce1d13e 600 if info.get('duration') and not info.get('__real_download') and self._duration_mismatch(
601 self._get_real_video_duration(filename, False), info['duration']):
165efb82 602 self.to_screen(f'Skipping {self.pp_key()} since the real and expected durations mismatch')
5ce1d13e 603 return [], info
9bdd99cf 604 '''
40025ee2 605
5ce1d13e 606 ext = info['ext']
2412044c 607 sub_langs, sub_names, sub_filenames = [], [], []
40025ee2 608 webm_vtt_warn = False
06167fbb 609 mp4_ass_warn = False
40025ee2
S
610
611 for lang, sub_info in subtitles.items():
a1c39673 612 if not os.path.exists(sub_info.get('filepath', '')):
8e25d624 613 self.report_warning(f'Skipping embedding {lang} subtitle because the file is missing')
614 continue
40025ee2 615 sub_ext = sub_info['ext']
503d4a44 616 if sub_ext == 'json':
06167fbb 617 self.report_warning('JSON subtitles cannot be embedded')
503d4a44 618 elif ext != 'webm' or ext == 'webm' and sub_ext == 'vtt':
40025ee2 619 sub_langs.append(lang)
2412044c 620 sub_names.append(sub_info.get('name'))
dcf64d43 621 sub_filenames.append(sub_info['filepath'])
40025ee2
S
622 else:
623 if not webm_vtt_warn and ext == 'webm' and sub_ext != 'vtt':
624 webm_vtt_warn = True
06167fbb 625 self.report_warning('Only WebVTT subtitles can be embedded in webm files')
626 if not mp4_ass_warn and ext == 'mp4' and sub_ext == 'ass':
627 mp4_ass_warn = True
628 self.report_warning('ASS subtitles cannot be properly embedded in mp4 files; expect issues')
40025ee2
S
629
630 if not sub_langs:
5ce1d13e 631 return [], info
40025ee2 632
14523ed9 633 input_files = [filename] + sub_filenames
496c1923 634
e205db3b 635 opts = [
e0da59fe 636 '-c', 'copy', '-map', '0', '-dn',
e205db3b
JMF
637 # Don't copy the existing subtitles, we may be running the
638 # postprocessor a second time
639 '-map', '-0:s',
7f903dd8
T
640 # Don't copy Apple TV chapters track, bin_data (see #19042, #19024,
641 # https://trac.ffmpeg.org/ticket/6016)
642 '-map', '-0:d',
e205db3b 643 ]
5ce1d13e 644 if info['ext'] == 'mp4':
083c1bb9 645 opts += ['-c:s', 'mov_text']
2412044c 646 for i, (lang, name) in enumerate(zip(sub_langs, sub_names)):
2875cf01 647 opts.extend(['-map', '%d:0' % (i + 1)])
04fb6928
S
648 lang_code = ISO639Utils.short2long(lang) or lang
649 opts.extend(['-metadata:s:s:%d' % i, 'language=%s' % lang_code])
2412044c 650 if name:
651 opts.extend(['-metadata:s:s:%d' % i, 'handler_name=%s' % name,
652 '-metadata:s:s:%d' % i, 'title=%s' % name])
496c1923 653
2875cf01 654 temp_filename = prepend_extension(filename, 'temp')
06167fbb 655 self.to_screen('Embedding subtitles in "%s"' % filename)
496c1923 656 self.run_ffmpeg_multiple_files(input_files, temp_filename, opts)
d75201a8 657 os.replace(temp_filename, filename)
496c1923 658
cffab0ee 659 files_to_delete = [] if self._already_have_subtitle else sub_filenames
5ce1d13e 660 return files_to_delete, info
496c1923
PH
661
662
663class FFmpegMetadataPP(FFmpegPostProcessor):
7dde84f3 664
dac5df5a 665 def __init__(self, downloader, add_metadata=True, add_chapters=True, add_infojson='if_exists'):
7a340e0d
NA
666 FFmpegPostProcessor.__init__(self, downloader)
667 self._add_metadata = add_metadata
668 self._add_chapters = add_chapters
dac5df5a 669 self._add_infojson = add_infojson
7a340e0d 670
7dde84f3 671 @staticmethod
672 def _options(target_ext):
673 yield from ('-map', '0', '-dn')
674 if target_ext == 'm4a':
675 yield from ('-vn', '-acodec', 'copy')
676 else:
677 yield from ('-c', 'copy')
678
8326b00a 679 @PostProcessor._restrict_to(images=False)
496c1923 680 def run(self, info):
7a340e0d 681 filename, metadata_filename = info['filepath'], None
dac5df5a 682 files_to_delete, options = [], []
7a340e0d
NA
683 if self._add_chapters and info.get('chapters'):
684 metadata_filename = replace_extension(filename, 'meta')
685 options.extend(self._get_chapter_opts(info['chapters'], metadata_filename))
dac5df5a 686 files_to_delete.append(metadata_filename)
7a340e0d
NA
687 if self._add_metadata:
688 options.extend(self._get_metadata_opts(info))
689
dac5df5a 690 if self._add_infojson:
691 if info['ext'] in ('mkv', 'mka'):
692 infojson_filename = info.get('infojson_filename')
693 options.extend(self._get_infojson_opts(info, infojson_filename))
694 if not infojson_filename:
695 files_to_delete.append(info.get('infojson_filename'))
696 elif self._add_infojson is True:
697 self.to_screen('The info-json can only be attached to mkv/mka files')
698
7a340e0d
NA
699 if not options:
700 self.to_screen('There isn\'t any metadata to add')
701 return [], info
702
703 temp_filename = prepend_extension(filename, 'temp')
704 self.to_screen('Adding metadata to "%s"' % filename)
705 self.run_ffmpeg_multiple_files(
706 (filename, metadata_filename), temp_filename,
707 itertools.chain(self._options(info['ext']), *options))
dac5df5a 708 for file in filter(None, files_to_delete):
709 os.remove(file) # Don't obey --keep-files
7a340e0d
NA
710 os.replace(temp_filename, filename)
711 return [], info
712
713 @staticmethod
714 def _get_chapter_opts(chapters, metadata_filename):
715 with io.open(metadata_filename, 'wt', encoding='utf-8') as f:
716 def ffmpeg_escape(text):
717 return re.sub(r'([\\=;#\n])', r'\\\1', text)
718
719 metadata_file_content = ';FFMETADATA1\n'
720 for chapter in chapters:
721 metadata_file_content += '[CHAPTER]\nTIMEBASE=1/1000\n'
722 metadata_file_content += 'START=%d\n' % (chapter['start_time'] * 1000)
723 metadata_file_content += 'END=%d\n' % (chapter['end_time'] * 1000)
724 chapter_title = chapter.get('title')
725 if chapter_title:
726 metadata_file_content += 'title=%s\n' % ffmpeg_escape(chapter_title)
727 f.write(metadata_file_content)
728 yield ('-map_metadata', '1')
729
730 def _get_metadata_opts(self, info):
496c1923 731 metadata = {}
b11d2101 732 meta_prefix = 'meta_'
4bd143a3
S
733
734 def add(meta_list, info_list=None):
b11d2101 735 value = next((
736 str(info[key]) for key in [meta_prefix] + list(variadic(info_list or meta_list))
737 if info.get(key) is not None), None)
738 if value not in ('', None):
739 metadata.update({meta_f: value for meta_f in variadic(meta_list)})
4bd143a3 740
2791e80b
S
741 # See [1-4] for some info on media metadata/metadata supported
742 # by ffmpeg.
743 # 1. https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/
744 # 2. https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
745 # 3. https://kodi.wiki/view/Video_file_tagging
2791e80b 746
4bd143a3
S
747 add('title', ('track', 'title'))
748 add('date', 'upload_date')
cd9b384c 749 add(('description', 'synopsis'), 'description')
750 add(('purl', 'comment'), 'webpage_url')
4bd143a3
S
751 add('track', 'track_number')
752 add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
753 add('genre')
754 add('album')
755 add('album_artist')
756 add('disc', 'disc_number')
2791e80b
S
757 add('show', 'series')
758 add('season_number')
759 add('episode_id', ('episode', 'episode_id'))
760 add('episode_sort', 'episode_number')
f279aaee 761 if 'embed-metadata' in self.get_param('compat_opts', []):
762 add('comment', 'description')
763 metadata.pop('synopsis', None)
496c1923 764
b11d2101 765 for key, value in info.items():
766 if value is not None and key != meta_prefix and key.startswith(meta_prefix):
767 metadata[key[len(meta_prefix):]] = value
84601bb7 768
7a340e0d
NA
769 for name, value in metadata.items():
770 yield ('-metadata', f'{name}={value}')
39c68260 771
7dde84f3 772 stream_idx = 0
773 for fmt in info.get('requested_formats') or []:
774 stream_count = 2 if 'none' not in (fmt.get('vcodec'), fmt.get('acodec')) else 1
775 if fmt.get('language'):
776 lang = ISO639Utils.short2long(fmt['language']) or fmt['language']
7a340e0d
NA
777 for i in range(stream_count):
778 yield ('-metadata:s:%d' % (stream_idx + i), 'language=%s' % lang)
7dde84f3 779 stream_idx += stream_count
496c1923 780
dac5df5a 781 def _get_infojson_opts(self, info, infofn):
782 if not infofn or not os.path.exists(infofn):
783 if self._add_infojson is not True:
784 return
785 infofn = infofn or '%s.temp' % (
786 self._downloader.prepare_filename(info, 'infojson')
787 or replace_extension(self._downloader.prepare_filename(info), 'info.json', info['ext']))
788 if not self._downloader._ensure_dir_exists(infofn):
789 return
790 self.write_debug(f'Writing info-json to: {infofn}')
791 write_json_file(self._downloader.sanitize_info(info, self.get_param('clean_infojson', True)), infofn)
792 info['infojson_filename'] = infofn
793
794 old_stream, new_stream = self.get_stream_number(info['filepath'], ('tags', 'mimetype'), 'application/json')
795 if old_stream is not None:
796 yield ('-map', '-0:%d' % old_stream)
797 new_stream -= 1
06167fbb 798
dac5df5a 799 yield ('-attach', infofn,
800 '-metadata:s:%d' % new_stream, 'mimetype=application/json')
496c1923
PH
801
802
803class FFmpegMergerPP(FFmpegPostProcessor):
8326b00a 804 @PostProcessor._restrict_to(images=False)
496c1923
PH
805 def run(self, info):
806 filename = info['filepath']
5b5fbc08 807 temp_filename = prepend_extension(filename, 'temp')
d03cfdce 808 args = ['-c', 'copy']
50eff38c 809 audio_streams = 0
d03cfdce 810 for (i, fmt) in enumerate(info['requested_formats']):
811 if fmt.get('acodec') != 'none':
a21e0ab1 812 args.extend(['-map', f'{i}:a:0'])
9dda99f2 813 aac_fixup = fmt['protocol'].startswith('m3u8') and self.get_audio_codec(fmt['filepath']) == 'aac'
814 if aac_fixup:
50eff38c 815 args.extend([f'-bsf:a:{audio_streams}', 'aac_adtstoasc'])
816 audio_streams += 1
d03cfdce 817 if fmt.get('vcodec') != 'none':
818 args.extend(['-map', '%u:v:0' % (i)])
1b77b347 819 self.to_screen('Merging formats into "%s"' % filename)
5b5fbc08
JMF
820 self.run_ffmpeg_multiple_files(info['__files_to_merge'], temp_filename, args)
821 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
d47aeb22 822 return info['__files_to_merge'], info
496c1923 823
13763ce5
S
824 def can_merge(self):
825 # TODO: figure out merge-capable ffmpeg version
826 if self.basename != 'avconv':
827 return True
828
829 required_version = '10-0'
830 if is_outdated_version(
831 self._versions[self.basename], required_version):
832 warning = ('Your copy of %s is outdated and unable to properly mux separate video and audio files, '
7a5c1cfe 833 'yt-dlp will download single file media. '
13763ce5
S
834 'Update %s to version %s or newer to fix this.') % (
835 self.basename, self.basename, required_version)
f446cc66 836 self.report_warning(warning)
13763ce5
S
837 return False
838 return True
839
0c14e2fb 840
fd7cfb64 841class FFmpegFixupPostProcessor(FFmpegPostProcessor):
842 def _fixup(self, msg, filename, options):
6271f1ca
PH
843 temp_filename = prepend_extension(filename, 'temp')
844
f89b3e2d 845 self.to_screen(f'{msg} of "{filename}"')
6271f1ca
PH
846 self.run_ffmpeg(filename, temp_filename, options)
847
d75201a8 848 os.replace(temp_filename, filename)
6271f1ca 849
fd7cfb64 850
851class FFmpegFixupStretchedPP(FFmpegFixupPostProcessor):
852 @PostProcessor._restrict_to(images=False, audio=False)
853 def run(self, info):
854 stretched_ratio = info.get('stretched_ratio')
855 if stretched_ratio not in (None, 1):
856 self._fixup('Fixing aspect ratio', info['filepath'], [
857 '-c', 'copy', '-map', '0', '-dn', '-aspect', '%f' % stretched_ratio])
592e97e8 858 return [], info
62cd676c
PH
859
860
fd7cfb64 861class FFmpegFixupM4aPP(FFmpegFixupPostProcessor):
8326b00a 862 @PostProcessor._restrict_to(images=False, video=False)
62cd676c 863 def run(self, info):
fd7cfb64 864 if info.get('container') == 'm4a_dash':
865 self._fixup('Correcting container', info['filepath'], [
866 '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4'])
592e97e8 867 return [], info
e9fade72
JMF
868
869
fd7cfb64 870class FFmpegFixupM3u8PP(FFmpegFixupPostProcessor):
e04b003e 871 def _needs_fixup(self, info):
872 yield info['ext'] in ('mp4', 'm4a')
873 yield info['protocol'].startswith('m3u8')
874 try:
875 metadata = self.get_metadata_object(info['filepath'])
876 except PostProcessingError as e:
877 self.report_warning(f'Unable to extract metadata: {e.msg}')
878 yield True
879 else:
880 yield traverse_obj(metadata, ('format', 'format_name'), casesense=False) == 'mpegts'
881
8326b00a 882 @PostProcessor._restrict_to(images=False)
f17f8651 883 def run(self, info):
e04b003e 884 if all(self._needs_fixup(info)):
885 self._fixup('Fixing MPEG-TS in MP4 container', info['filepath'], [
fd7cfb64 886 '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
f17f8651 887 return [], info
888
889
e36d50c5 890class FFmpegFixupTimestampPP(FFmpegFixupPostProcessor):
891
892 def __init__(self, downloader=None, trim=0.001):
893 # "trim" should be used when the video contains unintended packets
894 super(FFmpegFixupTimestampPP, self).__init__(downloader)
895 assert isinstance(trim, (int, float))
896 self.trim = str(trim)
897
898 @PostProcessor._restrict_to(images=False)
899 def run(self, info):
832e9000 900 if not self._features.get('setts'):
e36d50c5 901 self.report_warning(
902 'A re-encode is needed to fix timestamps in older versions of ffmpeg. '
832e9000 903 'Please install ffmpeg 4.4 or later to fixup without re-encoding')
e36d50c5 904 opts = ['-vf', 'setpts=PTS-STARTPTS']
905 else:
906 opts = ['-c', 'copy', '-bsf', 'setts=ts=TS-STARTPTS']
907 self._fixup('Fixing frame timestamp', info['filepath'], opts + ['-map', '0', '-dn', '-ss', self.trim])
908 return [], info
909
910
adbc4ec4
THD
911class FFmpegCopyStreamPostProcessor(FFmpegFixupPostProcessor):
912 MESSAGE = 'Copying stream'
913
e36d50c5 914 @PostProcessor._restrict_to(images=False)
915 def run(self, info):
adbc4ec4 916 self._fixup(self.MESSAGE, info['filepath'], ['-c', 'copy', '-map', '0', '-dn'])
e36d50c5 917 return [], info
918
919
adbc4ec4
THD
920class FFmpegFixupDurationPP(FFmpegCopyStreamPostProcessor):
921 MESSAGE = 'Fixing video duration'
922
923
924class FFmpegFixupDuplicateMoovPP(FFmpegCopyStreamPostProcessor):
925 MESSAGE = 'Fixing duplicate MOOV atoms'
926
927
e9fade72 928class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor):
81a23040 929 SUPPORTED_EXTS = ('srt', 'vtt', 'ass', 'lrc')
930
e9fade72
JMF
931 def __init__(self, downloader=None, format=None):
932 super(FFmpegSubtitlesConvertorPP, self).__init__(downloader)
933 self.format = format
934
935 def run(self, info):
936 subs = info.get('requested_subtitles')
e9fade72
JMF
937 new_ext = self.format
938 new_format = new_ext
939 if new_format == 'vtt':
940 new_format = 'webvtt'
941 if subs is None:
1b77b347 942 self.to_screen('There aren\'t any subtitles to convert')
592e97e8 943 return [], info
1b77b347 944 self.to_screen('Converting subtitles')
e04398e3 945 sub_filenames = []
e9fade72 946 for lang, sub in subs.items():
a1c39673 947 if not os.path.exists(sub.get('filepath', '')):
948 self.report_warning(f'Skipping embedding {lang} subtitle because the file is missing')
949 continue
e9fade72
JMF
950 ext = sub['ext']
951 if ext == new_ext:
1b77b347 952 self.to_screen('Subtitle file for %s is already in the requested format' % new_ext)
e9fade72 953 continue
503d4a44 954 elif ext == 'json':
1b77b347 955 self.to_screen(
956 'You have requested to convert json subtitles into another format, '
503d4a44 957 'which is currently not possible')
958 continue
dcf64d43 959 old_file = sub['filepath']
e04398e3 960 sub_filenames.append(old_file)
dcf64d43 961 new_file = replace_extension(old_file, new_ext)
bf6427d2 962
40fcba5e 963 if ext in ('dfxp', 'ttml', 'tt'):
f446cc66 964 self.report_warning(
1b77b347 965 'You have requested to convert dfxp (TTML) subtitles into another format, '
bf6427d2
YCH
966 'which results in style information loss')
967
e04398e3 968 dfxp_file = old_file
dcf64d43 969 srt_file = replace_extension(old_file, 'srt')
bf6427d2 970
3869028f 971 with open(dfxp_file, 'rb') as f:
bf6427d2
YCH
972 srt_data = dfxp2srt(f.read())
973
974 with io.open(srt_file, 'wt', encoding='utf-8') as f:
975 f.write(srt_data)
7e62c2eb 976 old_file = srt_file
bf6427d2 977
bf6427d2
YCH
978 subs[lang] = {
979 'ext': 'srt',
dcf64d43 980 'data': srt_data,
981 'filepath': srt_file,
bf6427d2
YCH
982 }
983
984 if new_ext == 'srt':
985 continue
7b8b007c
JMF
986 else:
987 sub_filenames.append(srt_file)
bf6427d2 988
e04398e3 989 self.run_ffmpeg(old_file, new_file, ['-f', new_format])
e9fade72
JMF
990
991 with io.open(new_file, 'rt', encoding='utf-8') as f:
992 subs[lang] = {
3547d265 993 'ext': new_ext,
e9fade72 994 'data': f.read(),
dcf64d43 995 'filepath': new_file,
e9fade72
JMF
996 }
997
dcf64d43 998 info['__files_to_move'][new_file] = replace_extension(
37242e56 999 info['__files_to_move'][sub['filepath']], new_ext)
dcf64d43 1000
e04398e3 1001 return sub_filenames, info
72755351 1002
1003
1004class FFmpegSplitChaptersPP(FFmpegPostProcessor):
7a340e0d
NA
1005 def __init__(self, downloader, force_keyframes=False):
1006 FFmpegPostProcessor.__init__(self, downloader)
1007 self._force_keyframes = force_keyframes
72755351 1008
1009 def _prepare_filename(self, number, chapter, info):
1010 info = info.copy()
1011 info.update({
1012 'section_number': number,
1013 'section_title': chapter.get('title'),
1014 'section_start': chapter.get('start_time'),
1015 'section_end': chapter.get('end_time'),
1016 })
1017 return self._downloader.prepare_filename(info, 'chapter')
1018
1019 def _ffmpeg_args_for_chapter(self, number, chapter, info):
1020 destination = self._prepare_filename(number, chapter, info)
1021 if not self._downloader._ensure_dir_exists(encodeFilename(destination)):
1022 return
1023
dcf64d43 1024 chapter['filepath'] = destination
72755351 1025 self.to_screen('Chapter %03d; Destination: %s' % (number, destination))
1026 return (
1027 destination,
1028 ['-ss', compat_str(chapter['start_time']),
a94bfd6c 1029 '-t', compat_str(chapter['end_time'] - chapter['start_time'])])
72755351 1030
8326b00a 1031 @PostProcessor._restrict_to(images=False)
72755351 1032 def run(self, info):
1033 chapters = info.get('chapters') or []
1034 if not chapters:
7a340e0d 1035 self.to_screen('Chapter information is unavailable')
72755351 1036 return [], info
1037
7a340e0d
NA
1038 in_file = info['filepath']
1039 if self._force_keyframes and len(chapters) > 1:
1040 in_file = self.force_keyframes(in_file, (c['start_time'] for c in chapters))
72755351 1041 self.to_screen('Splitting video by chapters; %d chapters found' % len(chapters))
1042 for idx, chapter in enumerate(chapters):
1043 destination, opts = self._ffmpeg_args_for_chapter(idx + 1, chapter, info)
7a340e0d
NA
1044 self.real_run_ffmpeg([(in_file, opts)], [(destination, ['-c', 'copy'])])
1045 if in_file != info['filepath']:
1046 os.remove(in_file)
72755351 1047 return [], info
8fa43c73 1048
1049
1050class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
81a23040 1051 SUPPORTED_EXTS = ('jpg', 'png')
1052
8fa43c73 1053 def __init__(self, downloader=None, format=None):
1054 super(FFmpegThumbnailsConvertorPP, self).__init__(downloader)
1055 self.format = format
1056
1057 @staticmethod
1058 def is_webp(path):
1059 with open(encodeFilename(path), 'rb') as f:
1060 b = f.read(12)
1061 return b[0:4] == b'RIFF' and b[8:] == b'WEBP'
1062
1063 def fixup_webp(self, info, idx=-1):
1064 thumbnail_filename = info['thumbnails'][idx]['filepath']
1065 _, thumbnail_ext = os.path.splitext(thumbnail_filename)
1066 if thumbnail_ext:
1067 thumbnail_ext = thumbnail_ext[1:].lower()
1068 if thumbnail_ext != 'webp' and self.is_webp(thumbnail_filename):
1069 self.to_screen('Correcting thumbnail "%s" extension to webp' % thumbnail_filename)
1070 webp_filename = replace_extension(thumbnail_filename, 'webp')
d75201a8 1071 os.replace(thumbnail_filename, webp_filename)
8fa43c73 1072 info['thumbnails'][idx]['filepath'] = webp_filename
1073 info['__files_to_move'][webp_filename] = replace_extension(
1074 info['__files_to_move'].pop(thumbnail_filename), 'webp')
1075
81a23040 1076 @staticmethod
1077 def _options(target_ext):
1078 if target_ext == 'jpg':
1079 return ['-bsf:v', 'mjpeg2jpeg']
1080 return []
1081
1082 def convert_thumbnail(self, thumbnail_filename, target_ext):
81a23040 1083 thumbnail_conv_filename = replace_extension(thumbnail_filename, target_ext)
337e0c62 1084
1085 self.to_screen('Converting thumbnail "%s" to %s' % (thumbnail_filename, target_ext))
1086 self.real_run_ffmpeg(
1087 [(thumbnail_filename, ['-f', 'image2', '-pattern_type', 'none'])],
1088 [(thumbnail_conv_filename.replace('%', '%%'), self._options(target_ext))])
a927acb1 1089 return thumbnail_conv_filename
8fa43c73 1090
1091 def run(self, info):
8fa43c73 1092 files_to_delete = []
1093 has_thumbnail = False
1094
1095 for idx, thumbnail_dict in enumerate(info['thumbnails']):
1096 if 'filepath' not in thumbnail_dict:
1097 continue
1098 has_thumbnail = True
1099 self.fixup_webp(info, idx)
1100 original_thumbnail = thumbnail_dict['filepath']
1101 _, thumbnail_ext = os.path.splitext(original_thumbnail)
1102 if thumbnail_ext:
1103 thumbnail_ext = thumbnail_ext[1:].lower()
15a4fd53 1104 if thumbnail_ext == 'jpeg':
1105 thumbnail_ext = 'jpg'
8fa43c73 1106 if thumbnail_ext == self.format:
1107 self.to_screen('Thumbnail "%s" is already in the requested format' % original_thumbnail)
1108 continue
1109 thumbnail_dict['filepath'] = self.convert_thumbnail(original_thumbnail, self.format)
1110 files_to_delete.append(original_thumbnail)
1111 info['__files_to_move'][thumbnail_dict['filepath']] = replace_extension(
1112 info['__files_to_move'][original_thumbnail], self.format)
1113
1114 if not has_thumbnail:
1115 self.to_screen('There aren\'t any thumbnails to convert')
1116 return files_to_delete, info