project.py 62.3 KB
Newer Older
Jeff Fortin Tam's avatar
Jeff Fortin Tam committed
1
# Pitivi video editor
Edward Hervey's avatar
Edward Hervey committed
2
#
3
#       pitivi/project.py
Edward Hervey's avatar
Edward Hervey committed
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
#
# Copyright (c) 2005, 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
# License along with this program; if not, write to the
Hicham HAOUARI's avatar
Hicham HAOUARI committed
19 20
# Free Software Foundation, Inc., 51 Franklin St, Fifth Floor,
# Boston, MA 02110-1301, USA.
Edward Hervey's avatar
Edward Hervey committed
21

Edward Hervey's avatar
Edward Hervey committed
22
"""
23
Project related classes
Edward Hervey's avatar
Edward Hervey committed
24 25
"""

26
import os
27
from gi.repository import GstPbutils
28 29 30
from gi.repository import GES
from gi.repository import Gst
from gi.repository import Gtk
31
from gi.repository import GLib
32
from gi.repository import GObject
Paul Lange's avatar
Paul Lange committed
33
import tarfile
34

35
from time import time
36
from datetime import datetime
37 38 39 40
from gettext import gettext as _
from pwd import getpwuid

from pitivi.undo.undo import UndoableAction
41
from pitivi.configure import get_ui_dir
42

43
from pitivi.utils.misc import quote_uri, path_from_uri, isWritable
44
from pitivi.utils.pipeline import PipelineError, Seeker
45 46
from pitivi.utils.loggable import Loggable
from pitivi.utils.signal import Signallable
47
from pitivi.utils.pipeline import Pipeline
48 49
from pitivi.utils.widgets import FractionWidget
from pitivi.utils.ripple_update_group import RippleUpdateGroup
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
50
from pitivi.utils.ui import frame_rates, audio_rates,\
51
    audio_channels, beautify_time_delta, get_combo_value, set_combo_value,\
52
    pixel_aspect_ratios, display_aspect_ratios, SPACING
Thibault Saunier's avatar
Thibault Saunier committed
53
from pitivi.preset import AudioPresetManager, DuplicatePresetNameException,\
54
    VideoPresetManager
55
from pitivi.render import CachedEncoderList
56 57


58 59 60 61
DEFAULT_MUXER = "oggmux"
DEFAULT_VIDEO_ENCODER = "theoraenc"
DEFAULT_AUDIO_ENCODER = "vorbisenc"

62
#------------------ Backend classes ------------------------------------------#
63 64


65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
class ProjectSettingsChanged(UndoableAction):

    def __init__(self, project, old, new):
        self.project = project
        self.oldsettings = old
        self.newsettings = new

    def do(self):
        self.project.setSettings(self.newsettings)
        self._done()

    def undo(self):
        self.project.setSettings(self.oldsettings)
        self._undone()


class ProjectLogObserver(UndoableAction):

    def __init__(self, log):
        self.log = log

    def startObserving(self, project):
87
        project.connect("notify-meta", self._settingsChangedCb)
88 89

    def stopObserving(self, project):
90
        try:
91
            project.disconnect_by_func(self._settingsChangedCb)
92 93 94 95
        except Exception:
            # This can happen when we interrupt the loading of a project,
            # such as in mainwindow's _projectManagerMissingUriCb
            pass
96

97 98 99
    def _settingsChangedCb(self, project, item, value):
        """
        FIXME Renable undo/redo
100 101 102 103
        action = ProjectSettingsChanged(project, old, new)
        self.log.begin("change project settings")
        self.log.push(action)
        self.log.commit()
104 105
        """
        pass
106 107 108 109 110 111 112


class ProjectManager(Signallable, Loggable):
    __signals__ = {
        "new-project-loading": ["uri"],
        "new-project-created": ["project"],
        "new-project-failed": ["uri", "exception"],
113
        "new-project-loaded": ["project", "fully_ready"],
114
        "save-project-failed": ["uri", "exception"],
115 116 117 118 119 120 121
        "project-saved": ["project", "uri"],
        "closing-project": ["project"],
        "project-closed": ["project"],
        "missing-uri": ["formatter", "uri", "factory"],
        "reverting-to-saved": ["project"],
    }

122
    def __init__(self, app_instance):
123 124
        Signallable.__init__(self)
        Loggable.__init__(self)
125
        self.app = app_instance
