Created
September 6, 2025 11:02
-
-
Save natyusha/2d7877de2c6924bafd6fc65f66a61178 to your computer and use it in GitHub Desktop.
A utility that automatically compresses timestamps for mkv chapter xml files from 23.976fps sources that have been converted to 24fps (only affects ChapterTimeStart as other timestamps can be removed)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import os, sys | |
import tkinter as tk | |
import xml.etree.ElementTree as ET | |
try: | |
from tkinterdnd2 import TkinterDnD, DND_FILES | |
except ImportError: | |
print('Please install tkinterdnd2: pip install tkinterdnd2') | |
sys.exit(1) | |
r""" | |
Description: | |
- A utility that automatically compresses timestamps for mkv chapter xml files from 23.976fps sources that have been converted to 24fps | |
- This only affects ChapterTimeStart (other time stamps are not required and can be safely removed) and will overwrite the xml file(s) | |
Author: | |
- natyusha | |
Requirements: | |
- programs : python 3.7+ | |
- pip : tkinterdnd2 (pip install tkinterdnd2) | |
Usage: | |
- Run the script and drag chapter xml file(s) into the window | |
""" | |
def parse_time(time_str): | |
"""Convert Matroska timestamp (HH:MM:SS.nnnnnnnnn) to milliseconds.""" | |
time_parts = time_str.split(':') | |
hours = int(time_parts[0]) | |
minutes = int(time_parts[1]) | |
seconds = float(time_parts[2]) | |
total_ms = (hours * 3600 + minutes * 60 + seconds) * 1000 | |
return total_ms | |
def format_time(ms): | |
"""Convert milliseconds back to Matroska timestamp format (HH:MM:SS.nnnnnnnnn), rounded to 3 decimal places.""" | |
seconds = ms / 1000 | |
hours = int(seconds // 3600) | |
seconds %= 3600 | |
minutes = int(seconds // 60) | |
seconds = seconds % 60 | |
seconds = round(seconds, 3) # round seconds to 3 decimal places | |
return f'{hours:02d}:{minutes:02d}:{seconds:012.9f}' # format with 9 decimal places, padding with zeros | |
def convert_chapters(input_file, console): | |
"""Apply 23.976fps to 24fps conversion to ChapterTimeStart timestamps.""" | |
scale_factor = 23.976 / 24 # Approximately 0.999 (999 / 1000) | |
try: | |
# parse the XML file | |
tree = ET.parse(input_file) | |
root = tree.getroot() | |
# process all ChapterTimeStart elements | |
for chapter in root.findall('.//ChapterTimeStart'): | |
time_ms = parse_time(chapter.text) | |
scaled_ms = time_ms * scale_factor | |
chapter.text = format_time(scaled_ms) | |
# overwrite the original file | |
tree.write(input_file, encoding='utf-8', xml_declaration=True) | |
console.insert(tk.END, f'Success: Timestamps converted in:\n{os.path.basename(input_file)}\n') | |
console.see(tk.END) | |
return True | |
except ET.ParseError: | |
console.insert(tk.END, f'Error: Failed to parse XML file:\n{input_file}\n') | |
console.see(tk.END) | |
except FileNotFoundError: | |
console.insert(tk.END, f'Error: File not found:\n{input_file}\n') | |
console.see(tk.END) | |
except Exception as e: | |
console.insert(tk.END, f'Error: An error occurred:\n{e}\n') | |
console.see(tk.END) | |
return False | |
def on_drop(event, console): | |
"""Handle drag-and-drop for multiple files.""" | |
# get the dropped file path | |
file_paths = event.data | |
# handle TkinterDnD2 formatting (braced or space-separated paths) | |
if file_paths.startswith('{') and file_paths.endswith('}'): | |
file_paths = file_paths[1:-1].split('} {') | |
else: | |
file_paths = file_paths.split() | |
# process each file | |
for file_path in file_paths: | |
if os.path.isfile(file_path) and file_path.lower().endswith('.xml'): | |
convert_chapters(file_path, console) | |
else: | |
console.insert(tk.END, f'Error: Not a valid XML chapter file:\n{file_path}\n') | |
console.see(tk.END) | |
def create_gui(): | |
"""Create the GUI window for drag-and-drop with a console.""" | |
root = TkinterDnD.Tk() # use TkinterDnD2.Tk for drag-and-drop support | |
root.title('23.976fps to 24fps Chapters') | |
root.geometry('500x200') | |
root.resizable(False, False) | |
# create a label to indicate drag-and-drop area | |
label = tk.Label( | |
root, | |
text='Drag and Drop a Chapter XML File Here', | |
font='TkHeadingFont', | |
justify='center', | |
borderwidth=2, | |
relief='groove', | |
padx=10, | |
pady=10 | |
) | |
label.pack(expand=False, fill='both', padx=10, pady=10) | |
# create a scrollable console for messages | |
console = tk.Text( | |
root, | |
font='TkFixedFont', | |
wrap='word', | |
state='normal' | |
) | |
console.pack(expand=True, fill='both', padx=10, pady=10) | |
# add scrollbar to the console | |
scrollbar = tk.Scrollbar(root, orient='vertical', command=console.yview) | |
scrollbar.pack(side='right', fill='y') | |
console['yscrollcommand'] = scrollbar.set | |
# bind drag-and-drop events to the root window | |
root.drop_target_register(DND_FILES) | |
root.dnd_bind('<<Drop>>', lambda event: on_drop(event, console)) | |
root.mainloop() | |
def main(): | |
create_gui() | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment