diff --git a/src/devedeng/dvdauthor_converter.py b/src/devedeng/dvdauthor_converter.py index 26ca131..11dfdb8 100644 --- a/src/devedeng/dvdauthor_converter.py +++ b/src/devedeng/dvdauthor_converter.py @@ -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\n') if not onlyone: diff --git a/src/devedeng/project.py b/src/devedeng/project.py index 4f1aa3c..53de2fa 100644 --- a/src/devedeng/project.py +++ b/src/devedeng/project.py @@ -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()):