126
        self.current_project = None
127
        self.disable_save = False  # Enforce "Save as" for backup and xptv files
128
        self._backup_lock = 0
129 130

    def loadProject(self, uri):
131 132 133 134 135
        """
        Load the given URI as a project. If a backup file exists, ask if it
        should be loaded instead, and if so, force the user to use "Save as"
        afterwards.
        """
136
        if self.current_project is not None and not self.closeRunningProject():
137 138
            return False

139 140
        self.emit("new-project-loading", uri)

141 142
        # We really want a path for os.path to work
        path = path_from_uri(uri)
143
        backup_path = self._makeBackupURI(path_from_uri(uri))
144 145
        use_backup = False
        try:
146
            time_diff = os.path.getmtime(backup_path) - os.path.getmtime(path)
147
            self.debug('Backup file is %d secs newer: %s', time_diff, backup_path)
148
        except OSError:
149
            self.debug('Backup file does not exist: %s', backup_path)
150 151 152
        else:
            if time_diff > 0:
                use_backup = self._restoreFromBackupDialog(time_diff)
153

154
        if use_backup:
155
            uri = self._makeBackupURI(uri)
156
            self.debug('Loading project from backup: %s', uri)
157 158

        # Load the project:
159
        self.current_project = Project(uri=uri)
160 161 162 163
        # For backup files and legacy formats, force the user to use "Save as"
        if use_backup or path.endswith(".xptv"):
            self.debug("Enforcing read-only mode")
            self.disable_save = True
164
        else:
165
            self.disable_save = False
166

167 168
        self.current_project.connect("missing-uri", self._missingURICb)
        self.current_project.connect("loaded", self._projectLoadedCb)
169

170 171 172
        if self.current_project.createTimeline():
            self.emit("new-project-created", self.current_project)
            self.current_project.connect("project-changed", self._projectChangedCb)
173
            return True
174 175 176 177 178
        else:
            self.emit("new-project-failed", uri,
                      _('This might be due to a bug or an unsupported project file format. '
                      'If you were trying to add a media file to your project, '
                      'use the "Import" button instead.'))
179
            # Reset projectManager and disconnect all the signals:
180
            self.newBlankProject(ignore_unsaved_changes=True)
181
            return False
182

183 184 185 186 187 188
    def _restoreFromBackupDialog(self, time_diff):
        """
        Ask if we need to load the autosaved project backup or not.

        @param time_diff: the difference, in seconds, between file mtimes
        """
189 190 191
        dialog = Gtk.Dialog(title="", transient_for=None)
        dialog.add_buttons(_("Ignore backup"), Gtk.ResponseType.REJECT,
                           _("Restore from backup"), Gtk.ResponseType.YES)
192 193 194
        # Even though we set the title to an empty string when creating dialog,
        # seems we really have to do it once more so it doesn't show "pitivi"...
        dialog.set_title("")
195
        dialog.set_icon_name("pitivi")
196 197
        dialog.set_transient_for(self.app.gui)
        dialog.set_modal(True)
198
        dialog.set_default_response(Gtk.ResponseType.YES)
199
        dialog.get_accessible().set_name("restore from backup dialog")
200

201
        primary = Gtk.Label()
202 203 204 205 206 207 208 209 210 211 212
        primary.set_line_wrap(True)
        primary.set_use_markup(True)
        primary.set_alignment(0, 0.5)

        message = _("An autosaved version of your project file was found. "
                    "It is %s newer than the saved project.\n\n"
                    "Would you like to load it instead?"
                    % beautify_time_delta(time_diff))
        primary.props.label = message

        # put the text in a vbox
213
        vbox = Gtk.VBox(homogeneous=False, spacing=SPACING * 2)
214
        vbox.pack_start(primary, True, True, 0)
215 216

        # make the [[image] text] hbox
217
        image = Gtk.Image.new_from_icon_name("dialog-question", Gtk.IconSize.DIALOG)
218
        hbox = Gtk.HBox(homogeneous=False, spacing=SPACING * 2)
219 220
        hbox.pack_start(image, False, True, 0)
        hbox.pack_start(vbox, True, True, 0)
221 222 223 224
        hbox.set_border_width(SPACING)

        # stuff the hbox in the dialog
        content_area = dialog.get_content_area()
225
        content_area.pack_start(hbox, True, True, 0)
226 227 228 229 230
        content_area.set_spacing(SPACING * 2)
        hbox.show_all()

        response = dialog.run()
        dialog.destroy()
231
        if response == Gtk.ResponseType.YES:
232 233 234 235
            return True
        else:
            return False

236
    def saveProject(self, uri=None, formatter_type=None, backup=False):
237
        """
238 239 240 241 242 243 244 245 246 247 248 249
        Save the current project. All arguments are optional, but the behavior
        will differ depending on the combination of which ones are set.

        If a URI is specified, this means we want to save to a new (different)
        location, so it will be used instead of the current project instance's
        existing URI.

        "backup=True" is for automatic backups: it ignores any "uri" arg, uses
        the current project instance to save to a special URI behind the scenes.

        "formatter_type" allows specifying a GES formatter type to use; if None,
        GES will default to GES.XmlFormatter.
250
        """
251 252
        if self.disable_save is True and (backup is True or uri is None):
            self.log("Read-only mode is enforced and no new URI was specified, ignoring save request")
253
            return False
254

255
        if backup:
256
            if self.current_project is not None and self.current_project.uri is not None:
257
                # Ignore whatever URI that is passed on to us. It's a trap.
258
                uri = self._makeBackupURI(self.current_project.uri)
259 260
            else:
                # Do not try to save backup files for blank projects.
261
                # It is possible that self.current_project.uri == None when the backup
262
                # timer sent us an old instance of the (now closed) project.
263
                return False
264
        elif uri is None:
265 266
            # "Normal save" scenario. The filechoosers in mainwindow ask users
            # for permission to overwrite the file (if needed), so we're safe.
267
            uri = self.current_project.uri
268
        else:
269 270
            # "Save As" (or "normal-save a blank project") scenario. We use the
            # provided URI, so ensure it's properly encoded, or GIO will fail:
271
            uri = quote_uri(uri)
272 273 274 275

            if not isWritable(path_from_uri(uri)):
                # TODO: this will not be needed when GTK+ bug #601451 is fixed
                self.emit("save-project-failed", uri,
276
                          _("You do not have permissions to write to this folder."))
277
                return False
278

279
        try:
280 281 282
            # "overwrite" is always True: our GTK filechooser save dialogs are
            # set to always ask the user on our behalf about overwriting, so
            # if saveProject is actually called, that means overwriting is OK.
283
            saved = self.current_project.save(self.current_project.timeline, uri, formatter_type, overwrite=True)
284
        except Exception, e:
285
            saved = False
286
            self.emit("save-project-failed", uri, e)
287 288 289 290

        if saved:
            if not backup:
                # Do not emit the signal when autosaving a backup file
291 292
                self.current_project.setModificationState(False)
                self.emit("project-saved", self.current_project, uri)
293
                self.debug('Saved project: %s', uri)
294 295
                # Update the project instance's uri,
                # otherwise, subsequent saves will be to the old uri.
296
                self.info("Setting the project instance's URI to: %s", uri)
297
                self.current_project.uri = uri
298
                self.disable_save = False
299
            else:
300
                self.debug('Saved backup: %s', uri)
301

302
        return saved
303

Paul Lange's avatar
Paul Lange committed
304 305 306 307 308 309
    def exportProject(self, project, uri):
        """
        Export a project to a *.tar archive which includes the project file
        and all sources
        """
        # write project file to temporary file
310 311
        project_name = project.name if project.name else _("project")
        asset = GES.Formatter.get_default()
312 313
        project_extension = asset.get_meta(GES.META_FORMATTER_EXTENSION)
        tmp_name = "%s.%s" % (project_name, project_extension)
Paul Lange's avatar
Paul Lange committed
314

315 316
        directory = os.path.dirname(uri)
        tmp_uri = os.path.join(directory, tmp_name)
Paul Lange's avatar
Paul Lange committed
317
        try:
318
            # saveProject updates the project URI... so we better back it up:
319
            _old_uri = self.current_project.uri
320
            self.saveProject(tmp_uri)
321
            self.current_project.uri = _old_uri
Paul Lange's avatar
Paul Lange committed
322 323 324 325 326 327 328 329 330

            # create tar file
            with tarfile.open(path_from_uri(uri), mode="w") as tar:
                # top directory in tar-file
                top = "%s-export" % project_name
                # add temporary project file
                tar.add(path_from_uri(tmp_uri), os.path.join(top, tmp_name))

                # get common path
331
                sources = project.listSources()
Paul Lange's avatar
Paul Lange committed
332 333 334 335 336 337 338
                if self._allSourcesInHomedir(sources):
                    common = os.path.expanduser("~")
                else:
                    common = "/"

                # add all sources
                for source in sources:
339
                    path = path_from_uri(source.get_id())
Paul Lange's avatar
Paul Lange committed
340 341
                    tar.add(path, os.path.join(top, os.path.relpath(path, common)))
                tar.close()
342 343 344 345 346 347 348 349
        # This catches errors with tarring; the GUI already shows errors while
        # saving projects (ex: permissions), so probably no GUI needed here.
        # Keep the exception generic enough to catch programming errors:
        except Exception, e:
            everything_ok = False
            self.error(e)
            tar_file = path_from_uri(uri)
            if os.path.isfile(tar_file):
350
                renamed = os.path.splitext(tar_file)[0] + " (CORRUPT)" + "." + project_extension + "_tar"
351 352 353 354
                self.warning('An error occurred, will save the tarball as "%s"' % renamed)
                os.rename(tar_file, renamed)
        else:
            everything_ok = True
Paul Lange's avatar
Paul Lange committed
355

356 357
        # Ensure we remove the temporary project file no matter what:
        try:
Paul Lange's avatar
Paul Lange committed
358
            os.remove(path_from_uri(tmp_uri))
359 360
        except OSError:
            pass
Paul Lange's avatar
Paul Lange committed
361

362
        return everything_ok
Paul Lange's avatar
Paul Lange committed
363 364 365 366 367 368 369 370

    def _allSourcesInHomedir(self, sources):
        """
        Checks if all sources are located in the users home directory
        """
        homedir = os.path.expanduser("~")

        for source in sources:
371
            if not path_from_uri(source.get_id()).startswith(homedir):
Paul Lange's avatar
Paul Lange committed
372 373 374 375
                return False

        return True

376 377 378
    def closeRunningProject(self):
        """ close the current project """

379
        if self.current_project is None:
380
            self.warning("Trying to close a project that was already closed/didn't exist")
381 382
            return True

383 384
        self.info("closing running project %s", self.current_project.uri)
        if not self.emit("closing-project", self.current_project):
385
            self.warning("Could not close project - this could be because there were unsaved changes and the user cancelled when prompted about them")
386 387
            return False

388
        self.emit("project-closed", self.current_project)
389 390 391 392
        # We should never choke on silly stuff like disconnecting signals
        # that were already disconnected. It blocks the UI for nothing.
        # This can easily happen when a project load/creation failed.
        try:
393
            self.current_project.disconnect_by_function(self._projectChangedCb)
394 395
        except Exception:
            self.debug("Tried disconnecting signals, but they were not connected")
396 397 398
        self._cleanBackup(self.current_project.uri)
        self.current_project.release()
        self.current_project = None
399 400 401

        return True

402 403 404 405 406 407 408 409 410 411 412 413 414
    def newBlankProject(self, emission=True, ignore_unsaved_changes=False):
        """
        Start up a new blank project.

        The ignore_unsaved_changes parameter is used in special cases to force
        the creation of a new project without prompting the user about unsaved
        changes. This is an "extreme" way to reset Pitivi's state.
        """
        if self.current_project is not None:
            # This will prompt users about unsaved changes (if any):
            if not ignore_unsaved_changes and not self.closeRunningProject():
                # The user has not made a decision, don't do anything
                return False
415 416 417

        if emission:
            self.emit("new-project-loading", None)
418
        # We don't have a URI here, None means we're loading a new project
419 420 421 422 423
        project = Project(_("New Project"))

        # setting default values for project metadata
        project.author = getpwuid(os.getuid()).pw_gecos.split(",")[0]

424
        project.createTimeline()
425
        self.current_project = project
426
        self.emit("new-project-created", project)
427 428

        project.connect("project-changed", self._projectChangedCb)
429
        self.emit("new-project-loaded", self.current_project, emission)
430
        self.time_loaded = time()
431 432 433 434

        return True

    def revertToSavedProject(self):
435 436 437
        """
        Discard all unsaved changes and reload current open project
        """
438
        if self.current_project.uri is None or not self.current_project.hasUnsavedModifications():
439
            return True
440
        if not self.emit("reverting-to-saved", self.current_project):
441
            return False
442

443 444
        uri = self.current_project.uri
        self.current_project.setModificationState(False)
445 446 447 448
        self.closeRunningProject()
        self.loadProject(uri)

    def _projectChangedCb(self, project):
449 450 451
        # _backup_lock is a timer, when a change in the project is done it is
        # set to 10 seconds. If before those 10 secs pass another change occurs,
        # 5 secs are added to the timeout callback instead of saving the backup
452 453 454 455 456
        # file. The limit is 60 seconds.
        uri = project.uri
        if uri is None:
            return

457 458 459
        if self._backup_lock == 0:
            self._backup_lock = 10
            GLib.timeout_add_seconds(self._backup_lock, self._saveBackupCb, project, uri)
460
        else:
461 462
            if self._backup_lock < 60:
                self._backup_lock += 5
463

464
    def _saveBackupCb(self, unused_project, unused_uri):
465 466
        if self._backup_lock > 10:
            self._backup_lock -= 5
467 468
            return True
        else:
469
            self.saveProject(backup=True)
470
            self._backup_lock = 0
471 472 473 474 475
        return False

    def _cleanBackup(self, uri):
        if uri is None:
            return
476
        path = path_from_uri(self._makeBackupURI(uri))
477 478
        if os.path.exists(path):
            os.remove(path)
479
            self.debug('Removed backup file: %s', path)
480 481

    def _makeBackupURI(self, uri):
482 483
        """
        Returns a backup file URI (or path if the given arg is not a URI).
484 485
        This does not guarantee that the backup file actually exists or that
        the file extension is actually a project file.
486 487 488

        @Param the project file path or URI
        """
489
        name, ext = os.path.splitext(uri)
490
        return name + ext + "~"
491

492
    def _missingURICb(self, project, error, asset, unused_what=None):
493
        return self.emit("missing-uri", project, error, asset)
494

495
    def _projectLoadedCb(self, unused_project, unused_timeline):
496 497
        self.debug("Project loaded %s", self.current_project.props.uri)
        self.emit("new-project-loaded", self.current_project, True)
498
        self.time_loaded = time()
499

Thibault Saunier's avatar
Thibault Saunier committed
500

501
class Project(Loggable, GES.Project):
Jeff Fortin Tam's avatar
Jeff Fortin Tam committed
502
    """The base class for Pitivi projects
503 504 505 506 507 508

    @ivar name: The name of the project
    @type name: C{str}
    @ivar description: A description of the project
    @type description: C{str}
    @ivar timeline: The timeline
509
    @type timeline: L{GES.Timeline}
510
    @ivar pipeline: The timeline's pipeline
511
    @type pipeline: L{Pipeline}
512 513 514 515
    @ivar loaded: Whether the project is fully loaded or not.
    @type loaded: C{bool}

    Signals:
516
     - C{project-changed}: Modifications were made to the project
517 518
     - C{start-importing}: Started to import files in bash
     - C{done-importing}: Done importing files in bash
519
    """
Edward Hervey's avatar
Edward Hervey committed
520

521 522 523 524 525 526 527
    __gsignals__ = {
        "start-importing": (GObject.SignalFlags.RUN_LAST, None, ()),
        "done-importing": (GObject.SignalFlags.RUN_LAST, None, ()),
        "project-changed": (GObject.SignalFlags.RUN_LAST, None, ()),
        "rendering-settings-changed": (GObject.SignalFlags.RUN_LAST, None,
                                       (GObject.TYPE_PYOBJECT,
                                        GObject.TYPE_PYOBJECT,))
528
    }
Edward Hervey's avatar
Edward Hervey committed
529

530
    def __init__(self, name="", uri=None, **unused_kwargs):
Edward Hervey's avatar
Edward Hervey committed
531
        """
532 533
        @param name: the name of the project
        @param uri: the uri of the project
Edward Hervey's avatar
Edward Hervey committed
534
        """
535
        Loggable.__init__(self)
536
        GES.Project.__init__(self, uri=uri, extractable_type=GES.Timeline)
537
        self.log("name:%s, uri:%s", name, uri)
538 539 540
        self.pipeline = None
        self.timeline = None
        self.seeker = Seeker()
Edward Hervey's avatar
Edward Hervey committed
541
        self.uri = uri
542

543
        # Follow imports
544
        self._dirty = False
545
        self.nb_remaining_file_to_import = 0
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602
        self.nb_imported_files = 0

        # Project property default values
        self.register_meta(GES.MetaFlag.READWRITE, "name", name)
        self.register_meta(GES.MetaFlag.READWRITE, "author",
                           getpwuid(os.getuid()).pw_gecos.split(",")[0])

        # Handle rendering setting
        self.set_meta("render-scale", 100.0)

        container_profile = \
            GstPbutils.EncodingContainerProfile.new("pitivi-profile",
                                                    _("Pitivi encoding profile"),
                                                    Gst.Caps("application/ogg"),
                                                    None)

        # Create video profile (We use the same default seetings as the project settings)
        video_profile = GstPbutils.EncodingVideoProfile.new(Gst.Caps("video/x-theora"),
                                                            None,
                                                            Gst.Caps("video/x-raw"),
                                                            0)

        # Create audio profile (We use the same default seetings as the project settings)
        audio_profile = GstPbutils.EncodingAudioProfile.new(Gst.Caps("audio/x-vorbis"),
                                                            None,
                                                            Gst.Caps("audio/x-raw"),
                                                            0)
        container_profile.add_profile(video_profile)
        container_profile.add_profile(audio_profile)
        # Keep a reference to those profiles
        # FIXME We should handle the case we have more than 1 audio and 1 video profiles
        self.container_profile = container_profile
        self.audio_profile = audio_profile
        self.video_profile = video_profile

        # Add the profile to ourself
        self.add_encoding_profile(container_profile)

        # Now set the presets/ GstElement that will be used
        # FIXME We might want to add the default Container/video decoder/audio encoder
        # into the application settings, for now we just make sure to pick one with
        # eighest probably the user has installed ie ogg+vorbis+theora

        self.muxer = DEFAULT_MUXER
        self.vencoder = DEFAULT_VIDEO_ENCODER
        self.aencoder = DEFAULT_AUDIO_ENCODER
        self._ensureAudioRestrictions()
        self._ensureVideoRestrictions()

        # FIXME That does not really belong to here and should be savable into
        # The serilized file. For now, just let it be here.
        # A (muxer -> containersettings) map.
        self._containersettings_cache = {}
        # A (vencoder -> vcodecsettings) map.
        self._vcodecsettings_cache = {}
        # A (aencoder -> acodecsettings) map.
        self._acodecsettings_cache = {}
603
        self._has_rendering_values = False
604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646

    #-----------------#
    # Our properties  #
    #-----------------#

    # Project specific properties
    @property
    def name(self):
        return self.get_meta("name")

    @name.setter
    def name(self, name):
        self.set_meta("name", name)
        self.setModificationState(True)

    @property
    def year(self):
        return self.get_meta("year")

    @year.setter
    def year(self, year):
        self.set_meta("year", year)
        self.setModificationState(True)

    @property
    def description(self):
        return self.get_meta("description")

    @description.setter
    def description(self, description):
        self.set_meta("description", description)
        self.setModificationState(True)

    @property
    def author(self):
        return self.get_meta("author")

    @author.setter
    def author(self, author):
        self.set_meta("author", author)
        self.setModificationState(True)

    # Encoding related properties
647 648 649 650 651 652 653
    def set_rendering(self, rendering):
        if rendering and self._has_rendering_values != rendering:
            self.videowidth = self.videowidth * self.render_scale / 100
            self.videoheight = self.videoheight * self.render_scale / 100
        elif self._has_rendering_values != rendering:
            self.videowidth = self.videowidth / self.render_scale * 100
            self.videoheight = self.videoheight / self.render_scale * 100
654 655 656 657 658 659
        else:
            restriction = self.video_profile.get_restriction().copy_nth(0)
            self.video_profile.set_restriction(restriction)

            restriction = self.audio_profile.get_restriction().copy_nth(0)
            self.audio_profile.set_restriction(restriction)
660 661
        self._has_rendering_values = rendering

662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677
    def set_video_restriction_value(self, name, value):
        if self.video_profile.get_restriction()[0][name] != value and value:
            restriction = self.video_profile.get_restriction().copy_nth(0)
            restriction[0][name] = value
            self.video_profile.set_restriction(restriction)
            return True
        return False

    def set_audio_restriction_value(self, name, value):
        if self.audio_profile.get_restriction()[0][name] != value and value:
            restriction = self.audio_profile.get_restriction().copy_nth(0)
            restriction[0][name] = value
            self.audio_profile.set_restriction(restriction)
            return True
        return False

678 679
    @property
    def videowidth(self):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
680
        return self.video_profile.get_restriction()[0]["width"]
681 682 683

    @videowidth.setter
    def videowidth(self, value):
684
        if value and self.set_video_restriction_value("width", int(value)):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
685
            self._emitChange("rendering-settings-changed", "width", value)
686 687 688

    @property
    def videoheight(self):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
689
        return self.video_profile.get_restriction()[0]["height"]
690 691 692

    @videoheight.setter
    def videoheight(self, value):
693
        if value and self.set_video_restriction_value("height", int(value)):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
694
            self._emitChange("rendering-settings-changed", "height", value)
695 696 697

    @property
    def videorate(self):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
698
        return self.video_profile.get_restriction()[0]["framerate"]
699 700 701

    @videorate.setter
    def videorate(self, value):
702
        if self.set_video_restriction_value("framerate", value):
703
            self._emitChange("rendering-settings-changed", "videorate", value)
704 705 706

    @property
    def videopar(self):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
707
        return self.video_profile.get_restriction()[0]["pixel-aspect-ratio"]
708 709 710

    @videopar.setter
    def videopar(self, value):
711 712
        if self.set_video_restriction_value("pixel-aspect-ratio", value):
            self._emitChange("rendering-settings-changed", "pixel-aspect-ratio", value)
713 714 715

    @property
    def audiochannels(self):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
716
        return self.audio_profile.get_restriction()[0]["channels"]
717 718 719

    @audiochannels.setter
    def audiochannels(self, value):
720
        if value and self.set_audio_restriction_value("channels", int(value)):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
721
            self._emitChange("rendering-settings-changed", "channels", value)
722 723 724

    @property
    def audiorate(self):
725 726 727 728
        try:
            return int(self.audio_profile.get_restriction()[0]["rate"])
        except TypeError:
            return None
729 730 731

    @audiorate.setter
    def audiorate(self, value):
732
        if value and self.set_audio_restriction_value("rate", int(value)):
Mathieu Duponchelle's avatar
Mathieu Duponchelle committed
733
            self._emitChange("rendering-settings-changed", "rate", value)
734 735 736 737 738 739 740 741 742 743 744 745 746 747

    @property
    def aencoder(self):
        return self.audio_profile.get_preset_name()

    @aencoder.setter
    def aencoder(self, value):
        if self.audio_profile.get_preset_name() != value and value:
            feature = Gst.Registry.get().lookup_feature(value)
            if feature is None:
                self.error("%s not in registry", value)
            else:
                for template in feature.get_static_pad_templates():
                    if template.name_template == "src":
748
                        audiotype = template.get_caps()[0].to_string()
749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767
                        break
                self.audio_profile.set_format(Gst.Caps(audiotype))
            self.audio_profile.set_preset_name(value)

            self._emitChange("rendering-settings-changed", "aencoder", value)

    @property
    def vencoder(self):
        return self.video_profile.get_preset_name()

    @vencoder.setter
    def vencoder(self, value):
        if self.video_profile.get_preset_name() != value and value:
            feature = Gst.Registry.get().lookup_feature(value)
            if feature is None:
                self.error("%s not in registry", value)
            else:
                for template in feature.get_static_pad_templates():
                    if template.name_template == "src":
768
                        videotype = template.get_caps()[0].to_string()
769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788
                        break
                self.video_profile.set_format(Gst.Caps(videotype))

            self.video_profile.set_preset_name(value)

            self._emitChange("rendering-settings-changed", "vencoder", value)

    @property
    def muxer(self):
        return self.container_profile.get_preset_name()

    @muxer.setter
    def muxer(self, value):
        if self.container_profile.get_preset_name() != value and value:
            feature = Gst.Registry.get().lookup_feature(value)
            if feature is None:
                self.error("%s not in registry", value)
            else:
                for template in feature.get_static_pad_templates():
                    if template.name_template == "src":
789
                        muxertype = template.get_caps()[0].to_string()
790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807
                        break
                self.container_profile.set_format(Gst.Caps(muxertype))
            self.container_profile.set_preset_name(value)

            self._emitChange("rendering-settings-changed", "muxer", value)

    @property
    def render_scale(self):
        return self.get_meta("render-scale")

    @render_scale.setter
    def render_scale(self, value):
        if value:
            return self.set_meta("render-scale", value)

    #--------------------------------------------#
    # GES.Project virtual methods implementation #
    #--------------------------------------------#
808
    def _handle_asset_loaded(self, unused_id):
809 810
        self.nb_imported_files += 1
        self.nb_remaining_file_to_import = len([asset for asset in self.get_loading_assets() if
Thibault Saunier's avatar
Thibault Saunier committed
811
                GObject.type_is_a(asset.get_extractable_type(), GES.UriClip)])
812 813 814
        if self.nb_remaining_file_to_import == 0:
            self.nb_imported_files = 0
            self._emitChange("done-importing")
815 816 817 818 819 820 821 822

    def do_asset_added(self, asset):
        """
        When GES.Project emit "asset-added" this vmethod
        get calls
        """
        self._handle_asset_loaded(asset.get_id())

823
    def do_loading_error(self, unused_error, id, unused_type):
824 825 826
        """ vmethod, get called on "asset-loading-error"""
        self._handle_asset_loaded(id)

827
    def do_loaded(self, unused_timeline):
828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858
        """ vmethod, get called on "loaded" """
        self._ensureTracks()
        #self._ensureLayer()

        encoders = CachedEncoderList()
        # The project just loaded, we need to check the new
        # encoding profiles and make use of it now.
        container_profile = self.list_encoding_profiles()[0]
        if container_profile is not self.container_profile:
            # The encoding profile might have been reset from the
            # Project file, we just take it as our
            self.container_profile = container_profile
            self.muxer = self._getElementFactoryName(encoders.muxers, container_profile)
            if self.muxer is None:
                self.muxer = DEFAULT_MUXER
            for profile in container_profile.get_profiles():
                if isinstance(profile, GstPbutils.EncodingVideoProfile):
                    self.video_profile = profile
                    if self.video_profile.get_restriction() is None:
                        self.video_profile.set_restriction(Gst.Caps("video/x-raw"))
                    self._ensureVideoRestrictions()

                    self.vencoder = self._getElementFactoryName(encoders.vencoders, profile)
                elif isinstance(profile, GstPbutils.EncodingAudioProfile):
                    self.audio_profile = profile
                    if self.audio_profile.get_restriction() is None:
                        self.audio_profile.set_restriction(Gst.Caps("audio/x-raw"))
                    self._ensureAudioRestrictions()
                    self.aencoder = self._getElementFactoryName(encoders.aencoders, profile)
                else:
                    self.warning("We do not handle profile: %s" % profile)
859

860 861 862
    #--------------------------------------------#
    #               Our API                      #
    #--------------------------------------------#
863

864 865
    def createTimeline(self):
        """
866
        Load the project.
867
        """
868
        # In this extract call the project is loaded from the file.
869 870 871
        self.timeline = self.extract()
        if self.timeline is None:
            return False
872 873
        if not self.timeline.get_layers():
            self.timeline.props.auto_transition = True
874
        self._calculateNbLoadingAssets()
875

876
        self.pipeline = Pipeline()
877 878 879 880 881
        try:
            self.pipeline.set_timeline(self.timeline)
        except PipelineError, e:
            self.warning("Failed to set the timeline to the pipeline: %s", e)
            return False
882

883 884
        return True

885 886
    def update_restriction_caps(self):
        caps = Gst.Caps.new_empty_simple("video/x-raw")
887

888 889
        caps.set_value("width", self.videowidth)
        caps.set_value("height", self.videoheight)
890
        caps.set_value("framerate", self.videorate)
891 892 893
        for track in self.timeline.get_tracks():
            if isinstance(track, GES.VideoTrack):
                track.set_restriction_caps(caps)
894
        self.pipeline.flushSeek()
895

896 897 898
    def addUris(self, uris):
        """
        Add c{uris} to the source list.
899

900 901 902
        The uris will be analyzed before being added.
        """
        # Do not try to reload URIS that we already have loaded
903
        for uri in uris:
Thibault Saunier's avatar
Thibault Saunier committed
904
            self.create_asset(quote_uri(uri), GES.UriClip)
905
        self._calculateNbLoadingAssets()
906 907

    def listSources(self):
Thibault Saunier's avatar
Thibault Saunier committed
908
        return self.list_assets(GES.UriClip)
909

910
    def release(self):
911 912
        if self.pipeline:
            self.pipeline.release()
913
        self.pipeline = None
914
        self.timeline = None
915

916 917