modify_chapters.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import copy
  2. import heapq
  3. import os
  4. from .common import PostProcessor
  5. from .ffmpeg import FFmpegPostProcessor, FFmpegSubtitlesConvertorPP
  6. from .sponsorblock import SponsorBlockPP
  7. from ..utils import PostProcessingError, orderedSet, prepend_extension
  8. _TINY_CHAPTER_DURATION = 1
  9. DEFAULT_SPONSORBLOCK_CHAPTER_TITLE = '[SponsorBlock]: %(category_names)l'
  10. class ModifyChaptersPP(FFmpegPostProcessor):
  11. def __init__(self, downloader, remove_chapters_patterns=None, remove_sponsor_segments=None, remove_ranges=None,
  12. *, sponsorblock_chapter_title=DEFAULT_SPONSORBLOCK_CHAPTER_TITLE, force_keyframes=False):
  13. FFmpegPostProcessor.__init__(self, downloader)
  14. self._remove_chapters_patterns = set(remove_chapters_patterns or [])
  15. self._remove_sponsor_segments = set(remove_sponsor_segments or []) - set(SponsorBlockPP.NON_SKIPPABLE_CATEGORIES.keys())
  16. self._ranges_to_remove = set(remove_ranges or [])
  17. self._sponsorblock_chapter_title = sponsorblock_chapter_title
  18. self._force_keyframes = force_keyframes
  19. @PostProcessor._restrict_to(images=False)
  20. def run(self, info):
  21. # Chapters must be preserved intact when downloading multiple formats of the same video.
  22. chapters, sponsor_chapters = self._mark_chapters_to_remove(
  23. copy.deepcopy(info.get('chapters')) or [],
  24. copy.deepcopy(info.get('sponsorblock_chapters')) or [])
  25. if not chapters and not sponsor_chapters:
  26. return [], info
  27. real_duration = self._get_real_video_duration(info['filepath'])
  28. if not chapters:
  29. chapters = [{'start_time': 0, 'end_time': info.get('duration') or real_duration, 'title': info['title']}]
  30. info['chapters'], cuts = self._remove_marked_arrange_sponsors(chapters + sponsor_chapters)
  31. if not cuts:
  32. return [], info
  33. elif not info['chapters']:
  34. self.report_warning('You have requested to remove the entire video, which is not possible')
  35. return [], info
  36. original_duration, info['duration'] = info.get('duration'), info['chapters'][-1]['end_time']
  37. if self._duration_mismatch(real_duration, original_duration, 1):
  38. if not self._duration_mismatch(real_duration, info['duration']):
  39. self.to_screen(f'Skipping {self.pp_key()} since the video appears to be already cut')
  40. return [], info
  41. if not info.get('__real_download'):
  42. raise PostProcessingError('Cannot cut video since the real and expected durations mismatch. '
  43. 'Different chapters may have already been removed')
  44. else:
  45. self.write_debug('Expected and actual durations mismatch')
  46. concat_opts = self._make_concat_opts(cuts, real_duration)
  47. self.write_debug('Concat spec = %s' % ', '.join(f'{c.get("inpoint", 0.0)}-{c.get("outpoint", "inf")}' for c in concat_opts))
  48. def remove_chapters(file, is_sub):
  49. return file, self.remove_chapters(file, cuts, concat_opts, self._force_keyframes and not is_sub)
  50. in_out_files = [remove_chapters(info['filepath'], False)]
  51. in_out_files.extend(remove_chapters(in_file, True) for in_file in self._get_supported_subs(info))
  52. # Renaming should only happen after all files are processed
  53. files_to_remove = []
  54. for in_file, out_file in in_out_files:
  55. mtime = os.stat(in_file).st_mtime
  56. uncut_file = prepend_extension(in_file, 'uncut')
  57. os.replace(in_file, uncut_file)
  58. os.replace(out_file, in_file)
  59. self.try_utime(in_file, mtime, mtime)
  60. files_to_remove.append(uncut_file)
  61. return files_to_remove, info
  62. def _mark_chapters_to_remove(self, chapters, sponsor_chapters):
  63. if self._remove_chapters_patterns:
  64. warn_no_chapter_to_remove = True
  65. if not chapters:
  66. self.to_screen('Chapter information is unavailable')
  67. warn_no_chapter_to_remove = False
  68. for c in chapters:
  69. if any(regex.search(c['title']) for regex in self._remove_chapters_patterns):
  70. c['remove'] = True
  71. warn_no_chapter_to_remove = False
  72. if warn_no_chapter_to_remove:
  73. self.to_screen('There are no chapters matching the regex')
  74. if self._remove_sponsor_segments:
  75. warn_no_chapter_to_remove = True
  76. if not sponsor_chapters:
  77. self.to_screen('SponsorBlock information is unavailable')
  78. warn_no_chapter_to_remove = False
  79. for c in sponsor_chapters:
  80. if c['category'] in self._remove_sponsor_segments:
  81. c['remove'] = True
  82. warn_no_chapter_to_remove = False
  83. if warn_no_chapter_to_remove:
  84. self.to_screen('There are no matching SponsorBlock chapters')
  85. sponsor_chapters.extend({
  86. 'start_time': start,
  87. 'end_time': end,
  88. 'category': 'manually_removed',
  89. '_categories': [('manually_removed', start, end, 'Manually removed')],
  90. 'remove': True,
  91. } for start, end in self._ranges_to_remove)
  92. return chapters, sponsor_chapters
  93. def _get_supported_subs(self, info):
  94. for sub in (info.get('requested_subtitles') or {}).values():
  95. sub_file = sub.get('filepath')
  96. # The file might have been removed by --embed-subs
  97. if not sub_file or not os.path.exists(sub_file):
  98. continue
  99. ext = sub['ext']
  100. if ext not in FFmpegSubtitlesConvertorPP.SUPPORTED_EXTS:
  101. self.report_warning(f'Cannot remove chapters from external {ext} subtitles; "{sub_file}" is now out of sync')
  102. continue
  103. # TODO: create __real_download for subs?
  104. yield sub_file
  105. def _remove_marked_arrange_sponsors(self, chapters):
  106. # Store cuts separately, since adjacent and overlapping cuts must be merged.
  107. cuts = []
  108. def append_cut(c):
  109. assert 'remove' in c, 'Not a cut is appended to cuts'
  110. last_to_cut = cuts[-1] if cuts else None
  111. if last_to_cut and last_to_cut['end_time'] >= c['start_time']:
  112. last_to_cut['end_time'] = max(last_to_cut['end_time'], c['end_time'])
  113. else:
  114. cuts.append(c)
  115. return len(cuts) - 1
  116. def excess_duration(c):
  117. # Cuts that are completely within the chapter reduce chapters' duration.
  118. # Since cuts can overlap, excess duration may be less that the sum of cuts' durations.
  119. # To avoid that, chapter stores the index to the fist cut within the chapter,
  120. # instead of storing excess duration. append_cut ensures that subsequent cuts (if any)
  121. # will be merged with previous ones (if necessary).
  122. cut_idx, excess = c.pop('cut_idx', len(cuts)), 0
  123. while cut_idx < len(cuts):
  124. cut = cuts[cut_idx]
  125. if cut['start_time'] >= c['end_time']:
  126. break
  127. if cut['end_time'] > c['start_time']:
  128. excess += min(cut['end_time'], c['end_time'])
  129. excess -= max(cut['start_time'], c['start_time'])
  130. cut_idx += 1
  131. return excess
  132. new_chapters = []
  133. def append_chapter(c):
  134. assert 'remove' not in c, 'Cut is appended to chapters'
  135. length = c['end_time'] - c['start_time'] - excess_duration(c)
  136. # Chapter is completely covered by cuts or sponsors.
  137. if length <= 0:
  138. return
  139. start = new_chapters[-1]['end_time'] if new_chapters else 0
  140. c.update(start_time=start, end_time=start + length)
  141. new_chapters.append(c)
  142. # Turn into a priority queue, index is a tie breaker.
  143. # Plain stack sorted by start_time is not enough: after splitting the chapter,
  144. # the part returned to the stack is not guaranteed to have start_time
  145. # less than or equal to the that of the stack's head.
  146. chapters = [(c['start_time'], i, c) for i, c in enumerate(chapters)]
  147. heapq.heapify(chapters)
  148. _, cur_i, cur_chapter = heapq.heappop(chapters)
  149. while chapters:
  150. _, i, c = heapq.heappop(chapters)
  151. # Non-overlapping chapters or cuts can be appended directly. However,
  152. # adjacent non-overlapping cuts must be merged, which is handled by append_cut.
  153. if cur_chapter['end_time'] <= c['start_time']:
  154. (append_chapter if 'remove' not in cur_chapter else append_cut)(cur_chapter)
  155. cur_i, cur_chapter = i, c
  156. continue
  157. # Eight possibilities for overlapping chapters: (cut, cut), (cut, sponsor),
  158. # (cut, normal), (sponsor, cut), (normal, cut), (sponsor, sponsor),
  159. # (sponsor, normal), and (normal, sponsor). There is no (normal, normal):
  160. # normal chapters are assumed not to overlap.
  161. if 'remove' in cur_chapter:
  162. # (cut, cut): adjust end_time.
  163. if 'remove' in c:
  164. cur_chapter['end_time'] = max(cur_chapter['end_time'], c['end_time'])
  165. # (cut, sponsor/normal): chop the beginning of the later chapter
  166. # (if it's not completely hidden by the cut). Push to the priority queue
  167. # to restore sorting by start_time: with beginning chopped, c may actually
  168. # start later than the remaining chapters from the queue.
  169. elif cur_chapter['end_time'] < c['end_time']:
  170. c['start_time'] = cur_chapter['end_time']
  171. c['_was_cut'] = True
  172. heapq.heappush(chapters, (c['start_time'], i, c))
  173. # (sponsor/normal, cut).
  174. elif 'remove' in c:
  175. cur_chapter['_was_cut'] = True
  176. # Chop the end of the current chapter if the cut is not contained within it.
  177. # Chopping the end doesn't break start_time sorting, no PQ push is necessary.
  178. if cur_chapter['end_time'] <= c['end_time']:
  179. cur_chapter['end_time'] = c['start_time']
  180. append_chapter(cur_chapter)
  181. cur_i, cur_chapter = i, c
  182. continue
  183. # Current chapter contains the cut within it. If the current chapter is
  184. # a sponsor chapter, check whether the categories before and after the cut differ.
  185. if '_categories' in cur_chapter:
  186. after_c = dict(cur_chapter, start_time=c['end_time'], _categories=[])
  187. cur_cats = []
  188. for cat_start_end in cur_chapter['_categories']:
  189. if cat_start_end[1] < c['start_time']:
  190. cur_cats.append(cat_start_end)
  191. if cat_start_end[2] > c['end_time']:
  192. after_c['_categories'].append(cat_start_end)
  193. cur_chapter['_categories'] = cur_cats
  194. if cur_chapter['_categories'] != after_c['_categories']:
  195. # Categories before and after the cut differ: push the after part to PQ.
  196. heapq.heappush(chapters, (after_c['start_time'], cur_i, after_c))
  197. cur_chapter['end_time'] = c['start_time']
  198. append_chapter(cur_chapter)
  199. cur_i, cur_chapter = i, c
  200. continue
  201. # Either sponsor categories before and after the cut are the same or
  202. # we're dealing with a normal chapter. Just register an outstanding cut:
  203. # subsequent append_chapter will reduce the duration.
  204. cur_chapter.setdefault('cut_idx', append_cut(c))
  205. # (sponsor, normal): if a normal chapter is not completely overlapped,
  206. # chop the beginning of it and push it to PQ.
  207. elif '_categories' in cur_chapter and '_categories' not in c:
  208. if cur_chapter['end_time'] < c['end_time']:
  209. c['start_time'] = cur_chapter['end_time']
  210. c['_was_cut'] = True
  211. heapq.heappush(chapters, (c['start_time'], i, c))
  212. # (normal, sponsor) and (sponsor, sponsor)
  213. else:
  214. assert '_categories' in c, 'Normal chapters overlap'
  215. cur_chapter['_was_cut'] = True
  216. c['_was_cut'] = True
  217. # Push the part after the sponsor to PQ.
  218. if cur_chapter['end_time'] > c['end_time']:
  219. # deepcopy to make categories in after_c and cur_chapter/c refer to different lists.
  220. after_c = dict(copy.deepcopy(cur_chapter), start_time=c['end_time'])
  221. heapq.heappush(chapters, (after_c['start_time'], cur_i, after_c))
  222. # Push the part after the overlap to PQ.
  223. elif c['end_time'] > cur_chapter['end_time']:
  224. after_cur = dict(copy.deepcopy(c), start_time=cur_chapter['end_time'])
  225. heapq.heappush(chapters, (after_cur['start_time'], cur_i, after_cur))
  226. c['end_time'] = cur_chapter['end_time']
  227. # (sponsor, sponsor): merge categories in the overlap.
  228. if '_categories' in cur_chapter:
  229. c['_categories'] = cur_chapter['_categories'] + c['_categories']
  230. # Inherit the cuts that the current chapter has accumulated within it.
  231. if 'cut_idx' in cur_chapter:
  232. c['cut_idx'] = cur_chapter['cut_idx']
  233. cur_chapter['end_time'] = c['start_time']
  234. append_chapter(cur_chapter)
  235. cur_i, cur_chapter = i, c
  236. (append_chapter if 'remove' not in cur_chapter else append_cut)(cur_chapter)
  237. return self._remove_tiny_rename_sponsors(new_chapters), cuts
  238. def _remove_tiny_rename_sponsors(self, chapters):
  239. new_chapters = []
  240. for i, c in enumerate(chapters):
  241. # Merge with the previous/next if the chapter is tiny.
  242. # Only tiny chapters resulting from a cut can be skipped.
  243. # Chapters that were already tiny in the original list will be preserved.
  244. if (('_was_cut' in c or '_categories' in c)
  245. and c['end_time'] - c['start_time'] < _TINY_CHAPTER_DURATION):
  246. if not new_chapters:
  247. # Prepend tiny chapter to the next one if possible.
  248. if i < len(chapters) - 1:
  249. chapters[i + 1]['start_time'] = c['start_time']
  250. continue
  251. else:
  252. old_c = new_chapters[-1]
  253. if i < len(chapters) - 1:
  254. next_c = chapters[i + 1]
  255. # Not a typo: key names in old_c and next_c are really different.
  256. prev_is_sponsor = 'categories' in old_c
  257. next_is_sponsor = '_categories' in next_c
  258. # Preferentially prepend tiny normals to normals and sponsors to sponsors.
  259. if (('_categories' not in c and prev_is_sponsor and not next_is_sponsor)
  260. or ('_categories' in c and not prev_is_sponsor and next_is_sponsor)):
  261. next_c['start_time'] = c['start_time']
  262. continue
  263. old_c['end_time'] = c['end_time']
  264. continue
  265. c.pop('_was_cut', None)
  266. cats = c.pop('_categories', None)
  267. if cats:
  268. category, _, _, category_name = min(cats, key=lambda c: c[2] - c[1])
  269. c.update({
  270. 'category': category,
  271. 'categories': orderedSet(x[0] for x in cats),
  272. 'name': category_name,
  273. 'category_names': orderedSet(x[3] for x in cats),
  274. })
  275. c['title'] = self._downloader.evaluate_outtmpl(self._sponsorblock_chapter_title, c.copy())
  276. # Merge identically named sponsors.
  277. if (new_chapters and 'categories' in new_chapters[-1]
  278. and new_chapters[-1]['title'] == c['title']):
  279. new_chapters[-1]['end_time'] = c['end_time']
  280. continue
  281. new_chapters.append(c)
  282. return new_chapters
  283. def remove_chapters(self, filename, ranges_to_cut, concat_opts, force_keyframes=False):
  284. in_file = filename
  285. out_file = prepend_extension(in_file, 'temp')
  286. if force_keyframes:
  287. in_file = self.force_keyframes(in_file, (t for c in ranges_to_cut for t in (c['start_time'], c['end_time'])))
  288. self.to_screen(f'Removing chapters from {filename}')
  289. self.concat_files([in_file] * len(concat_opts), out_file, concat_opts)
  290. if in_file != filename:
  291. self._delete_downloaded_files(in_file, msg=None)
  292. return out_file
  293. @staticmethod
  294. def _make_concat_opts(chapters_to_remove, duration):
  295. opts = [{}]
  296. for s in chapters_to_remove:
  297. # Do not create 0 duration chunk at the beginning.
  298. if s['start_time'] == 0:
  299. opts[-1]['inpoint'] = f'{s["end_time"]:.6f}'
  300. continue
  301. opts[-1]['outpoint'] = f'{s["start_time"]:.6f}'
  302. # Do not create 0 duration chunk at the end.
  303. if s['end_time'] < duration:
  304. opts.append({'inpoint': f'{s["end_time"]:.6f}'})
  305. return opts