render.py 53.1 KB
Newer Older
1
# -*- coding: utf-8 -*-
Jeff Fortin Tam's avatar
Jeff Fortin Tam committed
2
# Pitivi video editor
3
4
5
6
7
8
9
10
11
12
13
14
15
# Copyright (c) 2009, Edward Hervey <bilboed@bilboed.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
16
# License along with this program; if not, see <http://www.gnu.org/licenses/>.
Alexandru Băluț's avatar
Alexandru Băluț committed
17
"""Rendering-related classes and utilities."""
18
import os
19
import posixpath
20
import time
21
from gettext import gettext as _
22

23
from gi.repository import GES
24
from gi.repository import Gio
25
from gi.repository import GLib
26
from gi.repository import GObject
27
from gi.repository import Gst
28
from gi.repository import Gtk
29

30
from pitivi import configure
31
from pitivi.check import MISSING_SOFT_DEPS
32
from pitivi.preset import EncodingTargetManager
33
from pitivi.utils.loggable import Loggable
34
35
from pitivi.utils.misc import path_from_uri
from pitivi.utils.misc import show_user_manual
36
from pitivi.utils.ripple_update_group import RippleUpdateGroup
37
38
39
40
from pitivi.utils.ui import AUDIO_CHANNELS
from pitivi.utils.ui import AUDIO_RATES
from pitivi.utils.ui import beautify_eta
from pitivi.utils.ui import FRAME_RATES
41
42
from pitivi.utils.ui import get_combo_value
from pitivi.utils.ui import set_combo_value
43
from pitivi.utils.widgets import GstElementSettingsDialog
44
from pitivi.utils.widgets import TextWidget
45

46

47
class Encoders(Loggable):
luz.paz's avatar
luz.paz committed
48
    """Registry of available Muxers, Audio encoders and Video encoders.
49

luz.paz's avatar
luz.paz committed
50
    Also keeps the available combinations of those.
51

52
    It is a singleton. Use `Encoders()` to access the instance.
Alexandru Băluț's avatar
Alexandru Băluț committed
53
54

    Attributes:
55
56
57
58
59
60
61
62
63
        supported_muxers (List[Gst.ElementFactory]): The supported available
            muxers.
        supported_aencoders (List[Gst.ElementFactory]): The supported available
            audio encoders.
        supported_vencoders (List[Gst.ElementFactory]): The supported available
            video encoders.
        muxers (List[Gst.ElementFactory]): The available muxers.
        aencoders (List[Gst.ElementFactory]): The available audio encoders.
        vencoders (List[Gst.ElementFactory]): The available video encoders.
64
65
66
67
        compatible_audio_encoders (dict): Maps each muxer name to a list of
            compatible audio encoders ordered by rank.
        compatible_video_encoders (dict): Maps each muxer name to a list of
            compatible video encoders ordered by rank.
68
69
70
71
72
73
74
75
76
77
78
79
80
        default_muxer (str): The factory name of the default muxer.
        default_audio_encoder (str): The factory name of the default audio
            encoder.
        default_video_encoder (str): The factory name of the default video
            encoder.
    """

    OGG = "oggmux"
    MKV = "matroskamux"
    MP4 = "mp4mux"
    QUICKTIME = "qtmux"
    WEBM = "webmmux"

Thibault Saunier's avatar
Thibault Saunier committed
81
82
83
84
    if Gst.ElementFactory.find("fdkaacenc"):
        AAC = "fdkaacenc"
    else:
        AAC = "voaacenc"
85
86
87
88
89
90
91
92
93
94
    AC3 = "avenc_ac3_fixed"
    OPUS = "opusenc"
    VORBIS = "vorbisenc"

    JPEG = "jpegenc"
    THEORA = "theoraenc"
    VP8 = "vp8enc"
    X264 = "x264enc"

    SUPPORTED_ENCODERS_COMBINATIONS = [
95
        (WEBM, VORBIS, VP8),
96
97
98
99
100
101
102
103
104
105
106
107
108
        (OGG, VORBIS, THEORA),
        (OGG, OPUS, THEORA),
        (WEBM, OPUS, VP8),
        (MP4, AAC, X264),
        (MP4, AC3, X264),
        (QUICKTIME, AAC, JPEG),
        (MKV, OPUS, X264),
        (MKV, VORBIS, X264),
        (MKV, OPUS, JPEG),
        (MKV, VORBIS, JPEG)]
    """The combinations of muxers and encoders which are supported.

    Mirror of GES_ENCODING_TARGET_COMBINATIONS from
Alexandru Băluț's avatar
Alexandru Băluț committed
109
    https://gitlab.freedesktop.org/gstreamer/gst-editing-services/blob/master/tests/validate/geslaunch.py
110
    """
111
112
113

    _instance = None

Alexandru Băluț's avatar
Alexandru Băluț committed
114
    def __new__(cls):
Alexandru Băluț's avatar
Alexandru Băluț committed
115
        """Returns the singleton instance."""
116
        if not cls._instance:
117
            cls._instance = super(Encoders, cls).__new__(cls)
118
119
120
            # We have to initialize the instance here, otherwise
            # __init__ is called every time we use Encoders().
            Loggable.__init__(cls._instance)
121
122
            cls._instance._load_encoders()
            cls._instance._load_combinations()
123
124
        return cls._instance

125
    def _load_encoders(self):
126
        # pylint: disable=attribute-defined-outside-init
127
128
        self.aencoders = []
        self.vencoders = []
129
130
131
        self.muxers = Gst.ElementFactory.list_get_elements(
            Gst.ELEMENT_FACTORY_TYPE_MUXER,
            Gst.Rank.SECONDARY)
132
133
134
135
136
137
138
139
140

        for fact in Gst.ElementFactory.list_get_elements(
                Gst.ELEMENT_FACTORY_TYPE_ENCODER, Gst.Rank.SECONDARY):
            klist = fact.get_klass().split('/')
            if "Video" in klist or "Image" in klist:
                self.vencoders.append(fact)
            elif "Audio" in klist:
                self.aencoders.append(fact)

141
    def _load_combinations(self):
142
        # pylint: disable=attribute-defined-outside-init
143
144
145
        self.compatible_audio_encoders = {}
        self.compatible_video_encoders = {}
        useless_muxers = set()
146
        for muxer in self.muxers:
147
148
149
150
151
            aencs = self._find_compatible_encoders(self.aencoders, muxer)
            vencs = self._find_compatible_encoders(self.vencoders, muxer)
            if not aencs or not vencs:
                # The muxer is not compatible with no video encoder or
                # with no audio encoder.
152
                useless_muxers.add(muxer)
153
154
155
156
157
                continue

            muxer_name = muxer.get_name()
            self.compatible_audio_encoders[muxer_name] = aencs
            self.compatible_video_encoders[muxer_name] = vencs
158
159
160
161

        for muxer in useless_muxers:
            self.muxers.remove(muxer)

Alexandru Băluț's avatar
Alexandru Băluț committed
162
163
        self.factories_by_name = {fact.get_name(): fact
                                  for fact in self.muxers + self.aencoders + self.vencoders}
164
165

        good_muxers, good_aencoders, good_vencoders = zip(*self.SUPPORTED_ENCODERS_COMBINATIONS)
Alexandru Băluț's avatar
Alexandru Băluț committed
166
167
168
169
170
171
172
173
174
        self.supported_muxers = {muxer
                                 for muxer in self.muxers
                                 if muxer.get_name() in good_muxers}
        self.supported_aencoders = {encoder
                                    for encoder in self.aencoders
                                    if encoder.get_name() in good_aencoders}
        self.supported_vencoders = {encoder
                                    for encoder in self.vencoders
                                    if encoder.get_name() in good_vencoders}
175

176
177
178
179
        self.default_muxer, \
            self.default_audio_encoder, \
            self.default_video_encoder = self._pick_defaults()

180
    def _find_compatible_encoders(self, encoders, muxer):
Alexandru Băluț's avatar
Alexandru Băluț committed
181
        """Returns the list of encoders compatible with the specified muxer."""
182
        res = []
183
184
185
        sink_caps = [template.get_caps()
                     for template in muxer.get_static_pad_templates()
                     if template.direction == Gst.PadDirection.SINK]
186
        for encoder in encoders:
187
188
189
190
191
192
193
194
195
196
197
198
199
            for template in encoder.get_static_pad_templates():
                if not template.direction == Gst.PadDirection.SRC:
                    continue
                if self._can_muxer_sink_caps(template.get_caps(), sink_caps):
                    res.append(encoder)
                    break
        return sorted(res, key=lambda encoder: - encoder.get_rank())

    def _can_muxer_sink_caps(self, output_caps, sink_caps):
        """Checks whether the specified caps match the muxer's receptors."""
        for caps in sink_caps:
            if not caps.intersect(output_caps).is_empty():
                return True
200
        return False
201

202
203
204
205
206
207
208
    def _pick_defaults(self):
        """Picks the defaults for new projects.

        Returns:
            (str, str, str): The muxer, audio encoder, video encoder.
        """
        for muxer, audio, video in self.SUPPORTED_ENCODERS_COMBINATIONS:
209
210
211
            if muxer not in self.factories_by_name or \
                    audio not in self.factories_by_name or \
                    video not in self.factories_by_name:
212
213
214
215
216
217
                continue
            self.info("Default encoders: %s, %s, %s", muxer, audio, video)
            return muxer, audio, video
        self.warning("No good combination of container and encoders available.")
        return Encoders.OGG, Encoders.VORBIS, Encoders.THEORA

218
219
    def is_supported(self, factory):
        """Returns whether the specified factory is supported."""
220
        if isinstance(factory, str):
221
222
223
224
225
            factory = self.factories_by_name[factory]
        return factory in self.supported_muxers or\
            factory in self.supported_aencoders or\
            factory in self.supported_vencoders

226

227
def beautify_factory_name(factory):
Alexandru Băluț's avatar
Alexandru Băluț committed
228
229
230
    """Returns a nice name for the specified Gst.ElementFactory instance.

    Intended for removing redundant words and shorten the codec names.
231
232
233
234
235
236

    Args:
        factory (Gst.ElementFactory): The factory which needs to be displayed.

    Returns:
        str: Cleaned up name.
237
    """
Alexandru Băluț's avatar
Alexandru Băluț committed
238
239
    # Only replace lowercase versions of "format", "video", "audio"
    # otherwise they might be part of a trademark name.
240
    words_to_remove = ["Muxer", "muxer", "Encoder", "encoder",
241
242
243
                       "format", "video", "audio", "instead",
                       # Incorrect naming for Sorenson Spark:
                       "Flash Video (FLV) /", ]
244
    words_to_replace = [["version ", "v"], ["Microsoft", "MS"], ]
245
246
247
    name = factory.get_longname()
    for word in words_to_remove:
        name = name.replace(word, "")
248
249
    for match, replacement in words_to_replace:
        name = name.replace(match, replacement)
250
251
252
    return " ".join(word for word in name.split())


253
254
255
256
257
258
def extension_for_muxer(muxer_name):
    """Returns the file extension appropriate for the specified muxer.

    Args:
        muxer_name (str): The name of the muxer factory.
    """
259
260
261
    exts = {
        "asfmux": "asf",
        "avimux": "avi",
262
263
264
265
266
267
268
269
270
271
272
273
274
        "avmux_3g2": "3g2",
        "avmux_avm2": "avm2",
        "avmux_dvd": "vob",
        "avmux_flv": "flv",
        "avmux_ipod": "mp4",
        "avmux_mpeg": "mpeg",
        "avmux_mpegts": "mpeg",
        "avmux_psp": "mp4",
        "avmux_rm": "rm",
        "avmux_svcd": "mpeg",
        "avmux_swf": "swf",
        "avmux_vcd": "mpeg",
        "avmux_vob": "vob",
275
276
277
278
279
280
281
282
283
284
285
286
        "flvmux": "flv",
        "gppmux": "3gp",
        "matroskamux": "mkv",
        "mj2mux": "mj2",
        "mp4mux": "mp4",
        "mpegpsmux": "mpeg",
        "mpegtsmux": "mpeg",
        "mvemux": "mve",
        "mxfmux": "mxf",
        "oggmux": "ogv",
        "qtmux": "mov",
        "webmmux": "webm"}
287
    return exts.get(muxer_name)
288
289


290
291
292
293
294
# --------------------------------- Public classes -----------------------------#

class RenderingProgressDialog(GObject.Object):

    __gsignals__ = {
295
296
        "pause": (GObject.SignalFlags.RUN_LAST, None, ()),
        "cancel": (GObject.SignalFlags.RUN_LAST, None, ()),
297
298
299
    }

    def __init__(self, app, parent):
300
301
        GObject.Object.__init__(self)

302
        self.app = app
303
        self.main_render_dialog = parent
304
        self.builder = Gtk.Builder()
305
306
        self.builder.add_from_file(
            os.path.join(configure.get_ui_dir(), "renderingprogress.ui"))
307
308
309
310
311
312
        self.builder.connect_signals(self)

        self.window = self.builder.get_object("render-progress")
        self.table1 = self.builder.get_object("table1")
        self.progressbar = self.builder.get_object("progressbar")
        self.play_pause_button = self.builder.get_object("play_pause_button")
313
314
        self.play_rendered_file_button = self.builder.get_object(
            "play_rendered_file_button")
315
        self.close_button = self.builder.get_object("close_button")
316
317
        self.show_in_file_manager_button = self.builder.get_object(
            "show_in_file_manager_button")
318
        self.cancel_button = self.builder.get_object("cancel_button")
319
320
321
322
        self._filesize_est_label = self.builder.get_object(
            "estimated_filesize_label")
        self._filesize_est_value_label = self.builder.get_object(
            "estimated_filesize_value_label")
323
        # Parent the dialog with mainwindow, since renderingdialog is hidden.
324
        # It allows this dialog to properly minimize together with mainwindow
325
        self.window.set_transient_for(self.app.gui)
326
        self.window.set_icon_name("system-run-symbolic")
327

328
329
        self.play_rendered_file_button.get_style_context().add_class("suggested-action")

330
331
332
        # We will only show the close/play buttons when the render is done:
        self.play_rendered_file_button.hide()
        self.close_button.hide()
333
        self.show_in_file_manager_button.hide()
334

335
    def update_position(self, fraction):
336
        self.progressbar.set_fraction(fraction)
337
338
        self.window.set_title(
            _("Rendering — %d%% complete") % int(100 * fraction))
339

340
    def update_progressbar_eta(self, time_estimation):
341
342
343
344
345
346
347
        # Translators: this string indicates the estimated time
        # remaining until an action (such as rendering) completes.
        # The "%s" is an already-localized human-readable duration,
        # such as "31 seconds", "1 minute" or "1 hours, 14 minutes".
        # In some languages, "About %s left" can be expressed roughly as
        # "There remains approximatively %s" (to handle gender and plurals).
        self.progressbar.set_text(_("About %s left") % time_estimation)
348

349
    def set_filesize_estimate(self, estimated_filesize=None):
350
351
352
353
354
355
356
357
        if not estimated_filesize:
            self._filesize_est_label.hide()
            self._filesize_est_value_label.hide()
        else:
            self._filesize_est_value_label.set_text(estimated_filesize)
            self._filesize_est_label.show()
            self._filesize_est_value_label.show()

358
    def _delete_event_cb(self, unused_dialog_widget, unused_event):
Alexandru Băluț's avatar
Alexandru Băluț committed
359
360
        """Stops the rendering."""
        # The user closed the window by pressing Escape.
361
362
        self.emit("cancel")

363
    def _cancel_button_clicked_cb(self, unused_button):
364
365
        self.emit("cancel")

366
    def _pause_button_clicked_cb(self, unused_button):
367
368
        self.emit("pause")

369
    def _close_button_clicked_cb(self, unused_button):
370
371
372
373
374
        self.window.destroy()
        if self.main_render_dialog.notification is not None:
            self.main_render_dialog.notification.close()
        self.main_render_dialog.window.show()

375
    def _play_rendered_file_button_clicked_cb(self, unused_button):
376
        Gio.AppInfo.launch_default_for_uri(self.main_render_dialog.outfile, None)
377

378
379
380
381
    def _show_in_file_manager_button_clicked_cb(self, unused_button):
        directory_uri = posixpath.dirname(self.main_render_dialog.outfile)
        Gio.AppInfo.launch_default_for_uri(directory_uri, None)

382

383
class RenderDialog(Loggable):
384
385
    """Render dialog box.

Alexandru Băluț's avatar
Alexandru Băluț committed
386
387
388
389
390
391
392
    Args:
        app (Pitivi): The app.
        project (Project): The project to be rendered.

    Attributes:
        preferred_aencoder (str): The last audio encoder selected by the user.
        preferred_vencoder (str): The last video encoder selected by the user.
393
    """
394

395
    INHIBIT_REASON = _("Currently rendering")
396

397
398
    _factory_formats = {}

399
    def __init__(self, app, project):
400
401
402
403
        Loggable.__init__(self)

        self.app = app
        self.project = project
404
        self._pipeline = self.project.pipeline
405
406

        self.outfile = None
407
        self.notification = None
408
409

        # Variables to keep track of progress indication timers:
410
411
        self._filesize_estimate_timer = None
        self._time_estimate_timer = None
412
413
        self._is_rendering = False
        self._rendering_is_paused = False
414
        self._last_timestamp_when_pausing = 0
415
        self.current_position = None
416
417
        self._time_started = 0
        self._time_spent_paused = 0  # Avoids the ETA being wrong on resume
418
        self._is_filename_valid = True
419
420
421

        # Various gstreamer signal connection ID's
        # {object: sigId}
422
        self._gst_signal_handlers_ids = {}
423

424
425
        self.render_presets = EncodingTargetManager(project)
        self.render_presets.connect('profile-selected', self._encoding_profile_selected_cb)
426

427
428
        # Whether encoders changing are a result of changing the muxer.
        self.muxer_combo_changing = False
429
430
431
        self._create_ui()
        self.progress = None
        self.dialog = None
432

433
434
435
        # Directory and Filename
        self.filebutton.set_current_folder(self.app.settings.lastExportFolder)
        if not self.project.name:
436
            self._update_filename(_("Untitled"))
437
        else:
438
            self._update_filename(self.project.name)
439

440
441
442
443
444
        # Add a shortcut for the project folder (if saved)
        if self.project.uri:
            shortcut = os.path.dirname(self.project.uri)
            self.filebutton.add_shortcut_folder_uri(shortcut)

445
446
        self._setting_encoding_profile = False

447
        # We store these so that when the user tries various container formats,
448
        # (AKA muxers) we select these a/v encoders, if they are compatible with
449
        # the current container format.
450
451
        self.preferred_vencoder = self.project.vencoder
        self.preferred_aencoder = self.project.aencoder
yatinmaan's avatar
yatinmaan committed
452
        self.__replaced_assets = {}
453

454
455
456
        self.frame_rate_combo.set_model(FRAME_RATES)
        self.channels_combo.set_model(AUDIO_CHANNELS)
        self.sample_rate_combo.set_model(AUDIO_RATES)
457
        self.__initialize_muxers_model()
458
459
        self._display_settings()
        self._display_render_settings()
460

461
        self.window.connect("delete-event", self._delete_event_cb)
462
463
        self.project.connect("rendering-settings-changed",
                             self._rendering_settings_changed_cb)
464
465
466

        # Monitor changes

467
        self.widgets_group = RippleUpdateGroup()
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
        self.widgets_group.add_vertex(self.frame_rate_combo, signal="changed")
        self.widgets_group.add_vertex(self.channels_combo, signal="changed")
        self.widgets_group.add_vertex(self.sample_rate_combo, signal="changed")
        self.widgets_group.add_vertex(self.muxer_combo, signal="changed")
        self.widgets_group.add_vertex(self.audio_encoder_combo, signal="changed")
        self.widgets_group.add_vertex(self.video_encoder_combo, signal="changed")
        self.widgets_group.add_vertex(self.preset_menubutton,
                                      update_func=self._update_preset_menu_button)

        self.widgets_group.add_edge(self.frame_rate_combo, self.preset_menubutton)
        self.widgets_group.add_edge(self.audio_encoder_combo, self.preset_menubutton)
        self.widgets_group.add_edge(self.video_encoder_combo, self.preset_menubutton)
        self.widgets_group.add_edge(self.muxer_combo, self.preset_menubutton)
        self.widgets_group.add_edge(self.channels_combo, self.preset_menubutton)
        self.widgets_group.add_edge(self.sample_rate_combo, self.preset_menubutton)
483

484
485
486
487
488
489
    def _encoding_profile_selected_cb(self, unused_target, encoding_profile):
        self._set_encoding_profile(encoding_profile)

    def _set_encoding_profile(self, encoding_profile, recursing=False):
        old_profile = self.project.container_profile

490
        def rollback():
491
492
493
494
495
496
497
498
            if recursing:
                return

            self._set_encoding_profile(old_profile, True)

        def factory(x):
            return Encoders().factories_by_name.get(getattr(self.project, x))

499
        self.project.set_container_profile(encoding_profile)
500
501
502
        self._setting_encoding_profile = True

        if not set_combo_value(self.muxer_combo, factory('muxer')):
503
504
            rollback()
            return
505

506
        self.update_available_encoders()
507
508
        self._update_valid_audio_restrictions(Gst.ElementFactory.find(self.project.aencoder))
        self._update_valid_video_restrictions(Gst.ElementFactory.find(self.project.vencoder))
509
510
511
512
513
514
        for i, (combo, name, value) in enumerate([
                (self.audio_encoder_combo, "aencoder", factory("aencoder")),
                (self.video_encoder_combo, "vencoder", factory("vencoder")),
                (self.sample_rate_combo, "audiorate", self.project.audiorate),
                (self.channels_combo, "audiochannels", self.project.audiochannels),
                (self.frame_rate_combo, "videorate", self.project.videorate)]):
515
            if value is None:
516
517
                self.error("%d - Got no value for %s (%s)... rolling back",
                           i, name, combo)
518
519
                rollback()
                return
520
521
522
523

            if not set_combo_value(combo, value):
                self.error("%d - Could not set value %s for combo %s... rolling back",
                           i, value, combo)
524
525
                rollback()
                return
526

527
        self.update_resolution()
528
        self.project.add_encoding_profile(self.project.container_profile)
529
        self._update_file_extension()
530
        self._setting_encoding_profile = False
531

532
533
    def _update_preset_menu_button(self, unused_source, unused_target):
        self.render_presets.update_menu_actions()
534

535
    def _create_ui(self):
536
        builder = Gtk.Builder()
537
538
        builder.add_from_file(
            os.path.join(configure.get_ui_dir(), "renderingdialog.ui"))
539
540
541
        builder.connect_signals(self)

        self.window = builder.get_object("render-dialog")
542
543
544
545
        self.video_output_checkbutton = builder.get_object(
            "video_output_checkbutton")
        self.audio_output_checkbutton = builder.get_object(
            "audio_output_checkbutton")
546
        self.render_button = builder.get_object("render_button")
547
548
549
550
        self.video_settings_button = builder.get_object(
            "video_settings_button")
        self.audio_settings_button = builder.get_object(
            "audio_settings_button")
551
        self.frame_rate_combo = builder.get_object("frame_rate_combo")
552
        self.frame_rate_combo.set_model(FRAME_RATES)
553
554
        self.scale_spinbutton = builder.get_object("scale_spinbutton")
        self.channels_combo = builder.get_object("channels_combo")
555
        self.channels_combo.set_model(AUDIO_CHANNELS)
556
        self.sample_rate_combo = builder.get_object("sample_rate_combo")
557
        self.muxer_combo = builder.get_object("muxercombobox")
558
559
560
561
562
        self.audio_encoder_combo = builder.get_object("audio_encoder_combo")
        self.video_encoder_combo = builder.get_object("video_encoder_combo")
        self.filebutton = builder.get_object("filebutton")
        self.fileentry = builder.get_object("fileentry")
        self.resolution_label = builder.get_object("resolution_label")
563
564
        self.preset_menubutton = builder.get_object("preset_menubutton")

565
566
567
568
569
570
        text_widget = TextWidget(matches=r'^[a-z][a-z-0-9-]+$', combobox=True)
        self.presets_combo = text_widget.combo
        preset_table = builder.get_object("preset_table")
        preset_table.attach(text_widget, 1, 0, 1, 1)
        text_widget.show()

Thibault Saunier's avatar
Thibault Saunier committed
571
572
573
574
575
576
577
578
579
        self.__automatically_use_proxies = builder.get_object(
            "automatically_use_proxies")

        self.__always_use_proxies = builder.get_object("always_use_proxies")
        self.__always_use_proxies.props.group = self.__automatically_use_proxies

        self.__never_use_proxies = builder.get_object("never_use_proxies")
        self.__never_use_proxies.props.group = self.__automatically_use_proxies

580
581
        self.render_presets.setup_ui(self.presets_combo, self.preset_menubutton)
        self.render_presets.load_all()
582

583
        self.window.set_icon_name("system-run-symbolic")
584
585
        self.window.set_transient_for(self.app.gui)

586
587
588
589
590
591
592
593
        media_types = self.project.ges_timeline.ui.media_types

        self.audio_output_checkbutton.props.active = media_types & GES.TrackType.AUDIO
        self._update_audio_widgets_sensitivity()

        self.video_output_checkbutton.props.active = media_types & GES.TrackType.VIDEO
        self._update_video_widgets_sensitivity()

594
595
    def _rendering_settings_changed_cb(self, unused_project, unused_item):
        """Handles Project metadata changes."""
596
        self.update_resolution()
597

598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
    def __initialize_muxers_model(self):
        # By default show only supported muxers and encoders.
        model = self.create_combobox_model(Encoders().muxers)
        self.muxer_combo.set_model(model)

    def create_combobox_model(self, factories):
        """Creates a model for a combobox showing factories.

        Args:
            combobox (Gtk.ComboBox): The combobox to setup.
            factories (List[Gst.ElementFactory]): The factories to display.

        Returns:
            Gtk.ListStore: The model with (display name, factory, unsupported).
        """
        model = Gtk.TreeStore(str, object)
        data_supported = []
        data_unsupported = []
        for factory in factories:
            supported = Encoders().is_supported(factory)
            row = (beautify_factory_name(factory), factory)
            if supported:
                data_supported.append(row)
            else:
                data_unsupported.append(row)

        data_supported.sort()
        for row in data_supported:
            model.append(None, row)

628
629
630
631
632
633
634
635
        if data_unsupported:
            # Translators: This item appears in a combobox's popup and
            # contains as children the unsupported (but still available)
            # muxers and encoders.
            unsupported_iter = model.append(None, (_("Unsupported"), None))
            data_unsupported.sort()
            for row in data_unsupported:
                model.append(unsupported_iter, row)
636
637

        return model
638

639
    def _display_settings(self):
640
        """Applies the project settings to the UI."""
641
        # Video settings
642
        set_combo_value(self.frame_rate_combo, self.project.videorate)
643

644
        # Audio settings
645
646
647
648
        res = set_combo_value(self.channels_combo, self.project.audiochannels)
        assert res, self.project.audiochannels
        res = set_combo_value(self.sample_rate_combo, self.project.audiorate)
        assert res, self.project.audiorate
649

650
651
652
653
654
655
656
    def _update_audio_widgets_sensitivity(self):
        active = self.audio_output_checkbutton.get_active()
        self.channels_combo.set_sensitive(active)
        self.sample_rate_combo.set_sensitive(active)
        self.audio_encoder_combo.set_sensitive(active)
        self.audio_settings_button.set_sensitive(active)
        self.project.audio_profile.set_enabled(active)
657
        self.__update_render_button_sensitivity()
658
659
660
661
662
663
664
665

    def _update_video_widgets_sensitivity(self):
        active = self.video_output_checkbutton.get_active()
        self.scale_spinbutton.set_sensitive(active)
        self.frame_rate_combo.set_sensitive(active)
        self.video_encoder_combo.set_sensitive(active)
        self.video_settings_button.set_sensitive(active)
        self.project.video_profile.set_enabled(active)
666
        self.__update_render_button_sensitivity()
667

668
    def _display_render_settings(self):
669
        """Applies the project render settings to the UI."""
670
        # Video settings
Alexandru Băluț's avatar
Alexandru Băluț committed
671
        # This will trigger an update of the video resolution label.
672
        self.scale_spinbutton.set_value(self.project.render_scale)
673

674
        # Muxer settings
Alexandru Băluț's avatar
Alexandru Băluț committed
675
        # This will trigger an update of the codec comboboxes.
676
677
678
679
680
681
682
        muxer = Encoders().factories_by_name.get(self.project.muxer)
        if muxer:
            if not set_combo_value(self.muxer_combo, muxer):
                # The project's muxer is not available on this system.
                # Pick the first one available.
                first = self.muxer_combo.props.model.get_iter_first()
                set_combo_value(self.muxer_combo, first)
683

684
    def _check_filename(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
685
        """Displays a warning if the file path already exists."""
686
687
688
689
        path = self.filebutton.get_current_folder()
        if not path:
            # This happens when the window is initialized.
            return
690

691
        filename = self.fileentry.get_text()
692
693
694
695
696
697

        # Characters that cause pipeline failure.
        blacklist = ["/"]
        invalid_chars = "".join([ch for ch in blacklist if ch in filename])

        warning_icon = "dialog-warning"
698
        self._is_filename_valid = True
699
700
        if not filename:
            tooltip_text = _("A file name is required.")
701
            self._is_filename_valid = False
702
        elif os.path.exists(os.path.join(path, filename)):
703
704
705
            tooltip_text = _("This file already exists.\n"
                             "If you don't want to overwrite it, choose a "
                             "different file name or folder.")
706
707
        elif invalid_chars:
            tooltip_text = _("Remove invalid characters from the filename: %s") % invalid_chars
708
            self._is_filename_valid = False
709
710
711
        else:
            warning_icon = None
            tooltip_text = None
712

713
        self.fileentry.set_icon_from_icon_name(1, warning_icon)
714
        self.fileentry.set_icon_tooltip_text(1, tooltip_text)
715
        self.__update_render_button_sensitivity()
716

717
    def _get_filesize_estimate(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
718
        """Estimates the final file size.
719
720
721
722

        Estimates in megabytes (over 30 MB) are rounded to the nearest 10 MB
        to smooth out small variations. You'd be surprised how imprecision can
        improve perceived accuracy.
Alexandru Băluț's avatar
Alexandru Băluț committed
723
724
725

        Returns:
            str: A human-readable (ex: "14 MB") estimate for the file size.
726
        """
727
        if not self.current_position:
728
729
730
            return None

        current_filesize = os.stat(path_from_uri(self.outfile)).st_size
731
        length = self.project.ges_timeline.props.duration
732
733
        estimated_size = float(
            current_filesize * float(length) / self.current_position)
734
735
736
737
        # Now let's make it human-readable (instead of octets).
        # If it's in the giga range (10⁹) instead of mega (10⁶), use 2 decimals
        if estimated_size > 10e8:
            gigabytes = estimated_size / (10 ** 9)
738
            return _("%.2f GB") % gigabytes
739
740
        else:
            megabytes = int(estimated_size / (10 ** 6))
741
742
            if megabytes > 30:
                megabytes = int(round(megabytes, -1))  # -1 means round to 10
743
            return _("%d MB") % megabytes
744

745
    def _update_filename(self, basename):
746
        """Updates the filename UI element to show the specified file name."""
747
        extension = extension_for_muxer(self.project.muxer)
748
749
750
751
752
753
        if extension:
            name = "%s%s%s" % (basename, os.path.extsep, extension)
        else:
            name = basename
        self.fileentry.set_text(name)

754
    def _update_valid_restriction_values(self, caps, combo, caps_template,
Alexandru Băluț's avatar
Alexandru Băluț committed
755
756
                                         model, combo_value,
                                         caps_template_expander=None):
757
758
759
760
761
762
763
764
        def caps_template_expander_func(caps_template, value):
            return caps_template % value

        if not caps_template_expander:
            caps_template_expander = caps_template_expander_func

        model_headers = [model.get_column_type(i) for i in range(model.get_n_columns())]
        reduced_model = Gtk.ListStore(*model_headers)
765
        reduced = []
766
767
768
        for name, value in dict(model).items():
            ecaps = Gst.Caps(caps_template_expander(caps_template, value))
            if not caps.intersect(ecaps).is_empty():
769
                reduced.append((name, value))
770

771
772
        for value in sorted(reduced, key=lambda v: float(v[1])):
            reduced_model.append(value)
773
774
        combo.set_model(reduced_model)

775
        if not set_combo_value(combo, combo_value):
776
777
            combo.set_active(len(reduced_model) - 1)
            self.warning("%s in %s not supported, setting: %s",
778
                         combo_value, caps_template, get_combo_value(combo))
779
780
781
782
783
784
785
786

    def _update_valid_audio_restrictions(self, factory):
        template = [t for t in factory.get_static_pad_templates()
                    if t.direction == Gst.PadDirection.SINK][0]

        caps = template.static_caps.get()
        self._update_valid_restriction_values(caps, self.sample_rate_combo,
                                              "audio/x-raw,rate=(int)%d",
787
                                              AUDIO_RATES,
788
789
790
791
                                              self.project.audiorate)

        self._update_valid_restriction_values(caps, self.channels_combo,
                                              "audio/x-raw,channels=(int)%d",
792
                                              AUDIO_CHANNELS,
793
794
795
796
797
798
799
800
801
802
803
804
                                              self.project.audiochannels)

    def _update_valid_video_restrictions(self, factory):
        def fraction_expander_func(caps_template, value):
            return caps_template % (value.num, value.denom)

        template = [t for t in factory.get_static_pad_templates()
                    if t.direction == Gst.PadDirection.SINK][0]

        caps = template.static_caps.get()
        self._update_valid_restriction_values(
            caps, self.frame_rate_combo,
805
            "video/x-raw,framerate=(GstFraction)%d/%d", FRAME_RATES,
806
            self.project.videorate,
807
808
            caps_template_expander=fraction_expander_func)

809
    def update_available_encoders(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
810
        """Updates the encoder comboboxes to show the available encoders."""
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
        self.muxer_combo_changing = True
        try:
            model = self.create_combobox_model(
                Encoders().compatible_video_encoders[self.project.muxer])
            self.video_encoder_combo.set_model(model)
            self._update_encoder_combo(self.video_encoder_combo,
                                       self.preferred_vencoder)

            model = self.create_combobox_model(
                Encoders().compatible_audio_encoders[self.project.muxer])
            self.audio_encoder_combo.set_model(model)
            self._update_encoder_combo(self.audio_encoder_combo,
                                       self.preferred_aencoder)
        finally:
            self.muxer_combo_changing = False
826

827
    def _update_encoder_combo(self, encoder_combo, preferred_encoder):
Alexandru Băluț's avatar
Alexandru Băluț committed
828
        """Selects the specified encoder for the specified encoder combo."""
829
        if preferred_encoder:
830
            encoder = Encoders().factories_by_name.get(preferred_encoder)
831
832
833
            if set_combo_value(encoder_combo, encoder):
                # The preference was found in the combo's model
                # and has been activated.
834
                return
835
836
837
838
839
840
841
842
843
844
845
846
847
848

        # Pick the first encoder from the combobox's model.
        first = encoder_combo.props.model.get_iter_first()
        if not first:
            # Model is empty. Should not happen.
            self.warning("Model is empty")
            return

        if encoder_combo.props.model.iter_has_child(first):
            # There are no supported encoders and the first element is
            # the Unsupported group. Activate its first child.
            first = encoder_combo.props.model.iter_nth_child(first, 0)

        encoder_combo.set_active_iter(first)
849

850
    def _element_settings_dialog(self, factory, media_type):
Alexandru Băluț's avatar
Alexandru Băluț committed
851
        """Opens a dialog to edit the properties for the specified factory.
852

Alexandru Băluț's avatar
Alexandru Băluț committed
853
854
        Args:
            factory (Gst.ElementFactory): The factory for editing.
855
            media_type (str): String describing the media type ('audio' or 'video')
856
        """
857
        # Reconstitute the property name from the media type (vcodecsettings or acodecsettings)
858
859
        properties = getattr(self.project, media_type[0] + 'codecsettings')

860
        self.dialog = GstElementSettingsDialog(factory, properties=properties,
861
                                               caps=getattr(self.project, media_type + '_profile').get_format(),
862
                                               parent_window=self.window)
863
        self.dialog.ok_btn.connect(
864
            "clicked", self._ok_button_clicked_cb, media_type)
865

Alexandru Băluț's avatar
Alexandru Băluț committed
866
    def __additional_debug_info(self):
867
868
869
        if self.project.vencoder == 'x264enc':
            if self.project.videowidth % 2 or self.project.videoheight % 2:
                return "\n\n%s\n\n" % _("<b>Make sure your rendering size is even, "
Alexandru Băluț's avatar
Alexandru Băluț committed
870
                                        "x264enc might not be able to render otherwise.</b>\n\n")
871
872
873

        return ""

874
    def _show_render_error_dialog(self, error, unused_details):
875
        primary_message = _("Sorry, something didn’t work right.")
876
877
        secondary_message = "".join([
            _("An error occurred while trying to render your project."),
Alexandru Băluț's avatar
Alexandru Băluț committed
878
            self.__additional_debug_info(),
879
880
881
            _("You might want to check our troubleshooting guide or file a bug report. "
              "The GStreamer error was:"),
            "\n\n<i>" + str(error) + "</i>"])
882

883
        dialog = Gtk.MessageDialog(transient_for=self.window, modal=True,
884
885
                                   message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK,
                                   text=primary_message)
886
        dialog.set_property("secondary-text", secondary_message)
887
888
        dialog.set_property("secondary-use-markup", True)
        dialog.show_all()
889
890
891
        dialog.run()
        dialog.destroy()

892
    def start_action(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
893
        """Starts the render process."""
894
        self._pipeline.set_state(Gst.State.NULL)
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
895
        self._pipeline.set_mode(GES.PipelineFlags.RENDER)
896
        encodebin = self._pipeline.get_by_name("internal-encodebin")
897
        self._gst_signal_handlers_ids[encodebin] = encodebin.connect(
898
            "element-added", self.__element_added_cb)
899
        for element in encodebin.iterate_recurse():
900
            self.__set_properties(element)
901
        self._pipeline.set_state(Gst.State.PLAYING)
902
903
        self._is_rendering = True
        self._time_started = time.time()
904

905
    def _cancel_render(self, *unused_args):
906
        self.debug("Aborting render")
907
908
        self._shut_down()
        self._destroy_progress_window()
909

910
    def _shut_down(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
911
        """Shuts down the pipeline and disconnects from its signals."""
912
913
914
        self._is_rendering = False
        self._rendering_is_paused = False
        self._time_spent_paused = 0
915
        self._pipeline.set_state(Gst.State.NULL)
916
        self.project.set_rendering(False)
yatinmaan's avatar
yatinmaan committed
917
        self._use_proxy_assets()
918
        self._disconnect_from_gst()
Jeff Fortin Tam's avatar
Jeff Fortin Tam committed
919
        self._pipeline.set_mode(GES.PipelineFlags.FULL_PREVIEW)
920
        self._pipeline.set_state(Gst.State.PAUSED)
921

922
    def _pause_render(self, unused_progress):
923
        self._rendering_is_paused = self.progress.play_pause_button.get_active()
924
925
926
        if self._rendering_is_paused:
            self._last_timestamp_when_pausing = time.time()
        else:
927
            self._time_spent_paused += time.time() - self._last_timestamp_when_pausing
928
929
            self.debug(
                "Resuming render after %d seconds in pause", self._time_spent_paused)
930
        self.project.pipeline.toggle_playback()
931

932
    def _destroy_progress_window(self):
Alexandru Băluț's avatar
Alexandru Băluț committed
933
        """Handles the completion or the cancellation of the render process."""
934
935
        self.progress.window.destroy()
        self.progress = None
936
        self.window.show()  # Show the rendering dialog again
937

938
    def _disconnect_from_gst(self):
939
        for obj, handler_id in self._gst_signal_handlers_ids.items():
940
            obj.disconnect(handler_id)
941
        self._gst_signal_handlers_ids = {}
942
        try:
943
            self.project.pipeline.disconnect_by_func(self._update_position_cb)
944
945
946
        except TypeError:
            # The render was successful, so this was already disconnected
            pass
947
948
949
950

    def destroy(self):
        self.window.destroy()

951
952
    def _maybe_play_finished_sound(self):
        """Plays a sound to signal the render operation is done."""
953
        if "GSound" in MISSING_SOFT_DEPS: