467 lines · 15.6 KB
Raw Download
1
import tkinter as tk
2
from tkinter import ttk, filedialog, messagebox
3
import re
4
import os
5
6
# ── SRT helpers ────────────────────
7
8
9
def parse_time(s: str) -> float:
10
    """'HH:MM:SS,mmm' → seconds (float)"""
11
    s = s.strip().replace(".", ",")
12
    m = re.match(r"(\d+):(\d{2}):(\d{2})[,.](\d{3})", s)
13
    if not m:
14
        raise ValueError(f"Cannot parse time: {s!r}")
15
    h, mi, sc, ms = m.groups()
16
    return int(h) * 3600 + int(mi) * 60 + int(sc) + int(ms) / 1000
17
18
19
def format_time(t: float) -> str:
20
    """seconds (float) → 'HH:MM:SS,mmm'"""
21
    t = max(0.0, t)
22
    ms = round(t * 1000)
23
    h, ms = divmod(ms, 3_600_000)
24
    mi, ms = divmod(ms, 60_000)
25
    sc, ms = divmod(ms, 1000)
26
    return f"{int(h):02d}:{int(mi):02d}:{int(sc):02d},{int(ms):03d}"
27
28
29
TIMECODE_RE = re.compile(
30
    r"(\d+:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d+:\d{2}:\d{2}[,.]\d{3})"
31
)
32
33
34
def adjust_srt(content: str, offset_fn) -> str:
35
    """Apply offset_fn(original_seconds) → new_seconds to every timestamp."""
36
    lines = content.splitlines(keepends=True)
37
    out = []
38
    for line in lines:
39
        m = TIMECODE_RE.match(line)
40
        if m:
41
            t_start = parse_time(m.group(1))
42
            t_end = parse_time(m.group(2))
43
            new_start = offset_fn(t_start)
44
            new_end = offset_fn(t_end)
45
            line = f"{format_time(new_start)} --> {format_time(new_end)}\n"
46
        out.append(line)
47
    return "".join(out)
48
49
50
def parse_srt_entries(content: str):
51
    """Returns list of dicts: {start, end, text} — start/end are formatted strings."""
52
    entries = []
53
    blocks = re.split(r"\n\s*\n", content.strip())
54
    for block in blocks:
55
        lines = block.strip().splitlines()
56
        if len(lines) < 2:
57
            continue
58
        m = TIMECODE_RE.search(block)
59
        if not m:
60
            continue
61
        start_str = m.group(1)
62
        end_str = m.group(2)
63
        tc_line_idx = next(
64
            (i for i, l in enumerate(lines) if TIMECODE_RE.search(l)), None
65
        )
66
        if tc_line_idx is None:
67
            continue
68
        text_lines = lines[tc_line_idx + 1 :]
69
        text = " ".join(l.strip() for l in text_lines if l.strip())
70
        text = re.sub(r"<[^>]+>", "", text)
71
        if text:
72
            entries.append({"start": start_str, "end": end_str, "text": text})
73
    return entries
74
75
76
# ── GUI ────────────────────────────
77
78
BG = "#1e1e2e"
79
SURFACE = "#313244"
80
ACCENT = "#89b4fa"
81
ACCENT2 = "#b4befe"
82
FG = "#cdd6f4"
83
FG_DIM = "#6c7086"
84
GREEN = "#a6e3a1"
85
86
87
def trunc(t, n=65):
88
    return t if len(t) <= n else t[:n] + "…"
89
90
91
class App(tk.Tk):
92
    def __init__(self):
93
        super().__init__()
94
        self.title("SRT Timing Adjuster")
95
        self.resizable(True, False)
96
        self.configure(bg=BG, padx=20, pady=20)
97
98
        self._entries = []
99
        self._setup_styles()
100
        self._build_file_row()
101
        self._build_preview_strip()
102
103
        ttk.Separator(self, orient="horizontal").pack(fill="x", pady=12)
104
105
        self.nb = ttk.Notebook(self)
106
        self.nb.pack(fill="both")
107
108
        self._build_fixed_tab()
109
        self._build_drift_tab()
110
111
        ttk.Separator(self, orient="horizontal").pack(fill="x", pady=12)
112
        ttk.Button(self, text="Apply & Save", command=self._apply).pack()
113
114
        self.status = tk.Label(self, text="", bg=BG, fg=GREEN, font=("Segoe UI", 16))
115
        self.status.pack(pady=(8, 0))
116
117
    # ── Styles ────────────────────
118
119
    def _setup_styles(self):
120
        s = ttk.Style(self)
121
        s.theme_use("clam")
