From cfc29f62c8d226da9f036c48dca177989d9e2950 Mon Sep 17 00:00:00 2001 From: Quantum Date: Wed, 25 Dec 2024 19:45:05 -0500 Subject: [PATCH 1/4] [ModifyChapters] add --round-cuts-to-keyframes option --- test/test_postprocessors.py | 47 +++++++++++++++++++++++++ yt_dlp/__init__.py | 1 + yt_dlp/options.py | 11 ++++++ yt_dlp/postprocessor/ffmpeg.py | 26 ++++++++++++++ yt_dlp/postprocessor/modify_chapters.py | 43 ++++++++++++++++++---- 5 files changed, 122 insertions(+), 6 deletions(-) diff --git a/test/test_postprocessors.py b/test/test_postprocessors.py index 603f85c654..1ca90cd4c7 100644 --- a/test/test_postprocessors.py +++ b/test/test_postprocessors.py @@ -558,6 +558,53 @@ class TestModifyChaptersPP(unittest.TestCase): '[SponsorBlock]: Sponsor', 'c', ]), []) + def test_round_remove_chapter_Common(self): + keyframes = [1, 3, 5, 7] + chapters = self._pp._round_remove_chapters(keyframes, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 2, 'end_time': 6, 'remove': True}, + {'start_time': 6, 'end_time': 10, 'remove': False}, + ]) + self.assertEqual(chapters, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 3, 'end_time': 5, 'remove': True}, + {'start_time': 6, 'end_time': 10, 'remove': False}, + ]) + + def test_round_remove_chapter_AlreadyKeyframe(self): + keyframes = [1, 3, 5, 7] + chapters = self._pp._round_remove_chapters(keyframes, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 3, 'end_time': 7, 'remove': True}, + {'start_time': 6, 'end_time': 10, 'remove': False}, + ]) + self.assertEqual(chapters, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 3, 'end_time': 7, 'remove': True}, + {'start_time': 6, 'end_time': 10, 'remove': False}, + ]) + + def test_round_remove_chapter_RemoveEnd(self): + keyframes = [1, 3, 5, 7] + chapters = self._pp._round_remove_chapters(keyframes, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 3, 'end_time': 8, 'remove': True}, + ]) + self.assertEqual(chapters, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 3, 'end_time': 8, 'remove': True}, + ]) + + def test_round_remove_chapter_RemoveAfterLast(self): + keyframes = [1, 3, 5, 7] + chapters = self._pp._round_remove_chapters(keyframes, [ + {'start_time': 0, 'end_time': 2}, + {'start_time': 8, 'end_time': 9, 'remove': True}, + ]) + self.assertEqual(chapters, [ + {'start_time': 0, 'end_time': 2}, + ]) + def test_make_concat_opts_CommonCase(self): sponsor_chapters = [self._chapter(1, 2, 's1'), self._chapter(10, 20, 's2')] expected = '''ffconcat version 1.0 diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py index 20111175b1..dc97e4d40d 100644 --- a/yt_dlp/__init__.py +++ b/yt_dlp/__init__.py @@ -677,6 +677,7 @@ def get_postprocessors(opts): 'remove_ranges': opts.remove_ranges, 'sponsorblock_chapter_title': opts.sponsorblock_chapter_title, 'force_keyframes': opts.force_keyframes_at_cuts, + 'round_to_keyframes': opts.round_cuts_to_keyframes, } # FFmpegMetadataPP should be run after FFmpegVideoConvertorPP and # FFmpegExtractAudioPP as containers before conversion may not support diff --git a/yt_dlp/options.py b/yt_dlp/options.py index 06b65e0eac..08f5c6af06 100644 --- a/yt_dlp/options.py +++ b/yt_dlp/options.py @@ -1778,6 +1778,17 @@ def create_parser(): '--no-force-keyframes-at-cuts', action='store_false', dest='force_keyframes_at_cuts', help='Do not force keyframes around the chapters when cutting/splitting (default)') + postproc.add_option( + '--round-cuts-to-keyframes', + action='store_true', dest='round_cuts_to_keyframes', default=False, + help=( + 'Rounds cuts to the nearest keyframe when removing sections. ' + 'This may result in some more content being included than specified, but makes problems around cuts ' + 'less likely')) + postproc.add_option( + '--no-round-cuts-to-keyframes', + action='store_false', dest='round_cuts_to_keyframes', + help='Do not rounds cuts to the nearest keyframe when removing sections (default)') _postprocessor_opts_parser = lambda key, val='': ( *(item.split('=', 1) for item in (val.split(';') if val else [])), ('key', remove_end(key, 'PP'))) diff --git a/yt_dlp/postprocessor/ffmpeg.py b/yt_dlp/postprocessor/ffmpeg.py index 8965806ae7..12a6560b4c 100644 --- a/yt_dlp/postprocessor/ffmpeg.py +++ b/yt_dlp/postprocessor/ffmpeg.py @@ -393,6 +393,32 @@ class FFmpegPostProcessor(PostProcessor): string = string[1:] if string[0] == "'" else "'" + string return string[:-1] if string[-1] == "'" else string + "'" + def get_keyframe_timestamps(self, path, opts=[]): + if self.probe_basename != 'ffprobe': + if self.probe_available: + self.report_warning('Only ffprobe is supported for keyframe timestamp extraction') + raise PostProcessingError('ffprobe not found. Please install or provide the path using --ffmpeg-location') + + self.check_version() + + cmd = [ + self.probe_executable, + encodeArgument('-select_streams'), + encodeArgument('v:0'), + encodeArgument('-show_entries'), + encodeArgument('packet=pts_time,flags'), + encodeArgument('-print_format'), + encodeArgument('json'), + ] + + cmd += opts + cmd.append(self._ffmpeg_filename_argument(path)) + self.write_debug(f'ffprobe command line: {shell_quote(cmd)}') + stdout, _, _ = Popen.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) + result = json.loads(stdout) + + return [float(packet['pts_time']) for packet in result['packets'] if 'K' in packet['flags']] + def force_keyframes(self, filename, timestamps): timestamps = orderedSet(timestamps) if timestamps[0] == 0: diff --git a/yt_dlp/postprocessor/modify_chapters.py b/yt_dlp/postprocessor/modify_chapters.py index d82685ed85..7eec34474c 100644 --- a/yt_dlp/postprocessor/modify_chapters.py +++ b/yt_dlp/postprocessor/modify_chapters.py @@ -1,3 +1,4 @@ +import bisect import copy import heapq import os @@ -13,13 +14,16 @@ DEFAULT_SPONSORBLOCK_CHAPTER_TITLE = '[SponsorBlock]: %(category_names)l' class ModifyChaptersPP(FFmpegPostProcessor): def __init__(self, downloader, remove_chapters_patterns=None, remove_sponsor_segments=None, remove_ranges=None, - *, sponsorblock_chapter_title=DEFAULT_SPONSORBLOCK_CHAPTER_TITLE, force_keyframes=False): + *, sponsorblock_chapter_title=DEFAULT_SPONSORBLOCK_CHAPTER_TITLE, force_keyframes=False, + round_to_keyframes=False): FFmpegPostProcessor.__init__(self, downloader) self._remove_chapters_patterns = set(remove_chapters_patterns or []) - self._remove_sponsor_segments = set(remove_sponsor_segments or []) - set(SponsorBlockPP.NON_SKIPPABLE_CATEGORIES.keys()) + self._remove_sponsor_segments = set(remove_sponsor_segments or []) - set( + SponsorBlockPP.NON_SKIPPABLE_CATEGORIES.keys()) self._ranges_to_remove = set(remove_ranges or []) self._sponsorblock_chapter_title = sponsorblock_chapter_title self._force_keyframes = force_keyframes + self._round_to_keyframes = round_to_keyframes @PostProcessor._restrict_to(images=False) def run(self, info): @@ -35,7 +39,12 @@ class ModifyChaptersPP(FFmpegPostProcessor): if not chapters: chapters = [{'start_time': 0, 'end_time': info.get('duration') or real_duration, 'title': info['title']}] - info['chapters'], cuts = self._remove_marked_arrange_sponsors(chapters + sponsor_chapters) + chapters += sponsor_chapters + if self._round_to_keyframes: + keyframes = self.get_keyframe_timestamps(info['filepath']) + self._round_remove_chapters(keyframes, chapters) + + info['chapters'], cuts = self._remove_marked_arrange_sponsors(chapters) if not cuts: return [], info elif not info['chapters']: @@ -54,7 +63,8 @@ class ModifyChaptersPP(FFmpegPostProcessor): self.write_debug('Expected and actual durations mismatch') concat_opts = self._make_concat_opts(cuts, real_duration) - self.write_debug('Concat spec = {}'.format(', '.join(f'{c.get("inpoint", 0.0)}-{c.get("outpoint", "inf")}' for c in concat_opts))) + self.write_debug('Concat spec = {}'.format( + ', '.join(f'{c.get("inpoint", 0.0)}-{c.get("outpoint", "inf")}' for c in concat_opts))) def remove_chapters(file, is_sub): return file, self.remove_chapters(file, cuts, concat_opts, self._force_keyframes and not is_sub) @@ -117,7 +127,8 @@ class ModifyChaptersPP(FFmpegPostProcessor): continue ext = sub['ext'] if ext not in FFmpegSubtitlesConvertorPP.SUPPORTED_EXTS: - self.report_warning(f'Cannot remove chapters from external {ext} subtitles; "{sub_file}" is now out of sync') + self.report_warning( + f'Cannot remove chapters from external {ext} subtitles; "{sub_file}" is now out of sync') continue # TODO: create __real_download for subs? yield sub_file @@ -314,13 +325,33 @@ class ModifyChaptersPP(FFmpegPostProcessor): in_file = filename out_file = prepend_extension(in_file, 'temp') if force_keyframes: - in_file = self.force_keyframes(in_file, (t for c in ranges_to_cut for t in (c['start_time'], c['end_time']))) + in_file = self.force_keyframes(in_file, + (t for c in ranges_to_cut for t in (c['start_time'], c['end_time']))) self.to_screen(f'Removing chapters from {filename}') self.concat_files([in_file] * len(concat_opts), out_file, concat_opts) if in_file != filename: self._delete_downloaded_files(in_file, msg=None) return out_file + @staticmethod + def _round_remove_chapters(keyframes, chapters): + result = [] + for c in chapters: + if not c.get('remove', False) or not keyframes: + result.append(c) + continue + + start_frame = bisect.bisect_left(keyframes, c['start_time']) + if start_frame >= len(keyframes): + continue + + c['start_time'] = keyframes[start_frame] + if c['end_time'] < keyframes[-1]: + c['end_time'] = keyframes[bisect.bisect_right(keyframes, c['end_time']) - 1] + result.append(c) + + return result + @staticmethod def _make_concat_opts(chapters_to_remove, duration): opts = [{}] From a9abae0a92715e0909157bcf058b2eeb7872a031 Mon Sep 17 00:00:00 2001 From: Quantum Date: Thu, 26 Dec 2024 03:22:18 -0500 Subject: [PATCH 2/4] Only round end_time of removal segments We don't actually care if we chop out a segment after a keyframe, just that the next segment should start with one. So only the end_time requires rounding, not the start_time. --- test/test_postprocessors.py | 2 +- yt_dlp/postprocessor/modify_chapters.py | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/test/test_postprocessors.py b/test/test_postprocessors.py index 1ca90cd4c7..8d3fac86cb 100644 --- a/test/test_postprocessors.py +++ b/test/test_postprocessors.py @@ -567,7 +567,7 @@ class TestModifyChaptersPP(unittest.TestCase): ]) self.assertEqual(chapters, [ {'start_time': 0, 'end_time': 2}, - {'start_time': 3, 'end_time': 5, 'remove': True}, + {'start_time': 2, 'end_time': 5, 'remove': True}, {'start_time': 6, 'end_time': 10, 'remove': False}, ]) diff --git a/yt_dlp/postprocessor/modify_chapters.py b/yt_dlp/postprocessor/modify_chapters.py index 7eec34474c..a6477451ff 100644 --- a/yt_dlp/postprocessor/modify_chapters.py +++ b/yt_dlp/postprocessor/modify_chapters.py @@ -341,11 +341,6 @@ class ModifyChaptersPP(FFmpegPostProcessor): result.append(c) continue - start_frame = bisect.bisect_left(keyframes, c['start_time']) - if start_frame >= len(keyframes): - continue - - c['start_time'] = keyframes[start_frame] if c['end_time'] < keyframes[-1]: c['end_time'] = keyframes[bisect.bisect_right(keyframes, c['end_time']) - 1] result.append(c) From 751f71a644a33383fd5f79189658deb9564c8033 Mon Sep 17 00:00:00 2001 From: Quantum Date: Thu, 26 Dec 2024 03:27:07 -0500 Subject: [PATCH 3/4] Mark as conflicting with --force-keyframes-at-cuts --- yt_dlp/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py index dc97e4d40d..517f60a0ae 100644 --- a/yt_dlp/__init__.py +++ b/yt_dlp/__init__.py @@ -532,6 +532,8 @@ def validate_options(opts): report_conflict('--sponskrub', 'sponskrub', '--sponsorblock-remove', 'sponsorblock_remove') report_conflict('--sponskrub-cut', 'sponskrub_cut', '--split-chapter', 'split_chapters', val1=opts.sponskrub and opts.sponskrub_cut) + report_conflict('--force-keyframes-at-cuts', 'force_keyframes_at_cuts', '--round-cuts-to-keyframes', + 'round_cuts_to_keyframes') # Conflicts with --allow-unplayable-formats report_conflict('--embed-metadata', 'addmetadata') From ecae8eb19fef5c5edd91e6e40fa6eea57ce93837 Mon Sep 17 00:00:00 2001 From: Quantum Date: Sat, 11 Jan 2025 13:17:38 -0500 Subject: [PATCH 4/4] Handle cutting after last keyframe --- test/test_postprocessors.py | 8 ++++---- yt_dlp/postprocessor/modify_chapters.py | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/test/test_postprocessors.py b/test/test_postprocessors.py index 8d3fac86cb..510c13f81f 100644 --- a/test/test_postprocessors.py +++ b/test/test_postprocessors.py @@ -564,7 +564,7 @@ class TestModifyChaptersPP(unittest.TestCase): {'start_time': 0, 'end_time': 2}, {'start_time': 2, 'end_time': 6, 'remove': True}, {'start_time': 6, 'end_time': 10, 'remove': False}, - ]) + ], duration=12) self.assertEqual(chapters, [ {'start_time': 0, 'end_time': 2}, {'start_time': 2, 'end_time': 5, 'remove': True}, @@ -577,7 +577,7 @@ class TestModifyChaptersPP(unittest.TestCase): {'start_time': 0, 'end_time': 2}, {'start_time': 3, 'end_time': 7, 'remove': True}, {'start_time': 6, 'end_time': 10, 'remove': False}, - ]) + ], duration=12) self.assertEqual(chapters, [ {'start_time': 0, 'end_time': 2}, {'start_time': 3, 'end_time': 7, 'remove': True}, @@ -589,7 +589,7 @@ class TestModifyChaptersPP(unittest.TestCase): chapters = self._pp._round_remove_chapters(keyframes, [ {'start_time': 0, 'end_time': 2}, {'start_time': 3, 'end_time': 8, 'remove': True}, - ]) + ], duration=8) self.assertEqual(chapters, [ {'start_time': 0, 'end_time': 2}, {'start_time': 3, 'end_time': 8, 'remove': True}, @@ -600,7 +600,7 @@ class TestModifyChaptersPP(unittest.TestCase): chapters = self._pp._round_remove_chapters(keyframes, [ {'start_time': 0, 'end_time': 2}, {'start_time': 8, 'end_time': 9, 'remove': True}, - ]) + ], duration=10) self.assertEqual(chapters, [ {'start_time': 0, 'end_time': 2}, ]) diff --git a/yt_dlp/postprocessor/modify_chapters.py b/yt_dlp/postprocessor/modify_chapters.py index a6477451ff..f950823ca8 100644 --- a/yt_dlp/postprocessor/modify_chapters.py +++ b/yt_dlp/postprocessor/modify_chapters.py @@ -42,7 +42,7 @@ class ModifyChaptersPP(FFmpegPostProcessor): chapters += sponsor_chapters if self._round_to_keyframes: keyframes = self.get_keyframe_timestamps(info['filepath']) - self._round_remove_chapters(keyframes, chapters) + self._round_remove_chapters(keyframes, chapters, info.get('duration') or real_duration) info['chapters'], cuts = self._remove_marked_arrange_sponsors(chapters) if not cuts: @@ -334,13 +334,16 @@ class ModifyChaptersPP(FFmpegPostProcessor): return out_file @staticmethod - def _round_remove_chapters(keyframes, chapters): + def _round_remove_chapters(keyframes, chapters, duration): result = [] for c in chapters: if not c.get('remove', False) or not keyframes: result.append(c) continue + if c['end_time'] > keyframes[-1] and c['end_time'] != duration: + continue + if c['end_time'] < keyframes[-1]: c['end_time'] = keyframes[bisect.bisect_right(keyframes, c['end_time']) - 1] result.append(c)