Split over-long DVD projects across multiple discs

When a DVD project does not fit one disc at the chosen quality, offer to
split it into several full-quality discs instead of only degrading the
bitrate:

- A pure, unit-tested planner distributes whole titles across discs and
  time-splits a single over-long movie at the chapter boundary nearest each
  disc's capacity (even-time fallback when it has no chapters).
- on_create_disc_clicked asks split / reduce-bitrate / cancel, then authors
  each disc into its own subfolder and emits name-discN.iso per disc.
- dvdauthor_converter takes per-title segment lengths so a split segment's
  chapter markers stay inside the segment.

Also adds the project-level encode-profile selector (with custom-bitrate
fields) next to the disc-size control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-02 12:52:50 -07:00
parent 8b041f0725
commit 4f1ecb55ee
2 changed files with 354 additions and 7 deletions

View file

@ -28,7 +28,7 @@ class dvdauthor_converter(devedeng.executor.executor):
devedeng.executor.executor.__init__(self) devedeng.executor.executor.__init__(self)
self.config = devedeng.configuration_data.configuration.get_config() self.config = devedeng.configuration_data.configuration.get_config()
def create_dvd_project(self, path, name, file_movies, menu_entries, start_with_menu, play_all_opt, menuWide): def create_dvd_project(self, path, name, file_movies, menu_entries, start_with_menu, play_all_opt, menuWide, segment_lengths=None):
movie_path = os.path.join(path, "dvd_tree") movie_path = os.path.join(path, "dvd_tree")
try: try:
@ -36,7 +36,7 @@ class dvdauthor_converter(devedeng.executor.executor):
except: except:
pass pass
xml_file = self.create_dvdauthor_xml(path, file_movies, menu_entries, start_with_menu, play_all_opt, menuWide) xml_file = self.create_dvdauthor_xml(path, file_movies, menu_entries, start_with_menu, play_all_opt, menuWide, segment_lengths)
self.command_var = [] self.command_var = []
self.command_var.append("dvdauthor") self.command_var.append("dvdauthor")
@ -55,7 +55,7 @@ class dvdauthor_converter(devedeng.executor.executor):
self.progress_bar[1].set_text(data[0]) self.progress_bar[1].set_text(data[0])
return return
def create_dvdauthor_xml(self, movie_folder, file_movies_in, menu_entries, start_with_menu, play_all_opt, menuWide): def create_dvdauthor_xml(self, movie_folder, file_movies_in, menu_entries, start_with_menu, play_all_opt, menuWide, segment_lengths=None):
file_movies = [] file_movies = []
for element in file_movies_in: for element in file_movies_in:
@ -63,6 +63,11 @@ class dvdauthor_converter(devedeng.executor.executor):
continue continue
file_movies.append(element) file_movies.append(element)
# segment_lengths[i] (if provided and not None) is the effective length
# in seconds of title i, used when a movie was split across discs so
# chapter markers stay within the segment instead of the whole movie.
self._segment_lengths = segment_lengths
xmlpath = os.path.join(movie_folder, "xml_data") xmlpath = os.path.join(movie_folder, "xml_data")
xml_file_path = os.path.join(xmlpath, "dvdauthor.xml") xml_file_path = os.path.join(xmlpath, "dvdauthor.xml")
datapath = os.path.join(movie_folder, "dvd_tree") datapath = os.path.join(movie_folder, "dvd_tree")
@ -450,15 +455,26 @@ class dvdauthor_converter(devedeng.executor.executor):
xml_file.write('\t\t\t\t<vob file="' + xml_file.write('\t\t\t\t<vob file="' +
self.expand_xml(element.converted_filename) + '" ') self.expand_xml(element.converted_filename) + '" ')
xml_file.write('chapters="0') xml_file.write('chapters="0')
if (element.original_length > 5): # effective length for this title: a split segment uses its own
if (element.divide_in_chapters): # add chapters # duration (chapters stay inside the segment), otherwise the whole
# movie length
effective_length = element.original_length
is_segment = False
if self._segment_lengths is not None and titles < len(self._segment_lengths):
seg_len = self._segment_lengths[titles]
if seg_len:
effective_length = seg_len
is_segment = True
if (effective_length > 5):
# don't auto-subdivide split segments; just mark the end
if (element.divide_in_chapters) and (not is_segment):
manual_chapter_list = [] manual_chapter_list = []
if (element.chapter_list_entry): if (element.chapter_list_entry):
manual_chapter_list = element.chapter_list_entry.split( manual_chapter_list = element.chapter_list_entry.split(
",") ",")
toadd = element.chapter_size toadd = element.chapter_size
seconds = toadd * 60 seconds = toadd * 60
while seconds < (element.original_length - 4): while seconds < (effective_length - 4):
# Check manual_chapter_list for manual entries before # Check manual_chapter_list for manual entries before
# set next chapter # set next chapter
for idx, m_chapter in enumerate(manual_chapter_list): for idx, m_chapter in enumerate(manual_chapter_list):
@ -473,7 +489,7 @@ class dvdauthor_converter(devedeng.executor.executor):
xml_file.write("," + thetime) xml_file.write("," + thetime)
seconds += (toadd * 60) seconds += (toadd * 60)
xml_file.write( xml_file.write(
',' + self.return_time((element.original_length - 2), False)) ',' + self.return_time((effective_length - 2), False))
xml_file.write('" />\n') xml_file.write('" />\n')
if not onlyone: if not onlyone:

View file

@ -42,6 +42,121 @@ import devedeng.opensave
import devedeng.help import devedeng.help
import devedeng.separator import devedeng.separator
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
any manual chapter entries. Empty if the movie has no chapters. """
times = []
if not getattr(movie, "divide_in_chapters", False):
return times
length = movie.original_length
step = max(1, int(getattr(movie, "chapter_size", 5))) * 60
t = step
while t < (length - 4):
times.append(t)
t += step
entry = getattr(movie, "chapter_list_entry", None)
if entry:
for chunk in entry.split(","):
chunk = chunk.strip()
if ":" not in chunk:
continue
try:
mm, ss = chunk.split(":")[:2]
secs = int(mm) * 60 + int(ss)
except ValueError:
continue
if 0 < secs < length:
times.append(secs)
return sorted(set(times))
def plan_disc_split(movies, disc_capacity_kbytes, first_disc_reserved_kbytes=0.0):
""" Pure planning logic (no GTK) so it can be unit-tested.
movies: objects exposing get_kbytes_per_second(), original_length, and
(optionally) chapter info via _movie_chapter_seconds().
disc_capacity_kbytes: usable size of one disc in kBytes.
first_disc_reserved_kbytes: space taken by the menu on disc 1.
Returns a list of discs; each disc is a list of segments
(movie, start_sec, duration_sec) # duration 0 => to end of movie
"""
discs = []
current = []
current_used = first_disc_reserved_kbytes
def disc_capacity_for(index):
cap = disc_capacity_kbytes
if index == 0:
cap -= first_disc_reserved_kbytes
return cap
def flush():
nonlocal current, current_used
if current:
discs.append(current)
current = []
current_used = 0.0
for movie in movies:
rate = movie.get_kbytes_per_second() # kBytes per second
if rate <= 0:
rate = 1.0
length = movie.original_length
whole_size = rate * length
remaining_cap = disc_capacity_kbytes - current_used
if whole_size <= remaining_cap:
# fits whole on the current disc
current.append((movie, 0, 0))
current_used += whole_size
continue
# doesn't fit on what's left of the current disc
if whole_size <= disc_capacity_kbytes:
# fits on a fresh disc by itself: start a new disc
flush()
current.append((movie, 0, 0))
current_used = whole_size
continue
# the movie alone is bigger than a whole disc: split it by time.
flush()
chapters = _movie_chapter_seconds(movie)
start = 0
while start < length:
cap = disc_capacity_kbytes # each split-segment gets its own disc
max_secs = cap / rate
target_end = start + max_secs
if target_end >= length:
cut = length
else:
cut = _nearest_chapter_before(chapters, start, target_end)
if cut is None or cut <= start:
cut = target_end # no usable chapter: even time cut
duration = cut - start
# last segment to end of movie is encoded with duration 0
seg_duration = 0 if cut >= length else duration
discs.append([(movie, start, seg_duration)])
start = cut
flush()
if not discs:
discs = [[]]
return discs
def _nearest_chapter_before(chapters, start, target_end):
""" The largest chapter time that is > start and <= target_end, or None. """
best = None
for c in chapters:
if c > start and c <= target_end:
if best is None or c > best:
best = c
return best
class devede_project: class devede_project:
dvd_max_bps_base = {0: 9000} dvd_max_bps_base = {0: 9000}
@ -106,6 +221,9 @@ class devede_project:
self.wcreate_disc = builder.get_object("create_disc") self.wcreate_disc = builder.get_object("create_disc")
# DVD encode-profile selector (built in code, packed next to disc size)
self._build_profile_selector()
selection = self.wfiles.get_selection() selection = self.wfiles.get_selection()
selection.set_mode(Gtk.SelectionMode.BROWSE) selection.set_mode(Gtk.SelectionMode.BROWSE)
@ -153,6 +271,9 @@ class devede_project:
self.wframe_menu.hide() self.wframe_menu.hide()
self.wcreate_menu.set_active(False) self.wcreate_menu.set_active(False)
if hasattr(self, "wprofile_combo"):
self._update_profile_visibility()
def get_current_file(self): def get_current_file(self):
""" returns the currently selected file """ """ returns the currently selected file """
@ -483,6 +604,190 @@ class devede_project:
def on_disc_size_changed(self, c): def on_disc_size_changed(self, c):
self.refresh_disc_usage() self.refresh_disc_usage()
def plan_disc_split(self):
""" Builds a list of "disc plans" so the project fits across as many
discs as needed. Each disc plan is a list of segments:
(movie, start_sec, duration_sec)
where duration_sec == 0 means "whole remaining movie".
A title that fits is placed whole on the current disc (starting a
new disc if it would overflow). A single title bigger than one disc
is split by time at the chapter boundary nearest each disc's
capacity (falling back to an even time cut when it has no chapters).
"""
# per-disc capacity in kBytes (get_dvd_size already applies a margin)
disc_capacity = self.get_dvd_size()[0] * 1000.0
# menu lives on the first disc only; reserve its space there
first_disc_reserved = 0.0
if (self.disc_type == "dvd") and self.wcreate_menu.get_active():
first_disc_reserved = float(self.menu.get_estimated_size())
movies = [m for m in self.get_all_files()
if m.element_type == "file_movie"]
plan = plan_disc_split(movies, disc_capacity, first_disc_reserved)
return plan
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
chosen, two spin buttons for video/audio kbps become visible. """
# ordered to match the config string values
self._profile_order = ["compatibility", "high", "custom"]
labels = [_("Compatibility (4500k, old players)"),
_("High quality (fill disc)"),
_("Custom bitrate")]
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.set_margin_top(4)
box.set_margin_bottom(4)
box.pack_start(Gtk.Label(label=_("Encode profile:")), False, True, 0)
self.wprofile_combo = Gtk.ComboBoxText()
for text in labels:
self.wprofile_combo.append_text(text)
try:
active = self._profile_order.index(self.config.encode_profile)
except ValueError:
active = 0
self.wprofile_combo.set_active(active)
self.wprofile_combo.connect("changed", self.on_profile_changed)
box.pack_start(self.wprofile_combo, False, True, 0)
# custom bitrate spinners (hidden unless Custom is active)
self.wcustom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
spacing=4)
self.wcustom_box.pack_start(Gtk.Label(label=_("Video kbps:")),
False, True, 0)
self.wcustom_video = Gtk.SpinButton.new_with_range(1000, 9800, 100)
self.wcustom_video.set_value(self.config.custom_video_kbps)
self.wcustom_video.connect("value-changed", self.on_custom_rate_changed)
self.wcustom_box.pack_start(self.wcustom_video, False, True, 0)
self.wcustom_box.pack_start(Gtk.Label(label=_("Audio kbps:")),
False, True, 0)
self.wcustom_audio = Gtk.SpinButton.new_with_range(64, 448, 32)
self.wcustom_audio.set_value(self.config.custom_audio_kbps)
self.wcustom_audio.connect("value-changed", self.on_custom_rate_changed)
self.wcustom_box.pack_start(self.wcustom_audio, False, True, 0)
box.pack_start(self.wcustom_box, False, True, 0)
box.show_all()
self.wcustom_box.set_visible(
self.config.encode_profile == "custom")
# pack above the disc-size combo, in its container
parent = self.wdisc_size.get_parent()
if parent is not None:
parent.pack_start(box, False, True, 0)
try:
parent.reorder_child(box, 0)
except Exception:
pass
self._update_profile_visibility()
def _update_profile_visibility(self):
""" The profile only applies to DVD; hide the selector otherwise. """
is_dvd = (self.disc_type == "dvd")
if hasattr(self, "wprofile_combo"):
self.wprofile_combo.get_parent().set_visible(is_dvd)
def on_profile_changed(self, combo):
idx = combo.get_active()
if 0 <= idx < len(self._profile_order):
self.config.encode_profile = self._profile_order[idx]
self.wcustom_box.set_visible(self.config.encode_profile == "custom")
self.refresh_disc_usage()
def on_custom_rate_changed(self, spin):
self.config.custom_video_kbps = int(self.wcustom_video.get_value())
self.config.custom_audio_kbps = int(self.wcustom_audio.get_value())
self.refresh_disc_usage()
def _ask_split_choice(self, n_discs):
""" Ask the user how to handle a project that exceeds one disc.
Returns "split", "reduce", or "cancel". """
dialog = Gtk.MessageDialog(
transient_for=self.wmain_window,
modal=True,
message_type=Gtk.MessageType.QUESTION,
text=_("This project does not fit on a single disc."))
dialog.format_secondary_text(
_("At the current quality it needs %d discs. You can split it "
"into several discs (keeping quality) or reduce the bitrate to "
"fit a single disc.") % n_discs)
dialog.add_button(_("Cancel"), 1)
dialog.add_button(_("Reduce bitrate"), 2)
dialog.add_button(_("Split into %d discs") % n_discs, 3)
response = dialog.run()
dialog.destroy()
if response == 3:
return "split"
if response == 2:
return "reduce"
return "cancel"
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). """
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")
last_iso = None
for disc_index, segments in enumerate(split_plan):
disc_no = disc_index + 1
disc_path = os.path.join(data.path, "disc{:d}".format(disc_no))
movie_folder = os.path.join(disc_path, "movies")
try:
os.makedirs(movie_folder)
except OSError:
pass
deps = []
seg_movies = []
segment_lengths = []
for seg_index, (movie, start, duration) in enumerate(segments):
out = os.path.join(
movie_folder, "movie_{:d}.mpg".format(seg_index))
p = movie.do_conversion(out, duration=duration,
start_offset=start)
run_window.add_process(p)
deps.append(p)
seg_movies.append(movie)
# effective length of this segment for chapter math: an explicit
# duration, or whole-movie-from-start when duration is 0
if duration and duration > 0:
segment_lengths.append(int(duration))
elif start > 0:
segment_lengths.append(int(movie.original_length - start))
else:
segment_lengths.append(None) # whole, unsplit movie
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)
for d in deps:
dvdauthor.add_dependency(d)
run_window.add_process(dvdauthor)
isocreator = cv.get_mkiso()()
iso_name = "{:s}-disc{:d}".format(data.name, disc_no)
isocreator.create_iso(disc_path, iso_name, title)
isocreator.add_dependency(dvdauthor)
run_window.add_process(isocreator)
last_iso = os.path.join(disc_path, iso_name + ".iso")
# burning multiple discs interactively isn't automated; point the
# "done" handler at the last image and let the user burn each in turn
self.disc_image_name = last_iso
def on_create_disc_clicked(self, b): def on_create_disc_clicked(self, b):
if self.disc_type == "dvd": if self.disc_type == "dvd":
max_files = 62 max_files = 62
@ -530,8 +835,34 @@ class devede_project:
self.shutdown = data.shutdown self.shutdown = data.shutdown
# For DVD, decide whether the project needs more than one disc. The
# other formats keep their original single-image behaviour.
split_plan = None
if self.disc_type == "dvd":
plan = self.plan_disc_split()
if len(plan) > 1:
choice = self._ask_split_choice(len(plan))
if choice == "cancel":
return
if choice == "reduce":
# squeeze the bitrate so everything fits one disc
self.on_adjust_disc_usage_clicked(None)
plan = self.plan_disc_split()
# if it still doesn't fit, fall back to splitting
split_plan = plan if len(plan) > 1 else None
else: # "split"
split_plan = plan
run_window = devedeng.runner.runner() run_window = devedeng.runner.runner()
if (self.disc_type == "dvd") and (split_plan is not None):
self._build_split_dvd(run_window, data, split_plan)
run_window.connect("done", self.disc_done)
self.wmain_window.hide()
self.time_start = time.time()
run_window.run()
return
final_dependencies = [] final_dependencies = []
if (self.disc_type == "dvd") and (self.wcreate_menu.get_active()): if (self.disc_type == "dvd") and (self.wcreate_menu.get_active()):