122
        s.configure("TNotebook", background=BG, borderwidth=0)
123
        s.configure(
124
            "TNotebook.Tab",
125
            background=SURFACE,
126
            foreground=FG,
127
            padding=[14, 6],
128
            font=("Segoe UI", 20),
129
        )
130
        s.map(
131
            "TNotebook.Tab",
132
            background=[("selected", ACCENT)],
133
            foreground=[("selected", BG)],
134
        )
135
        s.configure("TFrame", background=BG)
136
        s.configure("TLabel", background=BG, foreground=FG, font=("Segoe UI", 18))
137
        s.configure(
138
            "Dim.TLabel", background=BG, foreground=FG_DIM, font=("Segoe UI", 16)
139
        )
140
        s.configure(
141
            "Head.TLabel",
142
            background=BG,
143
            foreground=ACCENT,
144
            font=("Segoe UI", 16, "bold"),
145
        )
146
        s.configure(
147
            "TEntry",
148
            fieldbackground=SURFACE,
149
            foreground=FG,
150
            insertcolor=FG,
151
            font=("Segoe UI", 18),
152
        )
153
        s.configure(
154
            "TButton",
155
            background=ACCENT,
156
            foreground=BG,
157
            font=("Segoe UI", 18, "bold"),
158
            padding=[10, 5],
159
        )
160
        s.map("TButton", background=[("active", ACCENT2)])
161
162
    # ── File row ──────────────────
163
164
    def _build_file_row(self):
165
        f = ttk.Frame(self)
166
        f.pack(fill="x", pady=(0, 4))
167
        ttk.Label(f, text="SRT file:").pack(side="left")
168
        self.file_var = tk.StringVar()
169
        self.file_var.trace_add("write", lambda *_: self._on_path_changed())
170
        ttk.Entry(f, textvariable=self.file_var, width=54).pack(side="left", padx=8)
171
        ttk.Button(f, text="Browse…", command=self._browse).pack(side="left")
172
173
    def _browse(self):
174
        path = filedialog.askopenfilename(
175
            filetypes=[("SRT files", "*.srt"), ("All files", "*.*")]
176
        )
177
        if path:
178
            self.file_var.set(path)
179
180
    def _on_path_changed(self):
181
        path = self.file_var.get().strip()
182
        if path and os.path.isfile(path):
183
            self._load_file(path)
184
185
    # ── Top preview strip (always visible) ──────────────────────────────
186
187
    def _build_preview_strip(self):
188
        """Two side-by-side info cards showing first & last subtitle."""
189
        outer = ttk.Frame(self)
190
        outer.pack(fill="x", pady=(8, 0))
191
        outer.columnconfigure(0, weight=1)
192
        outer.columnconfigure(1, weight=1)
193
194
        self._strip_cards = {}
195
        for col, which in enumerate(["first", "last"]):
196
            card = tk.Frame(outer, bg=SURFACE, padx=10, pady=8)
197
            card.grid(
198
                row=0, column=col, sticky="nsew", padx=(0, 6) if col == 0 else (6, 0)
199
            )
200
201
            icon = "▶  First subtitle" if which == "first" else "⏹  Last subtitle"
202
            tk.Label(
203
                card, text=icon, bg=SURFACE, fg=ACCENT, font=("Segoe UI", 16, "bold")
204
            ).pack(anchor="w")
205
206
            t_lbl = tk.Label(
207
                card, text="—", bg=SURFACE, fg=ACCENT2, font=("Segoe UI", 16, "bold")
208
            )
209
            t_lbl.pack(anchor="w", pady=(2, 3))
210
211
            d_lbl = tk.Label(
212
                card,
213
                text="(no file loaded)",
214
                bg=SURFACE,
215
                fg=FG,
216
                font=("Segoe UI", 16, "italic"),
217
                wraplength=270,
218
                justify="left",
219
            )
220
            d_lbl.pack(anchor="w", fill="x")
221
222
            self._strip_cards[which] = (t_lbl, d_lbl)
223
224
    def _update_strip(self, first, last):
225
        for which, entry in (("first", first), ("last", last)):
226
            t_lbl, d_lbl = self._strip_cards[which]
227
            t_lbl.config(text=entry["start"])
228
            d_lbl.config(text=trunc(entry["text"]))
229
230
    # ── Load file ─────────────────
231
232
    def _load_file(self, path: str):
233
        try:
234
            with open(path, encoding="utf-8-sig") as fh:
235
                content = fh.read()
236
        except Exception as e:
237
            messagebox.showerror("Error", f"Cannot read file:\n{e}")
238
            return
239
240
        entries = parse_srt_entries(content)
241
        if not entries:
