Mux the spoken subtitles into the output, and fix MP4 subtitle copying

--include-subs adds the subtitles the voiceover was made from to the finished
video as one more track, alongside whatever the source already carried. Useful
where the source has no Polish subtitles of its own, and for checking the
voiceover against the text it came from.

Every run now writes clips/<job>/voiceover_subs.srt: the cue text verbatim,
timestamps shifted by --start and cues outside --start..--end dropped. Verbatim
because this is what a viewer reads, not what the model was handed — dictionary
rewrites and spelled-out numbers must not leak into it. It is written whether
or not --include-subs was passed, because --rebuild --include-subs is given
only the video and would otherwise have nothing to mux; runs made before this
existed fall back to tts_input.srt with a note that the wording is the model's.

Copying subtitle tracks from an MP4 source was broken, and silently: MP4 stores
them as mov_text (tx3g), which the Matroska muxer rejects — "Subtitle codec
94213 is not supported" — and then no output file is written at all. Those
tracks are now decoded to SubRip in a temporary file and muxed from there,
while image formats (PGS, VOBSUB) are still stream-copied since they cannot
become text. Two details that follow from the detour: ffmpeg's tx3g decoder
emits the format's style records as <font face="Serif"><font size="18"> markup,
which players either honour (subtitles rendered at whatever pixel size the MP4
specified, usually unreadably small) or print literally — so the tags are
stripped; and a standalone .srt carries no language, so the source track's
language and title tags are re-applied by hand or the output reads "undefined".
An empty placeholder track, which rips often carry, is dropped rather than
muxed as an empty one.

