]> jfr.im git - yt-dlp.git/blame - yt_dlp/postprocessor/ffmpeg.py
[EmbedThumbnail] Do not remove id3v1 tags
[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):
88968992 740 metadata['common'].update({meta_f: value for meta_f in variadic(meta_list)})
4bd143a3 741
2791e80b
S
742 # See [1-4] for some info on media metadata/metadata supported
743 # by ffmpeg.
744 # 1. https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/
745 # 2. https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
746 # 3. https://kodi.wiki/view/Video_file_tagging
2791e80b 747
4bd143a3
S
748 add('title', ('track', 'title'))
749 add('date', 'upload_date')
cd9b384c 750 add(('description', 'synopsis'), 'description')
751 add(('purl', 'comment'), 'webpage_url')
4bd143a3
S
752 add('track', 'track_number')
753 add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
754 add('genre')
755 add('album')
756 add('album_artist')
757 add('disc', 'disc_number')
2791e80b
S
758 add('show', 'series')
759 add('season_number')
760 add('episode_id', ('episode', 'episode_id'))
761 add('episode_sort', 'episode_number')
f279aaee 762 if 'embed-metadata' in self.get_param('compat_opts', []):
763 add('comment', 'description')
88968992 764 metadata['common'].pop('synopsis', None)
496c1923 765
88968992 766 meta_regex = rf'{re.escape(meta_prefix)}(?P<i>\d+)?_(?P<key>.+)'
b11d2101 767 for key, value in info.items():
88968992 768 mobj = re.fullmatch(meta_regex, key)
769 if value is not None and mobj:
770 metadata[mobj.group('i') or 'common'][mobj.group('key')] = value
84601bb7 771
22fba53f 772 # Write id3v1 metadata also since Windows Explorer can't handle id3v2 tags
773 yield ('-write_id3v1', '1')
774
88968992 775 for name, value in metadata['common'].items():
7a340e0d 776 yield ('-metadata', f'{name}={value}')
39c68260 777
7dde84f3 778 stream_idx = 0
779 for fmt in info.get('requested_formats') or []:
780 stream_count = 2 if 'none' not in (fmt.get('vcodec'), fmt.get('acodec')) else 1
61e9d926 781 lang = ISO639Utils.short2long(fmt.get('language') or '') or fmt.get('language')
88968992 782 for i in range(stream_idx, stream_idx + stream_count):
783 if lang:
784 metadata[str(i)].setdefault('language', lang)
785 for name, value in metadata[str(i)].items():
786 yield (f'-metadata:s:{i}', f'{name}={value}')
7dde84f3 787 stream_idx += stream_count
496c1923 788
dac5df5a 789 def _get_infojson_opts(self, info, infofn):
790 if not infofn or not os.path.exists(infofn):
791 if self._add_infojson is not True:
792 return
793 infofn = infofn or '%s.temp' % (
794 self._downloader.prepare_filename(info, 'infojson')
795 or replace_extension(self._downloader.prepare_filename(info), 'info.json', info['ext']))
796 if not self._downloader._ensure_dir_exists(infofn):
797 return
798 self.write_debug(f'Writing info-json to: {infofn}')
799 write_json_file(self._downloader.sanitize_info(info, self.get_param('clean_infojson', True)), infofn)
800 info['infojson_filename'] = infofn
801
802 old_stream, new_stream = self.get_stream_number(info['filepath'], ('tags', 'mimetype'), 'application/json')
803 if old_stream is not None:
804 yield ('-map', '-0:%d' % old_stream)
805 new_stream -= 1
06167fbb 806
dac5df5a 807 yield ('-attach', infofn,
808 '-metadata:s:%d' % new_stream, 'mimetype=application/json')
496c1923
PH
809
810
811class FFmpegMergerPP(FFmpegPostProcessor):
8326b00a 812 @PostProcessor._restrict_to(images=False)
496c1923
PH
813 def run(self, info):
814 filename = info['filepath']
5b5fbc08 815 temp_filename = prepend_extension(filename, 'temp')
d03cfdce 816 args = ['-c', 'copy']
50eff38c 817 audio_streams = 0
d03cfdce 818 for (i, fmt) in enumerate(info['requested_formats']):
819 if fmt.get('acodec') != 'none':
a21e0ab1 820 args.extend(['-map', f'{i}:a:0'])
9dda99f2 821 aac_fixup = fmt['protocol'].startswith('m3u8') and self.get_audio_codec(fmt['filepath']) == 'aac'
822 if aac_fixup:
50eff38c 823 args.extend([f'-bsf:a:{audio_streams}', 'aac_adtstoasc'])
824 audio_streams += 1
d03cfdce 825 if fmt.get('vcodec') != 'none':
826 args.extend(['-map', '%u:v:0' % (i)])
1b77b347 827 self.to_screen('Merging formats into "%s"' % filename)
5b5fbc08
JMF
828 self.run_ffmpeg_multiple_files(info['__files_to_merge'], temp_filename, args)
829 os.rename(encodeFilename(temp_filename), encodeFilename(filename))
d47aeb22 830 return info['__files_to_merge'], info
496c1923 831
13763ce5
S
832 def can_merge(self):
833 # TODO: figure out merge-capable ffmpeg version
834 if self.basename != 'avconv':
835 return True
836
837 required_version = '10-0'
838 if is_outdated_version(
839 self._versions[self.basename], required_version):
840 warning = ('Your copy of %s is outdated and unable to properly mux separate video and audio files, '
7a5c1cfe 841 'yt-dlp will download single file media. '
13763ce5
S
842 'Update %s to version %s or newer to fix this.') % (
843 self.basename, self.basename, required_version)
f446cc66 844 self.report_warning(warning)
13763ce5
S
845 return False
846 return True
847
0c14e2fb 848
fd7cfb64 849class FFmpegFixupPostProcessor(FFmpegPostProcessor):
850 def _fixup(self, msg, filename, options):
6271f1ca
PH
851 temp_filename = prepend_extension(filename, 'temp')
852
f89b3e2d 853 self.to_screen(f'{msg} of "{filename}"')
6271f1ca
PH
854 self.run_ffmpeg(filename, temp_filename, options)
855
d75201a8 856 os.replace(temp_filename, filename)
6271f1ca 857
fd7cfb64 858
859class FFmpegFixupStretchedPP(FFmpegFixupPostProcessor):
860 @PostProcessor._restrict_to(images=False, audio=False)
861 def run(self, info):
862 stretched_ratio = info.get('stretched_ratio')
863 if stretched_ratio not in (None, 1):
864 self._fixup('Fixing aspect ratio', info['filepath'], [
397235c5 865 *self.stream_copy_opts(), '-aspect', '%f' % stretched_ratio])
592e97e8 866 return [], info
62cd676c
PH
867
868
fd7cfb64 869class FFmpegFixupM4aPP(FFmpegFixupPostProcessor):
8326b00a 870 @PostProcessor._restrict_to(images=False, video=False)
62cd676c 871 def run(self, info):
fd7cfb64 872 if info.get('container') == 'm4a_dash':
397235c5 873 self._fixup('Correcting container', info['filepath'], [*self.stream_copy_opts(), '-f', 'mp4'])
592e97e8 874 return [], info
e9fade72
JMF
875
876
fd7cfb64 877class FFmpegFixupM3u8PP(FFmpegFixupPostProcessor):
e04b003e 878 def _needs_fixup(self, info):
879 yield info['ext'] in ('mp4', 'm4a')
880 yield info['protocol'].startswith('m3u8')
881 try:
882 metadata = self.get_metadata_object(info['filepath'])
883 except PostProcessingError as e:
884 self.report_warning(f'Unable to extract metadata: {e.msg}')
885 yield True
886 else:
887 yield traverse_obj(metadata, ('format', 'format_name'), casesense=False) == 'mpegts'
888
8326b00a 889 @PostProcessor._restrict_to(images=False)
f17f8651 890 def run(self, info):
e04b003e 891 if all(self._needs_fixup(info)):
892 self._fixup('Fixing MPEG-TS in MP4 container', info['filepath'], [
397235c5 893 *self.stream_copy_opts(), '-f', 'mp4', '-bsf:a', 'aac_adtstoasc'])
f17f8651 894 return [], info
895
896
e36d50c5 897class FFmpegFixupTimestampPP(FFmpegFixupPostProcessor):
898
899 def __init__(self, downloader=None, trim=0.001):
900 # "trim" should be used when the video contains unintended packets
901 super(FFmpegFixupTimestampPP, self).__init__(downloader)
902 assert isinstance(trim, (int, float))
903 self.trim = str(trim)
904
905 @PostProcessor._restrict_to(images=False)
906 def run(self, info):
832e9000 907 if not self._features.get('setts'):
e36d50c5 908 self.report_warning(
909 'A re-encode is needed to fix timestamps in older versions of ffmpeg. '
832e9000 910 'Please install ffmpeg 4.4 or later to fixup without re-encoding')
e36d50c5 911 opts = ['-vf', 'setpts=PTS-STARTPTS']
912 else:
913 opts = ['-c', 'copy', '-bsf', 'setts=ts=TS-STARTPTS']
397235c5 914 self._fixup('Fixing frame timestamp', info['filepath'], opts + [*self.stream_copy_opts(False), '-ss', self.trim])
e36d50c5 915 return [], info
916
917
6970b600 918class FFmpegCopyStreamPP(FFmpegFixupPostProcessor):
adbc4ec4
THD
919 MESSAGE = 'Copying stream'
920
e36d50c5 921 @PostProcessor._restrict_to(images=False)
922 def run(self, info):
397235c5 923 self._fixup(self.MESSAGE, info['filepath'], self.stream_copy_opts())
e36d50c5 924 return [], info
925
926
6970b600 927class FFmpegFixupDurationPP(FFmpegCopyStreamPP):
adbc4ec4
THD
928 MESSAGE = 'Fixing video duration'
929
930
6970b600 931class FFmpegFixupDuplicateMoovPP(FFmpegCopyStreamPP):
adbc4ec4
THD
932 MESSAGE = 'Fixing duplicate MOOV atoms'
933
934
e9fade72 935class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor):
81a23040 936 SUPPORTED_EXTS = ('srt', 'vtt', 'ass', 'lrc')
937
e9fade72
JMF
938 def __init__(self, downloader=None, format=None):
939 super(FFmpegSubtitlesConvertorPP, self).__init__(downloader)
940 self.format = format
941
942 def run(self, info):
943 subs = info.get('requested_subtitles')
e9fade72
JMF
944 new_ext = self.format
945 new_format = new_ext
946 if new_format == 'vtt':
947 new_format = 'webvtt'
948 if subs is None:
1b77b347 949 self.to_screen('There aren\'t any subtitles to convert')
592e97e8 950 return [], info
1b77b347 951 self.to_screen('Converting subtitles')
e04398e3 952 sub_filenames = []
e9fade72 953 for lang, sub in subs.items():
a1c39673 954 if not os.path.exists(sub.get('filepath', '')):
955 self.report_warning(f'Skipping embedding {lang} subtitle because the file is missing')
956 continue
e9fade72
JMF
957 ext = sub['ext']
958 if ext == new_ext:
1b77b347 959 self.to_screen('Subtitle file for %s is already in the requested format' % new_ext)
e9fade72 960 continue
503d4a44 961 elif ext == 'json':
1b77b347 962 self.to_screen(
963 'You have requested to convert json subtitles into another format, '
503d4a44 964 'which is currently not possible')
965 continue
dcf64d43 966 old_file = sub['filepath']
e04398e3 967 sub_filenames.append(old_file)
dcf64d43 968 new_file = replace_extension(old_file, new_ext)
bf6427d2 969
40fcba5e 970 if ext in ('dfxp', 'ttml', 'tt'):
f446cc66 971 self.report_warning(
1b77b347 972 'You have requested to convert dfxp (TTML) subtitles into another format, '
bf6427d2
YCH
973 'which results in style information loss')
974
e04398e3 975 dfxp_file = old_file
dcf64d43 976 srt_file = replace_extension(old_file, 'srt')
bf6427d2 977
3869028f 978 with open(dfxp_file, 'rb') as f:
bf6427d2
YCH
979 srt_data = dfxp2srt(f.read())
980
981 with io.open(srt_file, 'wt', encoding='utf-8') as f:
982 f.write(srt_data)
7e62c2eb 983 old_file = srt_file
bf6427d2 984
bf6427d2
YCH
985 subs[lang] = {
986 'ext': 'srt',
dcf64d43 987 'data': srt_data,
988 'filepath': srt_file,
bf6427d2
YCH
989 }
990
991 if new_ext == 'srt':
992 continue
7b8b007c
JMF
993 else:
994 sub_filenames.append(srt_file)
bf6427d2 995
e04398e3 996 self.run_ffmpeg(old_file, new_file, ['-f', new_format])
e9fade72
JMF
997
998 with io.open(new_file, 'rt', encoding='utf-8') as f:
999 subs[lang] = {
3547d265 1000 'ext': new_ext,
e9fade72 1001 'data': f.read(),
dcf64d43 1002 'filepath': new_file,
e9fade72
JMF
1003 }
1004
dcf64d43 1005 info['__files_to_move'][new_file] = replace_extension(
37242e56 1006 info['__files_to_move'][sub['filepath']], new_ext)
dcf64d43 1007
e04398e3 1008 return sub_filenames, info
72755351 1009
1010
1011class FFmpegSplitChaptersPP(FFmpegPostProcessor):
7a340e0d
NA
1012 def __init__(self, downloader, force_keyframes=False):
1013 FFmpegPostProcessor.__init__(self, downloader)
1014 self._force_keyframes = force_keyframes
72755351 1015
1016 def _prepare_filename(self, number, chapter, info):
1017 info = info.copy()
1018 info.update({
1019 'section_number': number,
1020 'section_title': chapter.get('title'),
1021 'section_start': chapter.get('start_time'),
1022 'section_end': chapter.get('end_time'),
1023 })
1024 return self._downloader.prepare_filename(info, 'chapter')
1025
1026 def _ffmpeg_args_for_chapter(self, number, chapter, info):
1027 destination = self._prepare_filename(number, chapter, info)
1028 if not self._downloader._ensure_dir_exists(encodeFilename(destination)):
1029 return
1030
dcf64d43 1031 chapter['filepath'] = destination
72755351 1032 self.to_screen('Chapter %03d; Destination: %s' % (number, destination))
1033 return (
1034 destination,
1035 ['-ss', compat_str(chapter['start_time']),
a94bfd6c 1036 '-t', compat_str(chapter['end_time'] - chapter['start_time'])])
72755351 1037
8326b00a 1038 @PostProcessor._restrict_to(images=False)
72755351 1039 def run(self, info):
1040 chapters = info.get('chapters') or []
1041 if not chapters:
7a340e0d 1042 self.to_screen('Chapter information is unavailable')
72755351 1043 return [], info
1044
7a340e0d
NA
1045 in_file = info['filepath']
1046 if self._force_keyframes and len(chapters) > 1:
1047 in_file = self.force_keyframes(in_file, (c['start_time'] for c in chapters))
72755351 1048 self.to_screen('Splitting video by chapters; %d chapters found' % len(chapters))
1049 for idx, chapter in enumerate(chapters):
1050 destination, opts = self._ffmpeg_args_for_chapter(idx + 1, chapter, info)
397235c5 1051 self.real_run_ffmpeg([(in_file, opts)], [(destination, self.stream_copy_opts())])
7a340e0d
NA
1052 if in_file != info['filepath']:
1053 os.remove(in_file)
72755351 1054 return [], info
8fa43c73 1055
1056
1057class FFmpegThumbnailsConvertorPP(FFmpegPostProcessor):
72073451 1058 SUPPORTED_EXTS = ('jpg', 'png', 'webp')
81a23040 1059
8fa43c73 1060 def __init__(self, downloader=None, format=None):
1061 super(FFmpegThumbnailsConvertorPP, self).__init__(downloader)
1062 self.format = format
1063
1064 @staticmethod
1065 def is_webp(path):
1066 with open(encodeFilename(path), 'rb') as f:
1067 b = f.read(12)
1068 return b[0:4] == b'RIFF' and b[8:] == b'WEBP'
1069
1070 def fixup_webp(self, info, idx=-1):
1071 thumbnail_filename = info['thumbnails'][idx]['filepath']
1072 _, thumbnail_ext = os.path.splitext(thumbnail_filename)
1073 if thumbnail_ext:
1074 thumbnail_ext = thumbnail_ext[1:].lower()
1075 if thumbnail_ext != 'webp' and self.is_webp(thumbnail_filename):
1076 self.to_screen('Correcting thumbnail "%s" extension to webp' % thumbnail_filename)
1077 webp_filename = replace_extension(thumbnail_filename, 'webp')
d75201a8 1078 os.replace(thumbnail_filename, webp_filename)
8fa43c73 1079 info['thumbnails'][idx]['filepath'] = webp_filename
1080 info['__files_to_move'][webp_filename] = replace_extension(
1081 info['__files_to_move'].pop(thumbnail_filename), 'webp')
1082
81a23040 1083 @staticmethod
1084 def _options(target_ext):
1085 if target_ext == 'jpg':
1086 return ['-bsf:v', 'mjpeg2jpeg']
1087 return []
1088
1089 def convert_thumbnail(self, thumbnail_filename, target_ext):
81a23040 1090 thumbnail_conv_filename = replace_extension(thumbnail_filename, target_ext)
337e0c62 1091
1092 self.to_screen('Converting thumbnail "%s" to %s' % (thumbnail_filename, target_ext))
1093 self.real_run_ffmpeg(
1094 [(thumbnail_filename, ['-f', 'image2', '-pattern_type', 'none'])],
1095 [(thumbnail_conv_filename.replace('%', '%%'), self._options(target_ext))])
a927acb1 1096 return thumbnail_conv_filename
8fa43c73 1097
1098 def run(self, info):
8fa43c73 1099 files_to_delete = []
1100 has_thumbnail = False
1101
6a176775 1102 for idx, thumbnail_dict in enumerate(info.get('thumbnails') or []):
1103 original_thumbnail = thumbnail_dict.get('filepath')
1104 if not original_thumbnail:
8fa43c73 1105 continue
1106 has_thumbnail = True
1107 self.fixup_webp(info, idx)
8fa43c73 1108 _, thumbnail_ext = os.path.splitext(original_thumbnail)
1109 if thumbnail_ext:
1110 thumbnail_ext = thumbnail_ext[1:].lower()
15a4fd53 1111 if thumbnail_ext == 'jpeg':
1112 thumbnail_ext = 'jpg'
8fa43c73 1113 if thumbnail_ext == self.format:
1114 self.to_screen('Thumbnail "%s" is already in the requested format' % original_thumbnail)
1115 continue
1116 thumbnail_dict['filepath'] = self.convert_thumbnail(original_thumbnail, self.format)
1117 files_to_delete.append(original_thumbnail)
1118 info['__files_to_move'][thumbnail_dict['filepath']] = replace_extension(
1119 info['__files_to_move'][original_thumbnail], self.format)
1120
1121 if not has_thumbnail:
1122 self.to_screen('There aren\'t any thumbnails to convert')
1123 return files_to_delete, info
3b603dbd 1124
1125
1126class FFmpegConcatPP(FFmpegPostProcessor):
1127 def __init__(self, downloader, only_multi_video=False):
1128 self._only_multi_video = only_multi_video
1129 super().__init__(downloader)
1130
a44ca5a4 1131 def _get_codecs(self, file):
1132 codecs = traverse_obj(self.get_metadata_object(file), ('streams', ..., 'codec_name'))
1133 self.write_debug(f'Codecs = {", ".join(codecs)}')
1134 return tuple(codecs)
1135
3b603dbd 1136 def concat_files(self, in_files, out_file):
5cf34021 1137 if not self._downloader._ensure_dir_exists(out_file):
1138 return
3b603dbd 1139 if len(in_files) == 1:
6970b600 1140 if os.path.realpath(in_files[0]) != os.path.realpath(out_file):
1141 self.to_screen(f'Moving "{in_files[0]}" to "{out_file}"')
3b603dbd 1142 os.replace(in_files[0], out_file)
6970b600 1143 return []
3b603dbd 1144
a44ca5a4 1145 if len(set(map(self._get_codecs, in_files))) > 1:
3b603dbd 1146 raise PostProcessingError(
1147 'The files have different streams/codecs and cannot be concatenated. '
1148 'Either select different formats or --recode-video them to a common format')
6970b600 1149
1150 self.to_screen(f'Concatenating {len(in_files)} files; Destination: {out_file}')
3b603dbd 1151 super().concat_files(in_files, out_file)
6970b600 1152 return in_files
3b603dbd 1153
ed66a17e 1154 @PostProcessor._restrict_to(images=False, simulated=False)
3b603dbd 1155 def run(self, info):
460a1c08 1156 entries = info.get('entries') or []
ed66a17e 1157 if not any(entries) or (self._only_multi_video and info['_type'] != 'multi_video'):
3b603dbd 1158 return [], info
a44ca5a4 1159 elif traverse_obj(entries, (..., 'requested_downloads', lambda _, v: len(v) > 1)):
3b603dbd 1160 raise PostProcessingError('Concatenation is not supported when downloading multiple separate formats')
1161
ed66a17e 1162 in_files = traverse_obj(entries, (..., 'requested_downloads', 0, 'filepath')) or []
460a1c08 1163 if len(in_files) < len(entries):
1164 raise PostProcessingError('Aborting concatenation because some downloads failed')
3b603dbd 1165
1166 ie_copy = self._downloader._playlist_infodict(info)
460a1c08 1167 exts = traverse_obj(entries, (..., 'requested_downloads', 0, 'ext'), (..., 'ext'))
3b603dbd 1168 ie_copy['ext'] = exts[0] if len(set(exts)) == 1 else 'mkv'
1169 out_file = self._downloader.prepare_filename(ie_copy, 'pl_video')
1170
6970b600 1171 files_to_delete = self.concat_files(in_files, out_file)
3b603dbd 1172
1173 info['requested_downloads'] = [{
1174 'filepath': out_file,
1175 'ext': ie_copy['ext'],
1176 }]
6970b600 1177 return files_to_delete, info