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