]> jfr.im git - yt-dlp.git/blame - yt_dlp/postprocessor/ffmpeg.py
[Concat] Ensure final directory exists
[yt-dlp.git] / yt_dlp / postprocessor / ffmpeg.py
CommitLineData
3aa578ca
PH
1from __future__ import unicode_literals
2
88968992 3import collections
e9fade72 4import io
7dde84f3 5import itertools
496c1923
PH
6import os
7import subprocess
496c1923 8import time
fa2a36d9 9import re
06167fbb 10import json
496c1923 11
496c1923
PH
12from .common import AudioConversionError, PostProcessor
13
b11d2101 14from ..compat import compat_str
8c25f81b 15from ..utils import (
397235c5 16 determine_ext,
7a340e0d 17 dfxp2srt,
f07b74fc 18 encodeArgument,
496c1923 19 encodeFilename,
165efb82 20 float_or_none,
9af98e17 21 _get_exe_version_output,
22 detect_exe_version,
48844745 23 is_outdated_version,
7a340e0d
NA
24 ISO639Utils,
25 orderedSet,
d3c93ec2 26 Popen,
496c1923
PH
27 PostProcessingError,
28 prepend_extension,
06167fbb 29 replace_extension,
7a340e0d 30 shell_quote,
324ad820 31 traverse_obj,
6606817a 32 variadic,
dac5df5a 33 write_json_file,
496c1923
PH
34)
35
36
a755f825 37EXT_TO_OUT_FORMATS = {
21bfcd3d
PH
38 'aac': 'adts',
39 'flac': 'flac',
40 'm4a': 'ipod',
41 'mka': 'matroska',
42 'mkv': 'matroska',
43 'mpg': 'mpeg',
44 'ogv': 'ogg',
45 'ts': 'mpegts',
46 'wma': 'asf',
47 'wmv': 'asf',
abad8000 48 'vtt': 'webvtt',
21bfcd3d
PH
49}
50ACODECS = {
51 'mp3': 'libmp3lame',
52 'aac': 'aac',
53 'flac': 'flac',
54 'm4a': 'aac',
d2ae7e24 55 'opus': 'libopus',
21bfcd3d
PH
56 'vorbis': 'libvorbis',
57 'wav': None,
467b6b83 58 'alac': None,
a755f825 59}
60
61
496c1923
PH
62class FFmpegPostProcessorError(PostProcessingError):
63 pass
64
d799b47b 65
496c1923 66class FFmpegPostProcessor(PostProcessor):
d47aeb22 67 def __init__(self, downloader=None):
496c1923 68 PostProcessor.__init__(self, downloader)
73fac4e9 69 self._determine_executables()
496c1923 70
48844745 71 def check_version(self):
f740fae2 72 if not self.available:
beb4b92a 73 raise FFmpegPostProcessorError('ffmpeg not found. Please install or provide the path using --ffmpeg-location')
48844745 74
65bf37ef 75 required_version = '10-0' if self.basename == 'avconv' else '1.0'
48844745 76 if is_outdated_version(
73fac4e9 77 self._versions[self.basename], required_version):
3aa578ca 78 warning = 'Your copy of %s is outdated, update %s to version %s or newer if you encounter any errors.' % (
73fac4e9 79 self.basename, self.basename, required_version)
f446cc66 80 self.report_warning(warning)
48844745 81
8913ef74 82 @staticmethod
83 def get_versions_and_features(downloader=None):
84 pp = FFmpegPostProcessor(downloader)
85 return pp._versions, pp._features
86
496c1923 87 @staticmethod
73fac4e9 88 def get_versions(downloader=None):
8913ef74 89 return FFmpegPostProcessor.get_version_and_features(downloader)[0]
6271f1ca 90
73fac4e9
PH
91 def _determine_executables(self):
92 programs = ['avprobe', 'avconv', 'ffmpeg', 'ffprobe']
73fac4e9 93
9af98e17 94 def get_ffmpeg_version(path, prog):
95 out = _get_exe_version_output(path, ['-bsfs'])
96 ver = detect_exe_version(out) if out else False
a64646e4
RA
97 if ver:
98 regexs = [
cbdc688c 99 r'(?:\d+:)?([0-9.]+)-[0-9]+ubuntu[0-9.]+$', # Ubuntu, see [1]
5caa531a 100 r'n([0-9.]+)$', # Arch Linux
cbdc688c 101 # 1. http://www.ducea.com/2006/06/17/ubuntu-package-version-naming-explanation/
a64646e4
RA
102 ]
103 for regex in regexs:
104 mobj = re.match(regex, ver)
105 if mobj:
106 ver = mobj.group(1)
9af98e17 107 self._versions[prog] = ver
108 if prog != 'ffmpeg' or not out:
109 return
110
8913ef74 111 mobj = re.search(r'(?m)^\s+libavformat\s+(?:[0-9. ]+)\s+/\s+(?P<runtime>[0-9. ]+)', out)
112 lavf_runtime_version = mobj.group('runtime').replace(' ', '') if mobj else None
832e9000 113 self._features = {
114 'fdk': '--enable-libfdk-aac' in out,
115 'setts': 'setts' in out.splitlines(),
8913ef74 116 'needs_adtstoasc': is_outdated_version(lavf_runtime_version, '57.56.100', False),
832e9000 117 }
a64646e4 118
73fac4e9
PH
119 self.basename = None
120 self.probe_basename = None
73fac4e9
PH
121 self._paths = None
122 self._versions = None
9af98e17 123 self._features = {}
124
125 prefer_ffmpeg = self.get_param('prefer_ffmpeg', True)
126 location = self.get_param('ffmpeg_location')
127 if location is None:
128 self._paths = {p: p for p in programs}
129 else:
130 if not os.path.exists(location):
131 self.report_warning(
132 'ffmpeg-location %s does not exist! '
133 'Continuing without ffmpeg.' % (location))
134 self._versions = {}
135 return
136 elif os.path.isdir(location):
137 dirname, basename = location, None
138 else:
139 basename = os.path.splitext(os.path.basename(location))[0]
140 basename = next((p for p in programs if basename.startswith(p)), 'ffmpeg')
141 dirname = os.path.dirname(os.path.abspath(location))
142 if basename in ('ffmpeg', 'ffprobe'):
143 prefer_ffmpeg = True
144
145 self._paths = dict(
146 (p, os.path.join(dirname, p)) for p in programs)
147 if basename:
148 self._paths[basename] = location
149
150 self._versions = {}
151 for p in programs:
152 get_ffmpeg_version(self._paths[p], p)
73fac4e9 153
d4a24f40 154 if prefer_ffmpeg is False:
d28b5171 155 prefs = ('avconv', 'ffmpeg')
d4a24f40
S
156 else:
157 prefs = ('ffmpeg', 'avconv')
d28b5171
PH
158 for p in prefs:
159 if self._versions[p]:
73fac4e9
PH
160 self.basename = p
161 break
76b1bd67 162
d4a24f40 163 if prefer_ffmpeg is False:
1a253e13 164 prefs = ('avprobe', 'ffprobe')
d4a24f40
S
165 else:
166 prefs = ('ffprobe', 'avprobe')
1a253e13
PH
167 for p in prefs:
168 if self._versions[p]:
73fac4e9
PH
169 self.probe_basename = p
170 break
171
ee8dd27a 172 if self.basename == 'avconv':
173 self.deprecation_warning(
174 'Support for avconv is deprecated and may be removed in a future version. Use ffmpeg instead')
175 if self.probe_basename == 'avprobe':
176 self.deprecation_warning(
177 'Support for avprobe is deprecated and may be removed in a future version. Use ffprobe instead')
178
f740fae2 179 @property
73fac4e9
PH
180 def available(self):
181 return self.basename is not None
1a253e13 182
73fac4e9
PH
183 @property
184 def executable(self):
185 return self._paths[self.basename]
186
3da4b313
JMF
187 @property
188 def probe_available(self):
189 return self.probe_basename is not None
190
73fac4e9
PH
191 @property
192 def probe_executable(self):
193 return self._paths[self.probe_basename]
76b1bd67 194
397235c5 195 @staticmethod
196 def stream_copy_opts(copy=True, *, ext=None):
197 yield from ('-map', '0')
198 # Don't copy Apple TV chapters track, bin_data
199 # See https://github.com/yt-dlp/yt-dlp/issues/2, #19042, #19024, https://trac.ffmpeg.org/ticket/6016
5df1ac92 200 yield from ('-dn', '-ignore_unknown')
397235c5 201 if copy:
202 yield from ('-c', 'copy')
203 # For some reason, '-c copy -map 0' is not enough to copy subtitles
204 if ext in ('mp4', 'mov'):
205 yield from ('-c:s', 'mov_text')
206
30d9e209 207 def get_audio_codec(self, path):
eb35b163 208 if not self.probe_available and not self.available:
beb4b92a 209 raise PostProcessingError('ffprobe and ffmpeg not found. Please install or provide the path using --ffmpeg-location')
30d9e209 210 try:
eb35b163
RA
211 if self.probe_available:
212 cmd = [
213 encodeFilename(self.probe_executable, True),
214 encodeArgument('-show_streams')]
215 else:
216 cmd = [
217 encodeFilename(self.executable, True),
218 encodeArgument('-i')]
219 cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
f446cc66 220 self.write_debug('%s command line: %s' % (self.basename, shell_quote(cmd)))
d3c93ec2 221 handle = Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
222 stdout_data, stderr_data = handle.communicate_or_kill()
eb35b163
RA
223 expected_ret = 0 if self.probe_available else 1
224 if handle.wait() != expected_ret:
30d9e209
RA
225 return None
226 except (IOError, OSError):
227 return None
eb35b163
RA
228 output = (stdout_data if self.probe_available else stderr_data).decode('ascii', 'ignore')
229 if self.probe_available:
230 audio_codec = None
231 for line in output.split('\n'):
232 if line.startswith('codec_name='):
233 audio_codec = line.split('=')[1].strip()
234 elif line.strip() == 'codec_type=audio' and audio_codec is not None:
235 return audio_codec
236 else:
237 # Stream #FILE_INDEX:STREAM_INDEX[STREAM_ID](LANGUAGE): CODEC_TYPE: CODEC_NAME
238 mobj = re.search(
239 r'Stream\s*#\d+:\d+(?:\[0x[0-9a-f]+\])?(?:\([a-z]{3}\))?:\s*Audio:\s*([0-9a-z]+)',
240 output)
241 if mobj:
242 return mobj.group(1)
30d9e209
RA
243 return None
244
06167fbb 245 def get_metadata_object(self, path, opts=[]):
246 if self.probe_basename != 'ffprobe':
247 if self.probe_available:
248 self.report_warning('Only ffprobe is supported for metadata extraction')
beb4b92a 249 raise PostProcessingError('ffprobe not found. Please install or provide the path using --ffmpeg-location')
06167fbb 250 self.check_version()
251
252 cmd = [
253 encodeFilename(self.probe_executable, True),
254 encodeArgument('-hide_banner'),
255 encodeArgument('-show_format'),
256 encodeArgument('-show_streams'),
257 encodeArgument('-print_format'),
258 encodeArgument('json'),
259 ]
260
261 cmd += opts
262 cmd.append(encodeFilename(self._ffmpeg_filename_argument(path), True))
06869367 263 self.write_debug('ffprobe command line: %s' % shell_quote(cmd))
d3c93ec2 264 p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
06167fbb 265 stdout, stderr = p.communicate()
266 return json.loads(stdout.decode('utf-8', 'replace'))
267
268 def get_stream_number(self, path, keys, value):
269 streams = self.get_metadata_object(path)['streams']
270 num = next(
324ad820 271 (i for i, stream in enumerate(streams) if traverse_obj(stream, keys, casesense=False) == value),
06167fbb 272 None)
273 return num, len(streams)
274
5ce1d13e 275 def _get_real_video_duration(self, filepath, fatal=True):
165efb82 276 try:
5ce1d13e 277 duration = float_or_none(
278 traverse_obj(self.get_metadata_object(filepath), ('format', 'duration')))
279 if not duration:
165efb82 280 raise PostProcessingError('ffprobe returned empty duration')
5ce1d13e 281 return duration
165efb82 282 except PostProcessingError as e:
283 if fatal:
5ce1d13e 284 raise PostProcessingError(f'Unable to determine video duration: {e.msg}')
165efb82 285
286 def _duration_mismatch(self, d1, d2):
287 if not d1 or not d2:
288 return None
5ce1d13e 289 # The duration is often only known to nearest second. So there can be <1sec disparity natually.
290 # Further excuse an additional <1sec difference.
291 return abs(d1 - d2) > 2
165efb82 292
00034c14 293 def run_ffmpeg_multiple_files(self, input_paths, out_path, opts, **kwargs):
e92caff5 294 return self.real_run_ffmpeg(
295 [(path, []) for path in input_paths],
00034c14 296 [(out_path, opts)], **kwargs)
e92caff5 297
00034c14 298 def real_run_ffmpeg(self, input_path_opts, output_path_opts, *, expected_retcodes=(0,)):
48844745 299 self.check_version()
496c1923 300
52afb2ac 301 oldest_mtime = min(
7dde84f3 302 os.stat(encodeFilename(path)).st_mtime for path, _ in input_path_opts if path)
43bc8890 303
91b6c884 304 cmd = [encodeFilename(self.executable, True), encodeArgument('-y')]
ce52c7c1
S
305 # avconv does not have repeat option
306 if self.basename == 'ffmpeg':
307 cmd += [encodeArgument('-loglevel'), encodeArgument('repeat+info')]
5b1ecbb3 308
e92caff5 309 def make_args(file, args, name, number):
310 keys = ['_%s%d' % (name, number), '_%s' % name]
ca5db158 311 if name == 'o':
312 args += ['-movflags', '+faststart']
8eb4b1bb 313 if number == 1:
314 keys.append('')
e92caff5 315 args += self._configuration_args(self.basename, keys)
316 if name == 'i':
317 args.append('-i')
5b1ecbb3 318 return (
e92caff5 319 [encodeArgument(arg) for arg in args]
5b1ecbb3 320 + [encodeFilename(self._ffmpeg_filename_argument(file), True)])
321
e92caff5 322 for arg_type, path_opts in (('i', input_path_opts), ('o', output_path_opts)):
7dde84f3 323 cmd += itertools.chain.from_iterable(
324 make_args(path, list(opts), arg_type, i + 1)
325 for i, (path, opts) in enumerate(path_opts) if path)
496c1923 326
f446cc66 327 self.write_debug('ffmpeg command line: %s' % shell_quote(cmd))
d3c93ec2 328 p = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
329 stdout, stderr = p.communicate_or_kill()
00034c14 330 if p.returncode not in variadic(expected_retcodes):
06167fbb 331 stderr = stderr.decode('utf-8', 'replace').strip()
b1940459 332 self.write_debug(stderr)
06167fbb 333 raise FFmpegPostProcessorError(stderr.split('\n')[-1])
e92caff5 334 for out_path, _ in output_path_opts:
7dde84f3 335 if out_path:
336 self.try_utime(out_path, oldest_mtime, oldest_mtime)
06167fbb 337 return stderr.decode('utf-8', 'replace')
cc55d088 338
00034c14 339 def run_ffmpeg(self, path, out_path, opts, **kwargs):
340 return self.run_ffmpeg_multiple_files([path], out_path, opts, **kwargs)
496c1923 341
7a340e0d
NA
342 @staticmethod
343 def _ffmpeg_filename_argument(fn):
8a7bbd16
JMF
344 # Always use 'file:' because the filename may contain ':' (ffmpeg
345 # interprets that as a protocol) or can start with '-' (-- is broken in
346 # ffmpeg, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details)
b9f2fdd3 347 # Also leave '-' intact in order not to break streaming to stdout.
06167fbb 348 if fn.startswith(('http://', 'https://')):
349 return fn
d868f43c 350 return 'file:' + fn if fn != '-' else fn
496c1923 351
7a340e0d
NA
352 @staticmethod
353 def _quote_for_ffmpeg(string):
354 # See https://ffmpeg.org/ffmpeg-utils.html#toc-Quoting-and-escaping
355 # A sequence of '' produces '\'''\'';
356 # final replace removes the empty '' between \' \'.
357 string = string.replace("'", r"'\''").replace("'''", "'")
358 # Handle potential ' at string boundaries.
359 string = string[1:] if string[0] == "'" else "'" + string
360 return string[:-1] if string[-1] == "'" else string + "'"
361
362 def force_keyframes(self, filename, timestamps):
363 timestamps = orderedSet(timestamps)
364 if timestamps[0] == 0:
365 timestamps = timestamps[1:]
366 keyframe_file = prepend_extension(filename, 'keyframes.temp')
367 self.to_screen(f'Re-encoding "{filename}" with appropriate keyframes')
397235c5 368 self.run_ffmpeg(filename, keyframe_file, [
369 *self.stream_copy_opts(False, ext=determine_ext(filename)),
370 '-force_key_frames', ','.join(f'{t:.6f}' for t in timestamps)])
7a340e0d
NA
371 return keyframe_file
372
373 def concat_files(self, in_files, out_file, concat_opts=None):
374 """
375 Use concat demuxer to concatenate multiple files having identical streams.
376
377 Only inpoint, outpoint, and duration concat options are supported.
378 See https://ffmpeg.org/ffmpeg-formats.html#concat-1 for details
379 """
380 concat_file = f'{out_file}.concat'
381 self.write_debug(f'Writing concat spec to {concat_file}')
382 with open(concat_file, 'wt', encoding='utf-8') as f:
383 f.writelines(self._concat_spec(in_files, concat_opts))
384
397235c5 385 out_flags = list(self.stream_copy_opts(ext=determine_ext(out_file)))
7a340e0d 386
ae419aa9
NA
387 self.real_run_ffmpeg(
388 [(concat_file, ['-hide_banner', '-nostdin', '-f', 'concat', '-safe', '0'])],
389 [(out_file, out_flags)])
390 os.remove(concat_file)
7a340e0d
NA
391
392 @classmethod
393 def _concat_spec(cls, in_files, concat_opts=None):
394 if concat_opts is None:
395 concat_opts = [{}] * len(in_files)
396 yield 'ffconcat version 1.0\n'
397 for file, opts in zip(in_files, concat_opts):
398 yield f'file {cls._quote_for_ffmpeg(cls._ffmpeg_filename_argument(file))}\n'
399 # Iterate explicitly to yield the following directives in order, ignoring the rest.
400 for directive in 'inpoint', 'outpoint', 'duration':
401 if directive in opts:
402 yield f'{directive} {opts[directive]}\n'
403
496c1923
PH
404
405class FFmpegExtractAudioPP(FFmpegPostProcessor):
81a23040 406 COMMON_AUDIO_EXTS = ('wav', 'flac', 'm4a', 'aiff', 'mp3', 'ogg', 'mka', 'opus', 'wma')
d1b5f70b 407 SUPPORTED_EXTS = ('aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav', 'alac')
1de75fa1 408
496c1923
PH
409 def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
410 FFmpegPostProcessor.__init__(self, downloader)
81a23040 411 self._preferredcodec = preferredcodec or 'best'
31c49255 412 self._preferredquality = float_or_none(preferredquality)
496c1923
PH
413 self._nopostoverwrites = nopostoverwrites
414
31c49255 415 def _quality_args(self, codec):
416 if self._preferredquality is None:
417 return []
418 elif self._preferredquality > 10:
419 return ['-b:a', f'{self._preferredquality}k']
420
421 limits = {
422 'libmp3lame': (10, 0),
467b6b83 423 'libvorbis': (0, 10),
9af98e17 424 # FFmpeg's AAC encoder does not have an upper limit for the value of -q:a.
425 # Experimentally, with values over 4, bitrate changes were minimal or non-existent
426 'aac': (0.1, 4),
673c0057 427 'libfdk_aac': (1, 5),
39c04074 428 }.get(codec)
31c49255 429 if not limits:
430 return []
431
432 q = limits[1] + (limits[0] - limits[1]) * (self._preferredquality / 10)
673c0057
C
433 if codec == 'libfdk_aac':
434 return ['-vbr', f'{int(q)}']
31c49255 435 return ['-q:a', f'{q}']
436
496c1923 437 def run_ffmpeg(self, path, out_path, codec, more_opts):
496c1923
PH
438 if codec is None:
439 acodec_opts = []
440 else:
441 acodec_opts = ['-acodec', codec]
442 opts = ['-vn'] + acodec_opts + more_opts
443 try:
444 FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
445 except FFmpegPostProcessorError as err:
446 raise AudioConversionError(err.msg)
447
8326b00a 448 @PostProcessor._restrict_to(images=False)
496c1923 449 def run(self, information):
467b6b83 450 orig_path = path = information['filepath']
1de75fa1 451 orig_ext = information['ext']
452
81a23040 453 if self._preferredcodec == 'best' and orig_ext in self.COMMON_AUDIO_EXTS:
1de75fa1 454 self.to_screen('Skipping audio extraction since the file is already in a common audio format')
55b53b33 455 return [], information
496c1923
PH
456
457 filecodec = self.get_audio_codec(path)
458 if filecodec is None:
3aa578ca 459 raise PostProcessingError('WARNING: unable to obtain file audio codec with ffprobe')
496c1923
PH
460
461 more_opts = []
462 if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
463 if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
464 # Lossless, but in another container
465 acodec = 'copy'
466 extension = 'm4a'
467d3c9a 467 more_opts = ['-bsf:a', 'aac_adtstoasc']
21bfcd3d 468 elif filecodec in ['aac', 'flac', 'mp3', 'vorbis', 'opus']:
496c1923
PH
469 # Lossless if possible
470 acodec = 'copy'
471 extension = filecodec
472 if filecodec == 'aac':
473 more_opts = ['-f', 'adts']
474 if filecodec == 'vorbis':
475 extension = 'ogg'
467b6b83 476 elif filecodec == 'alac':
477 acodec = None
478 extension = 'm4a'
479 more_opts += ['-acodec', 'alac']
496c1923
PH
480 else:
481 # MP3 otherwise.
482 acodec = 'libmp3lame'
483 extension = 'mp3'
31c49255 484 more_opts = self._quality_args(acodec)
496c1923 485 else:
21bfcd3d
PH
486 # We convert the audio (lossy if codec is lossy)
487 acodec = ACODECS[self._preferredcodec]
673c0057
C
488 if acodec == 'aac' and self._features.get('fdk'):
489 acodec = 'libfdk_aac'
496c1923 490 extension = self._preferredcodec
31c49255 491 more_opts = self._quality_args(acodec)
496c1923
PH
492 if self._preferredcodec == 'aac':
493 more_opts += ['-f', 'adts']
467b6b83 494 elif self._preferredcodec == 'm4a':
467d3c9a 495 more_opts += ['-bsf:a', 'aac_adtstoasc']
467b6b83 496 elif self._preferredcodec == 'vorbis':
496c1923 497 extension = 'ogg'
467b6b83 498 elif self._preferredcodec == 'wav':
496c1923
PH
499 extension = 'wav'
500 more_opts += ['-f', 'wav']
467b6b83 501 elif self._preferredcodec == 'alac':
502 extension = 'm4a'
503 more_opts += ['-acodec', 'alac']
496c1923 504
3aa578ca 505 prefix, sep, ext = path.rpartition('.') # not os.path.splitext, since the latter does not work on unicode in all setups
467b6b83 506 temp_path = new_path = prefix + sep + extension
496c1923 507
467b6b83 508 if new_path == path:
509 orig_path = prepend_extension(path, 'orig')
510 temp_path = prepend_extension(path, 'temp')
511 if (self._nopostoverwrites and os.path.exists(encodeFilename(new_path))
512 and os.path.exists(encodeFilename(orig_path))):
1b77b347 513 self.to_screen('Post-process file %s exists, skipping' % new_path)
592e97e8 514 return [], information
496c1923
PH
515
516 try:
467b6b83 517 self.to_screen(f'Destination: {new_path}')
518 self.run_ffmpeg(path, temp_path, acodec, more_opts)
70a1165b
JMF
519 except AudioConversionError as e:
520 raise PostProcessingError(
521 'audio conversion failed: ' + e.msg)
522 except Exception:
523 raise PostProcessingError('error running ' + self.basename)
496c1923 524
467b6b83 525 os.replace(path, orig_path)
526 os.replace(temp_path, new_path)
527 information['filepath'] = new_path
528 information['ext'] = extension
529
496c1923
PH
530 # Try to update the date time for extracted audio file.
531 if information.get('filetime') is not None:
dd29eb7f
S
532 self.try_utime(
533 new_path, time.time(), information['filetime'],
534 errnote='Cannot update utime of audio file')
496c1923 535
467b6b83 536 return [orig_path], information
496c1923
PH
537
538
857f6313 539class FFmpegVideoConvertorPP(FFmpegPostProcessor):
5ca764c5 540 SUPPORTED_EXTS = ('mp4', 'mkv', 'flv', 'webm', 'mov', 'avi', 'mka', 'ogg', *FFmpegExtractAudioPP.SUPPORTED_EXTS)
81a23040 541 FORMAT_RE = re.compile(r'{0}(?:/{0})*$'.format(r'(?:\w+>)?(?:%s)' % '|'.join(SUPPORTED_EXTS)))
e6f21b3d 542 _ACTION = 'converting'
857f6313 543
efe87a10 544 def __init__(self, downloader=None, preferedformat=None):
857f6313 545 super(FFmpegVideoConvertorPP, self).__init__(downloader)
06167fbb 546 self._preferedformats = preferedformat.lower().split('/')
efe87a10 547
857f6313 548 def _target_ext(self, source_ext):
06167fbb 549 for pair in self._preferedformats:
550 kv = pair.split('>')
857f6313 551 if len(kv) == 1 or kv[0].strip() == source_ext:
552 return kv[-1].strip()
06167fbb 553
857f6313 554 @staticmethod
555 def _options(target_ext):
556 if target_ext == 'avi':
557 return ['-c:v', 'libxvid', '-vtag', 'XVID']
558 return []
559
8326b00a 560 @PostProcessor._restrict_to(images=False)
e6f21b3d 561 def run(self, info):
562 filename, source_ext = info['filepath'], info['ext'].lower()
81a23040 563 target_ext = self._target_ext(source_ext)
06167fbb 564 _skip_msg = (
e6f21b3d 565 f'could not find a mapping for {source_ext}' if not target_ext
566 else f'already is in target format {source_ext}' if source_ext == target_ext
06167fbb 567 else None)
568 if _skip_msg:
6970b600 569 self.to_screen(f'Not {self._ACTION} media file "{filename}"; {_skip_msg}')
e6f21b3d 570 return [], info
06167fbb 571
e6f21b3d 572 outpath = replace_extension(filename, target_ext, source_ext)
573 self.to_screen(f'{self._ACTION.title()} video from {source_ext} to {target_ext}; Destination: {outpath}')
574 self.run_ffmpeg(filename, outpath, self._options(target_ext))
857f6313 575
e6f21b3d 576 info['filepath'] = outpath
577 info['format'] = info['ext'] = target_ext
578 return [filename], info
efe87a10
FS
579
580
857f6313 581class FFmpegVideoRemuxerPP(FFmpegVideoConvertorPP):
e6f21b3d 582 _ACTION = 'remuxing'
496c1923 583
857f6313 584 @staticmethod
585 def _options(target_ext):
ed8d87f9 586 return FFmpegPostProcessor.stream_copy_opts()
496c1923
PH
587
588
589class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
cffab0ee 590 def __init__(self, downloader=None, already_have_subtitle=False):
591 super(FFmpegEmbedSubtitlePP, self).__init__(downloader)
592 self._already_have_subtitle = already_have_subtitle
593
8326b00a 594 @PostProcessor._restrict_to(images=False)
5ce1d13e 595 def run(self, info):
596 if info['ext'] not in ('mp4', 'webm', 'mkv'):
1b77b347 597 self.to_screen('Subtitles can only be embedded in mp4, webm or mkv files')
5ce1d13e 598 return [], info
599 subtitles = info.get('requested_subtitles')
c84dd8a9 600 if not subtitles:
1b77b347 601 self.to_screen('There aren\'t any subtitles to embed')
5ce1d13e 602 return [], info
496c1923 603
5ce1d13e 604 filename = info['filepath']
9bdd99cf 605
606 # Disabled temporarily. There needs to be a way to overide this
607 # in case of duration actually mismatching in extractor
608 # See: https://github.com/yt-dlp/yt-dlp/issues/1870, https://github.com/yt-dlp/yt-dlp/issues/1385
609 '''
5ce1d13e 610 if info.get('duration') and not info.get('__real_download') and self._duration_mismatch(
611 self._get_real_video_duration(filename, False), info['duration']):
165efb82 612 self.to_screen(f'Skipping {self.pp_key()} since the real and expected durations mismatch')
5ce1d13e 613 return [], info
9bdd99cf 614 '''
40025ee2 615
5ce1d13e 616 ext = info['ext']
2412044c 617 sub_langs, sub_names, sub_filenames = [], [], []
40025ee2 618 webm_vtt_warn = False
06167fbb 619 mp4_ass_warn = False
40025ee2
S
620
621 for lang, sub_info in subtitles.items():
a1c39673 622 if not os.path.exists(sub_info.get('filepath', '')):
8e25d624 623 self.report_warning(f'Skipping embedding {lang} subtitle because the file is missing')
624 continue
40025ee2 625 sub_ext = sub_info['ext']
503d4a44 626 if sub_ext == 'json':
06167fbb 627 self.report_warning('JSON subtitles cannot be embedded')
503d4a44 628 elif ext != 'webm' or ext == 'webm' and sub_ext == 'vtt':
40025ee2 629 sub_langs.append(lang)
2412044c 630 sub_names.append(sub_info.get('name'))
dcf64d43 631 sub_filenames.append(sub_info['filepath'])
40025ee2
S
632 else:
633 if not webm_vtt_warn and ext == 'webm' and sub_ext != 'vtt':
634 webm_vtt_warn = True
06167fbb 635 self.report_warning('Only WebVTT subtitles can be embedded in webm files')
636 if not mp4_ass_warn and ext == 'mp4' and sub_ext == 'ass':
637 mp4_ass_warn = True
638 self.report_warning('ASS subtitles cannot be properly embedded in mp4 files; expect issues')
40025ee2
S
639
640 if not sub_langs:
5ce1d13e 641 return [], info
40025ee2 642
14523ed9 643 input_files = [filename] + sub_filenames
496c1923 644
e205db3b 645 opts = [
397235c5 646 *self.stream_copy_opts(ext=info['ext']),
e205db3b
JMF
647 # Don't copy the existing subtitles, we may be running the
648 # postprocessor a second time
649 '-map', '-0:s',
650 ]
2412044c 651 for i, (lang, name) in enumerate(zip(sub_langs, sub_names)):
2875cf01 652 opts.extend(['-map', '%d:0' % (i + 1)])
04fb6928
S
653 lang_code = ISO639Utils.short2long(lang) or lang
654 opts.extend(['-metadata:s:s:%d' % i, 'language=%s' % lang_code])
2412044c 655 if name:
656 opts.extend(['-metadata:s:s:%d' % i, 'handler_name=%s' % name,
657 '-metadata:s:s:%d' % i, 'title=%s' % name])
496c1923 658
2875cf01 659 temp_filename = prepend_extension(filename, 'temp')
06167fbb 660 self.to_screen('Embedding subtitles in "%s"' % filename)
496c1923 661 self.run_ffmpeg_multiple_files(input_files, temp_filename, opts)
d75201a8 662 os.replace(temp_filename, filename)
496c1923 663
cffab0ee 664 files_to_delete = [] if self._already_have_subtitle else sub_filenames
5ce1d13e 665 return files_to_delete, info
496c1923
PH
666
667
668class FFmpegMetadataPP(FFmpegPostProcessor):
7dde84f3 669
dac5df5a 670 def __init__(self, downloader, add_metadata=True, add_chapters=True, add_infojson='if_exists'):
7a340e0d
NA
671 FFmpegPostProcessor.__init__(self, downloader)
672 self._add_metadata = add_metadata
673 self._add_chapters = add_chapters
dac5df5a 674 self._add_infojson = add_infojson
7a340e0d 675
7dde84f3 676 @staticmethod
677 def _options(target_ext):
397235c5 678 audio_only = target_ext == 'm4a'
ed8d87f9 679 yield from FFmpegPostProcessor.stream_copy_opts(not audio_only)
397235c5 680 if audio_only:
7dde84f3 681 yield from ('-vn', '-acodec', 'copy')
7dde84f3 682
8326b00a 683 @PostProcessor._restrict_to(images=False)
496c1923 684 def run(self, info):
7a340e0d 685 filename, metadata_filename = info['filepath'], None
dac5df5a 686 files_to_delete, options = [], []
7a340e0d
NA
687 if self._add_chapters and info.get('chapters'):
688 metadata_filename = replace_extension(filename, 'meta')
689 options.extend(self._get_chapter_opts(info['chapters'], metadata_filename))
dac5df5a 690 files_to_delete.append(metadata_filename)
7a340e0d
NA
691 if self._add_metadata:
692 options.extend(self._get_metadata_opts(info))
693
dac5df5a 694 if self._add_infojson:
695 if info['ext'] in ('mkv', 'mka'):
696 infojson_filename = info.get('infojson_filename')
697 options.extend(self._get_infojson_opts(info, infojson_filename))
698 if not infojson_filename:
699 files_to_delete.append(info.get('infojson_filename'))
700 elif self._add_infojson is True:
701 self.to_screen('The info-json can only be attached to mkv/mka files')
702
7a340e0d
NA
703 if not options:
704 self.to_screen('There isn\'t any metadata to add')
705 return [], info
706
707 temp_filename = prepend_extension(filename, 'temp')
708 self.to_screen('Adding metadata to "%s"' % filename)
709 self.run_ffmpeg_multiple_files(
710 (filename, metadata_filename), temp_filename,
711 itertools.chain(self._options(info['ext']), *options))
dac5df5a 712 for file in filter(None, files_to_delete):
713 os.remove(file) # Don't obey --keep-files
7a340e0d
NA
714 os.replace(temp_filename, filename)
715 return [], info
716
717 @staticmethod
718 def _get_chapter_opts(chapters, metadata_filename):
719 with io.open(metadata_filename, 'wt', encoding='utf-8') as f:
720 def ffmpeg_escape(text):
721 return re.sub(r'([\\=;#\n])', r'\\\1', text)
722
723 metadata_file_content = ';FFMETADATA1\n'
724 for chapter in chapters:
725 metadata_file_content += '[CHAPTER]\nTIMEBASE=1/1000\n'
726 metadata_file_content += 'START=%d\n' % (chapter['start_time'] * 1000)
727 metadata_file_content += 'END=%d\n' % (chapter['end_time'] * 1000)
728 chapter_title = chapter.get('title')
729 if chapter_title:
730 metadata_file_content += 'title=%s\n' % ffmpeg_escape(chapter_title)
731 f.write(metadata_file_content)
732 yield ('-map_metadata', '1')
733
734 def _get_metadata_opts(self, info):
88968992 735 meta_prefix = 'meta'
736 metadata = collections.defaultdict(dict)
4bd143a3
S
737
738 def add(meta_list, info_list=None):
b11d2101 739 value = next((
88968992 740 str(info[key]) for key in [f'{meta_prefix}_'] + list(variadic(info_list or meta_list))
b11d2101 741 if info.get(key) is not None), None)
742 if value not in ('', None):
88968992 743 metadata['common'].update({meta_f: value for meta_f in variadic(meta_list)})
4bd143a3 744
2791e80b
S
745 # See [1-4] for some info on media metadata/metadata supported
746 # by ffmpeg.
747 # 1. https://kdenlive.org/en/project/adding-meta-data-to-mp4-video/
748 # 2. https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
749 # 3. https://kodi.wiki/view/Video_file_tagging
2791e80b 750
4bd143a3
S
751 add('title', ('track', 'title'))
752 add('date', 'upload_date')
cd9b384c 753 add(('description', 'synopsis'), 'description')
754 add(('purl', 'comment'), 'webpage_url')
4bd143a3
S
755 add('track', 'track_number')
756 add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
757 add('genre')
758 add('album')
759 add('album_artist')
760 add('disc', 'disc_number')
2791e80b
S
761 add('show', 'series')
762 add('season_number')
763 add('episode_id', ('episode', 'episode_id'))
764 add('episode_sort', 'episode_number')
f279aaee 765 if 'embed-metadata' in self.get_param('compat_opts', []):
766 add('comment', 'description')
88968992 767 metadata['common'].pop('synopsis', None)
496c1923 768
88968992 769 meta_regex = rf'{re.escape(meta_prefix)}(?P<i>\d+)?_(?P<key>.+)'
b11d2101 770 for key, value in info.items():
88968992 771 mobj = re.fullmatch(meta_regex, key)
772 if value is not None and mobj:
773 metadata[mobj.group('i') or 'common'][mobj.group('key')] = value
84601bb7 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
1131 def concat_files(self, in_files, out_file):
5cf34021 1132 if not self._downloader._ensure_dir_exists(out_file):
1133 return
3b603dbd 1134 if len(in_files) == 1:
6970b600 1135 if os.path.realpath(in_files[0]) != os.path.realpath(out_file):
1136 self.to_screen(f'Moving "{in_files[0]}" to "{out_file}"')
3b603dbd 1137 os.replace(in_files[0], out_file)
6970b600 1138 return []
3b603dbd 1139
1140 codecs = [traverse_obj(self.get_metadata_object(file), ('streams', ..., 'codec_name')) for file in in_files]
1141 if len(set(map(tuple, codecs))) > 1:
1142 raise PostProcessingError(
1143 'The files have different streams/codecs and cannot be concatenated. '
1144 'Either select different formats or --recode-video them to a common format')
6970b600 1145
1146 self.to_screen(f'Concatenating {len(in_files)} files; Destination: {out_file}')
3b603dbd 1147 super().concat_files(in_files, out_file)
6970b600 1148 return in_files
3b603dbd 1149
ed66a17e 1150 @PostProcessor._restrict_to(images=False, simulated=False)
3b603dbd 1151 def run(self, info):
460a1c08 1152 entries = info.get('entries') or []
ed66a17e 1153 if not any(entries) or (self._only_multi_video and info['_type'] != 'multi_video'):
3b603dbd 1154 return [], info
460a1c08 1155 elif any(len(entry) > 1 for entry in traverse_obj(entries, (..., 'requested_downloads')) or []):
3b603dbd 1156 raise PostProcessingError('Concatenation is not supported when downloading multiple separate formats')
1157
ed66a17e 1158 in_files = traverse_obj(entries, (..., 'requested_downloads', 0, 'filepath')) or []
460a1c08 1159 if len(in_files) < len(entries):
1160 raise PostProcessingError('Aborting concatenation because some downloads failed')
3b603dbd 1161
1162 ie_copy = self._downloader._playlist_infodict(info)
460a1c08 1163 exts = traverse_obj(entries, (..., 'requested_downloads', 0, 'ext'), (..., 'ext'))
3b603dbd 1164 ie_copy['ext'] = exts[0] if len(set(exts)) == 1 else 'mkv'
1165 out_file = self._downloader.prepare_filename(ie_copy, 'pl_video')
1166
6970b600 1167 files_to_delete = self.concat_files(in_files, out_file)
3b603dbd 1168
1169 info['requested_downloads'] = [{
1170 'filepath': out_file,
1171 'ext': ie_copy['ext'],
1172 }]
6970b600 1173 return files_to_delete, info