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

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