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