Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doc/developers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ For PyQt6, install these packages:
qt6-tools-dev \
qt6-svg-dev

The PyQt6 runtime must provide ``QtCore``, ``QtGui``, ``QtWidgets``,
``QtSvg``, ``QtStateMachine``, and ``uic``. Some distributions split these
modules into separate packages. In particular, Ubuntu 24.04's
``python3-pyqt6`` package does not include ``QtStateMachine``. On that
release, use the PySide6 packages above or install the complete PyQt6 wheel
in a Python virtual environment:

.. code-block:: bash

python3 -m venv .venv
.venv/bin/python -m pip install PyQt6

Verify the selected Qt binding and its required modules before launching
OpenShot:

.. code-block:: bash

PYTHONPATH=src OPENSHOT_QT_API=pyqt6 .venv/bin/python -c \
"from qt_api import QT_API, QStateMachine; print(QT_API, QStateMachine)"

At this point, you should have all 3 OpenShot components source code cloned into local folders, the OpenShot
daily PPA installed, and all of the required development and runtime dependencies installed. This is a
great start, and we are now ready to start compiling some code!
Expand Down
26 changes: 26 additions & 0 deletions installer/build_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
windows_32bit = False
version_info = {}
windows_mode = "full"
LINUX_PORTAL_THEME_PLUGIN = (
"/usr/lib/x86_64-linux-gnu/qt5/plugins/platformthemes/"
"libqxdgdesktopportal.so"
)

# Create temp log
os.makedirs(os.path.join(PATH, 'build'), exist_ok=True)
Expand All @@ -85,6 +89,25 @@ def output(line):
log.write(line)


def install_linux_portal_theme(app_dir_path):
"""Bundle Qt's XDG desktop portal platform theme in the AppImage."""
if not os.path.isfile(LINUX_PORTAL_THEME_PLUGIN):
raise FileNotFoundError(
"Missing Qt XDG desktop portal plugin: %s\n"
"Install it on the build server with:\n"
" sudo apt-get install qt5-xdgdesktopportal-platformtheme"
% LINUX_PORTAL_THEME_PLUGIN
)

plugin_dir = os.path.join(
app_dir_path, "usr", "bin", "platformthemes")
os.makedirs(plugin_dir, exist_ok=True)
plugin_path = os.path.join(
plugin_dir, os.path.basename(LINUX_PORTAL_THEME_PLUGIN))
shutil.copy2(LINUX_PORTAL_THEME_PLUGIN, plugin_path)
output("Bundled Qt XDG desktop portal plugin: %s" % plugin_path)


def run_command(command, working_dir=None):
"""Utility function to return output from command line"""
short_command = shlex.split(command)[0] # We don't need to print args
Expand Down Expand Up @@ -748,6 +771,9 @@ def main():
shutil.copytree(os.path.join(PATH, "build", exe_dir),
os.path.join(app_dir_path, "usr", "bin"))

# Prefer the desktop's native file picker through XDG portals.
install_linux_portal_theme(app_dir_path)

# Copy .desktop file, replacing Exec= commandline
desk_in = os.path.join(PATH, "xdg", "org.openshot.OpenShot.desktop")
desk_out = os.path.join(app_dir_path, "org.openshot.OpenShot.desktop")
Expand Down
7 changes: 7 additions & 0 deletions installer/launch-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export LD_LIBRARY_PATH="${HERE}"
# Set some environment variables
export QT_PLUGIN_PATH="${HERE}"

# Prefer native desktop file dialogs through XDG Desktop Portal. Respect an
# explicit user override, and only select the theme when it was bundled.
if [[ -z "${QT_QPA_PLATFORMTHEME:-}" \
&& -f "${HERE}/platformthemes/libqxdgdesktopportal.so" ]]; then
export QT_QPA_PLATFORMTHEME="xdgdesktopportal"
fi

