From 8b041f072569819c54660e57fc6838d38b533d3a Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 2 Jun 2026 12:45:36 -0700 Subject: [PATCH] Add DVD encode profiles and subtitle hardsub burn-in Introduce three project-level DVD encode profiles (Compatibility 4500k / High quality VBR / Custom) via new config keys, and burn a chosen embedded subtitle track into the picture: - file_movie carries the hardsub selection (index/kind), persists it, and builds a subtitle-picker combobox in its properties page; set_final_rates maps the profile to the final video/audio bitrate. - ffmpeg burns text subs with the subtitles= filter after scaling and image subs with an overlay filtergraph before scaling; NTSC DVD audio is now AC3 for all profiles. A start_offset (-ss) is threaded through for segmenting. - avconv gets the text-sub path and start_offset for parity. Co-Authored-By: Claude Opus 4.8 --- src/devedeng/avconv.py | 43 +++++++++- src/devedeng/configuration_data.py | 29 ++++++- src/devedeng/ffmpeg.py | 125 ++++++++++++++++++++++++++-- src/devedeng/file_movie.py | 126 ++++++++++++++++++++++++++++- 4 files changed, 310 insertions(+), 13 deletions(-) diff --git a/src/devedeng/avconv.py b/src/devedeng/avconv.py index 9ef2708..9c3aca8 100644 --- a/src/devedeng/avconv.py +++ b/src/devedeng/avconv.py @@ -112,8 +112,9 @@ class avconv(devedeng.avbase.avbase): self.config = devedeng.configuration_data.configuration.get_config() self.check_version(["avconv", "-version"]) - def convert_file(self, file_project, output_file, video_length, pass2=False): + def convert_file(self, file_project, output_file, video_length, pass2=False, start_offset=0): + self.start_offset = start_offset if file_project.two_pass_encoding: if pass2: self.text = _("Converting %(X)s (pass 2)") % { @@ -123,7 +124,8 @@ class avconv(devedeng.avbase.avbase): "X": file_project.title_name} # Prepare the converting process for the second pass tmp = devedeng.avconv.avconv() - tmp.convert_file(file_project, output_file, video_length, True) + tmp.convert_file(file_project, output_file, video_length, True, + start_offset) # it deppends of this process tmp.add_dependency(self) # add it as a child process of this one @@ -147,6 +149,9 @@ class avconv(devedeng.avbase.avbase): self.final_length = video_length self.command_var = [] self.command_var.append("avconv") + if start_offset and start_offset > 0: + self.command_var.append("-ss") + self.command_var.append(str(start_offset)) self.command_var.append("-i") self.command_var.append(file_project.file_name) @@ -159,6 +164,9 @@ class avconv(devedeng.avbase.avbase): self.command_var.append("-itsoffset") self.command_var.append(str(file_project.audio_delay)) + if start_offset and start_offset > 0: + self.command_var.append("-ss") + self.command_var.append(str(start_offset)) self.command_var.append("-i") self.command_var.append(file_project.file_name) self.command_var.append("-map") @@ -243,6 +251,16 @@ class avconv(devedeng.avbase.avbase): str(file_project.width_final) + ":h=" + \ str(file_project.height_final) + # text subtitle burn-in (hardsub) as the last filter, after scaling. + # Image-based subs need an overlay filtergraph and are only handled + # by the ffmpeg backend; avconv users get text subs burned in. + if second_pass: + text_filter = self._avconv_text_subtitle_filter(file_project) + if text_filter is not None: + if (cmd_line != ""): + cmd_line += ",fifo," + cmd_line += text_filter + if cmd_line != "": self.command_var.append("-vf") self.command_var.append(cmd_line) @@ -519,6 +537,27 @@ class avconv(devedeng.avbase.avbase): return a return 384 + def _avconv_text_subtitle_filter(self, file_project): + """ Build a `subtitles=` filter to burn a text-based embedded subtitle + track into the picture, or None. Image-based subs are not handled by + the avconv backend (use the ffmpeg backend for those). """ + if getattr(file_project, "hardsub_kind", "text") != "text": + return None + index = getattr(file_project, "hardsub_index", -1) + if index is None or index < 0: + return None + subs = getattr(file_project, "embedded_subtitles", []) or [] + rel = None + for i, track in enumerate(subs): + if track["index"] == index: + rel = i + break + if rel is None: + return None + path = file_project.file_name.replace("\\", "\\\\").replace(":", "\\:") + path = path.replace("'", "\\'") + return "subtitles='%s':si=%d" % (path, rel) + def create_menu_mpeg(self, n_page, background_music, sound_length, pal, video_rate, audio_rate, output_path, use_mp2, widescreen): self.n_page = n_page diff --git a/src/devedeng/configuration_data.py b/src/devedeng/configuration_data.py index d8ac455..4b6fa50 100644 --- a/src/devedeng/configuration_data.py +++ b/src/devedeng/configuration_data.py @@ -118,6 +118,11 @@ class configuration(GObject.GObject): self.subt_fill_color = (1, 1, 1, 1) self.subt_outline_color = (0, 0, 0, 1) self.subt_outline_thickness = 0.0 + # DVD encode profile: "compatibility" (CBR 4500k, old-player safe), + # "high" (2-pass VBR filling the disc), or "custom" (user bitrates). + self.encode_profile = "compatibility" + self.custom_video_kbps = 4500 + self.custom_audio_kbps = 192 config_folder = self._get_config_folder() config_path = os.path.join(config_folder, "devedeng.cfg") @@ -185,6 +190,23 @@ class configuration(GObject.GObject): float(c[0]), float(c[1]), float(c[2]), 1.0) if linea[:27] == "subtitle_outilne_thickness:": self.subt_outline_thickness = float(linea[27:].strip()) + if linea[:15] == "encode_profile:": + profile = linea[15:].strip() + if profile in ("compatibility", "high", "custom"): + self.encode_profile = profile + continue + if linea[:18] == "custom_video_kbps:": + try: + self.custom_video_kbps = int(linea[18:].strip()) + except ValueError: + pass + continue + if linea[:18] == "custom_audio_kbps:": + try: + self.custom_audio_kbps = int(linea[18:].strip()) + except ValueError: + pass + continue config_data.close() except: pass @@ -249,7 +271,12 @@ class configuration(GObject.GObject): config_data.write("subtitle_outline_color:" + str(self.subt_outline_color[0]) + "," + str( self.subt_outline_color[1]) + "," + str(self.subt_outline_color[2]) + "\n") config_data.write("subtitle_outilne_thickness:" + - str(self.subt_outline_thickness)) + str(self.subt_outline_thickness) + "\n") + config_data.write("encode_profile:" + str(self.encode_profile) + "\n") + config_data.write("custom_video_kbps:" + + str(self.custom_video_kbps) + "\n") + config_data.write("custom_audio_kbps:" + + str(self.custom_audio_kbps)) config_data.close() # remove old configuration files diff --git a/src/devedeng/ffmpeg.py b/src/devedeng/ffmpeg.py index 535cd84..adf7ae3 100644 --- a/src/devedeng/ffmpeg.py +++ b/src/devedeng/ffmpeg.py @@ -133,8 +133,9 @@ class ffmpeg(devedeng.executor.executor): self._filter_sep = "," - def convert_file(self, file_project, output_file, video_length, pass2=False): + def convert_file(self, file_project, output_file, video_length, pass2=False, start_offset=0): + self.start_offset = start_offset if file_project.two_pass_encoding: if pass2: self.text = _("Converting %(X)s (pass 2)") % { @@ -144,7 +145,8 @@ class ffmpeg(devedeng.executor.executor): "X": file_project.title_name} # Prepare the converting process for the second pass tmp = devedeng.ffmpeg.ffmpeg() - tmp.convert_file(file_project, output_file, video_length, True) + tmp.convert_file(file_project, output_file, video_length, True, + start_offset) # it deppends of this process tmp.add_dependency(self) # add it as a child process of this one @@ -162,12 +164,24 @@ class ffmpeg(devedeng.executor.executor): # codification second_pass = True + # whether this encode burns in an image-based (PGS/VOBSUB) subtitle via + # an overlay filtergraph; only applies on the (final) encoding pass and + # when we are actually re-encoding the video + overlay_subs = (second_pass + and (not file_project.no_reencode_audio_video) + and self._image_overlay_active(file_project)) + if (video_length == 0): self.final_length = file_project.original_length else: self.final_length = video_length self.command_var = [] self.command_var.append("ffmpeg") + # input seek (for splitting a long movie across discs); placed before + # -i so ffmpeg seeks quickly and the output starts at offset 0 + if start_offset and start_offset > 0: + self.command_var.append("-ss") + self.command_var.append(str(start_offset)) self.command_var.append("-i") self.command_var.append(file_project.file_name) @@ -180,10 +194,18 @@ class ffmpeg(devedeng.executor.executor): self.command_var.append("-itsoffset") self.command_var.append(str(file_project.audio_delay)) + if start_offset and start_offset > 0: + self.command_var.append("-ss") + self.command_var.append(str(start_offset)) self.command_var.append("-i") self.command_var.append(file_project.file_name) - self.command_var.append("-map") - self.command_var.append("1:" + str(file_project.video_list[0])) + if overlay_subs: + # video comes from the overlay filtergraph output, mapped later + self.command_var.append("-map") + self.command_var.append("[vsub]") + else: + self.command_var.append("-map") + self.command_var.append("1:" + str(file_project.video_list[0])) if (not file_project.copy_sound) and (not file_project.no_reencode_audio_video): for l in file_project.audio_list: self.command_var.append("-map") @@ -255,9 +277,23 @@ class ffmpeg(devedeng.executor.executor): str(file_project.width_final) + ":" + \ str(file_project.height_final) - if cmd_line != "": - self.command_var.append("-vf") - self.command_var.append(cmd_line) + # --- subtitle burn-in (hardsub) --------------------------------- + # Image-based subs (PGS/VOBSUB) must be overlaid via filter_complex + # BEFORE scaling, so they stay aligned with the source frame; text + # subs are rendered by libass AFTER scaling, as the last -vf entry. + if overlay_subs: + self._apply_image_overlay(file_project, cmd_line) + cmd_line = "" # consumed by filter_complex above + else: + if second_pass: + text_filter = self.get_hardsub_text_filter(file_project) + if text_filter is not None: + if (cmd_line != ""): + cmd_line += self._filter_sep + cmd_line += text_filter + if cmd_line != "": + self.command_var.append("-vf") + self.command_var.append(cmd_line) self.command_var.append("-y") @@ -304,7 +340,13 @@ class ffmpeg(devedeng.executor.executor): self.command_var.append("ac3") else: self.command_var.append("-c:a") - if file_project.format_pal: + # PAL always uses AC3; for NTSC the original used MP2, + # but the DVD-safe profiles use AC3 (matches the known- + # good baseline and is more broadly player-compatible) + profile = getattr(self.config, "encode_profile", + "compatibility") + if file_project.format_pal or profile in ( + "compatibility", "high", "custom"): self.command_var.append("ac3") else: self.command_var.append("mp2") @@ -532,6 +574,73 @@ class ffmpeg(devedeng.executor.executor): return a return 384 + @staticmethod + def _escape_subtitle_path(path): + """ Escape a file path for use inside an ffmpeg filtergraph argument. + The `subtitles`/`ass` filters parse ':' and '\\' specially, and the + whole option may be wrapped in single quotes by the caller. """ + # backslash first, then the filtergraph-special characters + path = path.replace("\\", "\\\\") + path = path.replace(":", "\\:") + path = path.replace("'", "\\'") + path = path.replace("[", "\\[").replace("]", "\\]") + path = path.replace(",", "\\,") + return path + + def _hardsub_relative_index(self, file_project): + """ Returns the 0-based position of the chosen hardsub track among the + file's subtitle streams (what ffmpeg's `subtitles` filter `si=` + expects), or None if no track is selected / found. """ + index = getattr(file_project, "hardsub_index", -1) + if index is None or index < 0: + return None + subs = getattr(file_project, "embedded_subtitles", []) or [] + for rel, track in enumerate(subs): + if track["index"] == index: + return rel + return None + + def _image_overlay_active(self, file_project): + """ True when an image-based (PGS/VOBSUB) subtitle track is selected for + burn-in, which requires an overlay filtergraph rather than -vf. """ + if getattr(file_project, "hardsub_kind", "text") != "image": + return False + return self._hardsub_relative_index(file_project) is not None + + def _apply_image_overlay(self, file_project, pre_chain): + """ Replaces the plain -vf path with a -filter_complex that overlays the + bitmap subtitle stream onto the video and then applies the same + scaling/padding chain. The chosen subtitle stream is taken from the + primary input (index 1, the one used for -map 1:video). + + Overlay happens at source resolution; `pre_chain` (crop/pad/scale, + possibly empty) is applied to the overlaid result so the final size + and aspect are identical to the no-subtitle path. """ + rel = self._hardsub_relative_index(file_project) + video_idx = file_project.video_list[0] + # input 1 is the one mapped for video (see -map 1:video above) + graph = "[1:%d][1:s:%d]overlay" % (video_idx, rel) + if pre_chain: + graph += self._filter_sep + pre_chain + graph += "[vsub]" + self.command_var.append("-filter_complex") + self.command_var.append(graph) + self._overlay_video_label = "[vsub]" + + def get_hardsub_text_filter(self, file_project): + """ Builds the `subtitles=` filtergraph entry to burn a text-based + embedded subtitle track into the picture, or None if not applicable. + Must run AFTER scaling so the rendered text matches the DVD size. """ + if getattr(file_project, "hardsub_kind", "text") != "text": + return None + rel = self._hardsub_relative_index(file_project) + if rel is None: + return None + path = self._escape_subtitle_path(file_project.file_name) + # original_size used so libass renders at source res; ffmpeg then maps + # onto the already-scaled frame. The filter is placed last in the chain. + return "subtitles='%s':si=%d" % (path, rel) + def create_menu_mpeg(self, n_page, background_music, sound_length, pal, video_rate, audio_rate, output_path, use_mp2, widescreen): self.n_page = n_page diff --git a/src/devedeng/file_movie.py b/src/devedeng/file_movie.py index 84e173e..57d7828 100644 --- a/src/devedeng/file_movie.py +++ b/src/devedeng/file_movie.py @@ -66,6 +66,11 @@ class file_movie(devedeng.interface_manager.interface_manager): self.add_toggle("audio_rate_automatic", True) self.add_toggle("divide_in_chapters", True) self.add_toggle("force_subtitles", False) + # Hardsub (burn-in) of an embedded subtitle track. -1 means "none". + # hardsub_index is the ffmpeg stream index of the chosen track, and + # hardsub_kind is "text" or "image" (decides the burn-in filter). + self.hardsub_index = -1 + self.hardsub_kind = "text" self.add_toggle("mirror_horizontal", False) self.add_toggle("mirror_vertical", False) self.add_toggle("two_pass_encoding", False) @@ -181,6 +186,11 @@ class file_movie(devedeng.interface_manager.interface_manager): self.original_fps = film_analizer.original_fps self.original_file_size = film_analizer.original_file_size + # embedded subtitle tracks detected in the source file; the + # user can pick one of these to burn into the picture (hardsub) + self.embedded_subtitles = getattr( + film_analizer, "subtitle_tracks", []) + if self.original_audiorate <= 0: # just a guess, but usually correct self.original_audiorate = 224 @@ -230,6 +240,16 @@ class file_movie(devedeng.interface_manager.interface_manager): return estimated_size + def get_kbytes_per_second(self): + """ Estimated output size rate in kBytes per second of video, used to + plan how much of a movie fits on one disc when splitting. """ + self.set_final_rates() + sub_rate = 8 * len(self.subtitles_list) + total_kbps = (self.video_rate_final + + (self.audio_rate_final * self.audio_streams) + + sub_rate) + return total_kbps / 8.0 + def get_size_data(self): estimated_size = self.get_estimated_size() @@ -271,6 +291,18 @@ class file_movie(devedeng.interface_manager.interface_manager): else: self.audio_rate_auto = 224 + # DVD encode profiles override the auto audio rate (and, for the + # fixed-bitrate profiles, the video rate too). "high" leaves the video + # rate to the disc-fitting logic so it fills the disc. + profile = getattr(self.config, "encode_profile", "compatibility") + if (self.disc_type == "dvd") and (not self.sound5_1): + if profile == "compatibility": + self.audio_rate_auto = 192 + elif profile == "high": + self.audio_rate_auto = 448 + elif profile == "custom": + self.audio_rate_auto = self.config.custom_audio_kbps + if self.is_mpeg_ps or self.no_reencode_audio_video: self.video_rate_final = self.original_videorate self.audio_rate_final = self.original_audiorate @@ -280,6 +312,13 @@ class file_movie(devedeng.interface_manager.interface_manager): else: self.video_rate_final = self.video_rate + # fixed-bitrate DVD profiles pin the final video rate directly + if (self.disc_type == "dvd"): + if profile == "compatibility": + self.video_rate_final = 4500 + elif profile == "custom": + self.video_rate_final = self.config.custom_video_kbps + if self.copy_sound: self.audio_rate_final = self.original_audiorate else: @@ -491,6 +530,11 @@ class file_movie(devedeng.interface_manager.interface_manager): selection = self.wtreview_subtitles.get_selection() selection.set_mode(Gtk.SelectionMode.BROWSE) + # embedded-subtitle (hardsub) picker, built in code and packed at the + # top of the subtitles page (only meaningful for single-file editing) + if self.list_files is None: + self._build_hardsub_picker() + # elements in page VIDEO OPTIONS self.wsize_1920x1080 = self.builder.get_object("size_1920x1080") self.wsize_1280x720 = self.builder.get_object("size_1280x720") @@ -677,7 +721,73 @@ class file_movie(devedeng.interface_manager.interface_manager): else: self.wdel_subtitles.set_sensitive(True) - def do_conversion(self, output_path, duration=0): + def _build_hardsub_picker(self): + """ Build and pack a combobox that lets the user pick one embedded + subtitle track to burn into the picture (hardsub). The chosen track + is stored on self.hardsub_index / self.hardsub_kind. """ + tracks = getattr(self, "embedded_subtitles", []) or [] + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.set_margin_top(6) + box.set_margin_bottom(6) + box.set_margin_start(6) + box.set_margin_end(6) + + label = Gtk.Label(label=_("Burn in embedded subtitle:")) + box.pack_start(label, False, True, 0) + + self.whardsub_combo = Gtk.ComboBoxText() + # entry 0 is always "None"; remaining entries map to tracks in order + self.whardsub_combo.append_text(_("None (no hardsub)")) + for track in tracks: + lang = track.get("language") or "und" + kind = _("image") if track["kind"] == "image" else _("text") + extra = [] + if track.get("default"): + extra.append(_("default")) + if track.get("forced"): + extra.append(_("forced")) + suffix = (" [" + ", ".join(extra) + "]") if extra else "" + title = track.get("title") + title_part = (" - " + title) if title else "" + self.whardsub_combo.append_text( + "[%s] %s (%s)%s%s" % (lang, track.get("codec", ""), kind, + title_part, suffix)) + + # restore current selection + active = 0 + for i, track in enumerate(tracks): + if track["index"] == self.hardsub_index: + active = i + 1 + break + self.whardsub_combo.set_active(active) + self.whardsub_combo.connect("changed", self.on_hardsub_combo_changed) + box.pack_start(self.whardsub_combo, True, True, 0) + + box.show_all() + if len(tracks) == 0: + # nothing to burn in; show a disabled hint instead of an empty combo + self.whardsub_combo.set_sensitive(False) + + # pack at the top of the subtitles page (parent of the external-subs + # scrolled window) + page = self.wscrolledwindow_subtitles.get_parent() + if page is not None: + page.pack_start(box, False, True, 0) + page.reorder_child(box, 0) + + def on_hardsub_combo_changed(self, combo): + idx = combo.get_active() + tracks = getattr(self, "embedded_subtitles", []) or [] + if idx <= 0 or (idx - 1) >= len(tracks): + self.hardsub_index = -1 + self.hardsub_kind = "text" + else: + track = tracks[idx - 1] + self.hardsub_index = track["index"] + self.hardsub_kind = track["kind"] + + def do_conversion(self, output_path, duration=0, start_offset=0): self.converted_filename = output_path if self.is_mpeg_ps: @@ -690,7 +800,8 @@ class file_movie(devedeng.interface_manager.interface_manager): cv = devedeng.converter.converter.get_converter() disc_converter = cv.get_disc_converter() converter = disc_converter() - converter.convert_file(self, output_path, duration) + converter.convert_file(self, output_path, duration, + start_offset=start_offset) if len(self.subtitles_list) != 0: last_process = converter @@ -751,10 +862,21 @@ class file_movie(devedeng.interface_manager.interface_manager): if "files_to_set" in data: del data["files_to_set"] data["element_type"] = "file_movie" + # the hardsub selection isn't a registered interface variable, so + # serialize() doesn't capture it; store it explicitly. Skip it in + # group ("multiproperties") mode: the chosen stream index is per-file + # and must not be copied across a selection of different movies. + if self.list_files is None: + data["hardsub_index"] = self.hardsub_index + data["hardsub_kind"] = self.hardsub_kind return data def restore_element(self, data): self.unserialize(data) + if "hardsub_index" in data: + self.hardsub_index = data["hardsub_index"] + if "hardsub_kind" in data: + self.hardsub_kind = data["hardsub_kind"] def on_select_all_clicked(self, b): sel = self.wtreeview_multiproperties.get_selection()