UI: an "include the subtitles" checkbox next to the embedded-subtitles one. It
is cleared and greyed out when the embedded track is chosen, since those
subtitles already travel with the video, and it mirrors across linked jobs like
the other per-job settings. RoundCheck grew a set_enabled() for that. The
remove button moves into a column of its own spanning both rows, so it stays
centred now that the second row is taller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkWDx9dVB7xBpqntvjvm6A
This commit is contained in:
2026-08-31 21:15:27 +02:00
co-authored by Claude Opus 5
parent d350b314ad
commit 39452a4281
2 changed files with 233 additions and 12 deletions
+66 -5
View File
@@ -580,6 +580,7 @@ class RoundCheck(tk.Frame):
def __init__(self, master, variable, text, font, command=None, box=22):
super().__init__(master, bg=master.cget("background"))
self.var, self.command = variable, command
self._enabled = True
self.canvas = tk.Canvas(self, width=box, height=box,
bg=master.cget("background"),
highlightthickness=0, bd=0, cursor="hand2")
@@ -599,6 +600,8 @@ class RoundCheck(tk.Frame):
self.refresh()
def _toggle(self, _=None):
if not self._enabled:
return
self.var.set(not self.var.get())
self.refresh()
if self.command:
@@ -607,7 +610,19 @@ class RoundCheck(tk.Frame):
def refresh(self):
on = bool(self.var.get())
self.canvas.itemconfigure(self._tick, state="normal" if on else "hidden")
self.canvas.itemconfigure(self._box, fill="#ffffff" if on else FIELD)
if self._enabled:
fill = "#ffffff" if on else FIELD
else:
fill = "#8f8f8f" if on else SEP
self.canvas.itemconfigure(self._box, fill=fill)
def set_enabled(self, on):
"""Grey the box out and stop it responding to clicks."""
self._enabled = bool(on)
cursor = "hand2" if self._enabled else "arrow"
self.canvas.configure(cursor=cursor)
self.label.configure(cursor=cursor, fg=FG if self._enabled else "#828282")
self.refresh()
# Dropdown choices. The comboboxes are editable, so these are starting points
# rather than limits.
@@ -663,10 +678,18 @@ class JobBlock(tk.Frame):
self.f_btn = (F, 9, "bold")
self.f_num = (F, 13)
# The remove button gets a column of its own spanning both rows, so it
# stays vertically centred against the block however tall that grows.
close_col = tk.Frame(self, bg=BG)
close_col.pack(side="left", fill="y", padx=(0, 12))
RoundButton(close_col, "\u00d7", self.remove, bg="#4a4a4a", fg=MUTED,
hover="#7a3a3a", font=(F, 11, "bold"), padx=7, pady=5
).pack(expand=True)
# Both rows share one grid, so column 0 gets a single width and the two
# input fields line up exactly as they do in the mock-up.
grid = tk.Frame(self, bg=BG)
grid.pack(fill="x")
grid.pack(side="left", fill="both", expand=True)
grid.grid_columnconfigure(0, weight=1)
# ── Row 1: video + the four numeric dropdowns ───────────────────────
@@ -687,9 +710,6 @@ class JobBlock(tk.Frame):
if self.group is not None:
tk.Label(r1, text="linked", bg=BG, fg="#7fa8c9", font=(F, 8)
).pack(side="left", padx=(0, 6))
RoundButton(r1, "\u00d7", self.remove, bg="#4a4a4a", fg=MUTED,
hover="#7a3a3a", font=(F, 11, "bold"), padx=7, pady=5
).pack(side="left", padx=(10, 0))
# Mirror every numeric field to the other blocks in this group. A write
# trace catches typed values as well as menu picks.
@@ -713,6 +733,13 @@ class JobBlock(tk.Frame):
font=(F, 9), command=self.toggle_embedded, box=22)
self.emb_check.pack(side="left", padx=(0, 16))
# Only meaningful for an external .srt — an embedded track is already
# in the file, so the option is cleared and greyed out for those.
self.include_subs = tk.BooleanVar(value=False)
self.inc_check = RoundCheck(r2, self.include_subs, "include the\nsubtitles",
font=(F, 9), command=self._mirror_include, box=22)
self.inc_check.pack(side="left", padx=(0, 16))
# packed right-to-left so the group ends flush with the dropdowns above
for label, cmd in (("RE-BUILD", self.do_rebuild),
("RESUME ...", self.ask_resume),
@@ -786,6 +813,33 @@ class JobBlock(tk.Frame):
finally:
self.app._syncing = False
def _mirror_include(self):
"""Copy the include-subtitles state to the rest of the group."""
if self.app._syncing or self.group is None:
return
self.app._syncing = True
try:
want = self.include_subs.get()
for b in self._peers():
if b.embedded.get() or b.include_subs.get() == want:
continue
b.include_subs.set(want)
b.inc_check.refresh()
finally:
self.app._syncing = False
def _sync_include_state(self):
"""Follow the embedded-subtitles checkbox.
With an embedded track the subtitles already travel with the video, so
there is nothing to add — the option is cleared and greyed out.
"""
if self.embedded.get():
self.include_subs.set(False)
self.inc_check.set_enabled(False)
else:
self.inc_check.set_enabled(True)
# ── file selection ──────────────────────────────────────────────────────
def browse_video(self):
p = choose_files("Select video", VIDEO_TYPES,
@@ -820,6 +874,7 @@ class JobBlock(tk.Frame):
if not video or not Path(video).is_file():
self.embedded.set(False)
self.emb_check.refresh()
self._sync_include_state()
messagebox.showwarning("No video", "Choose a video file first.")
return
self.sub_field.grid_remove()
@@ -829,10 +884,12 @@ class JobBlock(tk.Frame):
self.sub_combo["values"] = []
# ffprobe on a large file takes a moment; keep the UI responsive.
threading.Thread(target=self._probe_subs, args=(video,), daemon=True).start()
self._sync_include_state()
self._mirror_embedded()
else:
self.sub_combo.grid_remove()
self.sub_field.grid()
self._sync_include_state()
self._mirror_embedded()
def _probe_subs(self, video):
@@ -916,6 +973,10 @@ class JobBlock(tk.Frame):
cmd += self.common_args()
# --regen only writes clips, so there is no mux for the track to join.
if self.include_subs.get() and not self.embedded.get() and mode != "regen":
cmd.append("--include-subs")
# The whole queue shares one extra dictionary, kept with the first
# video. --regen and --rebuild work purely from tts_input.srt, so the
# flag would do nothing there. A resume normally reuses that file too,