# For Debian-based systems with newer openssl, see:
# https://github.com/OpenShot/openshot-qt/issues/3242
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=918727
Expand Down
21 changes: 18 additions & 3 deletions src/classes/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
import traceback
import json

from qt_api import QT_API, QT_VERSION_STR, BINDING_VERSION_STR, Slot
from qt_api import QT_API, QT_VERSION_STR, BINDING_VERSION_STR, Qt, Slot
from qt_api import QApplication, QMessageBox, QTimer
from qt_api import request_android_storage_permission_if_needed

Expand Down Expand Up @@ -202,6 +202,21 @@ def check_libopenshot_version(self, info, openshot):
level="error",
))

@staticmethod
def _show_main_window(window):
"""Map the final window geometry, then restore its serialized dock state."""
state = window.windowState()
window.show()
if state & Qt.WindowFullScreen:
QTimer.singleShot(
0, lambda: (window.showNormal(), window.showFullScreen()))
elif state & Qt.WindowMaximized:
QTimer.singleShot(
0, lambda: (window.showNormal(), window.showMaximized()))
restore_state = getattr(window, "_restore_saved_window_state", None)
if callable(restore_state):
QTimer.singleShot(100, restore_state)

def gui(self):
"""
Initialize GUI and main window.
Expand Down Expand Up @@ -267,8 +282,8 @@ def gui(self):
# Connect our exit signals
self.aboutToQuit.connect(self.cleanup)

# Show main window
self.window.show()
# Show the main window using the state restored from saveGeometry().
self._show_main_window(self.window)

# On Android, prompt for All Files Access once the window is visible so
# the permission is in place before the user first taps Import Files.
Expand Down
6 changes: 3 additions & 3 deletions src/classes/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
import os
from time import strftime

VERSION = "3.5.1-dev"
MINIMUM_LIBOPENSHOT_VERSION = "0.7.0"
DATE = "20260402000000"
VERSION = "4.0.0"
MINIMUM_LIBOPENSHOT_VERSION = "1.0.0"
DATE = "20260725000000"
NAME = "openshot-qt"
PRODUCT_NAME = "OpenShot Video Editor"
GPL_VERSION = "3"
Expand Down
121 changes: 121 additions & 0 deletions src/classes/project_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import glob
import os
import random
import re
import shutil
import json

Expand Down Expand Up @@ -928,10 +929,130 @@ def upgrade_project_data_structures(self):
stroke_alpha = point.get("co", {}).get("Y", 1.0)
point["co"]["Y"] = 1.0 - stroke_alpha

# libopenshot 1.0 corrected location_x/y so +/-1 moves a scaled clip
# fully offscreen. Preserve the visual positions stored by released
# libopenshot versions which used the canvas size for these offsets.
if (
self._version_at_most(libopenshot_version, "0.7.0")
and self._version_at_most(openshot_version, "3.5.1")
and "-" not in openshot_version
):
self._migrate_legacy_crop_locations()

# Fix default project id (if found)
if self._data.get("id") == "T0":
self._data["id"] = self.generate_id()

@staticmethod
def _version_at_most(version, cutoff):
"""Compare the numeric components of release-like version strings."""
def numeric_version(value):
numbers = [int(part) for part in re.findall(r"\d+", str(value))[:3]]
return tuple((numbers + [0, 0, 0])[:3])

return numeric_version(version) <= numeric_version(cutoff)

@staticmethod
def _keyframe_value(keyframe_data, frame, default):
"""Evaluate serialized keyframe data, falling back safely if malformed."""
if not isinstance(keyframe_data, dict):
return default
try:
keyframe = openshot.Keyframe()
keyframe.SetJson(json.dumps(keyframe_data))
return keyframe.GetValue(int(round(frame)))
except (RuntimeError, TypeError, ValueError):
return default

@staticmethod
def _legacy_location_factor(value, canvas_size, clip_size, alignment):
"""Return the old/new location-unit ratio for one clip axis."""
if not value or canvas_size <= 0.0 or clip_size <= 0.0:
return 1.0
if alignment == "start":
denominator = clip_size if value < 0.0 else canvas_size
elif alignment == "end":
denominator = canvas_size if value < 0.0 else clip_size
else:
denominator = (canvas_size + clip_size) / 2.0
return canvas_size / denominator if denominator else 1.0

def _migrate_legacy_crop_locations(self):
"""Preserve positions of SCALE_CROP clips saved by libopenshot <= 0.7."""
canvas_width = float(self._data.get("width") or 0.0)
canvas_height = float(self._data.get("height") or 0.0)
if canvas_width <= 0.0 or canvas_height <= 0.0:
return

files = {
file_data.get("id"): file_data
for file_data in self._data.get("files", [])
if isinstance(file_data, dict)
}
horizontal_alignment = {
openshot.GRAVITY_TOP_LEFT: "start",
openshot.GRAVITY_LEFT: "start",
openshot.GRAVITY_BOTTOM_LEFT: "start",
openshot.GRAVITY_TOP_RIGHT: "end",
openshot.GRAVITY_RIGHT: "end",
openshot.GRAVITY_BOTTOM_RIGHT: "end",
}
vertical_alignment = {
openshot.GRAVITY_TOP_LEFT: "start",
openshot.GRAVITY_TOP: "start",
openshot.GRAVITY_TOP_RIGHT: "start",
openshot.GRAVITY_BOTTOM_LEFT: "end",
openshot.GRAVITY_BOTTOM: "end",
openshot.GRAVITY_BOTTOM_RIGHT: "end",
}

for clip in self._data.get("clips", []):
if clip.get("scale") != openshot.SCALE_CROP:
continue

reader = clip.get("reader") or files.get(clip.get("file_id"), {})
source_width = float(reader.get("width") or 0.0)
source_height = float(reader.get("height") or 0.0)
if source_width <= 0.0 or source_height <= 0.0:
continue

crop_scale = max(
canvas_width / source_width,
canvas_height / source_height,
)
base_width = source_width * crop_scale
base_height = source_height * crop_scale
gravity = clip.get("gravity", openshot.GRAVITY_CENTER)
x_alignment = horizontal_alignment.get(gravity, "center")
y_alignment = vertical_alignment.get(gravity, "center")
migrated = False

for property_name, canvas_size, base_size, alignment, scale_name in (
("location_x", canvas_width, base_width, x_alignment, "scale_x"),
("location_y", canvas_height, base_height, y_alignment, "scale_y"),
):
for point in clip.get(property_name, {}).get("Points", []):
coordinate = point.get("co")
if not isinstance(coordinate, dict) or "Y" not in coordinate:
continue
frame = coordinate.get("X", 1.0)
value = coordinate["Y"]
scale_value = self._keyframe_value(
clip.get(scale_name), frame, 1.0
)
factor = self._legacy_location_factor(
value, canvas_size, base_size * scale_value, alignment
)
if factor != 1.0:
coordinate["Y"] = value * factor
migrated = True

if migrated:
log.info(
"Migrating legacy SCALE_CROP location keyframes for clip %s",
clip.get("id", "<unknown>"),
)

def is_keyframe_valid(self, keyframe, default_value):
"""Check if a keyframe is not empty (i.e. > 1 point, or a non default_value)"""
points = keyframe.get("Points", [])
Expand Down
6 changes: 5 additions & 1 deletion src/language/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
ALL: openshot_lang.py

PYTHON ?= python3
PYRCC ?= pyrcc5

TRANSLATIONS:=$(shell /usr/bin/find ./ -type f -iname \*.qm -newer openshot_lang.py)

openshot_lang.py: openshot_lang.qrc $(TRANSLATIONS)
pyrcc5 openshot_lang.qrc -o $@
$(PYRCC) openshot_lang.qrc -o $@
$(PYTHON) neutralize_resource_import.py $@
Loading
Loading