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:
parent
8b041f0725
commit
4f1ecb55ee
2 changed files with 354 additions and 7 deletions
|
|
@ -28,7 +28,7 @@ class dvdauthor_converter(devedeng.executor.executor):
|
|||
devedeng.executor.executor.__init__(self)
|
||||
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")
|
||||
try:
|
||||
|
|
@ -36,7 +36,7 @@ class dvdauthor_converter(devedeng.executor.executor):
|
|||
except:
|
||||
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.append("dvdauthor")
|
||||
|
|
@ -55,7 +55,7 @@ class dvdauthor_converter(devedeng.executor.executor):
|
|||
self.progress_bar[1].set_text(data[0])
|
||||
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 = []
|
||||
for element in file_movies_in:
|
||||
|
|
@ -63,6 +63,11 @@ class dvdauthor_converter(devedeng.executor.executor):
|
|||
continue
|
||||
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")
|
||||
xml_file_path = os.path.join(xmlpath, "dvdauthor.xml")
|
||||
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="' +
|
||||
self.expand_xml(element.converted_filename) + '" ')
|
||||
xml_file.write('chapters="0')
|
||||
if (element.original_length > 5):
|
||||
if (element.divide_in_chapters): # add chapters
|
||||
# effective length for this title: a split segment uses its own
|
||||
# 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 = []
|
||||
if (element.chapter_list_entry):
|
||||
manual_chapter_list = element.chapter_list_entry.split(
|
||||
",")
|
||||
toadd = element.chapter_size
|
||||
seconds = toadd * 60
|
||||
while seconds < (element.original_length - 4):
|
||||
while seconds < (effective_length - 4):
|
||||
# Check manual_chapter_list for manual entries before
|
||||
# set next chapter
|
||||
for idx, m_chapter in enumerate(manual_chapter_list):
|
||||
|
|
@ -473,7 +489,7 @@ class dvdauthor_converter(devedeng.executor.executor):
|
|||
xml_file.write("," + thetime)
|
||||
seconds += (toadd * 60)
|
||||
xml_file.write(
|
||||
',' + self.return_time((element.original_length - 2), False))
|
||||
',' + self.return_time((effective_length - 2), False))
|
||||
xml_file.write('" />\n')
|
||||
|
||||
if not onlyone:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,121 @@ import devedeng.opensave
|
|||
import devedeng.help
|
||||
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:
|
||||
|
||||
dvd_max_bps_base = {0: 9000}
|
||||
|
|
@ -106,6 +221,9 @@ class devede_project:
|
|||
|
||||
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.set_mode(Gtk.SelectionMode.BROWSE)
|
||||
|
||||
|
|
@ -153,6 +271,9 @@ class devede_project:
|
|||
self.wframe_menu.hide()
|
||||
self.wcreate_menu.set_active(False)
|
||||
|
||||
if hasattr(self, "wprofile_combo"):
|
||||
self._update_profile_visibility()
|
||||
|
||||
def get_current_file(self):
|
||||
""" returns the currently selected file """
|
||||
|
||||
|
|
@ -483,6 +604,190 @@ class devede_project:
|
|||
def on_disc_size_changed(self, c):
|
||||
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):
|
||||
if self.disc_type == "dvd":
|
||||
max_files = 62
|
||||
|
|
@ -530,8 +835,34 @@ class devede_project:
|
|||
|
||||
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()
|
||||
|
||||
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 = []
|
||||
|
||||
if (self.disc_type == "dvd") and (self.wcreate_menu.get_active()):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue