]> jfr.im git - yt-dlp.git/blame - yt_dlp/postprocessor/ffmpeg.py
[lazy_extractors] Fix for search IEs
[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]
297 if name == 'o' and number == 1:
298 keys.append('')
299 args += self._configuration_args(self.basename, keys)
300 if name == 'i':
301 args.append('-i')
5b1ecbb3 302 return (
e92caff5 303 [encodeArgument(arg) for arg in args]
5b1ecbb3 304 + [encodeFilename(self._ffmpeg_filename_argument(file), True)])
305
e92caff5 306 for arg_type, path_opts in (('i', input_path_opts), ('o', output_path_opts)):
7dde84f3 307 cmd += itertools.chain.from_iterable(
308 make_args(path, list(opts), arg_type, i + 1)
309 for i, (path, opts) in enumerate(path_opts) if path)
496c1923 310
f446cc66 311 self.write_debug('ffmpeg command line: %s' % shell_quote(cmd))
d3c93ec2 312 p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
313 stdout, stderr = p.communicate_or_kill()
00034c14 314 if p.returncode not in variadic(expected_retcodes):
06167fbb 315 stderr = stderr.decode('utf-8', 'replace').strip()
b1940459 316 self.write_debug(stderr)
06167fbb 317 raise FFmpegPostProcessorError(stderr.split('\n')[-1])
e92caff5 318 for out_path, _ in output_path_opts:
7dde84f3 319 if out_path:
320 self.try_utime(out_path, oldest_mtime, oldest_mtime)
06167fbb 321 return stderr.decode('utf-8', 'replace')
cc55d088 322
00034c14 323 def run_ffmpeg(self, path, out_path, opts, **kwargs):
324 return self.run_ffmpeg_multiple_files([path], out_path, opts, **kwargs)
496c1923 325
7a340e0d
NA
326 @staticmethod
327 def _ffmpeg_filename_argument(fn):
8a7bbd16
JMF
328 # Always use 'file:' because the filename may contain ':' (ffmpeg
329 # interprets that as a protocol) or can start with '-' (-- is broken in
330 # ffmpeg, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details)
b9f2fdd3 331 # Also leave '-' intact in order not to break streaming to stdout.
06167fbb 332 if fn.startswith(('http://', 'https://')):
333 return fn
d868f43c 334 return 'file:' + fn if fn != '-' else fn
496c1923 335
7a340e0d
NA
336 @staticmethod
337 def _quote_for_ffmpeg(string):
338 # See https://ffmpeg.org/ffmpeg-utils.html#toc-Quoting-and-escaping
339 # A sequence of '' produces '\'''\'';
340 # final replace removes the empty '' between \' \'.
341 string = string.replace("'", r"'\''").replace("'''", "'")
342 # Handle potential ' at string boundaries.
343 string = string[1:] if string[0] == "'" else "'" + string
344 return string[:-1] if string[-1] == "'" else string + "'"
345
346 def force_keyframes(self, filename, timestamps):
347 timestamps = orderedSet(timestamps)
348 if timestamps[0] == 0:
349 timestamps = timestamps[1:]
350 keyframe_file = prepend_extension(filename, 'keyframes.temp')
351 self.to_screen(f'Re-encoding "{filename}" with appropriate keyframes')
352 self.run_ffmpeg(filename, keyframe_file, ['-force_key_frames', ','.join(
353 f'{t:.6f}' for t in timestamps)])
354 return keyframe_file
355
356 def concat_files(self, in_files, out_file, concat_opts=None):
357 """
358 Use concat demuxer to concatenate multiple files having identical streams.
359
360 Only inpoint, outpoint, and duration concat options are supported.
361 See https://ffmpeg.org/ffmpeg-formats.html#concat-1 for details
362 """
363 concat_file = f'{out_file}.concat'
364 self.write_debug(f'Writing concat spec to {concat_file}')
365 with open(concat_file, 'wt', encoding='utf-8') as f:
366 f.writelines(self._concat_spec(in_files, concat_opts))
367
368 out_flags = ['-c', 'copy']
369 if out_file.rpartition('.')[-1] in ('mp4', 'mov'):
370 # For some reason, '-c copy' is not enough to copy subtitles
371 out_flags.extend(['-c:s', 'mov_text', '-movflags', '+faststart'])
372
373 try:
374 self.real_run_ffmpeg(
375 [(concat_file, ['-hide_banner', '-nostdin', '-f', 'concat', '-safe', '0'])],
376 [(out_file, out_flags)])
377 finally:
378 os.remove(concat_file)
379
380 @classmethod
381 def _concat_spec(cls, in_files, concat_opts=None):
382 if concat_opts is None:
383 concat_opts = [{}] * len(in_files)
384 yield 'ffconcat version 1.0\n'
385 for file, opts in zip(in_files, concat_opts):
386 yield f'file {cls._quote_for_ffmpeg(cls._ffmpeg_filename_argument(file))}\n'
387 # Iterate explicitly to yield the following directives in order, ignoring the rest.
388 for directive in 'inpoint', 'outpoint', 'duration':
389 if directive in opts:
390 yield f'{directive} {opts[directive]}\n'
391
496c1923
PH
392
393class FFmpegExtractAudioPP(FFmpegPostProcessor):
81a23040 394 COMMON_AUDIO_EXTS = ('wav', 'flac', 'm4a', 'aiff', 'mp3', 'ogg', 'mka', 'opus', 'wma')
467b6b83 395 SUPPORTED_EXTS = ('best', 'aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav', 'alac')
1de75fa1 396
496c1923
PH
397 def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
398 FFmpegPostProcessor.__init__(self, downloader)
81a23040 399 self._preferredcodec = preferredcodec or 'best'
31c49255 400 self._preferredquality = float_or_none(preferredquality)
496c1923
PH
401 self._nopostoverwrites = nopostoverwrites
402
31c49255 403 def _quality_args(self, codec):
404 if self._preferredquality is None:
405 return []
406 elif self._preferredquality > 10:
407 return ['-b:a', f'{self._preferredquality}k']
408
409 limits = {
410 'libmp3lame': (10, 0),
467b6b83 411 'libvorbis': (0, 10),
9af98e17 412 # FFmpeg's AAC encoder does not have an upper limit for the value of -q:a.
413 # Experimentally, with values over 4, bitrate changes were minimal or non-existent
414 'aac': (0.1, 4),
673c0057 415 'libfdk_aac': (1, 5),
39c04074 416 }.get(codec)
31c49255 417 if not limits:
418 return []
419
420 q = limits[1] + (limits[0] - limits[1]) * (self._preferredquality / 10)
673c0057
C
421 if codec == 'libfdk_aac':
422 return ['-vbr', f'{int(q)}']
31c49255 423 return ['-q:a', f'{q}']
424
496c1923 425 def run_ffmpeg(self, path, out_path, codec, more_opts):
496c1923
PH
426 if codec is None:
427 acodec_opts = []
428 else:
429 acodec_opts = ['-acodec', codec]
430 opts = ['-vn'] + acodec_opts + more_opts
431 try:
432 FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
433 except FFmpegPostProcessorError as err:
434 raise AudioConversionError(err.msg)
435
8326b00a 436 @PostProcessor._restrict_to(images=False)
496c1923 437 def run(self, information):
467b6b83 438 orig_path = path = information['filepath']
1de75fa1 439 orig_ext = information['ext']
440
81a23040 441 if self._preferredcodec == 'best' and orig_ext in self.COMMON_AUDIO_EXTS:
1de75fa1 442 self.to_screen('Skipping audio extraction since the file is already in a common audio format')
55b53b33 443 return [], information
496c1923
PH
444
445 filecodec = self.get_audio_codec(path)
446 if filecodec is None:
3aa578ca 447 raise PostProcessingError('WARNING: unable to obtain file audio codec with ffprobe')
496c1923
PH
448
449 more_opts = []
450 if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
451 if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
452 # Lossless, but in another container
453 acodec = 'copy'
454 extension = 'm4a'
467d3c9a 455 more_opts = ['-bsf:a', 'aac_adtstoasc']
21bfcd3d 456 elif filecodec in ['aac', 'flac', 'mp3', 'vorbis', 'opus']:
496c1923
PH
457 # Lossless if possible
458 acodec = 'copy'
459 extension = filecodec
460 if filecodec == 'aac':
461 more_opts = ['-f', 'adts']
462 if filecodec == 'vorbis':
463 extension = 'ogg'
467b6b83 464 elif filecodec == 'alac':
465 acodec = None
466 extension = 'm4a'
467 more_opts += ['-acodec', 'alac']
496c1923
PH
468 else:
469 # MP3 otherwise.
470 acodec = 'libmp3lame'
471 extension = 'mp3'
31c49255 472 more_opts = self._quality_args(acodec)
496c1923 473 else:
21bfcd3d
PH
474 # We convert the audio (lossy if codec is lossy)
475 acodec = ACODECS[self._preferredcodec]
673c0057
C
476 if acodec == 'aac' and self._features.get('fdk'):
477 acodec = 'libfdk_aac'
496c1923 478 extension = self._preferredcodec
31c49255 479 more_opts = self._quality_args(acodec)
496c1923
PH
480 if self._preferredcodec == 'aac':
481 more_opts += ['-f', 'adts']
467b6b83 482 elif self._preferredcodec == 'm4a':
467d3c9a 483 more_opts += ['-bsf:a', 'aac_adtstoasc']
467b6b83 484 elif self._preferredcodec == 'vorbis':
496c1923 485 extension = 'ogg'
467b6b83 486 elif self._preferredcodec == 'wav':
496c1923
PH
487 extension = 'wav'
488 more_opts += ['-f', 'wav']
467b6b83 489 elif self._preferredcodec == 'alac':
490 extension = 'm4a'
491 more_opts += ['-acodec', 'alac']
496c1923 492
3aa578ca 493 prefix, sep, ext = path.rpartition('.') # not os.path.splitext, since the latter does not work on unicode in all setups
467b6b83 494 temp_path = new_path = prefix + sep + extension
496c1923 495
467b6b83 496 if new_path == path:
497 orig_path = prepend_extension(path, 'orig')
498 temp_path = prepend_extension(path, 'temp')
499 if (self._nopostoverwrites and os.path.exists(encodeFilename(new_path))
500 and os.path.exists(encodeFilename(orig_path))):
1b77b347 501 self.to_screen('Post-process file %s exists, skipping' % new_path)
592e97e8 502 return [], information
496c1923
PH
503
504 try:
467b6b83 505 self.to_screen(f'Destination: {new_path}')
506 self.run_ffmpeg(path, temp_path, acodec, more_opts)
70a1165b
JMF
507 except AudioConversionError as e:
508 raise PostProcessingError(
509 'audio conversion failed: ' + e.msg)
510 except Exception:
511 raise PostProcessingError('error running ' + self.basename)
496c1923 512
467b6b83 513 os.replace(path, orig_path)
514 os.replace(temp_path, new_path)
515 information['filepath'] = new_path
516 information['ext'] = extension
517
496c1923
PH
518 # Try to update the date time for extracted audio file.
519 if information.get('filetime') is not None:
dd29eb7f
S
520 self.try_utime(
521 new_path, time.time(), information['filetime'],
522 errnote='Cannot update utime of audio file')
496c1923 523
467b6b83 524 return [orig_path], information
496c1923
PH
525
526
857f6313 527class FFmpegVideoConvertorPP(FFmpegPostProcessor):
81a23040 528 SUPPORTED_EXTS = ('mp4', 'mkv', 'flv', 'webm', 'mov', 'avi', 'mp3', 'mka', 'm4a', 'ogg', 'opus')
529 FORMAT_RE = re.compile(r'{0}(?:/{0})*$'.format(r'(?:\w+>)?(?:%s)' % '|'.join(SUPPORTED_EXTS)))
e6f21b3d 530 _ACTION = 'converting'
857f6313 531
efe87a10 532 def __init__(self, downloader=None, preferedformat=None):
857f6313 533 super(FFmpegVideoConvertorPP, self).__init__(downloader)
06167fbb 534 self._preferedformats = preferedformat.lower().split('/')
efe87a10 535
857f6313 536 def _target_ext(self, source_ext):
06167fbb 537 for pair in self._preferedformats:
538 kv = pair.split('>')
857f6313 539 if len(kv) == 1 or kv[0].strip() == source_ext:
540 return kv[-1].strip()
06167fbb 541
857f6313 542 @staticmethod
543 def _options(target_ext):
544 if target_ext == 'avi':
545 return ['-c:v', 'libxvid', '-vtag', 'XVID']
546 return []
547
8326b00a 548 @PostProcessor._restrict_to(images=False)
e6f21b3d 549 def run(self, info):
550 filename, source_ext = info['filepath'], info['ext'].lower()
81a23040 551 target_ext = self._target_ext(source_ext)
06167fbb 552 _skip_msg = (
e6f21b3d 553 f'could not find a mapping for {source_ext}' if not target_ext
554 else f'already is in target format {source_ext}' if source_ext == target_ext
06167fbb 555 else None)
556 if _skip_msg:
e6f21b3d 557 self.to_screen(f'Not {self._ACTION} media file {filename!r}; {_skip_msg}')
558 return [], info
06167fbb 559
e6f21b3d 560 outpath = replace_extension(filename, target_ext, source_ext)
561 self.to_screen(f'{self._ACTION.title()} video from {source_ext} to {target_ext}; Destination: {outpath}')
562 self.run_ffmpeg(filename, outpath, self._options(target_ext))
857f6313 563
e6f21b3d 564 info['filepath'] = outpath
565 info['format'] = info['ext'] = target_ext
566 return [filename], info
efe87a10
FS
567
568
857f6313 569class FFmpegVideoRemuxerPP(FFmpegVideoConvertorPP):
e6f21b3d 570 _ACTION = 'remuxing'
496c1923 571
857f6313 572 @staticmethod
573 def _options(target_ext):
574 options = ['-c', 'copy', '-map', '0', '-dn']
575 if target_ext in ['mp4', 'm4a', 'mov']:
576 options.extend(['-movflags', '+faststart'])
577 return options
496c1923
PH
578
579
580class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
cffab0ee 581 def __init__(self, downloader=None, already_have_subtitle=False):
582 super(FFmpegEmbedSubtitlePP, self).__init__(downloader)
583 self._already_have_subtitle = already_have_subtitle
584
8326b00a 585 @PostProcessor._restrict_to(images=False)
5ce1d13e 586 def run(self, info):
587 if info['ext'] not in ('mp4', 'webm', 'mkv'):
1b77b347 588 self.to_screen('Subtitles can only be embedded in mp4, webm or mkv files')
5ce1d13e 589 return [], info
590 subtitles = info.get('requested_subtitles')
c84dd8a9 591 if not subtitles:
1b77b347 592 self.to_screen('There aren\'t any subtitles to embed')
5ce1d13e 593 return [], info
496c1923 594
5ce1d13e 595 filename = info['filepath']
596 if info.get('duration') and not info.get('__real_download') and self._duration_mismatch(
597 self._get_real_video_duration(filename, False), info['duration']):
165efb82 598 self.to_screen(f'Skipping {self.pp_key()} since the real and expected durations mismatch')
5ce1d13e 599 return [], info
40025ee2 600
5ce1d13e 601 ext = info['ext']
2412044c 602 sub_langs, sub_names, sub_filenames = [], [], []
40025ee2 603 webm_vtt_warn = False
06167fbb 604 mp4_ass_warn = False
40025ee2
S
605
606 for lang, sub_info in subtitles.items():
a1c39673 607 if not os.path.exists(sub_info.get('filepath', '')):
8e25d624 608 self.report_warning(f'Skipping embedding {lang} subtitle because the file is missing')
609 continue
40025ee2 610 sub_ext = sub_info['ext']
503d4a44 611 if sub_ext == 'json':
06167fbb 612 self.report_warning('JSON subtitles cannot be embedded')
503d4a44 613 elif ext != 'webm' or ext == 'webm' and sub_ext == 'vtt':
40025ee2 614 sub_langs.append(lang)
2412044c 615 sub_names.append(sub_info.get('name'))
dcf64d43 616 sub_filenames.append(sub_info['filepath'])
40025ee2
S
617 else:
618 if not webm_vtt_warn and ext == 'webm' and sub_ext != 'vtt':
619 webm_vtt_warn = True
06167fbb 620 self.report_warning('Only WebVTT subtitles can be embedded in webm files')
621 if not mp4_ass_warn and ext == 'mp4' and sub_ext == 'ass':
622 mp4_ass_warn = True
623 self.report_warning('ASS subtitles cannot be properly embedded in mp4 files; expect issues')
40025ee2
S
624
625 if not sub_langs:
5ce1d13e 626 return [], info
40025ee2 627
14523ed9 628 input_files = [filename] + sub_filenames
496c1923 629
e205db3b 630 opts = [
e0da59fe 631 '-c', 'copy', '-map', '0', '-dn',
e205db3b
JMF
632 # Don't copy the existing subtitles, we may be running the
633 # postprocessor a second time
634 '-map', '-0:s',
7f903dd8
T
635 # Don't copy Apple TV chapters track, bin_data (see #19042, #19024,
636 # https://trac.ffmpeg.org/ticket/6016)
637 '-map', '-0:d',
e205db3b 638 ]
5ce1d13e 639 if info['ext'] == 'mp4':
083c1bb9 640 opts += ['-c:s', 'mov_text']
2412044c 641 for i, (lang, name) in enumerate(zip(sub_langs, sub_names)):
2875cf01 642 opts.extend(['-map', '%d:0' % (i + 1)])
04fb6928
S
643 lang_code = ISO639Utils.short2long(lang) or lang
644 opts.extend(['-metadata:s:s:%d' % i, 'language=%s' % lang_code])
2412044c 645 if name:
646 opts.extend(['-metadata:s:s:%d' % i, 'handler_name=%s' % name,
647 '-metadata:s:s:%d' % i, 'title=%s' % name])
496c1923 648
2875cf01 649 temp_filename = prepend_extension(filename, 'temp')
06167fbb 650 self.to_screen('Embedding subtitles in "%s"' % filename)
496c1923 651 self.run_ffmpeg_multiple_files(input_files, temp_filename, opts)
d75201a8 652 os.replace(temp_filename, filename)
496c1923 653
cffab0ee 654 files_to_delete = [] if self._already_have_subtitle else sub_filenames
5ce1d13e 655 return files_to_delete, info
496c1923
PH
656
657
658class FFmpegMetadataPP(FFmpegPostProcessor):
7dde84f3 659
dac5df5a 660 def __init__(self, downloader, add_metadata=True, add_chapters=True, add_infojson='if_exists'):
7a340e0d
NA
661 FFmpegPostProcessor.__init__(self, downloader)
662 self._add_metadata = add_metadata
663 self._add_chapters = add_chapters
dac5df5a 664 self._add_infojson = add_infojson
7a340e0d 665
7dde84f3 666 @staticmethod
667 def _options(target_ext):
668 yield from ('-map', '0', '-dn')
669 if target_ext == 'm4a':
670 yield from ('-vn', '-acodec', 'copy')
671 else:
672 yield from ('-c', 'copy')
673
8326b00a 674 @PostProcessor._restrict_to(images=False)
496c1923 675 def run(self, info):
7a340e0d 676 filename, metadata_filename = info['filepath'], None
dac5df5a 677 files_to_delete, options = [], []
7a340e0d
NA
678 if self._add_chapters and info.get('chapters'):
679 metadata_filename = replace_extension(filename, 'meta')
680 options.extend(self._get_chapter_opts(info['chapters'], metadata_filename))
dac5df5a 681 files_to_delete.append(metadata_filename)
7a340e0d
NA
682 if self._add_metadata:
683 options.extend(self._get_metadata_opts(info))
684
dac5df5a 685 if self._add_infojson:
686 if info['ext'] in ('mkv', 'mka'):
687 infojson_filename = info.get('infojson_filename')
688 options.extend(self._get_infojson_opts(info, infojson_filename))
689 if not infojson_filename:
690 files_to_delete.append(info.get('infojson_filename'))
691 elif self._add_infojson is True:
692 self.to_screen('The info-json can only be attached to mkv/mka files')
693
7a340e0d
NA
694 if not options:
695 self.to_screen('There isn\'t any metadata to add')
696 return [], info
697
698 temp_filename = prepend_extension(filename, 'temp')
699 self.to_screen('Adding metadata to "%s"' % filename)
700 self.run_ffmpeg_multiple_files(
701 (filename, metadata_filename), temp_filename,
702 itertools.chain(self._options(info['ext']), *options))
dac5df5a 703 for file in filter(None, files_to_delete):
704 os.remove(file) # Don't obey --keep-files
7a340e0d
NA
705 os.replace(temp_filename, filename)
706 return [], info
707
708 @staticmethod
709 def _get_chapter_opts(chapters, metadata_filename):
710 with io.open(metadata_filename, 'wt', encoding='utf-8') as f:
711 def ffmpeg_escape(text):
712 return re.sub(r'([\\=;#\n])', r'\\\1', text)
713
714 metadata_file_content = ';FFMETADATA1\n'
715 for chapter in chapters:
716 metadata_file_content += '[CHAPTER]\nTIMEBASE=1/1000\n'
717 metadata_file_content += 'START=%d\n' % (chapter['start_time'] * 1000)
718 metadata_file_content += 'END=%d\n' % (chapter['end_time'] * 1000)
719 chapter_title = chapter.get('title')
720 if chapter_title:
721 metadata_file_content += 'title=%s\n' % ffmpeg_escape(chapter_title)
722 f.write(metadata_file_content)
723 yield ('-map_metadata', '1')
724
725 def _get_metadata_opts(self, info):
496c1923 726 metadata = {}
b11d2101 727 meta_prefix = 'meta_'
4bd143a3
S
728
729 def add(meta_list, info_list=None):
b11d2101 730 value = next((
731 str(info[key]) for key in [meta_prefix] + list(variadic(info_list or meta_list))
732 if info.get(key) is not None), None)
733 if value not in ('', None):
734 metadata.update({meta_f: value for meta_f in variadic(meta_list)})
4bd143a3 735
2791e80b
S
736 # See [1-4] for some info on media metadata/metadata supported
737 # by ffmpeg.
738 # 1. https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/
739 # 2. https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
740 # 3. https://kodi.wiki/view/Video_file_tagging
2791e80b 741
4bd143a3
S
742 add('title', ('track', 'title'))
743 add('date', 'upload_date')
cd9b384c 744 add(('description', 'synopsis'), 'description')
745 add(('purl', 'comment'), 'webpage_url')
4bd143a3
S
746 add('track', 'track_number')
747 add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
748 add('genre')
749 add('album')
750 add('album_artist')
751 add('disc', 'disc_number')
2791e80b
S
752 add('show', 'series')
753 add('season_number')
754 add('episode_id', ('episode', 'episode_id'))
755 add('episode_sort', 'episode_number')
f279aaee 756 if 'embed-metadata' in self.get_param('compat_opts', []):
757 add('comment', 'description')
758 metadata.pop('synopsis', None)
496c1923 759
b11d2101 760 for key, value in info.items():
761 if value is not None and key != meta_prefix and key.startswith(meta_prefix):
762 metadata[key[len(meta_prefix):]] = value
84601bb7 763
7a340e0d
NA
764 for name, value in metadata.items():
765 yield ('-metadata', f'{name}={value}')
39c68260 766
7dde84f3 767 stream_idx = 0
768 for fmt in info.get('requested_formats') or []:
769 stream_count = 2 if 'none' not in (fmt.get('vcodec'), fmt.get('acodec')) else 1
770 if fmt.get('language'):
771 lang = ISO639Utils.short2long(fmt['language']) or fmt['language']
7a340e0d
NA
772 for i in range(stream_count):
773 yield ('-metadata:s:%d' % (stream_idx + i), 'language=%s' % lang)
7dde84f3 774 stream_idx += stream_count
496c1923 775
dac5df5a 776 def _get_infojson_opts(self, info, infofn):
777 if not infofn or not os.path.exists(infofn):
778 if self._add_infojson is not True:
779 return
780 infofn = infofn or '%s.temp' % (
781 self._downloader.prepare_filename(info, 'infojson')
782 or replace_extension(self._downloader.prepare_filename(info), 'info.json', info['ext']))
783 if not self._downloader._ensure_dir_exists(infofn):
784 return
785 self.write_debug(f'Writing info-json to: {infofn}')
786 write_json_file(self._downloader.sanitize_info(info, self.get_param('clean_infojson', True)), infofn)
787 info['infojson_filename'] = infofn
788
789 old_stream, new_stream = self.get_stream_number(info['filepath'], ('tags', 'mimetype'), 'application/json')
790 if old_stream is not None:
791 yield ('-map', '-0:%d' % old_stream)
792 new_stream -= 1
06167fbb 793
dac5df5a 794 yield ('-attach', infofn,
795 '-metadata:s:%d' % new_stream, 'mimetype=application/json')
496c1923
PH
796
797
798class FFmpegMergerPP(FFmpegPostProcessor):
8326b00a 799 @PostProcessor._restrict_to(images=False)
496c1923
PH
800 def run(self, info):
801 filename = info['filepath']
5b5fbc08 802 temp_filename = prepend_extension(filename, 'temp')
d03cfdce 803 args = ['-c', 'copy']
50eff38c 804 audio_streams = 0
d03cfdce 805 for (i, fmt) in enumerate(info['requested_formats']):
806 if fmt.get('acodec') != 'none':
a21e0ab1 807 args.extend(['-map', f'{i}:a:0'])
9dda99f2 808 aac_fixup = fmt['protocol'].startswith('m3u8') and self.get_audio_codec(fmt['filepath']) == 'aac'
809 if aac_fixup:
50eff38c 810 args.extend([f'-bsf:a:{audio_streams}', 'aac_adtstoasc'])
811 audio_streams += 1
d03cfdce 812 if fmt.get('vcodec') != 'none':
813 args.extend(['-map', '%u:v:0' % (i)])
1b77b347 814 self.to_screen('Merging formats into "%s"' % filename)
5b5fbc08
JMF
815 self.run_ffmpeg_multiple_files(info['__files_to_merge'], temp_filename, args)
816 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
d47aeb22 817 return info['__files_to_merge'], info
496c1923 818
13763ce5
S
819 def can_merge(self):
820 # TODO: figure out merge-capable ffmpeg version
821 if self.basename != 'avconv':
822 return True
823
824 required_version = '10-0'
825 if is_outdated_version(
826 self._versions[self.basename], required_version):
827 warning = ('Your copy of %s is outdated and unable to properly mux separate video and audio files, '
7a5c1cfe 828 'yt-dlp will download single file media. '
13763ce5
S
829 'Update %s to version %s or newer to fix this.') % (
830 self.basename, self.basename, required_version)
f446cc66 831 self.report_warning(warning)
13763ce5
S
832 return False
833 return True
834
0c14e2fb 835
fd7cfb64 836class FFmpegFixupPostProcessor(FFmpegPostProcessor):
837 def _fixup(self, msg, filename, options):
6271f1ca
PH
838 temp_filename = prepend_extension(filename, 'temp')
839
f89b3e2d 840 self.to_screen(f'{msg} of "{filename}"')
6271f1ca
PH
841 self.run_ffmpeg(filename, temp_filename, options)
842
d75201a8 843 os.replace(temp_filename, filename)
6271f1ca 844
fd7cfb64 845
846class FFmpegFixupStretchedPP(FFmpegFixupPostProcessor):
847 @PostProcessor._restrict_to(images=False, audio=False)
848 def run(self, info):
849 stretched_ratio = info.get('stretched_ratio')
850 if stretched_ratio not in (None, 1):
851 self._fixup('Fixing aspect ratio', info['filepath'], [
852 '-c', 'copy', '-map', '0', '-dn', '-aspect', '%f' % stretched_ratio])
592e97e8 853 return [], info
62cd676c
PH
854
855
fd7cfb64 856class FFmpegFixupM4aPP(FFmpegFixupPostProcessor):
8326b00a 857 @PostProcessor._restrict_to(images=False, video=False)
62cd676c 858 def run(self, info):
fd7cfb64 859 if info.get('container') == 'm4a_dash':
860 self._fixup('Correcting container', info['filepath'], [
861 '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4'])
592e97e8 862 return [], info
e9fade72
JMF
863
864
fd7cfb64 865class FFmpegFixupM3u8PP(FFmpegFixupPostProcessor):
e04b003e 866 def _needs_fixup(self, info):
867 yield info['ext'] in ('mp4', 'm4a')
868 yield info['protocol'].startswith('m3u8')
869 try:
870 metadata = self.get_metadata_object(info['filepath'])
871 except PostProcessingError as e:
872 self.report_warning(f'Unable to extract metadata: {e.msg}')
873 yield True
874 else:
875 yield traverse_obj(metadata, ('format', 'format_name'), casesense=False) == 'mpegts'
876
8326b00a 877 @PostProcessor._restrict_to(images=False)
f17f8651 878 def run(self, info):
e04b003e 879 if all(self._needs_fixup(info)):
880 self._fixup('Fixing MPEG-TS in MP4 container', info['filepath'], [
fd7cfb64 881 '-c', 'copy', '-map', '0', '-dn', '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
f17f8651 882 return [], info
883
884
e36d50c5 885class FFmpegFixupTimestampPP(FFmpegFixupPostProcessor):
886
887 def __init__(self, downloader=None, trim=0.001):
888 # "trim" should be used when the video contains unintended packets
889 super(FFmpegFixupTimestampPP, self).__init__(downloader)
890 assert isinstance(trim, (int, float))
891 self.trim = str(trim)
892
893 @PostProcessor._restrict_to(images=False)
894 def run(self, info):
832e9000 895 if not self._features.get('setts'):
e36d50c5 896 self.report_warning(
897 'A re-encode is needed to fix timestamps in older versions of ffmpeg. '
832e9000 898 'Please install ffmpeg 4.4 or later to fixup without re-encoding')
e36d50c5 899 opts = ['-vf', 'setpts=PTS-STARTPTS']
900 else:
901 opts = ['-c', 'copy', '-bsf', 'setts=ts=TS-STARTPTS']
902 self._fixup('Fixing frame timestamp', info['filepath'], opts + ['-map', '0', '-dn', '-ss', self.trim])
903 return [], info
904
905
906class FFmpegFixupDurationPP(FFmpegFixupPostProcessor):
907 @PostProcessor._restrict_to(images=False)
908 def run(self, info):
909 self._fixup('Fixing video duration', info['filepath'], ['-c', 'copy', '-map', '0', '-dn'])
910 return [], info
911
912
e9fade72 913class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor):
81a23040 914 SUPPORTED_EXTS = ('srt', 'vtt', 'ass', 'lrc')
915
e9fade72
JMF
916 def __init__(self, downloader=None, format=None):
917 super(FFmpegSubtitlesConvertorPP, self).__init__(downloader)
918 self.format = format
919
920 def run(self, info):
921 subs = info.get('requested_subtitles')
e9fade72
JMF
922 new_ext = self.format
923 new_format = new_ext
924 if new_format == 'vtt':
925 new_format = 'webvtt'
926 if subs is None:
1b77b347 927 self.to_screen('There aren\'t any subtitles to convert')
592e97e8 928 return [], info
1b77b347 929 self.to_screen('Converting subtitles')
e04398e3 930 sub_filenames = []
e9fade72 931 for lang, sub in subs.items():
a1c39673 932 if not os.path.exists(sub.get('filepath', '')):
933 self.report_warning(f'Skipping embedding {lang} subtitle because the file is missing')
934 continue
e9fade72
JMF
935 ext = sub['ext']
936 if ext == new_ext:
1b77b347 937 self.to_screen('Subtitle file for %s is already in the requested format' % new_ext)
e9fade72 938 continue
503d4a44 939 elif ext == 'json':
1b77b347 940 self.to_screen(
941 'You have requested to convert json subtitles into another format, '
503d4a44 942 'which is currently not possible')
943 continue
dcf64d43 944 old_file = sub['filepath']
e04398e3 945 sub_filenames.append(old_file)
dcf64d43 946 new_file = replace_extension(old_file, new_ext)
bf6427d2 947
40fcba5e 948 if ext in ('dfxp', 'ttml', 'tt'):
f446cc66 949 self.report_warning(
1b77b347 950 'You have requested to convert dfxp (TTML) subtitles into another format, '
bf6427d2
YCH
951 'which results in style information loss')
952
e04398e3 953 dfxp_file = old_file
dcf64d43 954 srt_file = replace_extension(old_file, 'srt')
bf6427d2 955
3869028f 956 with open(dfxp_file, 'rb') as f:
bf6427d2
YCH
957 srt_data = dfxp2srt(f.read())
958
959 with io.open(srt_file, 'wt', encoding='utf-8') as f:
960 f.write(srt_data)
7e62c2eb 961 old_file = srt_file
bf6427d2 962
bf6427d2
YCH
963 subs[lang] = {
964 'ext': 'srt',
dcf64d43 965 'data': srt_data,
966 'filepath': srt_file,
bf6427d2
YCH
967 }
968
969 if new_ext == 'srt':
970 continue
7b8b007c
JMF
971 else:
972 sub_filenames.append(srt_file)
bf6427d2 973
e04398e3 974 self.run_ffmpeg(old_file, new_file, ['-f', new_format])
e9fade72
JMF
975
976 with io.open(new_file, 'rt', encoding='utf-8') as f:
977 subs[lang] = {
3547d265 978 'ext': new_ext,
e9fade72 979 'data': f.read(),
dcf64d43 980 'filepath': new_file,
e9fade72
JMF
981 }
982
dcf64d43 983 info['__files_to_move'][new_file] = replace_extension(
37242e56 984 info['__files_to_move'][sub['filepath']], new_ext)
dcf64d43 985
e04398e3 986 return sub_filenames, info
72755351 987
988
989class FFmpegSplitChaptersPP(FFmpegPostProcessor):
7a340e0d
NA
990 def __init__(self, downloader, force_keyframes=False):
991 FFmpegPostProcessor.__init__(self, downloader)
992 self._force_keyframes = force_keyframes
72755351 993
994 def _prepare_filename(self, number, chapter, info):
995 info = info.copy()
996 info.update({
997 'section_number': number,
998 'section_title': chapter.get('title'),
999 'section_start': chapter.get('start_time'),
1000 'section_end': chapter.get('end_time'),
1001 })
1002 return self._downloader.prepare_filename(info, 'chapter')
1003
1004 def _ffmpeg_args_for_chapter(self, number, chapter, info):
1005 destination = self._prepare_filename(number, chapter, info)
1006 if not self._downloader._ensure_dir_exists(encodeFilename(destination)):
1007 return
1008
dcf64d43 1009 chapter['filepath'] = destination
72755351 1010 self.to_screen('Chapter %03d; Destination: %s' % (number, destination))
1011 return (
1012 destination,
1013 ['-ss', compat_str(chapter['start_time']),
a94bfd6c 1014 '-t', compat_str(chapter['end_time'] - chapter['start_time'])])
72755351 1015
8326b00a 1016 @PostProcessor._restrict_to(images=False)
72755351 1017 def run(self, info):
1018 chapters = info.get('chapters') or []
1019 if not chapters:
7a340e0d 1020 self.to_screen('Chapter information is unavailable')
72755351 1021 return [], info
1022
7a340e0d
NA
1023 in_file = info['filepath']
1024 if self._force_keyframes and len(chapters) > 1:
1025 in_file = self.force_keyframes(in_file, (c['start_time'] for c in chapters))
72755351 1026 self.to_screen('Splitting video by chapters; %d chapters found' % len(chapters))
1027 for idx, chapter in enumerate(chapters):
1028 destination, opts = self._ffmpeg_args_for_chapter(idx + 1, chapter, info)
7a340e0d
NA
1029 self.real_run_ffmpeg([(in_file, opts)], [(destination, ['-c', 'copy'])])
1030 if in_file != info['filepath']:
1031 os.remove(in_file)
72755351 1032 return [], info
8fa43c73 1033
1034
1035class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
81a23040 1036 SUPPORTED_EXTS = ('jpg', 'png')
1037
8fa43c73 1038 def __init__(self, downloader=None, format=None):
1039 super(FFmpegThumbnailsConvertorPP, self).__init__(downloader)
1040 self.format = format
1041
1042 @staticmethod
1043 def is_webp(path):
1044 with open(encodeFilename(path), 'rb') as f:
1045 b = f.read(12)
1046 return b[0:4] == b'RIFF' and b[8:] == b'WEBP'
1047
1048 def fixup_webp(self, info, idx=-1):
1049 thumbnail_filename = info['thumbnails'][idx]['filepath']
1050 _, thumbnail_ext = os.path.splitext(thumbnail_filename)
1051 if thumbnail_ext:
1052 thumbnail_ext = thumbnail_ext[1:].lower()
1053 if thumbnail_ext != 'webp' and self.is_webp(thumbnail_filename):
1054 self.to_screen('Correcting thumbnail "%s" extension to webp' % thumbnail_filename)
1055 webp_filename = replace_extension(thumbnail_filename, 'webp')
d75201a8 1056 os.replace(thumbnail_filename, webp_filename)
8fa43c73 1057 info['thumbnails'][idx]['filepath'] = webp_filename
1058 info['__files_to_move'][webp_filename] = replace_extension(
1059 info['__files_to_move'].pop(thumbnail_filename), 'webp')
1060
81a23040 1061 @staticmethod
1062 def _options(target_ext):
1063 if target_ext == 'jpg':
1064 return ['-bsf:v', 'mjpeg2jpeg']
1065 return []
1066
1067 def convert_thumbnail(self, thumbnail_filename, target_ext):
81a23040 1068 thumbnail_conv_filename = replace_extension(thumbnail_filename, target_ext)
337e0c62 1069
1070 self.to_screen('Converting thumbnail "%s" to %s' % (thumbnail_filename, target_ext))
1071 self.real_run_ffmpeg(
1072 [(thumbnail_filename, ['-f', 'image2', '-pattern_type', 'none'])],
1073 [(thumbnail_conv_filename.replace('%', '%%'), self._options(target_ext))])
a927acb1 1074 return thumbnail_conv_filename
8fa43c73 1075
1076 def run(self, info):
8fa43c73 1077 files_to_delete = []
1078 has_thumbnail = False
1079
1080 for idx, thumbnail_dict in enumerate(info['thumbnails']):
1081 if 'filepath' not in thumbnail_dict:
1082 continue
1083 has_thumbnail = True
1084 self.fixup_webp(info, idx)
1085 original_thumbnail = thumbnail_dict['filepath']
1086 _, thumbnail_ext = os.path.splitext(original_thumbnail)
1087 if thumbnail_ext:
1088 thumbnail_ext = thumbnail_ext[1:].lower()
15a4fd53 1089 if thumbnail_ext == 'jpeg':
1090 thumbnail_ext = 'jpg'
8fa43c73 1091 if thumbnail_ext == self.format:
1092 self.to_screen('Thumbnail "%s" is already in the requested format' % original_thumbnail)
1093 continue
1094 thumbnail_dict['filepath'] = self.convert_thumbnail(original_thumbnail, self.format)
1095 files_to_delete.append(original_thumbnail)
1096 info['__files_to_move'][thumbnail_dict['filepath']] = replace_extension(
1097 info['__files_to_move'][original_thumbnail], self.format)
1098
1099 if not has_thumbnail:
1100 self.to_screen('There aren\'t any thumbnails to convert')
1101 return files_to_delete, info