OpenShot Video Editor  2.0.0
project_data.py
Go to the documentation of this file.
1 ##
2 #
3 # @file
4 # @brief This file listens to changes, and updates the primary project data
5 # @author Noah Figg <eggmunkee@hotmail.com>
6 # @author Jonathan Thomas <jonathan@openshot.org>
7 # @author Olivier Girard <eolinwen@gmail.com>
8 #
9 # @section LICENSE
10 #
11 # Copyright (c) 2008-2018 OpenShot Studios, LLC
12 # (http://www.openshotstudios.com). This file is part of
13 # OpenShot Video Editor (http://www.openshot.org), an open-source project
14 # dedicated to delivering high quality video editing and animation solutions
15 # to the world.
16 #
17 # OpenShot Video Editor is free software: you can redistribute it and/or modify
18 # it under the terms of the GNU General Public License as published by
19 # the Free Software Foundation, either version 3 of the License, or
20 # (at your option) any later version.
21 #
22 # OpenShot Video Editor is distributed in the hope that it will be useful,
23 # but WITHOUT ANY WARRANTY; without even the implied warranty of
24 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25 # GNU General Public License for more details.
26 #
27 # You should have received a copy of the GNU General Public License
28 # along with OpenShot Library. If not, see <http://www.gnu.org/licenses/>.
29 #
30 
31 import os
32 import random
33 import copy
34 import shutil
35 import glob
36 
37 from classes.json_data import JsonDataStore
38 from classes.updates import UpdateInterface
39 from classes import info, settings
40 from classes.logger import log
41 
42 
43 ##
44 # This class allows advanced searching of data structure, implements changes interface
45 class ProjectDataStore(JsonDataStore, UpdateInterface):
46 
47  def __init__(self):
48  JsonDataStore.__init__(self)
49  self.data_type = "project data" # Used in error messages
50  self.default_project_filepath = os.path.join(info.PATH, 'settings', '_default.project')
51 
52  # Set default filepath to user's home folder
53  self.current_filepath = None
54 
55  # Track changes after save
56  self.has_unsaved_changes = False
57 
58  # Load default project data on creation
59  self.new()
60 
61  ##
62  # Returns if project data Has unsaved changes
63  def needs_save(self):
64  return self.has_unsaved_changes
65 
66  ##
67  # Get copied value of a given key in data store
68  def get(self, key):
69 
70  # Verify key is valid type
71  if not isinstance(key, list):
72  log.warning("get() key must be a list. key: {}".format(key))
73  return None
74  if not key:
75  log.warning("Cannot get empty key.")
76  return None
77 
78  # Get reference to internal data structure
79  obj = self._data
80 
81  # Iterate through key list finding sub-objects either by name or by an object match criteria such as {"id":"ADB34"}.
82  for key_index in range(len(key)):
83  key_part = key[key_index]
84 
85  # Key_part must be a string or dictionary
86  if not isinstance(key_part, dict) and not isinstance(key_part, str):
87  log.error("Unexpected key part type: {}".format(type(key_part).__name__))
88  return None
89 
90  # If key_part is a dictionary and obj is a list or dict, each key is tested as a property of the items in the current object
91  # in the project data structure, and the first match is returned.
92  if isinstance(key_part, dict) and isinstance(obj, list):
93  # Overall status of finding a matching sub-object
94  found = False
95  # Loop through each item in object to find match
96  for item_index in range(len(obj)):
97  item = obj[item_index]
98  # True until something disqualifies this as a match
99  match = True
100  # Check each key in key_part dictionary and if not found to be equal as a property in item, move on to next item in list
101  for subkey in key_part.keys():
102  # Get each key in dictionary (i.e. "id", "layer", etc...)
103  subkey = subkey.lower()
104  # If object is missing the key or the values differ, then it doesn't match.
105  if not (subkey in item and item[subkey] == key_part[subkey]):
106  match = False
107  break
108  # If matched, set key_part to index of list or dict and stop loop
109  if match:
110  found = True
111  obj = item
112  break
113  # No match found, return None
114  if not found:
115  return None
116 
117  # If key_part is a string, homogenize to lower case for comparisons
118  if isinstance(key_part, str):
119  key_part = key_part.lower()
120 
121  # Check current obj type (should be dictionary)
122  if not isinstance(obj, dict):
123  log.warn(
124  "Invalid project data structure. Trying to use a key on a non-dictionary object. Key part: {} (\"{}\").\nKey: {}".format(
125  (key_index), key_part, key))
126  return None
127 
128  # If next part of path isn't in current dictionary, return failure
129  if not key_part in obj:
130  log.warn("Key not found in project. Mismatch on key part {} (\"{}\").\nKey: {}".format((key_index),
131  key_part,
132  key))
133  return None
134 
135  # Get the matching item
136  obj = obj[key_part]
137 
138  # After processing each key, we've found object, return it
139  return obj
140 
141  ##
142  # Prevent calling JsonDataStore set() method. It is not allowed in ProjectDataStore, as changes come from UpdateManager.
143  def set(self, key, value):
144  raise Exception("ProjectDataStore.set() is not allowed. Changes must route through UpdateManager.")
145 
146  ##
147  # Store setting, but adding isn't allowed. All possible settings must be in default settings file.
148  def _set(self, key, values=None, add=False, partial_update=False, remove=False):
149 
150  log.info(
151  "_set key: {} values: {} add: {} partial: {} remove: {}".format(key, values, add, partial_update, remove))
152  parent, my_key = None, ""
153 
154  # Verify key is valid type
155  if not isinstance(key, list):
156  log.warning("_set() key must be a list. key: {}".format(key))
157  return None
158  if not key:
159  log.warning("Cannot set empty key.")
160  return None
161 
162  # Get reference to internal data structure
163  obj = self._data
164 
165  # Iterate through key list finding sub-objects either by name or by an object match criteria such as {"id":"ADB34"}.
166  for key_index in range(len(key)):
167  key_part = key[key_index]
168 
169  # Key_part must be a string or dictionary
170  if not isinstance(key_part, dict) and not isinstance(key_part, str):
171  log.error("Unexpected key part type: {}".format(type(key_part).__name__))
172  return None
173 
174  # If key_part is a dictionary and obj is a list or dict, each key is tested as a property of the items in the current object
175  # in the project data structure, and the first match is returned.
176  if isinstance(key_part, dict) and isinstance(obj, list):
177  # Overall status of finding a matching sub-object
178  found = False
179  # Loop through each item in object to find match
180  for item_index in range(len(obj)):
181  item = obj[item_index]
182  # True until something disqualifies this as a match
183  match = True
184  # Check each key in key_part dictionary and if not found to be equal as a property in item, move on to next item in list
185  for subkey in key_part.keys():
186  # Get each key in dictionary (i.e. "id", "layer", etc...)
187  subkey = subkey.lower()
188  # If object is missing the key or the values differ, then it doesn't match.
189  if not (subkey in item and item[subkey] == key_part[subkey]):
190  match = False
191  break
192  # If matched, set key_part to index of list or dict and stop loop
193  if match:
194  found = True
195  obj = item
196  my_key = item_index
197  break
198  # No match found, return None
199  if not found:
200  return None
201 
202 
203  # If key_part is a string, homogenize to lower case for comparisons
204  if isinstance(key_part, str):
205  key_part = key_part.lower()
206 
207  # Check current obj type (should be dictionary)
208  if not isinstance(obj, dict):
209  return None
210 
211  # If next part of path isn't in current dictionary, return failure
212  if not key_part in obj:
213  log.warn("Key not found in project. Mismatch on key part {} (\"{}\").\nKey: {}".format((key_index),
214  key_part,
215  key))
216  return None
217 
218  # Get sub-object based on part key as new object, continue to next part
219  obj = obj[key_part]
220  my_key = key_part
221 
222 
223  # Set parent to the last set obj (if not final iteration)
224  if key_index < (len(key) - 1) or key_index == 0:
225  parent = obj
226 
227 
228  # After processing each key, we've found object and parent, return former value/s on update
229  ret = copy.deepcopy(obj)
230 
231  # Apply the correct action to the found item
232  if remove:
233  del parent[my_key]
234 
235  else:
236 
237  # Add or Full Update
238  # For adds to list perform an insert to index or the end if not specified
239  if add and isinstance(parent, list):
240  # log.info("adding to list")
241  parent.append(values)
242 
243  # Otherwise, set the given index
244  elif isinstance(values, dict):
245  # Update existing dictionary value
246  obj.update(values)
247 
248  else:
249 
250  # Update root string
251  self._data[my_key] = values
252 
253  # Return the previous value to the matching item (used for history tracking)
254  return ret
255 
256  # Load default project data
257  ##
258  # Try to load default project settings file, will raise error on failure
259  def new(self):
260  import openshot
261  self._data = self.read_from_file(self.default_project_filepath)
262  self.current_filepath = None
263  self.has_unsaved_changes = False
264 
265  # Get default profile
267  default_profile = s.get("default-profile")
268 
269  # Loop through profiles
270  for profile_folder in [info.USER_PROFILES_PATH, info.PROFILES_PATH]:
271  for file in os.listdir(profile_folder):
272  # Load Profile and append description
273  profile_path = os.path.join(profile_folder, file)
274  profile = openshot.Profile(profile_path)
275 
276  if default_profile == profile.info.description:
277  log.info("Setting default profile to %s" % profile.info.description)
278 
279  # Update default profile
280  self._data["profile"] = profile.info.description
281  self._data["width"] = profile.info.width
282  self._data["height"] = profile.info.height
283  self._data["fps"] = {"num" : profile.info.fps.num, "den" : profile.info.fps.den}
284  break
285 
286  # Get the default audio settings for the timeline (and preview playback)
287  default_sample_rate = int(s.get("default-samplerate"))
288  default_channel_ayout = s.get("default-channellayout")
289 
290  channels = 2
291  channel_layout = openshot.LAYOUT_STEREO
292  if default_channel_ayout == "LAYOUT_MONO":
293  channels = 1
294  channel_layout = openshot.LAYOUT_MONO
295  elif default_channel_ayout == "LAYOUT_STEREO":
296  channels = 2
297  channel_layout = openshot.LAYOUT_STEREO
298  elif default_channel_ayout == "LAYOUT_SURROUND":
299  channels = 3
300  channel_layout = openshot.LAYOUT_SURROUND
301  elif default_channel_ayout == "LAYOUT_5POINT1":
302  channels = 6
303  channel_layout = openshot.LAYOUT_5POINT1
304  elif default_channel_ayout == "LAYOUT_7POINT1":
305  channels = 8
306  channel_layout = openshot.LAYOUT_7POINT1
307 
308  # Set default samplerate and channels
309  self._data["sample_rate"] = default_sample_rate
310  self._data["channels"] = channels
311  self._data["channel_layout"] = channel_layout
312 
313  ##
314  # Load project from file
315  def load(self, file_path):
316 
317  self.new()
318 
319  if file_path:
320  # Default project data
321  default_project = self._data
322 
323  try:
324  # Attempt to load v2.X project file
325  project_data = self.read_from_file(file_path)
326 
327  except Exception as ex:
328  try:
329  # Attempt to load legacy project file (v1.X version)
330  project_data = self.read_legacy_project_file(file_path)
331 
332  except Exception as ex:
333  # Project file not recognized as v1.X or v2.X, bubble up error
334  raise ex
335 
336  # Merge default and project settings, excluding settings not in default.
337  self._data = self.merge_settings(default_project, project_data)
338 
339  # On success, save current filepath
340  self.current_filepath = file_path
341 
342  # Convert all paths back to absolute
344 
345  # Check if paths are all valid
347 
348  # Copy any project thumbnails to main THUMBNAILS folder
349  loaded_project_folder = os.path.dirname(self.current_filepath)
350  project_thumbnails_folder = os.path.join(loaded_project_folder, "thumbnail")
351  if os.path.exists(project_thumbnails_folder):
352  # Remove thumbnail path
353  shutil.rmtree(info.THUMBNAIL_PATH, True)
354 
355  # Copy project thumbnails folder
356  shutil.copytree(project_thumbnails_folder, info.THUMBNAIL_PATH)
357 
358  # Add to recent files setting
359  self.add_to_recent_files(file_path)
360 
361  # Upgrade any data structures
363 
364  # Get app, and distribute all project data through update manager
365  from classes.app import get_app
366  get_app().updates.load(self._data)
367 
368  # Clear needs save flag
369  self.has_unsaved_changes = False
370 
371  ##
372  # Attempt to read a legacy version 1.x openshot project file
373  def read_legacy_project_file(self, file_path):
374  import sys, pickle
375  from classes.query import File, Track, Clip, Transition
376  from classes.app import get_app
377  import openshot
378 
379  try:
380  import json
381  except ImportError:
382  import simplejson as json
383 
384  # Get translation method
385  _ = get_app()._tr
386 
387  # Append version info
388  v = openshot.GetVersion()
389  project_data = {}
390  project_data["version"] = {"openshot-qt" : info.VERSION,
391  "libopenshot" : v.ToString()}
392 
393  # Get FPS from project
394  from classes.app import get_app
395  fps = get_app().project.get(["fps"])
396  fps_float = float(fps["num"]) / float(fps["den"])
397 
398  # Import legacy openshot classes (from version 1.X)
399  from classes.legacy.openshot import classes as legacy_classes
400  from classes.legacy.openshot.classes import project as legacy_project
401  from classes.legacy.openshot.classes import sequences as legacy_sequences
402  from classes.legacy.openshot.classes import track as legacy_track
403  from classes.legacy.openshot.classes import clip as legacy_clip
404  from classes.legacy.openshot.classes import keyframe as legacy_keyframe
405  from classes.legacy.openshot.classes import files as legacy_files
406  from classes.legacy.openshot.classes import transition as legacy_transition
407  from classes.legacy.openshot.classes import effect as legacy_effect
408  from classes.legacy.openshot.classes import marker as legacy_marker
409  sys.modules['openshot.classes'] = legacy_classes
410  sys.modules['classes.project'] = legacy_project
411  sys.modules['classes.sequences'] = legacy_sequences
412  sys.modules['classes.track'] = legacy_track
413  sys.modules['classes.clip'] = legacy_clip
414  sys.modules['classes.keyframe'] = legacy_keyframe
415  sys.modules['classes.files'] = legacy_files
416  sys.modules['classes.transition'] = legacy_transition
417  sys.modules['classes.effect'] = legacy_effect
418  sys.modules['classes.marker'] = legacy_marker
419 
420  # Keep track of files that failed to load
421  failed_files = []
422 
423  with open(file_path.encode('UTF-8'), 'rb') as f:
424  try:
425  # Unpickle legacy openshot project file
426  v1_data = pickle.load(f, fix_imports=True, encoding="UTF-8")
427  file_lookup = {}
428 
429  # Loop through files
430  for item in v1_data.project_folder.items:
431  # Is this item a File (i.e. ignore folders)
432  if isinstance(item, legacy_files.OpenShotFile):
433  # Create file
434  try:
435  clip = openshot.Clip(item.name)
436  reader = clip.Reader()
437  file_data = json.loads(reader.Json())
438 
439  # Determine media type
440  if file_data["has_video"] and not self.is_image(file_data):
441  file_data["media_type"] = "video"
442  elif file_data["has_video"] and self.is_image(file_data):
443  file_data["media_type"] = "image"
444  elif file_data["has_audio"] and not file_data["has_video"]:
445  file_data["media_type"] = "audio"
446 
447  # Save new file to the project data
448  file = File()
449  file.data = file_data
450  file.save()
451 
452  # Keep track of new ids and old ids
453  file_lookup[item.unique_id] = file
454 
455  except:
456  # Handle exception quietly
457  msg = ("%s is not a valid video, audio, or image file." % item.name)
458  log.error(msg)
459  failed_files.append(item.name)
460 
461  # Delete all tracks
462  track_list = copy.deepcopy(Track.filter())
463  for track in track_list:
464  track.delete()
465 
466  # Create new tracks
467  track_counter = 0
468  for legacy_t in reversed(v1_data.sequences[0].tracks):
469  t = Track()
470  t.data = {"number": track_counter, "y": 0, "label": legacy_t.name}
471  t.save()
472 
473  track_counter += 1
474 
475  # Loop through clips
476  track_counter = 0
477  for sequence in v1_data.sequences:
478  for track in reversed(sequence.tracks):
479  for clip in track.clips:
480  # Get associated file for this clip
481  if clip.file_object.unique_id in file_lookup.keys():
482  file = file_lookup[clip.file_object.unique_id]
483  else:
484  # Skip missing file
485  log.info("Skipping importing missing file: %s" % clip.file_object.unique_id)
486  continue
487 
488  # Create clip
489  if (file.data["media_type"] == "video" or file.data["media_type"] == "image"):
490  # Determine thumb path
491  thumb_path = os.path.join(info.THUMBNAIL_PATH, "%s.png" % file.data["id"])
492  else:
493  # Audio file
494  thumb_path = os.path.join(info.PATH, "images", "AudioThumbnail.png")
495 
496  # Get file name
497  path, filename = os.path.split(file.data["path"])
498 
499  # Convert path to the correct relative path (based on this folder)
500  file_path = file.absolute_path()
501 
502  # Create clip object for this file
503  c = openshot.Clip(file_path)
504 
505  # Append missing attributes to Clip JSON
506  new_clip = json.loads(c.Json())
507  new_clip["file_id"] = file.id
508  new_clip["title"] = filename
509  new_clip["image"] = thumb_path
510 
511  # Check for optional start and end attributes
512  new_clip["start"] = clip.start_time
513  new_clip["end"] = clip.end_time
514  new_clip["position"] = clip.position_on_track
515  new_clip["layer"] = track_counter
516 
517  # Clear alpha (if needed)
518  if clip.video_fade_in or clip.video_fade_out:
519  new_clip["alpha"]["Points"] = []
520 
521  # Video Fade IN
522  if clip.video_fade_in:
523  # Add keyframes
524  start = openshot.Point(round(clip.start_time * fps_float) + 1, 0.0, openshot.BEZIER)
525  start_object = json.loads(start.Json())
526  end = openshot.Point(round((clip.start_time + clip.video_fade_in_amount) * fps_float) + 1, 1.0, openshot.BEZIER)
527  end_object = json.loads(end.Json())
528  new_clip["alpha"]["Points"].append(start_object)
529  new_clip["alpha"]["Points"].append(end_object)
530 
531  # Video Fade OUT
532  if clip.video_fade_out:
533  # Add keyframes
534  start = openshot.Point(round((clip.end_time - clip.video_fade_out_amount) * fps_float) + 1, 1.0, openshot.BEZIER)
535  start_object = json.loads(start.Json())
536  end = openshot.Point(round(clip.end_time * fps_float) + 1, 0.0, openshot.BEZIER)
537  end_object = json.loads(end.Json())
538  new_clip["alpha"]["Points"].append(start_object)
539  new_clip["alpha"]["Points"].append(end_object)
540 
541  # Clear Audio (if needed)
542  if clip.audio_fade_in or clip.audio_fade_out:
543  new_clip["volume"]["Points"] = []
544  else:
545  p = openshot.Point(1, clip.volume / 100.0, openshot.BEZIER)
546  p_object = json.loads(p.Json())
547  new_clip["volume"] = { "Points" : [p_object]}
548 
549  # Audio Fade IN
550  if clip.audio_fade_in:
551  # Add keyframes
552  start = openshot.Point(round(clip.start_time * fps_float) + 1, 0.0, openshot.BEZIER)
553  start_object = json.loads(start.Json())
554  end = openshot.Point(round((clip.start_time + clip.video_fade_in_amount) * fps_float) + 1, clip.volume / 100.0, openshot.BEZIER)
555  end_object = json.loads(end.Json())
556  new_clip["volume"]["Points"].append(start_object)
557  new_clip["volume"]["Points"].append(end_object)
558 
559  # Audio Fade OUT
560  if clip.audio_fade_out:
561  # Add keyframes
562  start = openshot.Point(round((clip.end_time - clip.video_fade_out_amount) * fps_float) + 1, clip.volume / 100.0, openshot.BEZIER)
563  start_object = json.loads(start.Json())
564  end = openshot.Point(round(clip.end_time * fps_float) + 1, 0.0, openshot.BEZIER)
565  end_object = json.loads(end.Json())
566  new_clip["volume"]["Points"].append(start_object)
567  new_clip["volume"]["Points"].append(end_object)
568 
569  # Save clip
570  clip_object = Clip()
571  clip_object.data = new_clip
572  clip_object.save()
573 
574  # Loop through transitions
575  for trans in track.transitions:
576  # Fix default transition
577  if not trans.resource or not os.path.exists(trans.resource):
578  trans.resource = os.path.join(info.PATH, "transitions", "common", "fade.svg")
579 
580  # Open up QtImageReader for transition Image
581  transition_reader = openshot.QtImageReader(trans.resource)
582 
583  trans_begin_value = 1.0
584  trans_end_value = -1.0
585  if trans.reverse:
586  trans_begin_value = -1.0
587  trans_end_value = 1.0
588 
589  brightness = openshot.Keyframe()
590  brightness.AddPoint(1, trans_begin_value, openshot.BEZIER)
591  brightness.AddPoint(round(trans.length * fps_float) + 1, trans_end_value, openshot.BEZIER)
592  contrast = openshot.Keyframe(trans.softness * 10.0)
593 
594  # Create transition dictionary
595  transitions_data = {
596  "id": get_app().project.generate_id(),
597  "layer": track_counter,
598  "title": "Transition",
599  "type": "Mask",
600  "position": trans.position_on_track,
601  "start": 0,
602  "end": trans.length,
603  "brightness": json.loads(brightness.Json()),
604  "contrast": json.loads(contrast.Json()),
605  "reader": json.loads(transition_reader.Json()),
606  "replace_image": False
607  }
608 
609  # Save transition
610  t = Transition()
611  t.data = transitions_data
612  t.save()
613 
614  # Increment track counter
615  track_counter += 1
616 
617  except Exception as ex:
618  # Error parsing legacy contents
619  msg = _("Failed to load project file %(path)s: %(error)s" % {"path": file_path, "error": ex})
620  log.error(msg)
621  raise Exception(msg)
622 
623  # Show warning if some files failed to load
624  if failed_files:
625  # Throw exception
626  raise Exception(_("Failed to load the following files:\n%s" % ", ".join(failed_files)))
627 
628  # Return mostly empty project_data dict (with just the current version #)
629  log.info("Successfully loaded legacy project file: %s" % file_path)
630  return project_data
631 
632  def is_image(self, file):
633  path = file["path"].lower()
634 
635  if path.endswith((".jpg", ".jpeg", ".png", ".bmp", ".svg", ".thm", ".gif", ".bmp", ".pgm", ".tif", ".tiff")):
636  return True
637  else:
638  return False
639 
640  ##
641  # Fix any issues with old project files (if any)
643  openshot_version = self._data["version"]["openshot-qt"]
644  libopenshot_version = self._data["version"]["libopenshot"]
645 
646  log.info(openshot_version)
647  log.info(libopenshot_version)
648 
649  if openshot_version == "0.0.0":
650  # If version = 0.0.0, this is the beta of OpenShot
651  # Fix alpha values (they are now flipped)
652  for clip in self._data["clips"]:
653  # Loop through keyframes for alpha
654  for point in clip["alpha"]["Points"]:
655  # Flip the alpha value
656  if "co" in point:
657  point["co"]["Y"] = 1.0 - point["co"]["Y"]
658  if "handle_left" in point:
659  point["handle_left"]["Y"] = 1.0 - point["handle_left"]["Y"]
660  if "handle_right" in point:
661  point["handle_right"]["Y"] = 1.0 - point["handle_right"]["Y"]
662 
663  elif openshot_version <= "2.1.0-dev":
664  # Fix handle_left and handle_right coordinates and default to ease in/out bezier curve
665  # using the new percent based keyframes
666  for clip_type in ["clips", "effects"]:
667  for clip in self._data[clip_type]:
668  for object in [clip] + clip.get('effects',[]):
669  for item_key, item_data in object.items():
670  # Does clip attribute have a {"Points": [...]} list
671  if type(item_data) == dict and "Points" in item_data:
672  for point in item_data.get("Points"):
673  # Convert to percent-based curves
674  if "handle_left" in point:
675  # Left handle
676  point.get("handle_left")["X"] = 0.5
677  point.get("handle_left")["Y"] = 1.0
678  if "handle_right" in point:
679  # Right handle
680  point.get("handle_right")["X"] = 0.5
681  point.get("handle_right")["Y"] = 0.0
682 
683  elif type(item_data) == dict and "red" in item_data:
684  for color in ["red", "blue", "green", "alpha"]:
685  for point in item_data.get(color).get("Points"):
686  # Convert to percent-based curves
687  if "handle_left" in point:
688  # Left handle
689  point.get("handle_left")["X"] = 0.5
690  point.get("handle_left")["Y"] = 1.0
691  if "handle_right" in point:
692  # Right handle
693  point.get("handle_right")["X"] = 0.5
694  point.get("handle_right")["Y"] = 0.0
695 
696  ##
697  # Save project file to disk
698  def save(self, file_path, move_temp_files=True, make_paths_relative=True):
699  import openshot
700 
701  # Move all temp files (i.e. Blender animations) to the project folder
702  if move_temp_files:
703  self.move_temp_paths_to_project_folder(file_path)
704 
705  # Convert all file paths to relative based on this new project file's directory
706  if make_paths_relative:
707  self.convert_paths_to_relative(file_path)
708 
709  # Append version info
710  v = openshot.GetVersion()
711  self._data["version"] = { "openshot-qt" : info.VERSION,
712  "libopenshot" : v.ToString() }
713 
714  # Try to save project settings file, will raise error on failure
715  self.write_to_file(file_path, self._data)
716 
717  # On success, save current filepath
718  self.current_filepath = file_path
719 
720  # Convert all paths back to absolute
721  if make_paths_relative:
723 
724  # Add to recent files setting
725  self.add_to_recent_files(file_path)
726 
727  # Track unsaved changes
728  self.has_unsaved_changes = False
729 
730  ##
731  # Move all temp files (such as Thumbnails, Titles, and Blender animations) to the project folder.
732  def move_temp_paths_to_project_folder(self, file_path):
733  try:
734  # Get project folder
735  new_project_folder = os.path.dirname(file_path)
736  new_thumbnails_folder = os.path.join(new_project_folder, "thumbnail")
737 
738  # Create project thumbnails folder
739  if not os.path.exists(new_thumbnails_folder):
740  os.mkdir(new_thumbnails_folder)
741 
742  # Copy all thumbnails to project
743  for filename in glob.glob(os.path.join(info.THUMBNAIL_PATH, '*.*')):
744  shutil.copy(filename, new_thumbnails_folder)
745 
746  # Loop through each file
747  for file in self._data["files"]:
748  path = file["path"]
749 
750  # Find any temp BLENDER file paths
751  if info.BLENDER_PATH in path or info.ASSETS_PATH in path:
752  log.info("Temp blender file path detected in file")
753 
754  # Get folder of file
755  folder_path, file_name = os.path.split(path)
756  parent_path, folder_name = os.path.split(folder_path)
757  new_parent_path = new_project_folder
758 
759  if os.path.isdir(path) or "%" in path:
760  # Update path to new folder
761  new_parent_path = os.path.join(new_project_folder, folder_name)
762 
763  # Copy blender tree into new folder
764  shutil.copytree(folder_path, new_parent_path)
765  else:
766  # New path
767  new_parent_path = os.path.join(new_project_folder, "assets")
768 
769  # Ensure blender folder exists
770  if not os.path.exists(new_parent_path):
771  os.mkdir(new_parent_path)
772 
773  # Copy titles/individual files into new folder
774  shutil.copy2(path, os.path.join(new_parent_path, file_name))
775 
776  # Update paths in project to new location
777  file["path"] = os.path.join(new_parent_path, file_name)
778 
779  # Loop through each clip
780  for clip in self._data["clips"]:
781  path = clip["reader"]["path"]
782 
783  # Find any temp BLENDER file paths
784  if info.BLENDER_PATH in path or info.ASSETS_PATH in path:
785  log.info("Temp blender file path detected in clip")
786 
787  # Get folder of file
788  folder_path, file_name = os.path.split(path)
789  parent_path, folder_name = os.path.split(folder_path)
790  # Update path to new folder
791  path = os.path.join(new_project_folder, folder_name)
792 
793  # Update paths in project to new location
794  clip["reader"]["path"] = os.path.join(path, file_name)
795 
796  # Loop through each file
797  for clip in self._data["clips"]:
798  path = clip["image"]
799 
800  # Find any temp BLENDER file paths
801  if info.BLENDER_PATH in path or info.ASSETS_PATH in path:
802  log.info("Temp blender file path detected in clip thumbnail")
803 
804  # Get folder of file
805  folder_path, file_name = os.path.split(path)
806  parent_path, folder_name = os.path.split(folder_path)
807  # Update path to new folder
808  path = os.path.join(new_project_folder, folder_name)
809 
810  # Update paths in project to new location
811  clip["image"] = os.path.join(path, file_name)
812 
813  except Exception as ex:
814  log.error("Error while moving temp files into project folder: %s" % str(ex))
815 
816  ##
817  # Add this project to the recent files list
818  def add_to_recent_files(self, file_path):
819  if "backup.osp" in file_path:
820  # Ignore backup recovery project
821  return
822 
824  recent_projects = s.get("recent_projects")
825 
826  # Remove existing project
827  if file_path in recent_projects:
828  recent_projects.remove(file_path)
829 
830  # Remove oldest item (if needed)
831  if len(recent_projects) > 10:
832  del recent_projects[0]
833 
834  # Append file path to end of recent files
835  recent_projects.append(file_path)
836 
837  # Save setting
838  s.set("recent_projects", recent_projects)
839  s.save()
840 
841  ##
842  # Convert all paths relative to this filepath
843  def convert_paths_to_relative(self, file_path):
844  try:
845  # Get project folder
846  existing_project_folder = None
847  if self.current_filepath:
848  existing_project_folder = os.path.dirname(self.current_filepath)
849  new_project_folder = os.path.dirname(file_path)
850 
851  # Loop through each file
852  for file in self._data["files"]:
853  path = file["path"]
854  # Find absolute path of file (if needed)
855  if not os.path.isabs(path):
856  # Convert path to the correct relative path (based on the existing folder)
857  path = os.path.abspath(os.path.join(existing_project_folder, path))
858 
859  # Convert absolute path to relavite
860  file["path"] = os.path.relpath(path, new_project_folder)
861 
862  # Loop through each clip
863  for clip in self._data["clips"]:
864  # Update reader path
865  path = clip["reader"]["path"]
866  # Find absolute path of file (if needed)
867  if not os.path.isabs(path):
868  # Convert path to the correct relative path (based on the existing folder)
869  path = os.path.abspath(os.path.join(existing_project_folder, path))
870  # Convert absolute path to relavite
871  clip["reader"]["path"] = os.path.relpath(path, new_project_folder)
872 
873  # Update clip image path
874  path = clip["image"]
875  # Find absolute path of file (if needed)
876  if not os.path.isabs(path):
877  # Convert path to the correct relative path (based on the existing folder)
878  path = os.path.abspath(os.path.join(existing_project_folder, path))
879  # Convert absolute path to relavite
880  clip["image"] = os.path.relpath(path, new_project_folder)
881 
882  # Loop through each transition
883  for effect in self._data["effects"]:
884  # Update reader path
885  path = effect["reader"]["path"]
886 
887  # Determine if this path is the official transition path
888  folder_path, file_path = os.path.split(path)
889  if os.path.join(info.PATH, "transitions") in folder_path:
890  # Yes, this is an OpenShot transitions
891  folder_path, category_path = os.path.split(folder_path)
892 
893  # Convert path to @transitions/ path
894  effect["reader"]["path"] = os.path.join("@transitions", category_path, file_path)
895  continue
896 
897  # Find absolute path of file (if needed)
898  if not os.path.isabs(path):
899  # Convert path to the correct relative path (based on the existing folder)
900  path = os.path.abspath(os.path.join(existing_project_folder, path))
901  # Convert absolute path to relavite
902  effect["reader"]["path"] = os.path.relpath(path, new_project_folder)
903 
904  except Exception as ex:
905  log.error("Error while converting absolute paths to relative paths: %s" % str(ex))
906 
907 
908  ##
909  # Check if all paths are valid, and prompt to update them if needed
911  # Get import path or project folder
912  starting_folder = None
913  if self._data["import_path"]:
914  starting_folder = os.path.join(self._data["import_path"])
915  elif self.current_filepath:
916  starting_folder = os.path.dirname(self.current_filepath)
917 
918  # Get translation method
919  from classes.app import get_app
920  _ = get_app()._tr
921 
922  from PyQt5.QtWidgets import QFileDialog, QMessageBox
923 
924  # Loop through each files (in reverse order)
925  for file in reversed(self._data["files"]):
926  path = file["path"]
927  parent_path, file_name_with_ext = os.path.split(path)
928  while not os.path.exists(path) and "%" not in path:
929  # File already exists! Prompt user to find missing file
930  QMessageBox.warning(None, _("Missing File (%s)") % file["id"], _("%s cannot be found.") % file_name_with_ext)
931  starting_folder = QFileDialog.getExistingDirectory(None, _("Find directory that contains: %s" % file_name_with_ext), starting_folder)
932  log.info("Missing folder chosen by user: %s" % starting_folder)
933  if starting_folder:
934  # Update file path and import_path
935  path = os.path.join(starting_folder, file_name_with_ext)
936  file["path"] = path
937  get_app().updates.update(["import_path"], os.path.dirname(path))
938  else:
939  log.info('Removed missing file: %s' % file_name_with_ext)
940  self._data["files"].remove(file)
941  break
942 
943  # Loop through each clip (in reverse order)
944  for clip in reversed(self._data["clips"]):
945  path = clip["reader"]["path"]
946  parent_path, file_name_with_ext = os.path.split(path)
947  while not os.path.exists(path) and "%" not in path:
948  # Clip already exists! Prompt user to find missing file
949  QMessageBox.warning(None, _("Missing File in Clip (%s)") % clip["id"], _("%s cannot be found.") % file_name_with_ext)
950  starting_folder = QFileDialog.getExistingDirectory(None, _("Find directory that contains: %s" % file_name_with_ext), starting_folder)
951  log.info("Missing folder chosen by user: %s" % starting_folder)
952  if starting_folder:
953  # Update clip path
954  path = os.path.join(starting_folder, file_name_with_ext)
955  clip["reader"]["path"] = path
956  else:
957  log.info('Removed missing clip: %s' % file_name_with_ext)
958  self._data["clips"].remove(clip)
959  break
960 
961  ##
962  # Convert all paths to absolute
964  try:
965  # Get project folder
966  existing_project_folder = None
967  if self.current_filepath:
968  existing_project_folder = os.path.dirname(self.current_filepath)
969 
970  # Loop through each file
971  for file in self._data["files"]:
972  path = file["path"]
973  # Find absolute path of file (if needed)
974  if not os.path.isabs(path):
975  # Convert path to the correct relative path (based on the existing folder)
976  path = os.path.abspath(os.path.join(existing_project_folder, path))
977 
978  # Convert absolute path to relavite
979  file["path"] = path
980 
981  # Loop through each clip
982  for clip in self._data["clips"]:
983  # Update reader path
984  path = clip["reader"]["path"]
985  # Find absolute path of file (if needed)
986  if not os.path.isabs(path):
987  # Convert path to the correct relative path (based on the existing folder)
988  path = os.path.abspath(os.path.join(existing_project_folder, path))
989  # Convert absolute path to relavite
990  clip["reader"]["path"] = path
991 
992  # Update clip image path
993  path = clip["image"]
994  # Find absolute path of file (if needed)
995  if not os.path.isabs(path):
996  # Convert path to the correct relative path (based on the existing folder)
997  path = os.path.abspath(os.path.join(existing_project_folder, path))
998  # Convert absolute path to relavite
999  clip["image"] = path
1000 
1001  # Loop through each transition
1002  for effect in self._data["effects"]:
1003  # Update reader path
1004  path = effect["reader"]["path"]
1005 
1006  # Determine if @transitions path is found
1007  if "@transitions" in path:
1008  path = path.replace("@transitions", os.path.join(info.PATH, "transitions"))
1009 
1010  # Find absolute path of file (if needed)
1011  if not os.path.isabs(path):
1012  # Convert path to the correct relative path (based on the existing folder)
1013  path = os.path.abspath(os.path.join(existing_project_folder, path))
1014  # Convert absolute path to relavite
1015  effect["reader"]["path"] = path
1016 
1017  except Exception as ex:
1018  log.error("Error while converting relative paths to absolute paths: %s" % str(ex))
1019 
1020  ##
1021  # This method is invoked by the UpdateManager each time a change happens (i.e UpdateInterface)
1022  def changed(self, action):
1023  # Track unsaved changes
1024  self.has_unsaved_changes = True
1025 
1026  if action.type == "insert":
1027  # Insert new item
1028  old_vals = self._set(action.key, action.values, add=True)
1029  action.set_old_values(old_vals) # Save previous values to reverse this action
1030 
1031  elif action.type == "update":
1032  # Update existing item
1033  old_vals = self._set(action.key, action.values, partial_update=action.partial_update)
1034  action.set_old_values(old_vals) # Save previous values to reverse this action
1035 
1036  elif action.type == "delete":
1037  # Delete existing item
1038  old_vals = self._set(action.key, remove=True)
1039  action.set_old_values(old_vals) # Save previous values to reverse this action
1040 
1041  # Utility methods
1042  ##
1043  # Generate random alphanumeric ids
1044  def generate_id(self, digits=10):
1045 
1046  chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
1047  id = ""
1048  for i in range(digits):
1049  c_index = random.randint(0, len(chars) - 1)
1050  id += (chars[c_index])
1051  return id
def needs_save(self)
Returns if project data Has unsaved changes.
Definition: project_data.py:63
def get_app()
Returns the current QApplication instance of OpenShot.
Definition: app.py:55
def generate_id(self, digits=10)
Generate random alphanumeric ids.
def convert_paths_to_absolute(self)
Convert all paths to absolute.
def read_legacy_project_file(self, file_path)
Attempt to read a legacy version 1.x openshot project file.
def load(self, file_path)
Load project from file.
def convert_paths_to_relative(self, file_path)
Convert all paths relative to this filepath.
def _set(self, key, values=None, add=False, partial_update=False, remove=False)
Store setting, but adding isn&#39;t allowed.
def move_temp_paths_to_project_folder(self, file_path)
Move all temp files (such as Thumbnails, Titles, and Blender animations) to the project folder...
def set(self, key, value)
Prevent calling JsonDataStore set() method.
def get_settings()
Get the current QApplication&#39;s settings instance.
Definition: settings.py:44
def add_to_recent_files(self, file_path)
Add this project to the recent files list.
This class allows advanced searching of data structure, implements changes interface.
Definition: project_data.py:45
def new(self)
Try to load default project settings file, will raise error on failure.
def save(self, file_path, move_temp_files=True, make_paths_relative=True)
Save project file to disk.
def changed(self, action)
This method is invoked by the UpdateManager each time a change happens (i.e UpdateInterface) ...
def get(self, key)
Get copied value of a given key in data store.
Definition: project_data.py:68
def check_if_paths_are_valid(self)
Check if all paths are valid, and prompt to update them if needed.
def upgrade_project_data_structures(self)
Fix any issues with old project files (if any)