Complete High-quality profile and add menus to split discs

High profile is now fully automatic instead of relying on the manual
"adjust disc usage" button:
- compute_high_profile_bitrate() (pure, unit-tested) picks the fewest discs
  that hold the content down to a quality floor, then fills each disc; a 2h
  movie lands on one filled disc (~3750k), a 4h movie on two, a 30min clip
  on one near the 9000k cap.
- apply_high_profile_bitrate() applies that rate to every title and forces
  2-pass; it runs before split planning so the existing splitter reuses it.

Split discs now get a per-disc menu (listing that disc's titles) when the
project has a menu enabled, honoring the startup/play-all settings; they
still auto-play when menus are off.

Fix: _adjust_audio_bitrate capped its table at 384 (an MP2 limit) and
silently clamped the High profile's 448k AC3 down to 384; extend the table
to AC3 rates (448/512/640) in both the ffmpeg and avconv backends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-04 05:18:33 -07:00
parent 5fe1013be8
commit 3d1ce504b9
3 changed files with 107 additions and 9 deletions

View file

@ -538,11 +538,14 @@ class avconv(devedeng.avbase.avbase):
self.command_var.append(output_file)
def _adjust_audio_bitrate(self, bitrate):
br = [32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
# includes AC3 rates above the MP2 ceiling (384) so 448k AC3 isn't
# silently clamped down
br = [32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384,
448, 512, 640]
for a in br:
if a >= bitrate:
return a
return 384
return br[-1]
def _avconv_text_subtitle_filter(self, file_project):
""" Build a `subtitles=` filter to burn a text-based embedded subtitle

View file

@ -596,11 +596,14 @@ class ffmpeg(devedeng.executor.executor):
print(self.command_var)
def _adjust_audio_bitrate(self, bitrate):
br = [32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
# includes AC3 rates above the MP2 ceiling (384) so the High profile's
# 448k AC3 isn't silently clamped down
br = [32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384,
448, 512, 640]
for a in br:
if a >= bitrate:
return a
return 384
return br[-1]
@staticmethod
def _escape_subtitle_path(path):

View file

@ -43,6 +43,39 @@ import devedeng.help
import devedeng.separator
def compute_high_profile_bitrate(total_runtime_s, total_audio_kbits,
disc_capacity_kbytes, menu_kbytes=0.0,
cap=9000, floor=3000):
""" Pure helper for the High-quality profile (no GTK), so it can be tested.
Strategy ("fewest discs, then fill"): use the fewest discs that can
hold the content without dropping below the quality floor (densest
packing), then raise the video bitrate to exactly fill those discs
(clamped to [floor, cap]). A short movie ends up on one disc near the
cap; a long one spreads across the minimum number of discs and fills
each, rather than spilling onto extra discs at maximum bitrate.
Returns (n_discs, video_bitrate_kbps).
"""
if total_runtime_s <= 0:
return (1, cap)
disc_kbits = disc_capacity_kbytes * 8.0
menu_kbits = menu_kbytes * 8.0
audio_kbits = total_audio_kbits
# how big is the content at the LOWEST acceptable quality? that gives the
# densest packing, i.e. the fewest discs needed
content_at_floor_kbits = (floor * total_runtime_s) + audio_kbits + menu_kbits
n = max(1, int(math.ceil(content_at_floor_kbits / disc_kbits)))
# fill those n discs: spread the available video budget over the runtime
video_budget_kbits = (n * disc_kbits) - audio_kbits - menu_kbits
video_bitrate = video_budget_kbits / total_runtime_s
if video_bitrate > cap:
video_bitrate = cap
if video_bitrate < floor:
video_bitrate = floor
return (n, int(video_bitrate))
def _movie_chapter_seconds(movie):
""" Returns a sorted list of candidate chapter-cut times (seconds) for a
movie: the automatic chapters (every chapter_size minutes) merged with
@ -629,6 +662,46 @@ class devede_project:
plan = plan_disc_split(movies, disc_capacity, first_disc_reserved)
return plan
def apply_high_profile_bitrate(self):
""" For the High-quality profile: compute the fill-the-disc video
bitrate (fewest discs at the cap, then fill) and apply it to every
movie, and force 2-pass encoding for accurate VBR. After this the
normal split planner produces the right number of discs because the
content now fills them. No-op for non-DVD or other profiles. """
if self.disc_type != "dvd":
return
if getattr(self.config, "encode_profile", "compatibility") != "high":
return
movies = [m for m in self.get_all_files()
if m.element_type == "file_movie"]
if not movies:
return
total_runtime = 0.0
total_audio_kbits = 0.0
for m in movies:
length = float(m.original_length)
total_runtime += length
audio_streams = max(1, getattr(m, "audio_streams", 1))
sub_rate = 8 * len(getattr(m, "subtitles_list", []))
# High profile uses AC3 448k audio
total_audio_kbits += (448 * audio_streams + sub_rate) * length
disc_capacity = self.get_dvd_size()[0] * 1000.0 # kBytes
menu_kbytes = 0.0
if self.wcreate_menu.get_active():
menu_kbytes = float(self.menu.get_estimated_size())
n_discs, video_bitrate = compute_high_profile_bitrate(
total_runtime, total_audio_kbits, disc_capacity, menu_kbytes)
for m in movies:
m.set_auto_video_audio_rate(video_bitrate, 448)
m.video_rate_automatic = True # use the computed auto rate
m.two_pass_encoding = True # VBR accuracy
self.refresh_disc_usage()
def _build_profile_selector(self):
""" Build the DVD encode-profile selector (Compatibility / High quality
/ Custom) and pack it next to the disc-size combo. When "Custom" is
@ -730,12 +803,19 @@ class devede_project:
def _build_split_dvd(self, run_window, data, split_plan):
""" Author each disc of a split DVD project into its own subfolder and
emit one ISO per disc (name-disc1.iso, name-disc2.iso, ...). Split
discs are authored without a menu: each plays its segment(s). """
emit one ISO per disc (name-disc1.iso, name-disc2.iso, ...).
If the project has a menu enabled, each disc gets its own menu
listing that disc's titles; otherwise each disc auto-plays its
segment(s). """
cv = devedeng.converter.converter.get_converter()
title = self.menu.title_text if self.menu.title_text else ""
title = title.strip()
menuWide = (self.menu.menu_aspect_ratio == "menu_aspect_16_9")
with_menu = (self.disc_type == "dvd") and self.wcreate_menu.get_active()
start_with_menu = with_menu and (
self.menu.at_startup == "menu_show_at_startup")
play_all = self.menu.play_all_c
last_iso = None
for disc_index, segments in enumerate(split_plan):
@ -767,12 +847,21 @@ class devede_project:
else:
segment_lengths.append(None) # whole, unsplit movie
# build this disc's own menu from its titles, if enabled
menu_entries = None
if with_menu:
menu_procs, menu_entries = self.menu.create_dvd_menus(
seg_movies, disc_path)
for mp in menu_procs:
run_window.add_process(mp)
deps.append(mp)
dvdauthor = devedeng.dvdauthor_converter.dvdauthor_converter()
# no menu on split discs: pass menu_entries=None, start_with_menu=False
dvdauthor.create_dvd_project(disc_path,
"{:s}-disc{:d}".format(data.name, disc_no),
seg_movies, None, False,
False, menuWide, segment_lengths)
seg_movies, menu_entries,
start_with_menu, play_all, menuWide,
segment_lengths)
for d in deps:
dvdauthor.add_dependency(d)
run_window.add_process(dvdauthor)
@ -839,6 +928,9 @@ class devede_project:
# other formats keep their original single-image behaviour.
split_plan = None
if self.disc_type == "dvd":
# High profile: derive the fill-the-disc bitrate first so the split
# planner sees the real per-disc sizes (this also enables 2-pass)
self.apply_high_profile_bitrate()
plan = self.plan_disc_split()
if len(plan) > 1:
choice = self._ask_split_choice(len(plan))