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