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