Skip to content

Instantly share code, notes, and snippets.

@natyusha
Created September 6, 2025 11:02
Show Gist options
  • Save natyusha/2d7877de2c6924bafd6fc65f66a61178 to your computer and use it in GitHub Desktop.
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)
#!/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