├── Auto_Chap ├── Auto_Chap.py ├── Changelog.md └── requirements.txt ├── Chapter_Snapper └── Chapter_Snapper.py ├── Converter └── Converter.py ├── Hidive_Splitter └── Hidive_Splitter.py ├── Overlap_Blue └── Overlap_Blue.py ├── P-Proper_Stutter └── P-Proper_Stutter.py ├── README.md ├── Regex_Stuff └── Regex_Stuff.py ├── Resampler └── Resampler.py ├── Sign_DeOverlap └── Sign_DeOverlap.py └── Style_Cleanup └── Style_Cleanup.py /Auto_Chap/Auto_Chap.py: -------------------------------------------------------------------------------- 1 | # Auto Chap V4.2 2 | import sys 3 | import json 4 | import os 5 | import urllib 6 | import time 7 | import warnings 8 | import argparse 9 | import shutil 10 | import math 11 | from pathlib import Path 12 | import requests 13 | import subprocess 14 | from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed 15 | import librosa 16 | import audioread.ffdec 17 | import numpy as np 18 | from scipy import signal 19 | import matplotlib.pyplot as plt 20 | 21 | ### Chapter Names 22 | PRE_OP = "Prologue" 23 | OPENING = "Opening" 24 | EPISODE = "Episode" 25 | ENDING = "Ending" 26 | POST_ED = "Epilogue" 27 | 28 | # Ignore librosa warnings about audioread. Try downgrading to librosa < 1.0 if it fully breaks 29 | warnings.filterwarnings("ignore", category=FutureWarning) 30 | warnings.filterwarnings("ignore", category=UserWarning) 31 | 32 | def parse_args(): 33 | parser = argparse.ArgumentParser(description="Automatic anime chapter generator using AnimeThemes.") 34 | parser.add_argument( 35 | "--input", "-i", type=Path, required=True, 36 | help="Video/Audio file.", 37 | ) 38 | 39 | parser.add_argument( 40 | "--output", "-o", type=Path, 41 | help="Output chapter file. Defaults to where the episode is.", 42 | ) 43 | 44 | parser.add_argument( 45 | "--search-name", "-s", type=str, 46 | help="Search to pass to animethemes.moe Example: Spy Classroom Season 2. To only use themes that are already downloaded, don't add this argument.", 47 | ) 48 | 49 | parser.add_argument( 50 | "--year", type=int, 51 | help="Release year to help filter the search. Put the negative number to allow that year or later.", 52 | ) 53 | 54 | parser.add_argument( 55 | "--snap", type=int, nargs='?', const=1000, default=None, 56 | help="Millisecond window to snap to nearest keyframe for frame-perfect chapters. Efficiently generates necessary keyframes from video. Defaults to 1000ms if no value added. Values higher than about 1000 currently crash.", 57 | ) 58 | 59 | parser.add_argument( 60 | "--episode-snap", type=float, default=4, 61 | help="Window in seconds to snap chapters to the start or end of the episode. This gets applied at the very end. Defaults to 4." 62 | ) 63 | 64 | parser.add_argument( 65 | "--score", type=int, default=2000, 66 | help="Score required for a theme to be accepted as a match. Increase it to reduce false positives, decrease it to be more lenient. Score is y-axis in charts divided by downsample factor. Defaults to 2000.", 67 | ) 68 | 69 | parser.add_argument( 70 | "--theme-portion", type=float, default=0.9, 71 | help="Portion of a theme required in the episode to be a match. Keep below 1 so that it can still match themes that get slightly cut off. Defaults to 0.9." 72 | ) 73 | 74 | parser.add_argument( 75 | "--downsample", type=int, default=32, 76 | help="Factor to downsample audio when matching, higher means speedier potentially with lower accuracy. Defaults to 32.", 77 | ) 78 | 79 | parser.add_argument( 80 | "--parallel-dl", type=int, default=10, 81 | help="How many themes to download in parallel. Defaults to 10.", 82 | ) 83 | 84 | parser.add_argument( 85 | "--work-path", "-w", type=Path, 86 | help="Place to create a .themes folder for storing persistent information per series. Defaults to where the episode is.", 87 | ) 88 | 89 | parser.add_argument( 90 | "--delete-themes", "-d", default=False, action="store_true", 91 | help="Delete the themes and charts after running.", 92 | ) 93 | 94 | parser.add_argument( 95 | "--charts", "-c", default=False, action="store_true", 96 | help="Make charts of where themes are matched in the episode. They can almost double processing time in some cases though.", 97 | ) 98 | 99 | args = parser.parse_args() 100 | args.no_download = False 101 | 102 | if args.search_name is None: 103 | args.no_download = True 104 | 105 | if args.work_path is None: 106 | args.work_path = Path(os.path.dirname(args.input)) 107 | 108 | if args.output is None: 109 | args.output = args.input.with_name(args.input.stem + ".chapters.txt") 110 | 111 | if args.snap is not None: 112 | if args.snap > 1000: 113 | print("Snap values higher than about 1000 currently crash SCXvid. Please lower it.", file=sys.stderr) 114 | sys.exit(1) 115 | 116 | if args.theme_portion <= 0: 117 | print("Theme portion must be more than 0.", file=sys.stderr) 118 | sys.exit(1) 119 | elif args.theme_portion > 1: 120 | print("Theme portion must be less than or equal to 1.", file=sys.stderr) 121 | sys.exit(1) 122 | 123 | args.episode_audio_path = None 124 | 125 | return args 126 | 127 | def print_seperator(): 128 | print("------------------------------") 129 | 130 | def make_folders(work_path): 131 | subdirectory_path = work_path / ".themes" / "charts" 132 | shutil.rmtree(subdirectory_path, ignore_errors=True) 133 | subdirectory_path.mkdir(parents=True) 134 | 135 | def get_series_json(args): 136 | api_search_call = f"https://api.animethemes.moe/search?fields[search]=anime&q={urllib.parse.quote(args.search_name)}" 137 | if args.year: 138 | if args.year < 0: 139 | api_search_call += f"&filter[year-gte]={abs(args.year)}" 140 | else: 141 | api_search_call += f"&filter[year]={args.year}" 142 | global_search = requests.get(api_search_call).json() 143 | series_slug = global_search["search"]["anime"][0]["slug"] 144 | series_json = requests.get(f"https://api.animethemes.moe/anime/{series_slug}?include=animethemes.animethemeentries.videos.audio&fields[audio]=filename,updated_at,link").json() 145 | return series_json["anime"] 146 | 147 | def download_theme(t_path, theme_name, url): 148 | response = requests.get(url) 149 | if response.status_code == 200: 150 | download_path = f'{t_path}/{theme_name}' 151 | download_path += ".ogg" 152 | with open(download_path, "wb") as file: 153 | file.write(response.content) 154 | print(f"{theme_name}: Downloaded ", file=sys.stderr) 155 | else: 156 | print(f"Failed to download {theme_name}. Status code:", response.status_code, file=sys.stderr) 157 | 158 | def download_themes(t_path, args, series_json): 159 | try: 160 | with open(os.path.join(t_path, "data.json")) as data: 161 | stored_data = json.load(data) 162 | except Exception: 163 | stored_data = {} 164 | 165 | # Reset themes if series different from last time 166 | if stored_data.get("series_name") != series_json["name"]: 167 | stored_data = {"series_name": series_json["name"]} 168 | files = os.listdir(t_path) 169 | for file in files: 170 | if file.endswith(".ogg"): 171 | file_path = os.path.join(t_path, file) 172 | os.remove(file_path) 173 | 174 | need_download = [] 175 | 176 | for theme in series_json["animethemes"]: 177 | audio_version = 1 178 | audio_links = [] 179 | cur_theme = theme["slug"] # OP1 or ED3, etc. 180 | if not cur_theme[-1].isdigit(): 181 | cur_theme = cur_theme + "1" 182 | for version in theme["animethemeentries"]: # Different video versions of theme 183 | full_cur_theme = cur_theme 184 | if audio_version > 1: 185 | full_cur_theme += f"v{audio_version}" 186 | for video in version["videos"]: 187 | if video["overlap"] != "None": # No overs or transitions 188 | continue 189 | try: # Look to see if it is in data.json or needs an update 190 | if video["audio"]["updated_at"] == stored_data[full_cur_theme]["updated_at"] and \ 191 | video["audio"]["link"] not in audio_links and \ 192 | os.path.isfile(os.path.join(t_path, full_cur_theme + ".ogg")): 193 | audio_links.append(video["audio"]["link"]) 194 | print(f"{full_cur_theme}: Found in directory", file=sys.stderr) 195 | audio_version += 1 196 | break 197 | except Exception: 198 | pass 199 | # Add to data.json 200 | stored_data[full_cur_theme] = {} 201 | stored_data[full_cur_theme]["updated_at"] = video["audio"]["updated_at"] 202 | stored_data[full_cur_theme]["animethemes_filename"] = video["audio"]["filename"] 203 | if video["audio"]["link"] not in audio_links: 204 | need_download.append((full_cur_theme, video["audio"]["link"])) 205 | audio_links.append(video["audio"]["link"]) 206 | audio_version += 1 207 | 208 | if len(need_download) > 0: 209 | print("Downloading themes...") 210 | 211 | with ThreadPoolExecutor(max_workers=args.parallel_dl) as executor: 212 | future_to_url = {executor.submit(download_theme, t_path, theme, url): (theme, url) for (theme, url) in need_download} 213 | for future in as_completed(future_to_url): 214 | url = future_to_url[future] 215 | try: 216 | data = future.result() 217 | except Exception as exc: 218 | print(f"{url} generated an exception: {exc}", file=sys.stderr) 219 | 220 | with open(os.path.join(t_path, "data.json"), "w") as outfile: 221 | json.dump(stored_data, outfile, indent=4) 222 | 223 | def generate_chart(theme_name, c, t_path, matched=True): 224 | try: 225 | fig, ax = plt.subplots() 226 | ax.plot(c) 227 | except Exception as exc: 228 | print(f"{theme_name}: Could not plot figure - {exc}", file=sys.stderr) 229 | return 230 | 231 | try: 232 | if matched: 233 | fig.savefig(os.path.join(f"{t_path}", "charts", f"{theme_name}_matched.png")) 234 | else: 235 | fig.savefig(os.path.join(f"{t_path}", "charts", f"{theme_name}.png")) 236 | except Exception as exc: 237 | print(f"{theme_name}: Could not save figure - {exc}", file=sys.stderr) 238 | return 239 | 240 | print(f"{theme_name}: Chart generated") 241 | 242 | def find_offset(y_episode, sr_episode, theme_file, t_path, args): 243 | theme_name = os.path.splitext(theme_file.name)[0] 244 | 245 | try: 246 | aro = audioread.ffdec.FFmpegAudioFile(str(theme_file)) 247 | y_theme, _ = librosa.load(aro, sr=sr_episode) 248 | except Exception as exc: 249 | print(f"{theme_name}: Could not load theme file - {exc}", file=sys.stderr) 250 | sys.exit(1) 251 | 252 | y_episode= y_episode[::args.downsample] 253 | y_theme = y_theme[::args.downsample] 254 | 255 | # 5 secs silence prepended to fix matches at the beginning of episode 256 | silence_length = int(5 * sr_episode / args.downsample) 257 | y_episode_adjust = np.empty(silence_length + len(y_episode), dtype=y_episode.dtype) 258 | y_episode_adjust[:silence_length] = 0 259 | y_episode_adjust[silence_length:] = y_episode 260 | 261 | duration = librosa.get_duration(path=str(theme_file)) 262 | y_theme_first_portion = y_theme[:int(sr_episode * ((duration + 5) * args.theme_portion) / args.downsample)] 263 | 264 | try: 265 | c = signal.correlate(y_episode_adjust, y_theme_first_portion, mode="valid", method="auto") 266 | except Exception as exc: 267 | print(f"{theme_name}: Error in correlate - {exc}", file=sys.stderr) 268 | return None, None 269 | 270 | required_score = args.score / args.downsample 271 | 272 | match_idx = np.argmax(c) 273 | score = np.max(c) 274 | offset = max(round((match_idx - silence_length) / (sr_episode / args.downsample), 2), 0) 275 | 276 | if score > required_score: 277 | print(f"{theme_name}: Matched from {get_timestamp(offset)} -> {get_timestamp(offset + duration)}", file=sys.stderr) 278 | if args.charts: 279 | with ProcessPoolExecutor() as executor: 280 | executor.submit(generate_chart, theme_name, c, t_path, True) 281 | return offset, (offset + duration) 282 | 283 | else: 284 | print(f"{theme_name}: Not matched", file=sys.stderr) 285 | if args.charts: 286 | with ProcessPoolExecutor() as executor: 287 | executor.submit(generate_chart, theme_name, c, t_path, False) 288 | return None, None 289 | 290 | def get_timestamp(timesec): 291 | timestamp = time.strftime(f"%H:%M:%S.{round(timesec%1*1000):03}", time.gmtime(timesec)) 292 | return timestamp 293 | 294 | def chapter_validator(offset_list, file_duration): 295 | if len(offset_list) == 0: 296 | print_seperator() 297 | print("No matches", file=sys.stderr) 298 | return False 299 | elif len(offset_list) == 2: 300 | return True 301 | elif len(offset_list) == 4: 302 | if offset_list[0] > (file_duration / 2) and offset_list[2] > (file_duration / 2): 303 | print_seperator() 304 | print("Chapters not valid. They both start in the second half", file=sys.stderr) 305 | return False 306 | elif offset_list[0] < (file_duration / 2) and offset_list[2] < (file_duration / 2): 307 | print_seperator() 308 | print("Chapters not valid. They both start in the first half", file=sys.stderr) 309 | return False 310 | else: 311 | return True 312 | else: 313 | print_seperator() 314 | print("Chapters not valid. Invalid number of offsets", file=sys.stderr) 315 | return False 316 | 317 | def generate_chapters(offset_list, file_duration, args): 318 | outfile = open(args.output, "w", encoding="utf-8") 319 | snap_beginning = False 320 | snap_end = False 321 | ed_only = False 322 | 323 | if offset_list[0] < args.episode_snap: 324 | snap_beginning = True 325 | 326 | if offset_list[-1] > (file_duration - args.episode_snap): 327 | snap_end = True 328 | 329 | if offset_list[0] < (file_duration / 2): 330 | pass 331 | else: 332 | ed_only = True 333 | 334 | outfile.write("CHAPTER01=00:00:00.000\n") 335 | if snap_beginning: 336 | outfile.write(f"CHAPTER01NAME={OPENING}\n") 337 | outfile.write(f"CHAPTER02={get_timestamp(offset_list[1])}\n") 338 | outfile.write(f"CHAPTER02NAME={EPISODE}\n") 339 | if len(offset_list) == 4: 340 | outfile.write(f"CHAPTER03={get_timestamp(offset_list[2])}\n") 341 | outfile.write(f"CHAPTER03NAME={ENDING}\n") 342 | if not snap_end: 343 | outfile.write(f"CHAPTER04={get_timestamp(offset_list[3])}\n") 344 | outfile.write(f"CHAPTER04NAME={POST_ED}\n") 345 | elif ed_only: 346 | outfile.write(f"CHAPTER01NAME={EPISODE}\n") 347 | outfile.write(f"CHAPTER02={get_timestamp(offset_list[0])}\n") 348 | outfile.write(f"CHAPTER02NAME={ENDING}\n") 349 | if not snap_end: 350 | outfile.write(f"CHAPTER03={get_timestamp(offset_list[1])}\n") 351 | outfile.write(f"CHAPTER03NAME={POST_ED}\n") 352 | else: 353 | outfile.write(f"CHAPTER01NAME={PRE_OP}\n") 354 | outfile.write(f"CHAPTER02={get_timestamp(offset_list[0])}\n") 355 | outfile.write(f"CHAPTER02NAME={OPENING}\n") 356 | outfile.write(f"CHAPTER03={get_timestamp(offset_list[1])}\n") 357 | outfile.write(f"CHAPTER03NAME={EPISODE}\n") 358 | if len(offset_list) == 4: 359 | outfile.write(f"CHAPTER04={get_timestamp(offset_list[2])}\n") 360 | outfile.write(f"CHAPTER04NAME={ENDING}\n") 361 | if not snap_end: 362 | outfile.write(f"CHAPTER05={get_timestamp(offset_list[3])}\n") 363 | outfile.write(f"CHAPTER05NAME={POST_ED}\n") 364 | 365 | outfile.close() 366 | 367 | def validate_themes(args, t_path): 368 | if args.no_download: 369 | valid = False 370 | if os.path.isdir(t_path): 371 | for theme_file in os.scandir(t_path): 372 | if ".ogg" in str(theme_file): 373 | valid = True 374 | if not valid: 375 | print("No valid themes. Specify a search-name to download themes.", file=sys.stderr) 376 | sys.exit(1) 377 | 378 | def try_download(args, t_path): 379 | if not args.no_download: 380 | print("Searching AnimeThemes...", end="", flush=True) 381 | try: 382 | series_json = get_series_json(args) 383 | print(f'\rAnimeThemes matched series: {series_json["name"]}', file=sys.stderr) 384 | print_seperator() 385 | download_themes(t_path, args, series_json) 386 | print_seperator() 387 | except Exception as exc: 388 | print(f"\rCouldn't access api or download: {exc}", file=sys.stderr) 389 | 390 | def extract_episode_audio(args): 391 | file_path = args.input 392 | 393 | extract_path = f"{str(file_path)}.autochap.wav" 394 | try: 395 | os.remove(extract_path) 396 | except OSError: 397 | pass 398 | 399 | if str(file_path).endswith(".mkv"): 400 | print("Extracting episode audio...", end="", flush=True) 401 | # Extract as a temp wav file 402 | output = subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-n", "-i", 403 | file_path, "-map", "0:a:0", extract_path], capture_output=True) 404 | if len(output.stderr) > 0: 405 | print("\rextraction error ", file=sys.stderr) 406 | print(output.stderr.decode()) 407 | sys.exit(1) 408 | 409 | args.episode_audio_path = extract_path 410 | print("\rExtracted episode audio ") 411 | else: 412 | args.episode_audio_path = str(args.input) 413 | 414 | def process_themes(t_path, args, theme_files, theme_type, y_episode, sr_episode): 415 | matched_flag = False 416 | local_offset_list = [] 417 | for (theme_name, theme_path) in theme_files: 418 | if theme_type in theme_name and matched_flag: 419 | print(f"{theme_name}: Skipping because already matched an {theme_type}", file=sys.stderr) 420 | continue 421 | offset1, offset2 = find_offset(y_episode, sr_episode, theme_path, t_path, args) 422 | if offset1 is not None: 423 | matched_flag = True 424 | local_offset_list.append(offset1) 425 | local_offset_list.append(offset2) 426 | 427 | return local_offset_list 428 | 429 | def match_themes(args, t_path): 430 | op_files = [] 431 | ed_files = [] 432 | for theme_file in os.scandir(t_path): 433 | if ".ogg" in str(theme_file): 434 | theme_name = os.path.splitext(Path(theme_file.path).name)[0] 435 | theme_path = Path(theme_file.path) 436 | if "OP" in theme_name: 437 | op_files.append((theme_name, theme_path)) 438 | elif "ED" in theme_name: 439 | ed_files.append((theme_name, theme_path)) 440 | 441 | offset_list = [] 442 | print("Matching themes...") 443 | 444 | try: 445 | y_episode, sr_episode = librosa.load(str(args.episode_audio_path), sr=None) 446 | except Exception as exc: 447 | print(f"Could not load input file - {str(args.episode_audio_path)}: {exc}", file=sys.stderr) 448 | sys.exit(1) 449 | 450 | with ThreadPoolExecutor(max_workers=2) as executor: 451 | future_op = executor.submit(process_themes, t_path, args, op_files, "OP", y_episode, sr_episode) 452 | future_ed = executor.submit(process_themes, t_path, args, ed_files, "ED", y_episode, sr_episode) 453 | 454 | for future in as_completed([future_op, future_ed]): 455 | offset_list.extend(future.result()) 456 | 457 | return offset_list 458 | 459 | def time_to_frame(timesec, framerate, floor = True): 460 | frame = timesec * framerate 461 | if floor: 462 | return math.floor(frame) 463 | else: 464 | return math.ceil(frame) 465 | 466 | def frame_to_time(frame, framerate, floor = True): 467 | if floor: 468 | middle_frame = max(0, frame - 0.5) 469 | else: 470 | middle_frame = frame + 0.5 471 | 472 | secs = middle_frame / framerate 473 | 474 | return secs 475 | 476 | def generate_search_pattern(window): 477 | result = [window + 1] 478 | 479 | for i in range(1, window + 1): 480 | result.append(window + 1 - i) 481 | result.append(window + 1 + i) 482 | 483 | return result 484 | 485 | def get_keyframe_frame(frame, snap_window_frames, clip_length, clip, core): 486 | # Generate the keyframes in range with one more at the beginning since the first is always keyframe 487 | # Scxvid needs to go sequentially and wwxd is inaccurate in testing 488 | search_start_frame = max(frame - snap_window_frames - 1, 0) 489 | search_end_frame = min(frame + snap_window_frames, clip_length) # Should already be one more than the last index 490 | if search_start_frame >= search_end_frame: 491 | return 492 | trimmed_clip = clip[search_start_frame:search_end_frame] 493 | try: 494 | scxvid_clip = core.scxvid.Scxvid(trimmed_clip) 495 | except Exception: 496 | raise ImportError("You need to install Scxvid in vapoursynth plugins\n" 497 | "https://github.com/dubhater/vapoursynth-scxvid") 498 | 499 | search_pattern = generate_search_pattern(snap_window_frames) 500 | for i in search_pattern: 501 | if i <= 0 or i >= trimmed_clip.num_frames: 502 | continue 503 | actual_frame = frame - snap_window_frames - 1 + i 504 | props = scxvid_clip.get_frame(i).props 505 | scenechange = props._SceneChangePrev 506 | if scenechange: 507 | return actual_frame 508 | 509 | def snap(args, offset_list): 510 | try: 511 | import vapoursynth as vs 512 | from vapoursynth import core 513 | except Exception: 514 | raise ImportError("You need to install in vapoursynth for snapping\n" 515 | "https://github.com/vapoursynth/vapoursynth") 516 | 517 | try: 518 | clip = core.ffms2.Source(source=args.input, cache=False) 519 | except Exception: 520 | raise ImportError("Could not load video or you haven't installed ffms2 in vapoursynth plugins for snapping\n" 521 | "https://github.com/FFMS/ffms2") 522 | 523 | print(f"Snapping chapters...", end="", flush=True) 524 | 525 | clip = core.resize.Bilinear(clip, 640, 360, format=vs.YUV420P8) 526 | 527 | clip_length = clip.num_frames 528 | fps = float(clip.fps.numerator) / float(clip.fps.denominator) 529 | 530 | snapped_offsets = [] 531 | for offset in offset_list: 532 | offset_frame = time_to_frame(offset, fps, floor=False) 533 | snap_window_frames = round(args.snap / 1000 * fps) 534 | snap_frame = get_keyframe_frame(offset_frame, snap_window_frames, clip_length, clip, core) 535 | 536 | if snap_frame: 537 | snapped_offsets.append(frame_to_time(snap_frame, fps, floor=True)) 538 | else: 539 | snapped_offsets.append(offset) 540 | 541 | return snapped_offsets 542 | 543 | def print_snapped_times(offset_list, file_duration, args): 544 | print("\rSnapped times: ", file=sys.stderr) 545 | 546 | # Episode duration snapping 547 | ep_snapped_offsets = offset_list.copy() 548 | if ep_snapped_offsets[0] < args.episode_snap: 549 | ep_snapped_offsets[0] = 0 550 | 551 | if ep_snapped_offsets[-1] > (file_duration - args.episode_snap): 552 | ep_snapped_offsets[-1] = file_duration 553 | 554 | print(f"{get_timestamp(ep_snapped_offsets[0])} -> {get_timestamp(ep_snapped_offsets[1])}", file=sys.stderr) 555 | 556 | if len(ep_snapped_offsets) == 4: 557 | print(f"{get_timestamp(ep_snapped_offsets[2])} -> {get_timestamp(ep_snapped_offsets[3])}", file=sys.stderr) 558 | 559 | def main(): 560 | args = parse_args() 561 | t_path = os.path.join(args.work_path, ".themes") 562 | 563 | try: 564 | validate_themes(args, t_path) 565 | make_folders(args.work_path) 566 | try_download(args, t_path) 567 | extract_episode_audio(args) 568 | offset_list = match_themes(args, t_path) 569 | 570 | file_duration = librosa.get_duration(path=str(args.episode_audio_path)) 571 | 572 | offset_list.sort() 573 | if chapter_validator(offset_list, file_duration): 574 | if args.snap: 575 | print_seperator() 576 | offset_list = snap(args, offset_list) 577 | print_snapped_times(offset_list, file_duration, args) 578 | generate_chapters(offset_list, file_duration, args) 579 | finally: 580 | if args.delete_themes: 581 | shutil.rmtree(t_path) 582 | try: 583 | if args.episode_audio_path.endswith(".autochap.wav"): 584 | os.remove(args.episode_audio_path) 585 | except Exception: 586 | pass 587 | 588 | if __name__ == "__main__": 589 | main() -------------------------------------------------------------------------------- /Auto_Chap/Changelog.md: -------------------------------------------------------------------------------- 1 | # Auto_Chap Changelog 2 | 3 | ## V3.0 4 | - Make chapters frame-perfect by using `--snap` to snap them to scene changes within a certain millisecond window. This requires new dependencies [Vapoursynth](https://github.com/vapoursynth/vapoursynth), [ffm2](https://github.com/FFMS/ffms2), and [vapoursynth-scxvid](https://github.com/dubhater/vapoursynth-scxvid). If you are not using this feature then these dependencies do not need to be installed. It efficiently generates needed keyframes and is very fast compared to generating keyframes for the entire episode, taking only about 2 seconds. 5 | - Filter search year of the series using `--year`. 6 | - Filter for series released on or after that year using a negative number after `--year`. I added this because search for multi-season shows can sometimes give you the wrong one. 7 | 8 | ## V3.1 9 | - Fix for matches right at the beginning of the episode being off by a few seconds. This is quite important as it would sometimes skip the first cut after the OP such as in Apothecary Diaries episode 12. 10 | - Switch to using AnimeThemes' slug for theme names so that stuff like ED-TV for Frieren are now downloaded. 11 | - Added progress indicator messages. 12 | - More error handling for snap. 13 | - Printed snapped times should now be the same as final output chapter times. 14 | - Snap now defaults to 1000ms if no value added. 15 | 16 | ## V3.2 17 | - Theme downloading now checks all versions of the theme so that stuff like Frieren's cour 2 ED that uses a different part of the same song can now be grabbed. 18 | - Redid progress indicators again with separators and better formatted messages. 19 | - Theme downloader now checks if a theme is actually present in the directory even if data.json says it should be and re-downloads if it is not. 20 | 21 | ## V3.3 22 | - Updated to work on new animethemes api. Since "updated_at" times are no longer used, data.json now uses the "filename" data for each theme to keep track of if they need updating. This means every series will have to be updated and themes redownloaded to fit the new data.json format. 23 | 24 | ## V3.4 25 | - Found out how to access "updated_at" again which should mean that it can detect more accurately when a theme has been updated. Updated data.json to store both "updated_at" and the animethemes "filename" but unfortunately this format change means all themes will have to be redownloaded again. 26 | 27 | ## V4.0 28 | - Up to **2-3x faster theme matching** by using ffmpeg to extract the audio from mkv to a temp file, downsampling the audio, and using 2 threads for matching OP and ED in parallel. Chart creation is also done in parallel on a separate process 29 | - The downsampling factor defaults to 8 and can be changed using `--downsample 32` for example. There are diminishing returns as you increase it. 30 | - ffmpeg is required in PATH, it will now create a temp `.autochap.wav` file if the input is an mkv file. The temp file uses the first audio track and may be quite big. 31 | - Up to **2-5x faster theme downloading** by downloading in parallel. Speed up depends on how many themes in the series, internet speeds, etc. 32 | - Themes to download in parallel defaults to 10 and can be changed using `--parallel-dl 2` for example 33 | - Reduced false positives where only the beginning of a theme is played by using the entire theme audio to match instead of first 30 seconds 34 | - Fixed `--year` to only match that specific year if the number is non-negative 35 | 36 | ## V4.1 37 | - Up to **2-3x faster theme matching** than V4.0 38 | - Speed-up applies more when there are more themes that need to be matched. Optimisation includes only loading the episode audio once at the beginning and using the audioread module to load theme files. 39 | - Fixed compatibility issue with macos caused by audioread loading. 40 | - Use `--score` to adjust how lenient the matching should be. Increase from default to reduce false positives. Decrease it to be more lenient. Score is y-axis in charts divided by the downsample factor. 41 | - Increased default score from 2000 to 4000 42 | - Increased default downsample factor from 8 to 32 43 | 44 | ## V4.1a 45 | - Revert default score from 4000 back to 2000 46 | - Please give me feedback on the change in downsample default. If 32 causes any errors then let me know. 47 | 48 | ## V4.2 49 | - Fixed non-matches for episodes where themes in the episode are slightly shorter such as when a theme is played at the very end of the episode. Now uses the beginning 90% of the theme for matching. The portion of the theme used can be changed using `--theme-portion` 50 | - Moved episode snap option for snapping chapters to the start and end of the episode to an optional argument using `--episode-snap` The default is still 4 seconds so that previews/endscreens 5 seconds long will get their own chapter. 51 | - Fixed typo in chapter output when there is only an ED. 52 | - Rearranged arguments and fixed typos. 53 | - Trimmed trailing whitespace. 54 | - Standardised error messages. -------------------------------------------------------------------------------- /Auto_Chap/requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.31.0 2 | numpy==1.26.2 3 | scipy==1.11.4 4 | matplotlib==3.8.2 5 | librosa==0.10.1 -------------------------------------------------------------------------------- /Chapter_Snapper/Chapter_Snapper.py: -------------------------------------------------------------------------------- 1 | # Chapter Snapper V2.1 2 | import sys 3 | import bisect 4 | import math 5 | import re 6 | import time 7 | import argparse 8 | from pathlib import Path 9 | 10 | def parse_args(): 11 | parser = argparse.ArgumentParser(description="Snap chapters to nearest keyframe.") 12 | parser.add_argument( 13 | "--input", "-i", type=Path, required=True, 14 | help="Chapter file. Must be in simple format.", 15 | ) 16 | 17 | parser.add_argument( 18 | "--keyframes", "-kf", type=Path, required=True, 19 | help="SCXvid keyframes. Try to have minimal mkv delay or it might not line up.", 20 | ) 21 | 22 | parser.add_argument( 23 | "--output", "-o", type=Path, 24 | help="Output chapter file. Defaults to where input is.", 25 | ) 26 | 27 | parser.add_argument( 28 | "--snap-ms", "-s", type=int, default=1000, 29 | help="How many milliseconds to consider snapping to. Defaults to 1000ms.", 30 | ) 31 | 32 | parser.add_argument( 33 | "--fps", type=float, default=23.976, 34 | help="FPS of the video. Defaults to 23.976.", 35 | ) 36 | 37 | args = parser.parse_args() 38 | 39 | if args.output is None: 40 | args.output = args.input.with_name(args.input.stem + "_snapped.txt") 41 | 42 | return args 43 | 44 | def parse_srt_time(string): 45 | hours, minutes, seconds, milliseconds = map(int, re.match(r"(\d+):(\d+):(\d+)\.(\d+)", string).groups()) 46 | return hours * 3600000 + minutes * 60000 + seconds * 1000 + milliseconds 47 | 48 | def parse_scxvid_keyframes(text): 49 | return [i-3 for i,line in enumerate(text.splitlines()) if line and line[0] == "i"] 50 | 51 | def parse_keyframes(path): 52 | with open(path) as file_object: 53 | text = file_object.read() 54 | if text.find("# XviD 2pass stat file")>=0: 55 | frames = parse_scxvid_keyframes(text) 56 | else: 57 | raise Exception("Unsupported keyframes type") 58 | if 0 not in frames: 59 | frames.insert(0, 0) 60 | return frames 61 | 62 | class Timecodes(object): 63 | TIMESTAMP_END = 1 64 | TIMESTAMP_START = 2 65 | 66 | def __init__(self, times, default_fps): 67 | super(Timecodes, self).__init__() 68 | self.times = times 69 | self.default_frame_duration = 1000.0 / default_fps if default_fps else None 70 | 71 | def get_frame_time(self, number, kind=None): 72 | if kind == self.TIMESTAMP_START: 73 | prev = self.get_frame_time(number-1) 74 | curr = self.get_frame_time(number) 75 | return prev + int(round((curr - prev) / 2.0)) 76 | elif kind == self.TIMESTAMP_END: 77 | curr = self.get_frame_time(number) 78 | after = self.get_frame_time(number+1) 79 | return curr + int(round((after - curr) / 2.0)) 80 | 81 | try: 82 | return self.times[number] 83 | except IndexError: 84 | if not self.default_frame_duration: 85 | raise ValueError("Cannot calculate frame timestamp without frame duration") 86 | past_end, last_time = number, 0 87 | if self.times: 88 | past_end, last_time = (number - len(self.times) + 1), self.times[-1] 89 | 90 | return int(round(past_end * self.default_frame_duration + last_time)) 91 | 92 | def get_frame_number(self, ms, kind=None): 93 | if kind == self.TIMESTAMP_START: 94 | return self.get_frame_number(ms - 1) + 1 95 | elif kind == self.TIMESTAMP_END: 96 | return self.get_frame_number(ms - 1) 97 | 98 | if self.times and self.times[-1] >= ms: 99 | return bisect.bisect_left(self.times, ms) 100 | 101 | if not self.default_frame_duration: 102 | raise ValueError("Cannot calculate frame for this timestamp without frame duration") 103 | 104 | if ms < 0: 105 | return int(math.floor(ms / self.default_frame_duration)) 106 | 107 | last_time = self.times[-1] if self.times else 0 108 | return int((ms - last_time) / self.default_frame_duration) + len(self.times) 109 | 110 | @classmethod 111 | def _convert_v1_to_v2(cls, default_fps, overrides): 112 | # start, end, fps 113 | overrides = [(int(x[0]), int(x[1]), float(x[2])) for x in overrides] 114 | if not overrides: 115 | return [] 116 | 117 | fps = [default_fps] * (overrides[-1][1] + 1) 118 | for start, end, fps in overrides: 119 | fps[start:end + 1] = [fps] * (end - start + 1) 120 | 121 | v2 = [0] 122 | for d in (1000.0 / f for f in fps): 123 | v2.append(v2[-1] + d) 124 | return v2 125 | 126 | @classmethod 127 | def parse(cls, text): 128 | lines = text.splitlines() 129 | if not lines: 130 | return [] 131 | first = lines[0].lower().lstrip() 132 | if first.startswith("# timecode format v2"): 133 | tcs = [x for x in lines[1:]] 134 | return Timecodes(tcs, None) 135 | elif first.startswith("# timecode format v1"): 136 | default = float(lines[1].lower().replace("assume ", "")) 137 | overrides = (x.split(",") for x in lines[2:]) 138 | return Timecodes(cls._convert_v1_to_v2(default, overrides), default) 139 | else: 140 | raise Exception("This timecodes format is not supported") 141 | 142 | @classmethod 143 | def from_file(cls, path): 144 | with open(path) as file: 145 | return cls.parse(file.read()) 146 | 147 | @classmethod 148 | def cfr(cls, fps): 149 | return Timecodes([], default_fps=fps) 150 | 151 | def get_closest_kf(frame, keyframes): 152 | idx = bisect.bisect_left(keyframes, frame) 153 | if idx == len(keyframes): 154 | return keyframes[-1] 155 | if idx == 0 or keyframes[idx] - frame < frame - (keyframes[idx-1]): 156 | return keyframes[idx] 157 | return keyframes[idx-1] 158 | 159 | def validate_chapters(chapter_read): 160 | if not chapter_read[0].startswith("CHAPTER01="): 161 | print("Invalid chapter format.", file=sys.stderr) 162 | sys.exit(1) 163 | 164 | def apply(chapter_lines, timecodes, keyframes_list, snap_ms): 165 | for idx, line in enumerate(chapter_lines): 166 | if idx % 2 == 0: 167 | chapter_split = line.split("=") 168 | start_ms = parse_srt_time(chapter_split[1]) 169 | start_frame = timecodes.get_frame_number(start_ms, timecodes.TIMESTAMP_START) 170 | closest_frame = get_closest_kf(start_frame, keyframes_list) 171 | closest_time = timecodes.get_frame_time(closest_frame, timecodes.TIMESTAMP_START) 172 | 173 | if abs(closest_time - start_ms) <= snap_ms and start_ms != 0: 174 | start_ms = max(0, closest_time) 175 | timesec = start_ms/1000 176 | timestamp = time.strftime(f"%H:%M:%S.{round(timesec%1*1000):03}", time.gmtime(timesec)) 177 | chapter_lines[idx] = f"{chapter_split[0]}={timestamp}\n" 178 | 179 | def main(): 180 | args = parse_args() 181 | 182 | timecodes = Timecodes.cfr(args.fps) 183 | keyframes_list = parse_keyframes(args.keyframes) 184 | 185 | with open(args.input, "r") as chapter_file: 186 | chapter_lines = chapter_file.readlines() 187 | 188 | validate_chapters(chapter_lines) 189 | apply(chapter_lines, timecodes, keyframes_list, args.snap_ms) 190 | 191 | with open(args.output, "w") as out_file: 192 | out_file.writelines(chapter_lines) 193 | 194 | if __name__ == "__main__": 195 | main() -------------------------------------------------------------------------------- /Converter/Converter.py: -------------------------------------------------------------------------------- 1 | # Converter V1.6 2 | 3 | import sys 4 | import copy 5 | from datetime import timedelta 6 | import ass 7 | from ass.data import Color as Color 8 | 9 | def set_info(doc): 10 | doc.info["Title"] = "[SubsPlus+]" 11 | doc.info["YCbCr Matrix"] = "TV.709" 12 | try: 13 | doc.info.pop("LayoutResX") 14 | doc.info.pop("LayoutResY") 15 | except KeyError: 16 | pass 17 | 18 | def sort_signs(obj): 19 | if "\\pos" in obj.text: 20 | return 1 21 | else: 22 | return 0 23 | 24 | 25 | def detect_styles(doc): 26 | def gen_style(style, event): 27 | new_style_num = style_num 28 | 29 | # Identify style type 30 | if "\\pos" in event.text: 31 | type = "Caption" 32 | else: 33 | type = "Subtitle" 34 | 35 | try: 36 | # Already mapped 37 | style_name = style_map[event.style][type] 38 | except: 39 | # Do the mapping 40 | if type == "Subtitle": 41 | # If the other type is already in, then it's a double type style 42 | try: 43 | style_map[event.style]["Caption"] 44 | # 900 styles for duplicates 45 | style_name = f"Subtitle-{style_num + 900}" 46 | except KeyError: 47 | style_name = f"Subtitle-{style_num}" 48 | new_style_num += 1 49 | new_style = copy.deepcopy(style) 50 | new_style.name = style_name 51 | subtitle_styles.append(new_style) 52 | elif type == "Caption": 53 | try: 54 | style_map[event.style]["Subtitle"] 55 | style_name = f"Caption-{style_num + 900}" 56 | except KeyError: 57 | style_name = f"Caption-{style_num}" 58 | new_style_num += 1 59 | new_style = copy.deepcopy(style) 60 | new_style.name = style_name 61 | caption_styles.append(new_style) 62 | 63 | try: 64 | style_map[event.style][type] = style_name 65 | except: 66 | style_map[event.style] = {} 67 | style_map[event.style][type] = style_name 68 | 69 | event.style = style_name 70 | return new_style_num 71 | 72 | # Should match with the Q style number 73 | style_num = 0 74 | subtitle_styles = [] 75 | caption_styles = [] 76 | # Key = Q style, Value = new style 77 | style_map = {} 78 | for style in doc.styles: 79 | for event in doc.events: 80 | if style.name == event.style: 81 | style_num = gen_style(style, event) 82 | 83 | doc.styles = subtitle_styles 84 | doc.styles.extend(caption_styles) 85 | 86 | 87 | def song_detection(doc): 88 | # Figure out which styles are songs 89 | 90 | # Pass 1: 9 consecutive lines (Additive) 91 | possible_song_styles = [style.name for style in doc.styles if "Subtitle" in style.name and style.fontsize == 40] 92 | 93 | consecutive_lines = 0 94 | styles_involved = set() 95 | song_styles = set() 96 | for event in doc.events: 97 | if event.style not in possible_song_styles: 98 | consecutive_lines = 0 99 | styles_involved = set() 100 | continue 101 | consecutive_lines += 1 102 | styles_involved.add(event.style) 103 | if consecutive_lines > 8 and event.style not in song_styles: 104 | # song_styles.update(styles_involved) 105 | song_styles.add(event.style) 106 | 107 | # Pass 2: Time based 7 consecutive (Additive) 108 | 109 | for style in possible_song_styles: 110 | consecutive_events = 0 111 | allowed_pauses = 1 112 | last_event_end = timedelta(0) 113 | for event in doc.events: 114 | if event.style != style: 115 | continue 116 | time_difference = event.start - last_event_end 117 | if time_difference < timedelta(seconds=1): 118 | # if they are consecutive within 1 second 119 | consecutive_events += 1 120 | if consecutive_events == 7: # arbritary amount 121 | song_styles.add(style) 122 | break 123 | elif time_difference < timedelta(seconds=5)and allowed_pauses > 0: 124 | allowed_pauses -= 1 125 | consecutive_events += 1 126 | if consecutive_events == 7: # arbritary amount 127 | song_styles.add(style) 128 | break 129 | else: 130 | consecutive_events = 0 131 | allowed_pauses = 1 132 | last_event_end = event.end 133 | 134 | # Pass 3: Fill Gap (Additive) 135 | last_event_end = timedelta(seconds=-50) 136 | for event in doc.events: 137 | if event.style not in song_styles: 138 | continue 139 | time_difference = event.start - last_event_end 140 | if time_difference < timedelta(seconds=10) and time_difference > timedelta(seconds=0): 141 | # Gap found, look for song events inside 142 | for inside_event in doc.events: 143 | if inside_event.style not in possible_song_styles: 144 | continue 145 | if not (inside_event.start >= last_event_end and inside_event.end <= event.start): 146 | continue 147 | print(f"Gap filled: {inside_event.text}") 148 | song_styles.add(inside_event.style) 149 | last_event_end = event.end 150 | 151 | # Pass 4: Subset styles (Additive) 152 | # Basically for when they do romaji and english at the same time 153 | 154 | remaining_contenders = [item for item in possible_song_styles if item not in song_styles] 155 | 156 | song_styles_times = [] # List of list (each style) of tuple: (event.start, event.end) 157 | for style in song_styles: 158 | style_times = [] 159 | for event in doc.events: 160 | if event.style != style: 161 | continue 162 | style_times.append((event.start, event.end)) 163 | song_styles_times.append(style_times) 164 | 165 | remaining_contenders_times = {} 166 | for style in remaining_contenders: 167 | style_times = [] 168 | for event in doc.events: 169 | if event.style != style: 170 | continue 171 | style_times.append((event.start, event.end)) 172 | remaining_contenders_times[style] = style_times 173 | 174 | # Find subsets 175 | for remaining_style_key in remaining_contenders_times: 176 | remaining_style_value = remaining_contenders_times[remaining_style_key] 177 | for song_style in song_styles_times: 178 | if all(item in song_style for item in remaining_style_value): 179 | print(f"By Subset: {remaining_style_key}") 180 | song_styles.add(remaining_style_key) 181 | 182 | # Update style 183 | for style in doc.styles: 184 | if style.name in song_styles: 185 | style.name = style.name.replace("Subtitle", "Song") 186 | 187 | # Update events 188 | for event in doc.events: 189 | if event.style in song_styles: 190 | event.style = event.style.replace("Subtitle", "Song") 191 | 192 | 193 | def restrictive_song_detection(doc): 194 | song_style = ass.Style(name='Song', fontname='Sub Alegreya', fontsize=46, primary_color=Color(r=0xff, g=0xff, b=0xff, a=0x00), secondary_color=Color(r=0xff, g=0xff, b=0xff, a=0x00), outline_color=Color(r=0x72, g=0x0c, b=0x5f, a=0x00), back_color=Color(r=0x00, g=0x00, b=0x00, a=0xa0), bold=True, italic=False, underline=False, strike_out=False, scale_x=100.0, scale_y=100.0, spacing=0.1, angle=0.0, border_style=1, outline=2.2, shadow=0, alignment=8, margin_l=100, margin_r=100, margin_v=25, encoding=1) 195 | possible_song_styles = [style.name for style in doc.styles if "Subtitle" in style.name and style.fontsize == 40] 196 | 197 | song_blocks = [] # Blocks of song events 198 | 199 | # Find all blocks of song events (More than 8 consecutive an8) 200 | consecutive_lines = 0 201 | events_involved = [] 202 | for event in doc.events: 203 | if event.style not in possible_song_styles: 204 | if consecutive_lines > 8: 205 | song_blocks.append(events_involved) 206 | consecutive_lines = 0 207 | events_involved = [] 208 | continue 209 | consecutive_lines += 1 210 | events_involved.append(event) 211 | 212 | if len(song_blocks) == 0: 213 | return 214 | 215 | # Add Song style to styles 216 | insert_pos = len(doc.styles) 217 | for idx, style in enumerate(doc.styles): 218 | if "Caption" in style.name: 219 | insert_pos = idx 220 | break 221 | doc.styles.insert(insert_pos, song_style) 222 | 223 | for event in song_blocks[0]: 224 | event.style = "Song" 225 | 226 | if len(song_blocks) >= 2: 227 | # Skip doing middle song blocks. Since hopefully ED will be the last one 228 | for event in song_blocks[-1]: 229 | event.style = "Song" 230 | 231 | 232 | def manual_caption2song(doc): 233 | pass 234 | 235 | 236 | def rescale_captions(doc): 237 | for style in doc.styles: 238 | if "Caption" in style.name: 239 | style.fontsize = round(style.fontsize / 1.2 * 1.125) 240 | 241 | 242 | def restyler(doc): 243 | for style in doc.styles: 244 | if "Subtitle" in style.name: 245 | style.primary_color = Color.from_ass("&H00FFFFFF") 246 | style.secondary_color = Color.from_ass("&H00FFFFFF") 247 | style.outline_color = Color.from_ass("&H00000000") 248 | style.back_color = Color.from_ass("&HA0000000") 249 | style.bold = True 250 | style.outline = 2.4 251 | style.shadow = 1 252 | style.margin_l = 40 253 | style.margin_r = 40 254 | style.margin_v = 40 255 | if style.fontname == "Swis721 BT": 256 | if style.fontsize == 40: 257 | style.alignment = 8 258 | if style.fontsize == 48 or style.fontsize == 40: 259 | style.fontsize = 50 260 | style.fontname = "SPOverrideF" 261 | elif style.fontname == "Chiller" and style.fontsize < 63: # Change later 262 | # send to an8 263 | pass 264 | elif "Song" in style.name: 265 | style.fontname = "Sub Alegreya" 266 | style.fontsize = 46 267 | style.primary_color = Color.from_ass("&H00FFFFFF") 268 | style.secondary_color = Color.from_ass("&H00FFFFFF") 269 | style.outline_color = Color.from_ass("&H005F0C72") 270 | style.back_color = Color.from_ass("&HA0000000") 271 | style.bold = True 272 | style.spacing = 0.1 273 | style.outline = 2.2 274 | style.shadow = 0 275 | style.margin_l = 40 276 | style.margin_r = 40 277 | style.margin_v = 25 278 | style.alignment = 8 279 | elif "Caption" in style.name: 280 | if style.primary_color.to_ass() == "&H0094FDFF": 281 | style.primary_color = Color.from_ass("&H0000FFFF") 282 | style.secondary_color = Color.from_ass("&H0000FFFF") 283 | style.outline = 1 284 | style.shadow = 1 285 | style.margin_l = 20 286 | style.margin_r = 20 287 | style.margin_v = 20 288 | # style.spacing = 0.01 289 | 290 | 291 | def fix_small_font_shenanigans(doc): 292 | # Fix times where it is going subtitle font size 48 -> 40 -> 32 as an effect 293 | 294 | styles_by_name = {style.name: style for style in doc.styles} 295 | 296 | for i, event in enumerate(doc.events): 297 | style = styles_by_name.get(event.style) 298 | if not (style and style.alignment == 8 and "Subtitle" in style.name): 299 | continue 300 | 301 | if i + 1 < len(doc.events): 302 | next_event = doc.events[i + 1] 303 | next_style = styles_by_name.get(next_event.style) 304 | if not ("Subtitle" in next_style.name and next_style.fontsize < 40): 305 | continue 306 | small_style = copy.deepcopy(style) 307 | small_style.name = "Subtitle-Small" 308 | small_style.alignment = 2 309 | small_style.fontsize = 40 310 | doc.styles.append(small_style) 311 | event.style = "Subtitle-Small" 312 | 313 | 314 | 315 | def main(inpath, outpath): 316 | with open(inpath, encoding='utf_8_sig') as f: 317 | doc = ass.parse(f) 318 | 319 | set_info(doc) 320 | doc.events = sorted(doc.events, key=sort_signs) 321 | detect_styles(doc) 322 | song_detection(doc) 323 | # restrictive_song_detection(doc) 324 | restyler(doc) 325 | fix_small_font_shenanigans(doc) 326 | rescale_captions(doc) 327 | 328 | with open(outpath, "w", encoding='utf_8_sig') as f: 329 | doc.dump_file(f) 330 | 331 | 332 | if __name__ == "__main__": 333 | if len(sys.argv) != 3: 334 | sys.exit(f"Usage: {sys.argv[0]} infile.ass outfile.ass") 335 | main(sys.argv[1], sys.argv[2]) 336 | -------------------------------------------------------------------------------- /Hidive_Splitter/Hidive_Splitter.py: -------------------------------------------------------------------------------- 1 | # Hidive_Splitter V2.1 2 | import sys 3 | import re 4 | 5 | # match an ASS event with named groups and newline on the end 6 | def line2dict(line): 7 | line_pattern = re.compile(r"(?P[^:]*): ?(?P\d*), ?(?P[^,]*), ?(?P[^,]*), ?(?P