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