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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user