242
            messagebox.showwarning("Warning", "No subtitle entries found.")
243
            return
244
245
        self._entries = entries
246
        first, last = entries[0], entries[-1]
247
248
        self._update_strip(first, last)
249
        self._update_fixed_tab(first, last)
250
        self._update_drift_tab(first, last)
251
252
        self.status.config(
253
            text=f"Loaded {len(entries)} subtitles  ·  {os.path.basename(path)}"
254
        )
255
256
    # ── Fixed-offset tab ────────────────
257
258
    def _build_fixed_tab(self):
259
        tab = ttk.Frame(self.nb, padding=16)
260
        self.nb.add(tab, text="Fixed Offset")
261
        tab.columnconfigure(1, weight=1)
262
263
        ttk.Label(tab, text="Shift all timestamps by a constant amount.").grid(
264
            row=0, column=0, columnspan=3, sticky="w", pady=(0, 12)
265
        )
266
267
        ttk.Label(tab, text="Offset (seconds):").grid(row=1, column=0, sticky="w")
268
        self.fixed_offset = tk.StringVar(value="0.0")
269
        ttk.Entry(tab, textvariable=self.fixed_offset, width=12).grid(
270
            row=1, column=1, sticky="w", padx=8
271
        )
272
        ttk.Label(
273
            tab, text="← negative = earlier  /  positive = later", style="Dim.TLabel"
274
        ).grid(row=1, column=2, sticky="w")
275
276
        ttk.Separator(tab, orient="horizontal").grid(
277
            row=2, column=0, columnspan=3, sticky="ew", pady=14
278
        )
279
280
        ttk.Label(
281
            tab,
282
            text="File reference times",
283
            foreground=ACCENT,
284
            font=("Segoe UI", 16, "bold"),
285
        ).grid(row=3, column=0, columnspan=3, sticky="w", pady=(0, 8))
286
287
        # Two cards
288
        card_frame = ttk.Frame(tab)
289
        card_frame.grid(row=4, column=0, columnspan=3, sticky="ew")
290
        card_frame.columnconfigure(0, weight=1)
291
        card_frame.columnconfigure(1, weight=1)
292
293
        self._fx_labels = {}
294
        for col, which in enumerate(["first", "last"]):
295
            card = tk.Frame(card_frame, bg=SURFACE, padx=10, pady=8)
296
            card.grid(
297
                row=0, column=col, sticky="nsew", padx=(0, 6) if col == 0 else (6, 0)
298
            )
299
300
            icon = "▶  First" if which == "first" else "⏹  Last"
301
            tk.Label(
302
                card, text=icon, bg=SURFACE, fg=ACCENT, font=("Segoe UI", 16, "bold")
303
            ).pack(anchor="w")
304
305
            t_lbl = tk.Label(
306
                card, text="—", bg=SURFACE, fg=ACCENT2, font=("Segoe UI", 16, "bold")
307
            )
308
            t_lbl.pack(anchor="w", pady=(2, 3))
309
310
            d_lbl = tk.Label(
311
                card,
312
                text="(no file)",
313
                bg=SURFACE,
314
                fg=FG,
315
                font=("Segoe UI", 16, "italic"),
316
                wraplength=220,
317
                justify="left",
318
            )
319
            d_lbl.pack(anchor="w", fill="x")
320
321
            self._fx_labels[which] = (t_lbl, d_lbl)
322
323
    def _update_fixed_tab(self, first, last):
324
        for which, entry in (("first", first), ("last", last)):
325
            t_lbl, d_lbl = self._fx_labels[which]
326
            t_lbl.config(text=entry["start"])
327
            d_lbl.config(text=trunc(entry["text"], 55))
328
329
    # ── Drift tab ─────────────────
330
331
    def _build_drift_tab(self):
332
        tab = ttk.Frame(self.nb, padding=16)
333
        self.nb.add(tab, text="Gradual Drift")
334
335
        note = (
336
            "Map two SRT timestamps to their true video times.\n"
337
            "The offset is linearly interpolated and extrapolated across the file."
338
        )
339
        ttk.Label(tab, text=note, style="Dim.TLabel").grid(
340
            row=0, column=0, columnspan=3, sticky="w", pady=(0, 12)
341
        )
342
343
        # Column headers
344
        for c, h in enumerate(
345
            ["Sync point", "SRT time  (HH:MM:SS,mmm)", "Video time  (HH:MM:SS,mmm)"]
346
        ):
347
            ttk.Label(
348
                tab, text=h, foreground=ACCENT, font=("Segoe UI", 16, "bold")
349
            ).grid(row=1, column=c, padx=(0 if c == 0 else 8), pady=(0, 6), sticky="w")
350
351
        self.drift_vars = {}
352
        self._dr_labels = {}
353
354
        for row_idx, (label, which) in enumerate(
355
            [("Point A", "first"), ("Point B", "last")], start=2
356
        ):
357
            # Left info block
358
            info = tk.Frame(tab, bg=BG)
359
            info.grid(row=row_idx, column=0, sticky="nsw", pady=6, padx=(0, 12))
360
361
            tk.Label(
362
                info, text=label, bg=BG, fg=FG, font=("Segoe UI", 20, "bold")
363
            ).pack(anchor="w")
364
365
            t_lbl = tk.Label(
366
                info, text="—", bg=BG, fg=ACCENT2, font=("Segoe UI", 16, "bold")
367
            )
368
            t_lbl.pack(anchor="w")
369
370
            d_lbl = tk.Label(
371
                info,
372
                text="(no file)",
373
                bg=BG,
374
                fg=FG_DIM,
375
                font=("Segoe UI", 16, "italic"),
376
                wraplength=150,
377
                justify="left",
378
            )
379
            d_lbl.pack(anchor="w")
380
381
            self._dr_labels[which] = (t_lbl, d_lbl)
382
383
            # SRT + Video entries
384
            for col, key in enumerate(["srt", "vid"], start=1):
385
                var = tk.StringVar(value="00:00:00,000")
386
                ttk.Entry(tab, textvariable=var, width=20).grid(
387
                    row=row_idx, column=col, padx=8, pady=6, sticky="w"
388
                )
389
                self.drift_vars[(label, key)] = var
390
391
        ttk.Label(
392
            tab,
393
            text="Tip: set Video time to what your player shows for each line of dialogue.",
394
            style="Dim.TLabel",
395
        ).grid(row=4, column=0, columnspan=3, sticky="w", pady=(10, 0))
396
397
    def _update_drift_tab(self, first, last):
398
        for which, entry, label in (
399
            ("first", first, "Point A"),
400
            ("last", last, "Point B"),
401
        ):
402
            t_lbl, d_lbl = self._dr_labels[which]
403
            t_lbl.config(text=entry["start"])
404
            d_lbl.config(text=trunc(entry["text"], 45))
405
            # Pre-load both SRT and Video fields with the actual SRT time
406
            self.drift_vars[(label, "srt")].set(entry["start"])
407
            self.drift_vars[(label, "vid")].set(entry["start"])
408
409
    # ── Apply & Save ────────────────
410
411
    def _apply(self):
412
        path = self.file_var.get().strip()
413
        if not path or not os.path.isfile(path):
414
            messagebox.showerror("Error", "Please select a valid .srt file.")
415
            return
416
417
        try:
418
            with open(path, encoding="utf-8-sig") as fh:
419
                content = fh.read()
420
        except Exception as e:
421
            messagebox.showerror("Error", f"Cannot read file:\n{e}")
422
            return
423
424
        tab_idx = self.nb.index(self.nb.select())
425
426
        try:
427
            if tab_idx == 0:
428
                offset = float(self.fixed_offset.get())
429
                result = adjust_srt(content, lambda t, o=offset: t + o)
430
            else:
431
                srt_a = parse_time(self.drift_vars[("Point A", "srt")].get())
432
                vid_a = parse_time(self.drift_vars[("Point A", "vid")].get())
433
                srt_b = parse_time(self.drift_vars[("Point B", "srt")].get())
434
                vid_b = parse_time(self.drift_vars[("Point B", "vid")].get())
435
436
                if abs(srt_b - srt_a) < 0.001:
437
                    messagebox.showerror(
438
                        "Error", "Point A and Point B SRT times must differ."
439
                    )
440
                    return
441
442
                off_a = vid_a - srt_a
443
                off_b = vid_b - srt_b
444
445
                def drift_offset(t, sa=srt_a, sb=srt_b, oa=off_a, ob=off_b):
446
                    frac = (t - sa) / (sb - sa)
447
                    return t + oa + frac * (ob - oa)
448
449
                result = adjust_srt(content, drift_offset)
450
451
        except ValueError as e:
452
            messagebox.showerror("Error", f"Invalid input:\n{e}")
453
            return
454
455
        try:
456
            with open(path, "w", encoding="utf-8") as fh:
457
                fh.write(result)
458
        except Exception as e:
459
            messagebox.showerror("Error", f"Cannot write file:\n{e}")
460
            return
461
462
        self.status.config(text=f"✓ Saved: {os.path.basename(path)}")
463
464
465
if __name__ == "__main__":
466
    App().mainloop()
467