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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-02 12:45:36 -07:00
parent a158119d41
commit 8b041f0725
4 changed files with 310 additions and 13 deletions

View file

@ -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

View file

@ -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

View file

@ -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,8 +194,16 @@ 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)
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):
@ -255,6 +277,20 @@ class ffmpeg(devedeng.executor.executor):
str(file_project.width_final) + ":" + \
str(file_project.height_final)
# --- 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)
@ -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

View file

@ -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()