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 copy
32 import glob
33 import os
34 import random
35 import shutil
36 
37 from classes import info, settings
38 from classes.json_data import JsonDataStore
39 from classes.logger import log
40 from classes.updates import UpdateInterface
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  parent.append(values)
241 
242  # Otherwise, set the given index
243  elif isinstance(values, dict):
244  # Update existing dictionary value
245  obj.update(values)
246 
247  else:
248 
249  # Update root string
250  self._data[my_key] = values
251 
252  # Return the previous value to the matching item (used for history tracking)
253  return ret
254 
255  # Load default project data
256  ##
257  # Try to load default project settings file, will raise error on failure
258  def new(self):
259  import openshot
260  self._data = self.read_from_file(self.default_project_filepath)
261  self.current_filepath = None
262  self.has_unsaved_changes = False
263 
264  # Get default profile
266  default_profile = s.get("default-profile")
267 
268  # Loop through profiles
269  for profile_folder in [info.USER_PROFILES_PATH, info.PROFILES_PATH]:
270  for file in os.listdir(profile_folder):
271  # Load Profile and append description
272  profile_path = os.path.join(profile_folder, file)
273  profile = openshot.Profile(profile_path)
274 
275  if default_profile == profile.info.description:
276  log.info("Setting default profile to %s" % profile.info.description)
277 
278  # Update default profile
279  self._data["profile"] = profile.info.description
280  self._data["width"] = profile.info.width
281  self._data["height"] = profile.info.height
282  self._data["fps"] = {"num" : profile.info.fps.num, "den" : profile.info.fps.den}
283  break
284 
285  # Get the default audio settings for the timeline (and preview playback)
286  default_sample_rate = int(s.get("default-samplerate"))
287  default_channel_ayout = s.get("default-channellayout")
288 
289  channels = 2
290  channel_layout = openshot.LAYOUT_STEREO
291  if default_channel_ayout == "LAYOUT_MONO":
292  channels = 1
293  channel_layout = openshot.LAYOUT_MONO
294  elif default_channel_ayout == "LAYOUT_STEREO":
295  channels = 2
296  channel_layout = openshot.LAYOUT_STEREO
297  elif default_channel_ayout == "LAYOUT_SURROUND":
298  channels = 3
299  channel_layout = openshot.LAYOUT_SURROUND
300  elif default_channel_ayout == "LAYOUT_5POINT1":
301  channels = 6
302  channel_layout = openshot.LAYOUT_5POINT1
303  elif default_channel_ayout == "LAYOUT_7POINT1":
304  channels = 8
305  channel_layout = openshot.LAYOUT_7POINT1
306 
307  # Set default samplerate and channels
308  self._data["sample_rate"] = default_sample_rate
309  self._data["channels"] = channels
310  self._data["channel_layout"] = channel_layout
311 
312  ##
313  # Load project from file
314  def load(self, file_path):
315 
316  self.new()
317 
318  if file_path:
319  log.info("Loading project file: {}".format(file_path))
320 
321  # Default project data
322  default_project = self._data
323 
324  try:
325  # Attempt to load v2.X project file
326  project_data = self.read_from_file(file_path, path_mode="absolute")
327 
328  except Exception as ex:
329  try:
330  # Attempt to load legacy project file (v1.X version)
331  project_data = self.read_legacy_project_file(file_path)
332 
333  except Exception as ex:
334  # Project file not recognized as v1.X or v2.X, bubble up error
335  raise ex
336 
337  # Merge default and project settings, excluding settings not in default.
338  self._data = self.merge_settings(default_project, project_data)
339 
340  # On success, save current filepath
341  self.current_filepath = file_path
342 
343  # Check if paths are all valid
345 
346  # Copy any project thumbnails to main THUMBNAILS folder
347  loaded_project_folder = os.path.dirname(self.current_filepath)
348  project_thumbnails_folder = os.path.join(loaded_project_folder, "thumbnail")
349  if os.path.exists(project_thumbnails_folder):
350  # Remove thumbnail path
351  shutil.rmtree(info.THUMBNAIL_PATH, True)
352 
353  # Copy project thumbnails folder
354  shutil.copytree(project_thumbnails_folder, info.THUMBNAIL_PATH)
355 
356  # Add to recent files setting
357  self.add_to_recent_files(file_path)
358 
359  # Upgrade any data structures
361 
362  # Get app, and distribute all project data through update manager
363  from classes.app import get_app
364  get_app().updates.load(self._data)
365 
366  # Clear needs save flag
367  self.has_unsaved_changes = False
368 
369  ##
370  # Scale keyframe X coordinate by some factor, except for 1 (leave that alone)
371  def scale_keyframe_value(self, original_value, scale_factor):
372  if original_value == 1.0:
373  # This represents the first frame of a clip (so we want to maintain that)
374  return original_value
375  else:
376  # Round to nearest INT
377  return round(original_value * scale_factor)
378 
379  ##
380  # Adjust all keyframe coordinates from previous FPS to new FPS (using a scale factor)
381  def rescale_keyframes(self, scale_factor):
382  log.info('Scale all keyframes by a factor of %s' % scale_factor)
383 
384  # Loop through all clips (and look for Keyframe objects)
385  # Scale the X coordinate by factor (which represents the frame #)
386  for clip in self._data.get('clips', []):
387  for attribute in clip:
388  if type(clip.get(attribute)) == dict and "Points" in clip.get(attribute):
389  for point in clip.get(attribute).get("Points"):
390  if "co" in point:
391  point["co"]["X"] = self.scale_keyframe_value(point["co"].get("X", 0.0), scale_factor)
392  if type(clip.get(attribute)) == dict and "red" in clip.get(attribute):
393  for color in clip.get(attribute):
394  for point in clip.get(attribute).get(color).get("Points"):
395  if "co" in point:
396  point["co"]["X"] = self.scale_keyframe_value(point["co"].get("X", 0.0), scale_factor)
397  for effect in clip.get("effects", []):
398  for attribute in effect:
399  if type(effect.get(attribute)) == dict and "Points" in effect.get(attribute):
400  for point in effect.get(attribute).get("Points"):
401  if "co" in point:
402  point["co"]["X"] = self.scale_keyframe_value(point["co"].get("X", 0.0), scale_factor)
403  if type(effect.get(attribute)) == dict and "red" in effect.get(attribute):
404  for color in effect.get(attribute):
405  for point in effect.get(attribute).get(color).get("Points"):
406  if "co" in point:
407  point["co"]["X"] = self.scale_keyframe_value(point["co"].get("X", 0.0), scale_factor)
408 
409  # Loop through all effects/transitions (and look for Keyframe objects)
410  # Scale the X coordinate by factor (which represents the frame #)
411  for effect in self._data.get('effects',[]):
412  for attribute in effect:
413  if type(effect.get(attribute)) == dict and "Points" in effect.get(attribute):
414  for point in effect.get(attribute).get("Points"):
415  if "co" in point:
416  point["co"]["X"] = self.scale_keyframe_value(point["co"].get("X", 0.0), scale_factor)
417  if type(effect.get(attribute)) == dict and "red" in effect.get(attribute):
418  for color in effect.get(attribute):
419  for point in effect.get(attribute).get(color).get("Points"):
420  if "co" in point:
421  point["co"]["X"] = self.scale_keyframe_value(point["co"].get("X", 0.0), scale_factor)
422 
423  # Get app, and distribute all project data through update manager
424  from classes.app import get_app
425  get_app().updates.load(self._data)
426 
427  ##
428  # Attempt to read a legacy version 1.x openshot project file
429  def read_legacy_project_file(self, file_path):
430  import sys, pickle
431  from classes.query import File, Track, Clip, Transition
432  from classes.app import get_app
433  import openshot
434 
435  try:
436  import json
437  except ImportError:
438  import simplejson as json
439 
440  # Get translation method
441  _ = get_app()._tr
442 
443  # Append version info
444  v = openshot.GetVersion()
445  project_data = {}
446  project_data["version"] = {"openshot-qt" : info.VERSION,
447  "libopenshot" : v.ToString()}
448 
449  # Get FPS from project
450  from classes.app import get_app
451  fps = get_app().project.get(["fps"])
452  fps_float = float(fps["num"]) / float(fps["den"])
453 
454  # Import legacy openshot classes (from version 1.X)
455  from classes.legacy.openshot import classes as legacy_classes
456  from classes.legacy.openshot.classes import project as legacy_project
457  from classes.legacy.openshot.classes import sequences as legacy_sequences
458  from classes.legacy.openshot.classes import track as legacy_track
459  from classes.legacy.openshot.classes import clip as legacy_clip
460  from classes.legacy.openshot.classes import keyframe as legacy_keyframe
461  from classes.legacy.openshot.classes import files as legacy_files
462  from classes.legacy.openshot.classes import transition as legacy_transition
463  from classes.legacy.openshot.classes import effect as legacy_effect
464  from classes.legacy.openshot.classes import marker as legacy_marker
465  sys.modules['openshot.classes'] = legacy_classes
466  sys.modules['classes.project'] = legacy_project
467  sys.modules['classes.sequences'] = legacy_sequences
468  sys.modules['classes.track'] = legacy_track
469  sys.modules['classes.clip'] = legacy_clip
470  sys.modules['classes.keyframe'] = legacy_keyframe
471  sys.modules['classes.files'] = legacy_files
472  sys.modules['classes.transition'] = legacy_transition
473  sys.modules['classes.effect'] = legacy_effect
474  sys.modules['classes.marker'] = legacy_marker
475 
476  # Keep track of files that failed to load
477  failed_files = []
478 
479  with open(file_path.encode('UTF-8'), 'rb') as f:
480  try:
481  # Unpickle legacy openshot project file
482  v1_data = pickle.load(f, fix_imports=True, encoding="UTF-8")
483  file_lookup = {}
484 
485  # Loop through files
486  for item in v1_data.project_folder.items:
487  # Is this item a File (i.e. ignore folders)
488  if isinstance(item, legacy_files.OpenShotFile):
489  # Create file
490  try:
491  clip = openshot.Clip(item.name)
492  reader = clip.Reader()
493  file_data = json.loads(reader.Json(), strict=False)
494 
495  # Determine media type
496  if file_data["has_video"] and not self.is_image(file_data):
497  file_data["media_type"] = "video"
498  elif file_data["has_video"] and self.is_image(file_data):
499  file_data["media_type"] = "image"
500  elif file_data["has_audio"] and not file_data["has_video"]:
501  file_data["media_type"] = "audio"
502 
503  # Save new file to the project data
504  file = File()
505  file.data = file_data
506  file.save()
507 
508  # Keep track of new ids and old ids
509  file_lookup[item.unique_id] = file
510 
511  except:
512  # Handle exception quietly
513  msg = ("%s is not a valid video, audio, or image file." % item.name)
514  log.error(msg)
515  failed_files.append(item.name)
516 
517  # Delete all tracks
518  track_list = copy.deepcopy(Track.filter())
519  for track in track_list:
520  track.delete()
521 
522  # Create new tracks
523  track_counter = 0
524  for legacy_t in reversed(v1_data.sequences[0].tracks):
525  t = Track()
526  t.data = {"number": track_counter, "y": 0, "label": legacy_t.name}
527  t.save()
528 
529  track_counter += 1
530 
531  # Loop through clips
532  track_counter = 0
533  for sequence in v1_data.sequences:
534  for track in reversed(sequence.tracks):
535  for clip in track.clips:
536  # Get associated file for this clip
537  if clip.file_object.unique_id in file_lookup.keys():
538  file = file_lookup[clip.file_object.unique_id]
539  else:
540  # Skip missing file
541  log.info("Skipping importing missing file: %s" % clip.file_object.unique_id)
542  continue
543 
544  # Create clip
545  if (file.data["media_type"] == "video" or file.data["media_type"] == "image"):
546  # Determine thumb path
547  thumb_path = os.path.join(info.THUMBNAIL_PATH, "%s.png" % file.data["id"])
548  else:
549  # Audio file
550  thumb_path = os.path.join(info.PATH, "images", "AudioThumbnail.png")
551 
552  # Get file name
553  path, filename = os.path.split(file.data["path"])
554 
555  # Convert path to the correct relative path (based on this folder)
556  file_path = file.absolute_path()
557 
558  # Create clip object for this file
559  c = openshot.Clip(file_path)
560 
561  # Append missing attributes to Clip JSON
562  new_clip = json.loads(c.Json(), strict=False)
563  new_clip["file_id"] = file.id
564  new_clip["title"] = filename
565  new_clip["image"] = thumb_path
566 
567  # Check for optional start and end attributes
568  new_clip["start"] = clip.start_time
569  new_clip["end"] = clip.end_time
570  new_clip["position"] = clip.position_on_track
571  new_clip["layer"] = track_counter
572 
573  # Clear alpha (if needed)
574  if clip.video_fade_in or clip.video_fade_out:
575  new_clip["alpha"]["Points"] = []
576 
577  # Video Fade IN
578  if clip.video_fade_in:
579  # Add keyframes
580  start = openshot.Point(round(clip.start_time * fps_float) + 1, 0.0, openshot.BEZIER)
581  start_object = json.loads(start.Json(), strict=False)
582  end = openshot.Point(round((clip.start_time + clip.video_fade_in_amount) * fps_float) + 1, 1.0, openshot.BEZIER)
583  end_object = json.loads(end.Json(), strict=False)
584  new_clip["alpha"]["Points"].append(start_object)
585  new_clip["alpha"]["Points"].append(end_object)
586 
587  # Video Fade OUT
588  if clip.video_fade_out:
589  # Add keyframes
590  start = openshot.Point(round((clip.end_time - clip.video_fade_out_amount) * fps_float) + 1, 1.0, openshot.BEZIER)
591  start_object = json.loads(start.Json(), strict=False)
592  end = openshot.Point(round(clip.end_time * fps_float) + 1, 0.0, openshot.BEZIER)
593  end_object = json.loads(end.Json(), strict=False)
594  new_clip["alpha"]["Points"].append(start_object)
595  new_clip["alpha"]["Points"].append(end_object)
596 
597  # Clear Audio (if needed)
598  if clip.audio_fade_in or clip.audio_fade_out:
599  new_clip["volume"]["Points"] = []
600  else:
601  p = openshot.Point(1, clip.volume / 100.0, openshot.BEZIER)
602  p_object = json.loads(p.Json(), strict=False)
603  new_clip["volume"] = { "Points" : [p_object]}
604 
605  # Audio Fade IN
606  if clip.audio_fade_in:
607  # Add keyframes
608  start = openshot.Point(round(clip.start_time * fps_float) + 1, 0.0, openshot.BEZIER)
609  start_object = json.loads(start.Json(), strict=False)
610  end = openshot.Point(round((clip.start_time + clip.video_fade_in_amount) * fps_float) + 1, clip.volume / 100.0, openshot.BEZIER)
611  end_object = json.loads(end.Json(), strict=False)
612  new_clip["volume"]["Points"].append(start_object)
613  new_clip["volume"]["Points"].append(end_object)
614 
615  # Audio Fade OUT
616  if clip.audio_fade_out:
617  # Add keyframes
618  start = openshot.Point(round((clip.end_time - clip.video_fade_out_amount) * fps_float) + 1, clip.volume / 100.0, openshot.BEZIER)
619  start_object = json.loads(start.Json(), strict=False)
620  end = openshot.Point(round(clip.end_time * fps_float) + 1, 0.0, openshot.BEZIER)
621  end_object = json.loads(end.Json(), strict=False)
622  new_clip["volume"]["Points"].append(start_object)
623  new_clip["volume"]["Points"].append(end_object)
624 
625  # Save clip
626  clip_object = Clip()
627  clip_object.data = new_clip
628  clip_object.save()
629 
630  # Loop through transitions
631  for trans in track.transitions:
632  # Fix default transition
633  if not trans.resource or not os.path.exists(trans.resource):
634  trans.resource = os.path.join(info.PATH, "transitions", "common", "fade.svg")
635 
636  # Open up QtImageReader for transition Image
637  transition_reader = openshot.QtImageReader(trans.resource)
638 
639  trans_begin_value = 1.0
640  trans_end_value = -1.0
641  if trans.reverse:
642  trans_begin_value = -1.0
643  trans_end_value = 1.0
644 
645  brightness = openshot.Keyframe()
646  brightness.AddPoint(1, trans_begin_value, openshot.BEZIER)
647  brightness.AddPoint(round(trans.length * fps_float) + 1, trans_end_value, openshot.BEZIER)
648  contrast = openshot.Keyframe(trans.softness * 10.0)
649 
650  # Create transition dictionary
651  transitions_data = {
652  "id": get_app().project.generate_id(),
653  "layer": track_counter,
654  "title": "Transition",
655  "type": "Mask",
656  "position": trans.position_on_track,
657  "start": 0,
658  "end": trans.length,
659  "brightness": json.loads(brightness.Json(), strict=False),
660  "contrast": json.loads(contrast.Json(), strict=False),
661  "reader": json.loads(transition_reader.Json(), strict=False),
662  "replace_image": False
663  }
664 
665  # Save transition
666  t = Transition()
667  t.data = transitions_data
668  t.save()
669 
670  # Increment track counter
671  track_counter += 1
672 
673  except Exception as ex:
674  # Error parsing legacy contents
675  msg = _("Failed to load project file %(path)s: %(error)s" % {"path": file_path, "error": ex})
676  log.error(msg)
677  raise Exception(msg)
678 
679  # Show warning if some files failed to load
680  if failed_files:
681  # Throw exception
682  raise Exception(_("Failed to load the following files:\n%s" % ", ".join(failed_files)))
683 
684  # Return mostly empty project_data dict (with just the current version #)
685  log.info("Successfully loaded legacy project file: %s" % file_path)
686  return project_data
687 
688  def is_image(self, file):
689  path = file["path"].lower()
690 
691  if path.endswith((".jpg", ".jpeg", ".png", ".bmp", ".svg", ".thm", ".gif", ".bmp", ".pgm", ".tif", ".tiff")):
692  return True
693  else:
694  return False
695 
696  ##
697  # Fix any issues with old project files (if any)
699  openshot_version = self._data["version"]["openshot-qt"]
700  libopenshot_version = self._data["version"]["libopenshot"]
701 
702  log.info(openshot_version)
703  log.info(libopenshot_version)
704 
705  if openshot_version == "0.0.0":
706  # If version = 0.0.0, this is the beta of OpenShot
707  # Fix alpha values (they are now flipped)
708  for clip in self._data["clips"]:
709  # Loop through keyframes for alpha
710  for point in clip["alpha"]["Points"]:
711  # Flip the alpha value
712  if "co" in point:
713  point["co"]["Y"] = 1.0 - point["co"]["Y"]
714  if "handle_left" in point:
715  point["handle_left"]["Y"] = 1.0 - point["handle_left"]["Y"]
716  if "handle_right" in point:
717  point["handle_right"]["Y"] = 1.0 - point["handle_right"]["Y"]
718 
719  elif openshot_version <= "2.1.0-dev":
720  # Fix handle_left and handle_right coordinates and default to ease in/out bezier curve
721  # using the new percent based keyframes
722  for clip_type in ["clips", "effects"]:
723  for clip in self._data[clip_type]:
724  for object in [clip] + clip.get('effects',[]):
725  for item_key, item_data in object.items():
726  # Does clip attribute have a {"Points": [...]} list
727  if type(item_data) == dict and "Points" in item_data:
728  for point in item_data.get("Points"):
729  # Convert to percent-based curves
730  if "handle_left" in point:
731  # Left handle
732  point.get("handle_left")["X"] = 0.5
733  point.get("handle_left")["Y"] = 1.0
734  if "handle_right" in point:
735  # Right handle
736  point.get("handle_right")["X"] = 0.5
737  point.get("handle_right")["Y"] = 0.0
738 
739  elif type(item_data) == dict and "red" in item_data:
740  for color in ["red", "blue", "green", "alpha"]:
741  for point in item_data.get(color).get("Points"):
742  # Convert to percent-based curves
743  if "handle_left" in point:
744  # Left handle
745  point.get("handle_left")["X"] = 0.5
746  point.get("handle_left")["Y"] = 1.0
747  if "handle_right" in point:
748  # Right handle
749  point.get("handle_right")["X"] = 0.5
750  point.get("handle_right")["Y"] = 0.0
751 
752  ##
753  # Save project file to disk
754  def save(self, file_path, move_temp_files=True, make_paths_relative=True):
755  import openshot
756 
757  log.info("Saving project file: {}".format(file_path))
758 
759  # Move all temp files (i.e. Blender animations) to the project folder
760  if move_temp_files:
761  self.move_temp_paths_to_project_folder(file_path)
762 
763  # Append version info
764  v = openshot.GetVersion()
765  self._data["version"] = { "openshot-qt" : info.VERSION,
766  "libopenshot" : v.ToString() }
767 
768  # Try to save project settings file, will raise error on failure
769  self.write_to_file(file_path, self._data, path_mode="relative", previous_path=self.current_filepath)
770 
771  # On success, save current filepath
772  self.current_filepath = file_path
773 
774  # Add to recent files setting
775  self.add_to_recent_files(file_path)
776 
777  # Track unsaved changes
778  self.has_unsaved_changes = False
779 
780  ##
781  # Move all temp files (such as Thumbnails, Titles, and Blender animations) to the project folder.
782  def move_temp_paths_to_project_folder(self, file_path):
783  try:
784  # Get project folder
785  new_project_folder = os.path.dirname(file_path)
786  new_thumbnails_folder = os.path.join(new_project_folder, "thumbnail")
787 
788  # Create project thumbnails folder
789  if not os.path.exists(new_thumbnails_folder):
790  os.mkdir(new_thumbnails_folder)
791 
792  # Copy all thumbnails to project
793  for filename in glob.glob(os.path.join(info.THUMBNAIL_PATH, '*.*')):
794  shutil.copy(filename, new_thumbnails_folder)
795 
796  # Loop through each file
797  for file in self._data["files"]:
798  path = file["path"]
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 file")
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  new_parent_path = new_project_folder
808 
809  if os.path.isdir(path) or "%" in path:
810  # Update path to new folder
811  new_parent_path = os.path.join(new_project_folder, folder_name)
812 
813  # Copy blender tree into new folder
814  shutil.copytree(folder_path, new_parent_path)
815  else:
816  # New path
817  new_parent_path = os.path.join(new_project_folder, "assets")
818 
819  # Ensure blender folder exists
820  if not os.path.exists(new_parent_path):
821  os.mkdir(new_parent_path)
822 
823  # Copy titles/individual files into new folder
824  shutil.copy2(path, os.path.join(new_parent_path, file_name))
825 
826  # Update paths in project to new location
827  file["path"] = os.path.join(new_parent_path, file_name)
828 
829  # Loop through each clip
830  for clip in self._data["clips"]:
831  path = clip["reader"]["path"]
832 
833  # Find any temp BLENDER file paths
834  if info.BLENDER_PATH in path or info.ASSETS_PATH in path:
835  log.info("Temp blender file path detected in clip")
836 
837  # Get folder of file
838  folder_path, file_name = os.path.split(path)
839  parent_path, folder_name = os.path.split(folder_path)
840  # Update path to new folder
841  path = os.path.join(new_project_folder, folder_name)
842 
843  # Update paths in project to new location
844  clip["reader"]["path"] = os.path.join(path, file_name)
845 
846  # Loop through each file
847  for clip in self._data["clips"]:
848  path = clip["image"]
849 
850  # Find any temp BLENDER file paths
851  if info.BLENDER_PATH in path or info.ASSETS_PATH in path:
852  log.info("Temp blender file path detected in clip thumbnail")
853 
854  # Get folder of file
855  folder_path, file_name = os.path.split(path)
856  parent_path, folder_name = os.path.split(folder_path)
857  # Update path to new folder
858  path = os.path.join(new_project_folder, folder_name)
859 
860  # Update paths in project to new location
861  clip["image"] = os.path.join(path, file_name)
862 
863  except Exception as ex:
864  log.error("Error while moving temp files into project folder: %s" % str(ex))
865 
866  ##
867  # Add this project to the recent files list
868  def add_to_recent_files(self, file_path):
869  if "backup.osp" in file_path:
870  # Ignore backup recovery project
871  return
872 
874  recent_projects = s.get("recent_projects")
875 
876  # Remove existing project
877  if file_path in recent_projects:
878  recent_projects.remove(file_path)
879 
880  # Remove oldest item (if needed)
881  if len(recent_projects) > 10:
882  del recent_projects[0]
883 
884  # Append file path to end of recent files
885  recent_projects.append(file_path)
886 
887  # Save setting
888  s.set("recent_projects", recent_projects)
889  s.save()
890 
891  ##
892  # Check if all paths are valid, and prompt to update them if needed
894  # Get import path or project folder
895  starting_folder = None
896  if self._data["import_path"]:
897  starting_folder = os.path.join(self._data["import_path"])
898  elif self.current_filepath:
899  starting_folder = os.path.dirname(self.current_filepath)
900 
901  # Get translation method
902  from classes.app import get_app
903  _ = get_app()._tr
904 
905  from PyQt5.QtWidgets import QFileDialog, QMessageBox
906 
907  log.info("checking project files...")
908 
909  # Loop through each files (in reverse order)
910  for file in reversed(self._data["files"]):
911  path = file["path"]
912  parent_path, file_name_with_ext = os.path.split(path)
913 
914  log.info("checking file %s" % path)
915  while not os.path.exists(path) and "%" not in path:
916  # File already exists!
917  # try to find file with previous starting folder:
918  if starting_folder and os.path.exists(os.path.join(starting_folder, file_name_with_ext)):
919  # Update file path
920  path = os.path.abspath(os.path.join(starting_folder, file_name_with_ext))
921  file["path"] = path
922  get_app().updates.update(["import_path"], os.path.dirname(path))
923  log.info("Auto-updated missing file: %s" % path)
924  break
925  else:
926  # Prompt user to find missing file
927  QMessageBox.warning(None, _("Missing File (%s)") % file["id"], _("%s cannot be found.") % file_name_with_ext)
928  starting_folder = QFileDialog.getExistingDirectory(None, _("Find directory that contains: %s" % file_name_with_ext), starting_folder)
929  log.info("Missing folder chosen by user: %s" % starting_folder)
930  if starting_folder:
931  # Update file path and import_path
932  path = os.path.abspath(os.path.join(starting_folder, file_name_with_ext))
933  file["path"] = path
934  get_app().updates.update(["import_path"], os.path.dirname(path))
935  else:
936  log.info('Removed missing file: %s' % file_name_with_ext)
937  self._data["files"].remove(file)
938  break
939 
940  # Loop through each clip (in reverse order)
941  for clip in reversed(self._data["clips"]):
942  path = clip["reader"]["path"]
943  parent_path, file_name_with_ext = os.path.split(path)
944 
945  log.info("checking file %s" % path)
946  while not os.path.exists(path) and "%" not in path:
947  # Clip already exists! Prompt user to find missing file
948  # try to find clip with previous starting folder:
949  if starting_folder and os.path.exists(os.path.join(starting_folder, file_name_with_ext)):
950  # Update clip path
951  path = os.path.abspath(os.path.join(starting_folder, file_name_with_ext))
952  clip["reader"]["path"] = path
953  log.info("Auto-updated missing file: %s" % clip["reader"]["path"])
954  break
955  else:
956  QMessageBox.warning(None, _("Missing File in Clip (%s)") % clip["id"], _("%s cannot be found.") % file_name_with_ext)
957  starting_folder = QFileDialog.getExistingDirectory(None, _("Find directory that contains: %s" % file_name_with_ext), starting_folder)
958  log.info("Missing folder chosen by user: %s" % starting_folder)
959  if starting_folder:
960  # Update clip path
961  path = os.path.abspath(os.path.join(starting_folder, file_name_with_ext))
962  clip["reader"]["path"] = path
963  else:
964  log.info('Removed missing clip: %s' % file_name_with_ext)
965  self._data["clips"].remove(clip)
966  break
967 
968  ##
969  # This method is invoked by the UpdateManager each time a change happens (i.e UpdateInterface)
970  def changed(self, action):
971  # Track unsaved changes
972  self.has_unsaved_changes = True
973 
974  if action.type == "insert":
975  # Insert new item
976  old_vals = self._set(action.key, action.values, add=True)
977  action.set_old_values(old_vals) # Save previous values to reverse this action
978 
979  elif action.type == "update":
980  # Update existing item
981  old_vals = self._set(action.key, action.values, partial_update=action.partial_update)
982  action.set_old_values(old_vals) # Save previous values to reverse this action
983 
984  elif action.type == "delete":
985  # Delete existing item
986  old_vals = self._set(action.key, remove=True)
987  action.set_old_values(old_vals) # Save previous values to reverse this action
988 
989  # Utility methods
990  ##
991  # Generate random alphanumeric ids
992  def generate_id(self, digits=10):
993 
994  chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
995  id = ""
996  for i in range(digits):
997  c_index = random.randint(0, len(chars) - 1)
998  id += (chars[c_index])
999  return id
def _set
Store setting, but adding isn't allowed.
def add_to_recent_files
Add this project to the recent files list.
def scale_keyframe_value
Scale keyframe X coordinate by some factor, except for 1 (leave that alone)
def rescale_keyframes
Adjust all keyframe coordinates from previous FPS to new FPS (using a scale factor) ...
def get_app
Returns the current QApplication instance of OpenShot.
Definition: app.py:55
def load
Load project from file.
def new
Try to load default project settings file, will raise error on failure.
def read_legacy_project_file
Attempt to read a legacy version 1.x openshot project file.
def generate_id
Generate random alphanumeric ids.
def set
Prevent calling JsonDataStore set() method.
def check_if_paths_are_valid
Check if all paths are valid, and prompt to update them if needed.
def get
Get copied value of a given key in data store.
Definition: project_data.py:68
This class allows advanced searching of data structure, implements changes interface.
Definition: project_data.py:45
def move_temp_paths_to_project_folder
Move all temp files (such as Thumbnails, Titles, and Blender animations) to the project folder...
def save
Save project file to disk.
def needs_save
Returns if project data Has unsaved changes.
Definition: project_data.py:63
def upgrade_project_data_structures
Fix any issues with old project files (if any)
def changed
This method is invoked by the UpdateManager each time a change happens (i.e UpdateInterface) ...
def get_settings
Get the current QApplication's settings instance.
Definition: settings.py:44