From 25947e722b7c666860241cdeb565f7fb331cfc1a Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 24 Apr 2026 23:07:46 -0500 Subject: [PATCH 01/23] Add Film Grain clip presets and UI integration - Add Film Grain preset helper with editable preset values - Add clip context menu presets: No Film Grain, 35mm Fine, 35mm Classic, 35mm Gritty, 16mm Classic, Super 8, High ISO - Add timeline helper tests for applying, replacing, and removing Film Grain effects - Add preset unit tests and Film Grain effect icons - Assign Film Grain a timeline color - Fix integer property dragging for Seed and remove noisy drag debug logging --- src/classes/film_grain_presets.py | 157 +++++++++++++++++++ src/effects/icons/filmgrain.png | Bin 0 -> 25052 bytes src/effects/icons/filmgrain@2x.png | Bin 0 -> 98640 bytes src/tests/test_film_grain_presets.py | 80 ++++++++++ src/tests/test_timeline_helpers.py | 131 ++++++++++++++++ src/windows/views/properties_tableview.py | 15 +- src/windows/views/timeline.py | 95 +++++++++++ src/windows/views/timeline_backend/colors.py | 1 + 8 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 src/classes/film_grain_presets.py create mode 100644 src/effects/icons/filmgrain.png create mode 100644 src/effects/icons/filmgrain@2x.png create mode 100644 src/tests/test_film_grain_presets.py diff --git a/src/classes/film_grain_presets.py b/src/classes/film_grain_presets.py new file mode 100644 index 0000000000..2b781f1895 --- /dev/null +++ b/src/classes/film_grain_presets.py @@ -0,0 +1,157 @@ +""" + @file + @brief Reusable Film Grain preset payload helpers +""" + +import copy + + +FILM_GRAIN_CLASS_NAME = "FilmGrain" + +FILM_GRAIN_PRESET_NONE = "none" +FILM_GRAIN_PRESET_35MM_FINE = "35mm_fine" +FILM_GRAIN_PRESET_35MM_CLASSIC = "35mm_classic" +FILM_GRAIN_PRESET_35MM_GRITTY = "35mm_gritty" +FILM_GRAIN_PRESET_16MM_CLASSIC = "16mm_classic" +FILM_GRAIN_PRESET_SUPER_8 = "super_8" +FILM_GRAIN_PRESET_HIGH_ISO = "high_iso" + + +def _constant_property(value): + return { + "Points": [ + { + "co": {"X": 1.0, "Y": float(value)}, + "handle_left": {"X": 0.5, "Y": 1.0}, + "handle_right": {"X": 0.5, "Y": 0.0}, + "handle_type": 0, + "interpolation": 0, + } + ] + } + + +def _set_scalar(effect_json, key, value): + effect_json[key] = _constant_property(value) + + +def is_film_grain_effect(effect_json): + if not isinstance(effect_json, dict): + return False + return effect_json.get("class_name") == FILM_GRAIN_CLASS_NAME + + +def _base_values(): + return { + "amount": 0.25, + "size": 0.20, + "softness": 0.25, + "clump": 0.20, + "shadows": 0.80, + "midtones": 1.00, + "highlights": 0.55, + "color_amount": 0.20, + "color_variation": 0.35, + "evolution": 0.65, + "coherence": 0.55, + } + + +def apply_film_grain_preset(effect_json, preset_name): + payload = copy.deepcopy(effect_json or {}) + + values = _base_values() + if preset_name == FILM_GRAIN_PRESET_35MM_FINE: + values.update({ + "amount": 0.14, + "size": 0.12, + "softness": 0.35, + "clump": 0.10, + "shadows": 0.65, + "midtones": 0.70, + "highlights": 0.35, + "color_amount": 0.08, + "color_variation": 0.20, + "evolution": 0.45, + "coherence": 0.75, + }) + elif preset_name == FILM_GRAIN_PRESET_35MM_CLASSIC: + values.update({ + "amount": 0.24, + "size": 0.18, + "softness": 0.22, + "clump": 0.18, + "shadows": 0.85, + "midtones": 1.00, + "highlights": 0.55, + "color_amount": 0.16, + "color_variation": 0.30, + "evolution": 0.60, + "coherence": 0.62, + }) + elif preset_name == FILM_GRAIN_PRESET_35MM_GRITTY: + values.update({ + "amount": 0.34, + "size": 0.32, + "softness": 0.28, + "clump": 0.30, + "shadows": 0.95, + "midtones": 1.00, + "highlights": 0.62, + "color_amount": 0.20, + "color_variation": 0.38, + "evolution": 0.66, + "coherence": 0.56, + }) + elif preset_name == FILM_GRAIN_PRESET_16MM_CLASSIC: + values.update({ + "amount": 0.42, + "size": 0.46, + "softness": 0.38, + "clump": 0.44, + "shadows": 1.00, + "midtones": 1.00, + "highlights": 0.70, + "color_amount": 0.28, + "color_variation": 0.48, + "evolution": 0.75, + "coherence": 0.45, + }) + elif preset_name == FILM_GRAIN_PRESET_SUPER_8: + values.update({ + "amount": 0.62, + "size": 0.72, + "softness": 0.50, + "clump": 0.70, + "shadows": 1.00, + "midtones": 0.95, + "highlights": 0.85, + "color_amount": 0.42, + "color_variation": 0.65, + "evolution": 0.88, + "coherence": 0.32, + }) + elif preset_name == FILM_GRAIN_PRESET_HIGH_ISO: + values.update({ + "amount": 0.52, + "size": 0.24, + "softness": 0.18, + "clump": 0.24, + "shadows": 1.00, + "midtones": 0.95, + "highlights": 0.42, + "color_amount": 0.55, + "color_variation": 0.78, + "evolution": 0.82, + "coherence": 0.38, + }) + else: + raise ValueError("Unknown film grain preset: {}".format(preset_name)) + + for key, value in values.items(): + _set_scalar(payload, key, value) + + if "seed" not in payload: + payload["seed"] = 1 + + return payload diff --git a/src/effects/icons/filmgrain.png b/src/effects/icons/filmgrain.png new file mode 100644 index 0000000000000000000000000000000000000000..17d1663204e9705234365fde02f0fa8102e3c817 GIT binary patch literal 25052 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 DV8&;5 literal 0 HcmV?d00001 diff --git a/src/effects/icons/filmgrain@2x.png b/src/effects/icons/filmgrain@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..cd2706535bc214e671064732dd861264272a83d7 GIT binary patch literal 98640 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ì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 0: # Move to the left by a small amount - self.new_value -= 0.50 + self.new_value -= step elif drag_diff < 0: # Move to the right by a small amount - self.new_value += 0.50 + self.new_value += step # Clamp value between min and max (just incase user drags too big) self.new_value = max(property_min, self.new_value) self.new_value = min(property_max, self.new_value) if property_type == "int": - self.new_value = round(self.new_value, 0) + if self.new_value >= 0: + self.new_value = math.floor(self.new_value + 0.5) + else: + self.new_value = math.ceil(self.new_value - 0.5) # Update value of this property self.clip_properties_model.value_updated(self.selected_item, -1, self.new_value) @@ -1093,6 +1097,7 @@ def mouseReleaseEvent(self, event): # Allow new selection and prepare to set minimum move threshold self.lock_selection = False self.previous_x = -1 + self.new_value = None @pyqtSlot(QColor) def color_callback(self, newColor: QColor): diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index 7f9abcd853..da09574411 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -55,6 +55,18 @@ apply_color_grade_preset, is_color_grade_effect, ) +from classes.film_grain_presets import ( + FILM_GRAIN_CLASS_NAME, + FILM_GRAIN_PRESET_16MM_CLASSIC, + FILM_GRAIN_PRESET_35MM_CLASSIC, + FILM_GRAIN_PRESET_35MM_FINE, + FILM_GRAIN_PRESET_35MM_GRITTY, + FILM_GRAIN_PRESET_HIGH_ISO, + FILM_GRAIN_PRESET_NONE, + FILM_GRAIN_PRESET_SUPER_8, + apply_film_grain_preset, + is_film_grain_effect, +) from classes.effect_init import effect_options from classes.logger import log from classes.query import File, Clip, Transition, Track, Effect @@ -1585,6 +1597,31 @@ def ShowClipMenu(self, clip_id=None): Analyze_Colors.triggered.connect(lambda: get_app().window.show_scope_video_docks()) menu.addMenu(Color_Menu) + Film_Grain_Menu = StyledContextMenu(title=_("Film Grain"), parent=self) + Film_Grain_None = Film_Grain_Menu.addAction(_("No Film Grain")) + Film_Grain_None.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_NONE, clip_ids)) + Film_Grain_Menu.addSeparator() + Film_Grain_35mm_Fine = Film_Grain_Menu.addAction(_("35mm Fine")) + Film_Grain_35mm_Fine.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_35MM_FINE, clip_ids)) + Film_Grain_35mm_Classic = Film_Grain_Menu.addAction(_("35mm Classic")) + Film_Grain_35mm_Classic.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_35MM_CLASSIC, clip_ids)) + Film_Grain_35mm_Gritty = Film_Grain_Menu.addAction(_("35mm Gritty")) + Film_Grain_35mm_Gritty.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_35MM_GRITTY, clip_ids)) + Film_Grain_16mm_Classic = Film_Grain_Menu.addAction(_("16mm Classic")) + Film_Grain_16mm_Classic.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_16MM_CLASSIC, clip_ids)) + Film_Grain_Super_8 = Film_Grain_Menu.addAction(_("Super 8")) + Film_Grain_Super_8.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_SUPER_8, clip_ids)) + Film_Grain_High_ISO = Film_Grain_Menu.addAction(_("High ISO")) + Film_Grain_High_ISO.triggered.connect(partial( + self.Film_Grain_Triggered, FILM_GRAIN_PRESET_HIGH_ISO, clip_ids)) + menu.addMenu(Film_Grain_Menu) + # Layout Menu Layout_Menu = StyledContextMenu(title=_("Layout"), parent=self) Layout_None = Layout_Menu.addAction(_("Reset Layout")) @@ -2193,6 +2230,13 @@ def _create_color_grade_effect_json(self): effect.Id(get_app().project.generate_id()) return json.loads(effect.Json()) + def _create_film_grain_effect_json(self): + effect = openshot.EffectInfo().CreateEffect(FILM_GRAIN_CLASS_NAME) + if effect is None: + raise RuntimeError("Unable to create Film Grain effect") + effect.Id(get_app().project.generate_id()) + return json.loads(effect.Json()) + def _ensure_color_grade_effect(self, clip): if not clip or not self._clip_has_video(clip): return None, False @@ -2259,6 +2303,57 @@ def Color_Triggered(self, preset_name, clip_ids): self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) get_app().updates.apply_last_action_to_history(original_clip_data) + def Film_Grain_Triggered(self, preset_name, clip_ids): + """Apply Film Grain presets for selected clips.""" + for clip_id in clip_ids: + clip = Clip.get(id=clip_id) + if not clip or not self._clip_has_video(clip): + continue + + original_clip_data = json.loads(json.dumps(clip.data)) + effects = clip.data.get("effects") + if not isinstance(effects, list): + effects = list(effects) if effects else [] + clip.data["effects"] = effects + + matching_indexes = [ + index for index, effect_json in enumerate(effects) + if is_film_grain_effect(effect_json) + ] + + if preset_name == FILM_GRAIN_PRESET_NONE: + if not matching_indexes: + continue + clip.data["effects"] = [ + effect_json for effect_json in effects + if not is_film_grain_effect(effect_json) + ] + self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) + get_app().updates.apply_last_action_to_history(original_clip_data) + continue + + source_effect = ( + effects[matching_indexes[0]] + if matching_indexes + else self._create_film_grain_effect_json() + ) + preset_effect = apply_film_grain_preset(source_effect, preset_name) + + if matching_indexes: + existing_effect = effects[matching_indexes[0]] + if existing_effect.get("id"): + preset_effect["id"] = existing_effect["id"] + if "order" in existing_effect: + preset_effect["order"] = existing_effect["order"] + for index in reversed(matching_indexes[1:]): + del effects[index] + effects[matching_indexes[0]] = preset_effect + else: + effects.append(preset_effect) + + self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) + get_app().updates.apply_last_action_to_history(original_clip_data) + def Adjust_Colors_Triggered(self, clip_ids): """Ensure a Color Grade effect exists and open the video scopes.""" first_effect_id = None diff --git a/src/windows/views/timeline_backend/colors.py b/src/windows/views/timeline_backend/colors.py index 881ef3cfbb..a198d05af3 100644 --- a/src/windows/views/timeline_backend/colors.py +++ b/src/windows/views/timeline_backend/colors.py @@ -51,6 +51,7 @@ "Distortion": "#7393B3", "Echo": "#5C4033", "Expander": "#C4A484", + "FilmGrain": "#c49a4a", "Glow": "#e8b84b", "Hue": "#2d7b6b", "LensFlare": "#7c29d1", From a18771441576bd39049b787c21d1a5f91ce36f17 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 24 Apr 2026 23:16:32 -0500 Subject: [PATCH 02/23] Updating user-guide with Film Grain documentation and usage notes. --- doc/effects.rst | 61 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/doc/effects.rst b/doc/effects.rst index 85fde94db1..487c899060 100644 --- a/doc/effects.rst +++ b/doc/effects.rst @@ -84,7 +84,7 @@ identify a clip's start is by utilizing the 'next/previous marker' feature on th List of Effects --------------- -OpenShot Video Editor has a total of 36 built-in video and audio effects: 27 video effects and 9 audio effects. +OpenShot Video Editor has a total of 37 built-in video and audio effects: 28 video effects and 9 audio effects. These effects can be added to a clip by dragging the effect onto a clip. The following table contains the name and short description of each effect. @@ -144,6 +144,10 @@ the name and short description of each effect. :width: 50px :alt: Displacement Map Icon +.. |filmgrain_icon| image:: ../src/effects/icons/filmgrain@2x.png + :width: 50px + :alt: Film Grain Icon + .. |glow_icon| image:: ../src/effects/icons/glow@2x.png :width: 50px :alt: Glow Icon @@ -256,6 +260,7 @@ the name and short description of each effect. |crop_icon| Crop Crop out parts of your video. |deinterlace_icon| Deinterlace Remove interlacing from video. |displace_icon| Displacement Map Use a grayscale image or video to warp the frame. + |filmgrain_icon| Film Grain Add natural film-inspired texture and motion. |glow_icon| Glow Add a soft outer or inner glow to visible pixels. |hue_icon| Hue Adjust hue / color. |lensflare_icon| Lens Flare Simulate sunlight hitting a lens with flares. @@ -932,6 +937,60 @@ Usage Notes replace_image ``(int, choices: ['Yes', 'No'])`` Replace the output image with the processed map, useful for previewing or debugging the distortion map ========================== ============================================================================ +Film Grain +"""""""""" +The **Film Grain** effect adds a gentle moving texture to your video, similar to the tiny speckles you see in +real film photography. This can make very clean digital footage feel warmer, more natural, or more cinematic. +It can also help blend mixed footage together, especially when one clip looks too sharp or too smooth compared +to the rest of your project. + +If you are new to film grain, start small. A little grain can add life and texture without calling attention to +itself. Stronger settings can be useful for vintage looks, music videos, horror scenes, documentary recreations, +or footage that should feel like older 16mm or Super 8 film. + +You can add Film Grain from the :guilabel:`Effects` tab, or right-click a clip and choose +:guilabel:`Film Grain` to start with a preset such as :guilabel:`35mm Fine`, +:guilabel:`35mm Classic`, :guilabel:`16mm Classic`, :guilabel:`Super 8`, or +:guilabel:`High ISO`. Presets only set the properties for you; all controls remain visible and editable. + +Simple starting points: + +- **Clean cinematic texture**: try :guilabel:`35mm Fine`, then lower ``amount`` if it feels too visible. +- **Classic film look**: try :guilabel:`35mm Classic` for a balanced grain pattern. +- **Older home-movie style**: try :guilabel:`Super 8`, which uses larger, more active grain. +- **Low-light camera noise style**: try :guilabel:`High ISO`, then adjust ``color_amount`` to control how colorful the grain feels. + +The grain is deterministic, which means the same settings render the same grain pattern each time. Change +``seed`` when you want a different repeatable grain pattern on a clip. + +.. table:: + :widths: 26 80 + + ========================== ============================================================================ + Property Name Description + ========================== ============================================================================ + amount ``(float, 0 to 1)`` Overall grain intensity. Lower values are subtle; higher values are more visible and gritty. + size ``(float, 0 to 1)`` Grain scale. Lower values create fine grain; higher values create larger, coarser grain. + softness ``(float, 0 to 1)`` Softens the grain texture. Lower values look crisp; higher values look smoother and more organic. + clump ``(float, 0 to 1)`` Controls how even or clustered the grain appears. Higher values create more irregular groups of grain. + shadows ``(float, 0 to 1)`` Grain strength in dark areas of the image. + midtones ``(float, 0 to 1)`` Grain strength in middle brightness areas, such as skin tones and everyday objects. + highlights ``(float, 0 to 1)`` Grain strength in bright areas, such as skies, windows, and lights. + color_amount ``(float, 0 to 1)`` How much the grain affects color. Lower values are mostly luma grain; higher values add more chroma grain. + color_variation ``(float, 0 to 1)`` How independently the red, green, and blue grain changes. Higher values feel more colorful and random. + evolution ``(float, 0 to 1)`` How much the grain renews over time. Higher values make the texture change more from frame to frame. + coherence ``(float, 0 to 1)`` How stable and smooth the grain remains between frames. Higher values feel calmer and less jumpy. + seed ``(int, 0 to 1000000)`` Selects the exact repeatable grain pattern. Change this to get a different look without changing intensity. + ========================== ============================================================================ + +**Usage notes** + +- Grain is easiest to judge while the video is playing, not on a single paused frame. +- If faces or bright skies look too noisy, lower ``highlights`` or ``amount``. +- If shadows look too clean compared to the rest of the image, raise ``shadows`` slightly. +- If the grain looks too digital or sharp, raise ``softness`` or lower ``color_variation``. +- If the grain looks too busy during motion, lower ``evolution`` or raise ``coherence``. + Glow """" The Glow effect creates a soft halo from the clip's visible pixels. It can render either outside the subject From d85447a075a0943428f7678dd2c51b8fe6e23ad1 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sat, 25 Apr 2026 18:35:09 -0500 Subject: [PATCH 03/23] =?UTF-8?q?Reorganize=20clip=20context=20menu=20for?= =?UTF-8?q?=20clarity=20and=20discoverability=20-=20Group=20Rotate,=20Crop?= =?UTF-8?q?,=20and=20Layout=20under=20a=20new=20Transform=20submenu=20with?= =?UTF-8?q?=20a=20"No=20Transform"=20reset=20(single=20undo=20step)=20-=20?= =?UTF-8?q?Rename=20Color=20=E2=86=92=20Look;=20nest=20Color=20presets=20a?= =?UTF-8?q?nd=20Film=20Grain=20as=20parallel=20submenus=20with=20a=20unifi?= =?UTF-8?q?ed=20"Reset=20Look"=20-=20Rename=20Volume=20=E2=86=92=20Audio;?= =?UTF-8?q?=20nest=20volume=20controls=20under=20Volume;=20move=20Separate?= =?UTF-8?q?=20Audio=20(renamed=20to=20Separate)=20and=20waveform=20toggle?= =?UTF-8?q?=20inside=20-=20Rename=20Time=20=E2=86=92=20Speed,=20Fast/Slow?= =?UTF-8?q?=20=E2=86=92=20Speed=20Up/Slow=20Down,=20"Reset=20Time"=20?= =?UTF-8?q?=E2=86=92=20Reset=20-=20Rename=20Animate=20=E2=86=92=20Motion?= =?UTF-8?q?=20-=20Remove=20standalone=20Display=20submenu;=20waveform=20to?= =?UTF-8?q?ggle=20moves=20into=20Audio=20(audio-only=20clips)=20-=20Add=20?= =?UTF-8?q?view-waveform.svg=20and=20view-waveform-flat.svg=20icons=20-=20?= =?UTF-8?q?Remove=20stray=20debug=20print()=20statement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cosmic/images/view-waveform-flat.svg | 3 + src/themes/cosmic/images/view-waveform.svg | 6 + src/windows/views/timeline.py | 181 +++++++++++------- 3 files changed, 116 insertions(+), 74 deletions(-) create mode 100644 src/themes/cosmic/images/view-waveform-flat.svg create mode 100644 src/themes/cosmic/images/view-waveform.svg diff --git a/src/themes/cosmic/images/view-waveform-flat.svg b/src/themes/cosmic/images/view-waveform-flat.svg new file mode 100644 index 0000000000..10dfb9f87a --- /dev/null +++ b/src/themes/cosmic/images/view-waveform-flat.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/themes/cosmic/images/view-waveform.svg b/src/themes/cosmic/images/view-waveform.svg new file mode 100644 index 0000000000..3d7a671f01 --- /dev/null +++ b/src/themes/cosmic/images/view-waveform.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index da09574411..fba2e42b67 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -1453,9 +1453,9 @@ def ShowClipMenu(self, clip_id=None): Fade_Menu.addMenu(Position_Menu) menu.addMenu(Fade_Menu) - # Animate Menu - Animate_Menu = StyledContextMenu(title=_("Animate"), parent=self) - Animate_None = Animate_Menu.addAction(_("No Animation")) + # Motion Menu + Animate_Menu = StyledContextMenu(title=_("Motion"), parent=self) + Animate_None = Animate_Menu.addAction(_("No Motion")) Animate_None.triggered.connect(partial(self.Animate_Triggered, MenuAnimate.NONE, clip_ids)) Animate_Menu.addSeparator() for position, position_label in [ @@ -1546,7 +1546,12 @@ def ShowClipMenu(self, clip_id=None): # Add Each position menu menu.addMenu(Animate_Menu) - # Rotate Menu + # Transform Menu (Rotate, Crop, Layout) + Transform_Menu = StyledContextMenu(title=_("Transform"), parent=self) + No_Transform = Transform_Menu.addAction(_("No Transform")) + No_Transform.triggered.connect(partial(self.No_Transform_Triggered, clip_ids)) + Transform_Menu.addSeparator() + Rotation_Menu = StyledContextMenu(title=_("Rotate"), parent=self) Rotation_None = Rotation_Menu.addAction(_("No Rotation")) Rotation_None.triggered.connect(partial( @@ -1561,7 +1566,7 @@ def ShowClipMenu(self, clip_id=None): Rotation_180_Flip = Rotation_Menu.addAction(_("Rotate 180 (Flip)")) Rotation_180_Flip.triggered.connect(partial( self.Rotate_Triggered, MenuRotate.FLIP_180, clip_ids)) - menu.addMenu(Rotation_Menu) + Transform_Menu.addMenu(Rotation_Menu) Crop_Menu = StyledContextMenu(title=_("Crop"), parent=self) Crop_None = Crop_Menu.addAction(_("No Crop")) @@ -1571,13 +1576,50 @@ def ShowClipMenu(self, clip_id=None): Crop_NoResize.triggered.connect(partial(self.Crop_Triggered, clip_ids, 'crop')) Crop_Resize = Crop_Menu.addAction(_("Crop (Resize)")) Crop_Resize.triggered.connect(partial(self.Crop_Triggered, clip_ids, 'resize')) - menu.addMenu(Crop_Menu) + Transform_Menu.addMenu(Crop_Menu) + + Layout_Menu = StyledContextMenu(title=_("Layout"), parent=self) + Layout_None = Layout_Menu.addAction(_("Reset Layout")) + Layout_None.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.NONE, clip_ids)) + Layout_Menu.addSeparator() + Layout_Center = Layout_Menu.addAction(_("1/4 Size - Center")) + Layout_Center.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.CENTER, clip_ids)) + Layout_Top_Left = Layout_Menu.addAction(_("1/4 Size - Top Left")) + Layout_Top_Left.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.TOP_LEFT, clip_ids)) + Layout_Top_Right = Layout_Menu.addAction(_("1/4 Size - Top Right")) + Layout_Top_Right.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.TOP_RIGHT, clip_ids)) + Layout_Bottom_Left = Layout_Menu.addAction(_("1/4 Size - Bottom Left")) + Layout_Bottom_Left.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.BOTTOM_LEFT, clip_ids)) + Layout_Bottom_Right = Layout_Menu.addAction(_("1/4 Size - Bottom Right")) + Layout_Bottom_Right.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.BOTTOM_RIGHT, clip_ids)) + Layout_Menu.addSeparator() + Layout_Bottom_All_With_Aspect = Layout_Menu.addAction(_("Show All (Maintain Ratio)")) + Layout_Bottom_All_With_Aspect.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.ALL_WITH_ASPECT, clip_ids)) + Layout_Bottom_All_Without_Aspect = Layout_Menu.addAction(_("Show All (Distort)")) + Layout_Bottom_All_Without_Aspect.triggered.connect(partial( + self.Layout_Triggered, MenuLayout.ALL_WITHOUT_ASPECT, clip_ids)) + Transform_Menu.addMenu(Layout_Menu) + + menu.addMenu(Transform_Menu) if self._clip_has_video(clip): + # Look Menu (Color + Film Grain) + Look_Menu = StyledContextMenu(title=_("Look"), parent=self) + Reset_Look = Look_Menu.addAction(_("Reset Look")) + Reset_Look.triggered.connect(lambda: ( + self.Color_Triggered(COLOR_PRESET_RESET, clip_ids), + self.Film_Grain_Triggered(FILM_GRAIN_PRESET_NONE, clip_ids) + )) + Look_Menu.addSeparator() + Color_Menu = StyledContextMenu(title=_("Color"), parent=self) - Reset_Color = Color_Menu.addAction(_("Reset Color")) - Reset_Color.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_RESET, clip_ids)) - Color_Menu.addSeparator() Auto_Contrast = Color_Menu.addAction(_("Auto Contrast")) Auto_Contrast.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_AUTO_CONTRAST, clip_ids)) Lift_Shadows = Color_Menu.addAction(_("Lift Shadows")) @@ -1586,16 +1628,7 @@ def ShowClipMenu(self, clip_id=None): Warm_Up.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_WARM_UP, clip_ids)) Boost_Color = Color_Menu.addAction(_("Boost Color")) Boost_Color.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_BOOST_COLOR, clip_ids)) - Color_Menu.addSeparator() - Adjust_Colors = Color_Menu.addAction( - QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-color.svg")), - _("Adjust Colors")) - Adjust_Colors.triggered.connect(partial(self.Adjust_Colors_Triggered, clip_ids)) - Analyze_Colors = Color_Menu.addAction( - QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-analysis.svg")), - _("Analyze Colors")) - Analyze_Colors.triggered.connect(lambda: get_app().window.show_scope_video_docks()) - menu.addMenu(Color_Menu) + Look_Menu.addMenu(Color_Menu) Film_Grain_Menu = StyledContextMenu(title=_("Film Grain"), parent=self) Film_Grain_None = Film_Grain_Menu.addAction(_("No Film Grain")) @@ -1620,41 +1653,23 @@ def ShowClipMenu(self, clip_id=None): Film_Grain_High_ISO = Film_Grain_Menu.addAction(_("High ISO")) Film_Grain_High_ISO.triggered.connect(partial( self.Film_Grain_Triggered, FILM_GRAIN_PRESET_HIGH_ISO, clip_ids)) - menu.addMenu(Film_Grain_Menu) + Look_Menu.addMenu(Film_Grain_Menu) - # Layout Menu - Layout_Menu = StyledContextMenu(title=_("Layout"), parent=self) - Layout_None = Layout_Menu.addAction(_("Reset Layout")) - Layout_None.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.NONE, clip_ids)) - Layout_Menu.addSeparator() - Layout_Center = Layout_Menu.addAction(_("1/4 Size - Center")) - Layout_Center.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.CENTER, clip_ids)) - Layout_Top_Left = Layout_Menu.addAction(_("1/4 Size - Top Left")) - Layout_Top_Left.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.TOP_LEFT, clip_ids)) - Layout_Top_Right = Layout_Menu.addAction(_("1/4 Size - Top Right")) - Layout_Top_Right.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.TOP_RIGHT, clip_ids)) - Layout_Bottom_Left = Layout_Menu.addAction(_("1/4 Size - Bottom Left")) - Layout_Bottom_Left.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.BOTTOM_LEFT, clip_ids)) - Layout_Bottom_Right = Layout_Menu.addAction(_("1/4 Size - Bottom Right")) - Layout_Bottom_Right.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.BOTTOM_RIGHT, clip_ids)) - Layout_Menu.addSeparator() - Layout_Bottom_All_With_Aspect = Layout_Menu.addAction(_("Show All (Maintain Ratio)")) - Layout_Bottom_All_With_Aspect.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.ALL_WITH_ASPECT, clip_ids)) - Layout_Bottom_All_Without_Aspect = Layout_Menu.addAction(_("Show All (Distort)")) - Layout_Bottom_All_Without_Aspect.triggered.connect(partial( - self.Layout_Triggered, MenuLayout.ALL_WITHOUT_ASPECT, clip_ids)) - menu.addMenu(Layout_Menu) + Look_Menu.addSeparator() + Adjust_Colors = Look_Menu.addAction( + QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-color.svg")), + _("Adjust Colors")) + Adjust_Colors.triggered.connect(partial(self.Adjust_Colors_Triggered, clip_ids)) + Analyze_Colors = Look_Menu.addAction( + QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-analysis.svg")), + _("Analyze Colors")) + Analyze_Colors.triggered.connect(lambda: get_app().window.show_scope_video_docks()) + + menu.addMenu(Look_Menu) - # Time Menu - Time_Menu = StyledContextMenu(title=_("Time"), parent=self) - Time_None = Time_Menu.addAction(_("Reset Time")) + # Speed Menu + Time_Menu = StyledContextMenu(title=_("Speed"), parent=self) + Time_None = Time_Menu.addAction(_("Reset")) Time_None.triggered.connect(partial(self.Time_Triggered, MenuTime.NONE, clip_ids, '1X')) Time_Menu.addSeparator() @@ -1665,8 +1680,8 @@ def ShowClipMenu(self, clip_id=None): Time_Menu.addSeparator() for speed, speed_values in [ - (_("Fast"), ['2X', '4X', '8X', '16X']), - (_("Slow"), ['1/2X', '1/4X', '1/8X', '1/16X']) + (_("Speed Up"), ['2X', '4X', '8X', '16X']), + (_("Slow Down"), ['1/2X', '1/4X', '1/8X', '1/16X']) ]: Speed_Menu = StyledContextMenu(title=speed, parent=self) @@ -1723,7 +1738,9 @@ def ShowClipMenu(self, clip_id=None): # Add menu to parent menu.addMenu(Time_Menu) - # Volume Menu + # Audio Menu (Volume, Separate Audio, Waveform, Analyze Levels) + Audio_Menu = StyledContextMenu(title=_("Audio"), parent=self) + Volume_Menu = StyledContextMenu(title=_("Volume"), parent=self) Volume_None = Volume_Menu.addAction(_("Reset Volume")) Volume_None.triggered.connect(partial(self.Volume_Triggered, MenuVolume.NONE, clip_ids)) @@ -1769,28 +1786,41 @@ def ShowClipMenu(self, clip_id=None): # Add levels Position_Menu.addSeparator() - # Volume levels menu optinos + # Volume levels menu options for level in reversed(range(0, 140, 10)): action = Position_Menu.addAction(_("Level {level}%").format(level=level)) action.triggered.connect(partial(self.Volume_Triggered, MenuVolume.LEVEL, clip_ids, position, level)) Volume_Menu.addMenu(Position_Menu) - Volume_Menu.addSeparator() - Analyze_Audio = Volume_Menu.addAction( - QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-analysis.svg")), - _("Audio Levels")) - Analyze_Audio.triggered.connect(lambda: get_app().window.show_scope_audio_dock()) - menu.addMenu(Volume_Menu) + Audio_Menu.addMenu(Volume_Menu) - # Add separate audio menu - Split_Audio_Channels_Menu = StyledContextMenu(title=_("Separate Audio"), parent=self) + Split_Audio_Channels_Menu = StyledContextMenu(title=_("Separate"), parent=self) Split_Single_Clip = Split_Audio_Channels_Menu.addAction(_("Single Clip (all channels)")) Split_Single_Clip.triggered.connect(partial( self.Split_Audio_Triggered, MenuSplitAudio.SINGLE, clip_ids)) Split_Multiple_Clips = Split_Audio_Channels_Menu.addAction(_("Multiple Clips (each channel)")) Split_Multiple_Clips.triggered.connect(partial( self.Split_Audio_Triggered, MenuSplitAudio.MULTIPLE, clip_ids)) - menu.addMenu(Split_Audio_Channels_Menu) + Audio_Menu.addMenu(Split_Audio_Channels_Menu) + + Audio_Menu.addSeparator() + if not self._clip_has_video(clip): + if self._clip_has_visible_waveform(clip): + ToggleWaveform = Audio_Menu.addAction( + QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-waveform-flat.svg")), + _("Hide Waveform")) + ToggleWaveform.triggered.connect(partial(self.Hide_Waveform_Triggered, clip_ids)) + else: + ToggleWaveform = Audio_Menu.addAction( + QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-waveform.svg")), + _("Show Waveform")) + ToggleWaveform.triggered.connect(partial(self.Show_Waveform_Triggered, clip_ids)) + Analyze_Levels = Audio_Menu.addAction( + QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-analysis.svg")), + _("Analyze Levels")) + Analyze_Levels.triggered.connect(lambda: get_app().window.show_scope_audio_dock()) + + menu.addMenu(Audio_Menu) # If Playhead overlapping clip if clip: @@ -1824,16 +1854,8 @@ def ShowClipMenu(self, clip_id=None): menu.addMenu(Slice_Menu) - # Add clip display menu (waveform or thumbnail) - menu.addSeparator() - Waveform_Menu = StyledContextMenu(title=_("Display"), parent=self) - ShowWaveform = Waveform_Menu.addAction(_("Show Waveform")) - ShowWaveform.triggered.connect(partial(self.Show_Waveform_Triggered, clip_ids)) - HideWaveform = Waveform_Menu.addAction(_("Show Thumbnail")) - HideWaveform.triggered.connect(partial(self.Hide_Waveform_Triggered, clip_ids)) - menu.addMenu(Waveform_Menu) - # Properties + menu.addSeparator() menu.addAction(self.window.actionProperties) # Remove Clip Menu @@ -3460,6 +3482,17 @@ def Rotate_Triggered(self, action, clip_ids, position="Start of Clip"): # Save changes self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) + def No_Transform_Triggered(self, clip_ids): + """Reset rotation, crop, and layout for all selected clips in a single undo step.""" + tid = self.get_uuid() + get_app().updates.transaction_id = tid + try: + self.Rotate_Triggered(MenuRotate.NONE, clip_ids) + self.Crop_Triggered(clip_ids, 'none') + self.Layout_Triggered(MenuLayout.NONE, clip_ids) + finally: + get_app().updates.transaction_id = None + def Time_Triggered(self, action, clip_ids, speed="1X", playhead_position=0.0): """Callback for time context menus""" log.debug(action) From da6fe632899e3f16d8a95fa761aa842328ff7947 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Sun, 26 Apr 2026 16:45:22 -0500 Subject: [PATCH 04/23] Re-organizing clip menu options - Hiding options that are audio-only, when no audio track exists - Hiding options that are video-only, when no video track or clip::waveform exists - Re-organization Fade In/Out menu to be simpler, and to clobber previous fade in/out options so they no longer collide (using this menu multiple times is fine now). - Fade In/Out now fades both audio and video (if both exist), or only audio (if audio only), or only video (if only video). - Audio & Volume menu behaves similarly to Fade In/Out, works much better, and simpler. Only appears when audio tracks are present. - Analyze Audio (to show scope for audio levels) is always present regardless of audio track - Fixed Color options to be useful anytime a video track (or waveform true) on a clip --- src/windows/views/timeline.py | 393 ++++++++++++++++------------------ 1 file changed, 183 insertions(+), 210 deletions(-) diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index fba2e42b67..a9a1b6aa32 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -1215,8 +1215,6 @@ def ShowTimelineMenu(self, position, layer_number): # Get clipboard copied_object = ClipboardManager.from_mime(get_app().clipboard().mimeData()) - if copied_object: - print(f"Copied object found: {type(copied_object).__name__}") # Determine if clipboard has FULL clip or transition data (or a list of multiple objects) has_clipboard = False @@ -1316,13 +1314,16 @@ def ShowClipMenu(self, clip_id=None): fps = get_app().project.get("fps") fps_float = float(fps["num"]) / float(fps["den"]) + # Determine visual/audio capability from reader and clip properties + _reader = clip.data.get("reader", {}) if clip else {} + clip_has_visual = bool(_reader.get("has_video", True)) or bool(clip.data.get("waveform", False)) + clip_has_audio = bool(_reader.get("has_audio", True)) + # Get playhead position playhead_position = float(self.window.preview_thread.current_frame - 1) / fps_float # Get clipboard copied_object = ClipboardManager.from_mime(get_app().clipboard().mimeData()) - if copied_object: - print(f"Copied object found: {type(copied_object).__name__}") has_clipboard = False if copied_object and isinstance(copied_object, Clip): has_clipboard = True @@ -1407,50 +1408,33 @@ def ShowClipMenu(self, clip_id=None): # Add menu to parent menu.addMenu(Alignment_Menu) - # Fade In Menu + # Fade Menu Fade_Menu = StyledContextMenu(title=_("Fade"), parent=self) Fade_None = Fade_Menu.addAction(_("No Fade")) Fade_None.triggered.connect(partial(self.Fade_Triggered, MenuFade.NONE, clip_ids)) Fade_Menu.addSeparator() - for position, position_label in [ - ("Start of Clip", _("Start of Clip")), - ("End of Clip", _("End of Clip")), - ("Entire Clip", _("Entire Clip")) - ]: - Position_Menu = StyledContextMenu(title=position_label, parent=self) - if position == "Start of Clip": - Fade_In_Fast = Position_Menu.addAction(_("Fade In (Fast)")) - Fade_In_Fast.triggered.connect(partial( - self.Fade_Triggered, MenuFade.IN_FAST, clip_ids, position)) - Fade_In_Slow = Position_Menu.addAction(_("Fade In (Slow)")) - Fade_In_Slow.triggered.connect(partial( - self.Fade_Triggered, MenuFade.IN_SLOW, clip_ids, position)) - - elif position == "End of Clip": - Fade_Out_Fast = Position_Menu.addAction(_("Fade Out (Fast)")) - Fade_Out_Fast.triggered.connect(partial( - self.Fade_Triggered, MenuFade.OUT_FAST, clip_ids, position)) - Fade_Out_Slow = Position_Menu.addAction(_("Fade Out (Slow)")) - Fade_Out_Slow.triggered.connect(partial( - self.Fade_Triggered, MenuFade.OUT_SLOW, clip_ids, position)) + Fade_In_Menu = StyledContextMenu(title=_("Fade In"), parent=self) + Fade_In_Fast = Fade_In_Menu.addAction(_("Fast")) + Fade_In_Fast.triggered.connect(partial(self.Fade_Triggered, MenuFade.IN_FAST, clip_ids, "Start of Clip")) + Fade_In_Slow = Fade_In_Menu.addAction(_("Slow")) + Fade_In_Slow.triggered.connect(partial(self.Fade_Triggered, MenuFade.IN_SLOW, clip_ids, "Start of Clip")) + Fade_Menu.addMenu(Fade_In_Menu) + + Fade_Out_Menu = StyledContextMenu(title=_("Fade Out"), parent=self) + Fade_Out_Fast = Fade_Out_Menu.addAction(_("Fast")) + Fade_Out_Fast.triggered.connect(partial(self.Fade_Triggered, MenuFade.OUT_FAST, clip_ids, "End of Clip")) + Fade_Out_Slow = Fade_Out_Menu.addAction(_("Slow")) + Fade_Out_Slow.triggered.connect(partial(self.Fade_Triggered, MenuFade.OUT_SLOW, clip_ids, "End of Clip")) + Fade_Menu.addMenu(Fade_Out_Menu) + + Fade_In_Out_Menu = StyledContextMenu(title=_("Fade In and Out"), parent=self) + Fade_In_Out_Fast = Fade_In_Out_Menu.addAction(_("Fast")) + Fade_In_Out_Fast.triggered.connect(partial(self.Fade_Triggered, MenuFade.IN_OUT_FAST, clip_ids, "Entire Clip")) + Fade_In_Out_Slow = Fade_In_Out_Menu.addAction(_("Slow")) + Fade_In_Out_Slow.triggered.connect(partial(self.Fade_Triggered, MenuFade.IN_OUT_SLOW, clip_ids, "Entire Clip")) + Fade_Menu.addMenu(Fade_In_Out_Menu) - else: - Fade_In_Out_Fast = Position_Menu.addAction(_("Fade In and Out (Fast)")) - Fade_In_Out_Fast.triggered.connect(partial( - self.Fade_Triggered, MenuFade.IN_OUT_FAST, clip_ids, position)) - Fade_In_Out_Slow = Position_Menu.addAction(_("Fade In and Out (Slow)")) - Fade_In_Out_Slow.triggered.connect(partial( - self.Fade_Triggered, MenuFade.IN_OUT_SLOW, clip_ids, position)) - Position_Menu.addSeparator() - Fade_In_Slow = Position_Menu.addAction(_("Fade In (Entire Clip)")) - Fade_In_Slow.triggered.connect(partial( - self.Fade_Triggered, MenuFade.IN_SLOW, clip_ids, position)) - Fade_Out_Slow = Position_Menu.addAction(_("Fade Out (Entire Clip)")) - Fade_Out_Slow.triggered.connect(partial( - self.Fade_Triggered, MenuFade.OUT_SLOW, clip_ids, position)) - - Fade_Menu.addMenu(Position_Menu) menu.addMenu(Fade_Menu) # Motion Menu @@ -1544,7 +1528,8 @@ def ShowClipMenu(self, clip_id=None): Animate_Menu.addMenu(Position_Menu) # Add Each position menu - menu.addMenu(Animate_Menu) + if clip_has_visual: + menu.addMenu(Animate_Menu) # Transform Menu (Rotate, Crop, Layout) Transform_Menu = StyledContextMenu(title=_("Transform"), parent=self) @@ -1607,9 +1592,10 @@ def ShowClipMenu(self, clip_id=None): self.Layout_Triggered, MenuLayout.ALL_WITHOUT_ASPECT, clip_ids)) Transform_Menu.addMenu(Layout_Menu) - menu.addMenu(Transform_Menu) + if clip_has_visual: + menu.addMenu(Transform_Menu) - if self._clip_has_video(clip): + if clip_has_visual: # Look Menu (Color + Film Grain) Look_Menu = StyledContextMenu(title=_("Look"), parent=self) Reset_Look = Look_Menu.addAction(_("Reset Look")) @@ -1745,54 +1731,38 @@ def ShowClipMenu(self, clip_id=None): Volume_None = Volume_Menu.addAction(_("Reset Volume")) Volume_None.triggered.connect(partial(self.Volume_Triggered, MenuVolume.NONE, clip_ids)) Volume_Menu.addSeparator() - for position, position_label in [ - ("Start of Clip", _("Start of Clip")), - ("End of Clip", _("End of Clip")), - ("Entire Clip", _("Entire Clip")) - ]: - Position_Menu = StyledContextMenu(title=position_label, parent=self) - - if position == "Start of Clip": - Fade_In_Fast = Position_Menu.addAction(_("Fade In (Fast)")) - Fade_In_Fast.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_IN_FAST, clip_ids, position)) - Fade_In_Slow = Position_Menu.addAction(_("Fade In (Slow)")) - Fade_In_Slow.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_IN_SLOW, clip_ids, position)) - - elif position == "End of Clip": - Fade_Out_Fast = Position_Menu.addAction(_("Fade Out (Fast)")) - Fade_Out_Fast.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_OUT_FAST, clip_ids, position)) - Fade_Out_Slow = Position_Menu.addAction(_("Fade Out (Slow)")) - Fade_Out_Slow.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_OUT_SLOW, clip_ids, position)) - else: - Fade_In_Out_Fast = Position_Menu.addAction(_("Fade In and Out (Fast)")) - Fade_In_Out_Fast.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_IN_OUT_FAST, clip_ids, position)) - Fade_In_Out_Slow = Position_Menu.addAction(_("Fade In and Out (Slow)")) - Fade_In_Out_Slow.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_IN_OUT_SLOW, clip_ids, position)) - Position_Menu.addSeparator() - Fade_In_Slow = Position_Menu.addAction(_("Fade In (Entire Clip)")) - Fade_In_Slow.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_IN_SLOW, clip_ids, position)) - Fade_Out_Slow = Position_Menu.addAction(_("Fade Out (Entire Clip)")) - Fade_Out_Slow.triggered.connect(partial( - self.Volume_Triggered, MenuVolume.FADE_OUT_SLOW, clip_ids, position)) - - # Add levels - Position_Menu.addSeparator() + Vol_Level_Menu = StyledContextMenu(title=_("Level"), parent=self) + for level in reversed(range(0, 140, 10)): + vol_action = Vol_Level_Menu.addAction(_("Level {level}%").format(level=level)) + vol_action.triggered.connect(partial(self.Volume_Triggered, MenuVolume.LEVEL, clip_ids, "Entire Clip", level)) + Volume_Menu.addMenu(Vol_Level_Menu) - # Volume levels menu options - for level in reversed(range(0, 140, 10)): - action = Position_Menu.addAction(_("Level {level}%").format(level=level)) - action.triggered.connect(partial(self.Volume_Triggered, MenuVolume.LEVEL, clip_ids, position, level)) + Volume_Menu.addSeparator() - Volume_Menu.addMenu(Position_Menu) - Audio_Menu.addMenu(Volume_Menu) + Vol_Fade_In_Menu = StyledContextMenu(title=_("Fade In"), parent=self) + Vol_Fade_In_Fast = Vol_Fade_In_Menu.addAction(_("Fast")) + Vol_Fade_In_Fast.triggered.connect(partial(self.Volume_Triggered, MenuVolume.FADE_IN_FAST, clip_ids, "Start of Clip")) + Vol_Fade_In_Slow = Vol_Fade_In_Menu.addAction(_("Slow")) + Vol_Fade_In_Slow.triggered.connect(partial(self.Volume_Triggered, MenuVolume.FADE_IN_SLOW, clip_ids, "Start of Clip")) + Volume_Menu.addMenu(Vol_Fade_In_Menu) + + Vol_Fade_Out_Menu = StyledContextMenu(title=_("Fade Out"), parent=self) + Vol_Fade_Out_Fast = Vol_Fade_Out_Menu.addAction(_("Fast")) + Vol_Fade_Out_Fast.triggered.connect(partial(self.Volume_Triggered, MenuVolume.FADE_OUT_FAST, clip_ids, "End of Clip")) + Vol_Fade_Out_Slow = Vol_Fade_Out_Menu.addAction(_("Slow")) + Vol_Fade_Out_Slow.triggered.connect(partial(self.Volume_Triggered, MenuVolume.FADE_OUT_SLOW, clip_ids, "End of Clip")) + Volume_Menu.addMenu(Vol_Fade_Out_Menu) + + Vol_Fade_In_Out_Menu = StyledContextMenu(title=_("Fade In and Out"), parent=self) + Vol_Fade_In_Out_Fast = Vol_Fade_In_Out_Menu.addAction(_("Fast")) + Vol_Fade_In_Out_Fast.triggered.connect(partial(self.Volume_Triggered, MenuVolume.FADE_IN_OUT_FAST, clip_ids, "Entire Clip")) + Vol_Fade_In_Out_Slow = Vol_Fade_In_Out_Menu.addAction(_("Slow")) + Vol_Fade_In_Out_Slow.triggered.connect(partial(self.Volume_Triggered, MenuVolume.FADE_IN_OUT_SLOW, clip_ids, "Entire Clip")) + Volume_Menu.addMenu(Vol_Fade_In_Out_Menu) + + if clip_has_audio: + Audio_Menu.addMenu(Volume_Menu) Split_Audio_Channels_Menu = StyledContextMenu(title=_("Separate"), parent=self) Split_Single_Clip = Split_Audio_Channels_Menu.addAction(_("Single Clip (all channels)")) @@ -1801,9 +1771,11 @@ def ShowClipMenu(self, clip_id=None): Split_Multiple_Clips = Split_Audio_Channels_Menu.addAction(_("Multiple Clips (each channel)")) Split_Multiple_Clips.triggered.connect(partial( self.Split_Audio_Triggered, MenuSplitAudio.MULTIPLE, clip_ids)) - Audio_Menu.addMenu(Split_Audio_Channels_Menu) + if clip_has_audio: + Audio_Menu.addMenu(Split_Audio_Channels_Menu) - Audio_Menu.addSeparator() + if clip_has_audio: + Audio_Menu.addSeparator() if not self._clip_has_video(clip): if self._clip_has_visible_waveform(clip): ToggleWaveform = Audio_Menu.addAction( @@ -2245,6 +2217,23 @@ def _clip_has_video(self, clip): has_video = reader.get("has_video") return True if has_video is None else bool(has_video) + def _clip_has_audio(self, clip): + if not clip: + return False + reader = clip.data.get("reader", {}) if isinstance(clip.data, dict) else {} + has_audio = reader.get("has_audio") + return True if has_audio is None else bool(has_audio) + + def _clip_has_visual(self, clip): + """Return True if the clip has video OR has waveform rendering enabled.""" + if not clip: + return False + reader = clip.data.get("reader", {}) if isinstance(clip.data, dict) else {} + has_video = reader.get("has_video") + if has_video is None or bool(has_video): + return True + return bool(clip.data.get("waveform", False)) + def _create_color_grade_effect_json(self): effect = openshot.EffectInfo().CreateEffect(COLOR_GRADE_CLASS_NAME) if effect is None: @@ -2260,7 +2249,7 @@ def _create_film_grain_effect_json(self): return json.loads(effect.Json()) def _ensure_color_grade_effect(self, clip): - if not clip or not self._clip_has_video(clip): + if not clip or not self._clip_has_visual(clip): return None, False effects = clip.data.get("effects") @@ -2280,7 +2269,7 @@ def Color_Triggered(self, preset_name, clip_ids): """Apply or reset Color Grade presets for selected clips.""" for clip_id in clip_ids: clip = Clip.get(id=clip_id) - if not clip or not self._clip_has_video(clip): + if not clip or not self._clip_has_visual(clip): continue original_clip_data = json.loads(json.dumps(clip.data)) @@ -2329,7 +2318,7 @@ def Film_Grain_Triggered(self, preset_name, clip_ids): """Apply Film Grain presets for selected clips.""" for clip_id in clip_ids: clip = Clip.get(id=clip_id) - if not clip or not self._clip_has_video(clip): + if not clip or not self._clip_has_visual(clip): continue original_clip_data = json.loads(json.dumps(clip.data)) @@ -2382,7 +2371,7 @@ def Adjust_Colors_Triggered(self, clip_ids): first_clip_id = None for clip_id in clip_ids: clip = Clip.get(id=clip_id) - if not clip or not self._clip_has_video(clip): + if not clip or not self._clip_has_visual(clip): continue original_clip_data = json.loads(json.dumps(clip.data)) @@ -2693,6 +2682,13 @@ def AddPoint(self, keyframe, new_point): # Replace points with new list keyframe["Points"] = cleaned_points + def _remove_keypoints_in_range(self, points_data, frame_start, frame_end): + """Remove all keyframe points with X in [frame_start, frame_end].""" + points_data["Points"] = [ + p for p in points_data["Points"] + if not (frame_start <= p.get("co", {}).get("X", -1) <= frame_end) + ] + def Copy_Triggered(self, action, clip_ids, tran_ids, effect_ids): """Callback for copy context menus""" @@ -3014,12 +3010,13 @@ def Align_Triggered(self, action, clip_ids, tran_ids): self.update_transition_data(tran.data, only_basic_props=False) def Fade_Triggered(self, action, clip_ids, position="Entire Clip", transaction_id=None): - """Callback for fade context menus""" + """Callback for fade context menus — fades both alpha (video) and volume (audio)""" log.debug(action) # Get FPS from project fps = get_app().project.get("fps") fps_float = float(fps["num"]) / float(fps["den"]) + clips_with_waveforms = [] # Create a transaction ID for all operations in this function (if not provided) tid = transaction_id or self.get_uuid() @@ -3034,7 +3031,6 @@ def Fade_Triggered(self, action, clip_ids, position="Entire Clip", transaction_i # Get existing clip object clip = Clip.get(id=clip_id) if not clip: - # Invalid clip, skip to next item continue start_of_clip = round(float(clip.data["start"]) * fps_float) + 1 @@ -3057,9 +3053,8 @@ def Fade_Triggered(self, action, clip_ids, position="Entire Clip", transaction_i start_animation = max(1.0, end_of_clip - (3.0 * fps_float)) end_animation = end_of_clip - # Fade in and out (special case) + # Fade in and out (special case) — recurse for start + end independently if position == "Entire Clip" and action in [MenuFade.IN_OUT_FAST, MenuFade.IN_OUT_SLOW]: - # Call this method for the start and end of the clip if action == MenuFade.IN_OUT_FAST: self.Fade_Triggered(MenuFade.IN_FAST, clip_ids, "Start of Clip", transaction_id=tid) self.Fade_Triggered(MenuFade.OUT_FAST, clip_ids, "End of Clip", transaction_id=tid) @@ -3068,32 +3063,67 @@ def Fade_Triggered(self, action, clip_ids, position="Entire Clip", transaction_i self.Fade_Triggered(MenuFade.OUT_SLOW, clip_ids, "End of Clip", transaction_id=tid) return - if action == MenuFade.NONE: - # Clear all keyframes - p = openshot.Point(1, 1.0, openshot.BEZIER) - p_object = json.loads(p.Json()) - clip.data['alpha'] = {"Points": [p_object]} - - if action in [MenuFade.IN_FAST, MenuFade.IN_SLOW]: - # Add keyframes - start = openshot.Point(start_animation, 0.0, openshot.BEZIER) - start_object = json.loads(start.Json()) - end = openshot.Point(end_animation, 1.0, openshot.BEZIER) - end_object = json.loads(end.Json()) - self.AddPoint(clip.data['alpha'], start_object) - self.AddPoint(clip.data['alpha'], end_object) + reader = clip.data.get("reader", {}) if isinstance(clip.data, dict) else {} + fade_alpha = bool(reader.get("has_video", True)) or bool(clip.data.get("waveform", False)) + fade_volume = bool(reader.get("has_audio", True)) - if action in [MenuFade.OUT_FAST, MenuFade.OUT_SLOW]: - # Add keyframes - start = openshot.Point(start_animation, 1.0, openshot.BEZIER) - start_object = json.loads(start.Json()) - end = openshot.Point(end_animation, 0.0, openshot.BEZIER) - end_object = json.loads(end.Json()) - self.AddPoint(clip.data['alpha'], start_object) - self.AddPoint(clip.data['alpha'], end_object) + if action == MenuFade.NONE: + p_object = json.loads(openshot.Point(1, 1.0, openshot.BEZIER).Json()) + if fade_alpha: + clip.data['alpha'] = {"Points": [p_object]} + if fade_volume: + clip.data['volume'] = {"Points": [p_object]} + + elif action in [MenuFade.IN_FAST, MenuFade.IN_SLOW]: + # Clear the full slow-fade zone (3 sec from start) so Fast can replace Slow + # and vice versa. No midpoint cap — it caused short clips to miss the start keypoint. + fade_in_zone_end = min(start_of_clip + (3.0 * fps_float), end_of_clip) + + # Read the steady-state value at the zone boundary BEFORE clearing — + # any previous fade has fully settled there. + c = self.window.timeline_sync.timeline.GetClip(clip_id) + target_alpha = c.alpha.GetValue(int(round(fade_in_zone_end))) if c else 1.0 + target_vol = c.volume.GetValue(int(round(fade_in_zone_end))) if c else 1.0 + + if fade_alpha: + self._remove_keypoints_in_range(clip.data['alpha'], start_of_clip, fade_in_zone_end) + self.AddPoint(clip.data['alpha'], json.loads(openshot.Point(start_animation, 0.0, openshot.BEZIER).Json())) + self.AddPoint(clip.data['alpha'], json.loads(openshot.Point(end_animation, target_alpha, openshot.BEZIER).Json())) + if fade_volume: + self._remove_keypoints_in_range(clip.data['volume'], start_of_clip, fade_in_zone_end) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(start_animation, 0.0, openshot.BEZIER).Json())) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(end_animation, target_vol, openshot.BEZIER).Json())) + + elif action in [MenuFade.OUT_FAST, MenuFade.OUT_SLOW]: + # Clear the full slow-fade zone (3 sec from end) so Fast can replace Slow + # and vice versa. No midpoint cap — it caused short clips to miss the start keypoint. + fade_out_zone_start = max(1.0, end_of_clip - (3.0 * fps_float)) + + # Read the steady-state value at the zone boundary BEFORE clearing — + # any previous fade starts at or after this point. + c = self.window.timeline_sync.timeline.GetClip(clip_id) + source_alpha = c.alpha.GetValue(int(round(fade_out_zone_start))) if c else 1.0 + source_vol = c.volume.GetValue(int(round(fade_out_zone_start))) if c else 1.0 + + if fade_alpha: + self._remove_keypoints_in_range(clip.data['alpha'], fade_out_zone_start, end_of_clip) + self.AddPoint(clip.data['alpha'], json.loads(openshot.Point(start_animation, source_alpha, openshot.BEZIER).Json())) + self.AddPoint(clip.data['alpha'], json.loads(openshot.Point(end_animation, 0.0, openshot.BEZIER).Json())) + if fade_volume: + self._remove_keypoints_in_range(clip.data['volume'], fade_out_zone_start, end_of_clip) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(start_animation, source_vol, openshot.BEZIER).Json())) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(end_animation, 0.0, openshot.BEZIER).Json())) # Save changes self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True, transaction_id=tid) + + # Track clips with waveforms for refresh + if clip.data.get("ui", {}).get("audio_data", []): + clips_with_waveforms.append(clip.id) + + # Refresh waveforms affected by volume change + if clips_with_waveforms: + self.Show_Waveform_Triggered(clips_with_waveforms, transaction_id=tid) finally: # Reset transaction id only if we created it (not if it was passed in) if not transaction_id: @@ -3313,132 +3343,77 @@ def Volume_Triggered(self, action, clip_ids, position="Entire Clip", level=1.0, """Callback for volume context menus""" log.debug(action) - # Get FPS from project fps = get_app().project.get("fps") fps_float = float(fps["num"]) / float(fps["den"]) clips_with_waveforms = [] - # Create a transaction ID for all operations in this function (if not provided) tid = transaction_id or self.get_uuid() try: - # Set transaction ID get_app().updates.transaction_id = tid - # Loop through each selected clip for clip_id in clip_ids: - - # Get existing clip object clip = Clip.get(id=clip_id) if not clip: - # Invalid clip, skip to next item continue start_of_clip = round(float(clip.data["start"]) * fps_float) + 1 end_of_clip = round(float(clip.data["end"]) * fps_float) + 1 - # Determine the beginning and ending of this animation - # ["Start of Clip", "End of Clip", "Entire Clip"] + # Speed-dependent animation boundaries start_animation = start_of_clip end_animation = end_of_clip - if position == "Start of Clip" and action in [ - MenuVolume.FADE_IN_FAST, - MenuVolume.FADE_OUT_FAST - ]: - start_animation = start_of_clip + if position == "Start of Clip" and action in [MenuVolume.FADE_IN_FAST, MenuVolume.FADE_OUT_FAST]: end_animation = min(start_of_clip + (1.0 * fps_float), end_of_clip) - - elif position == "Start of Clip" and action in [ - MenuVolume.FADE_IN_SLOW, - MenuVolume.FADE_OUT_SLOW - ]: - start_animation = start_of_clip + elif position == "Start of Clip" and action in [MenuVolume.FADE_IN_SLOW, MenuVolume.FADE_OUT_SLOW]: end_animation = min(start_of_clip + (3.0 * fps_float), end_of_clip) - - elif position == "End of Clip" and action in [ - MenuVolume.FADE_IN_FAST, - MenuVolume.FADE_OUT_FAST - ]: + elif position == "End of Clip" and action in [MenuVolume.FADE_IN_FAST, MenuVolume.FADE_OUT_FAST]: start_animation = max(1.0, end_of_clip - (1.0 * fps_float)) - end_animation = end_of_clip - - elif position == "End of Clip" and action in [ - MenuVolume.FADE_IN_SLOW, - MenuVolume.FADE_OUT_SLOW - ]: + elif position == "End of Clip" and action in [MenuVolume.FADE_IN_SLOW, MenuVolume.FADE_OUT_SLOW]: start_animation = max(1.0, end_of_clip - (3.0 * fps_float)) - end_animation = end_of_clip - elif position == "Start of Clip": - # Only used when setting levels (a single keyframe) - start_animation = start_of_clip - end_animation = start_of_clip - - elif position == "End of Clip": - # Only used when setting levels (a single keyframe) - start_animation = end_of_clip - end_animation = end_of_clip - - # Fade in and out (special case) - if position == "Entire Clip" and action == MenuVolume.FADE_IN_OUT_FAST: - # Call this method for the start and end of the clip - self.Volume_Triggered(MenuVolume.FADE_IN_FAST, clip_ids, "Start of Clip", transaction_id=tid) - self.Volume_Triggered(MenuVolume.FADE_OUT_FAST, clip_ids, "End of Clip", transaction_id=tid) - return - if position == "Entire Clip" and action == MenuVolume.FADE_IN_OUT_SLOW: - # Call this method for the start and end of the clip - self.Volume_Triggered(MenuVolume.FADE_IN_SLOW, clip_ids, "Start of Clip", transaction_id=tid) - self.Volume_Triggered(MenuVolume.FADE_OUT_SLOW, clip_ids, "End of Clip", transaction_id=tid) + # Fade in and out — recurse for start + end independently + if position == "Entire Clip" and action in [MenuVolume.FADE_IN_OUT_FAST, MenuVolume.FADE_IN_OUT_SLOW]: + if action == MenuVolume.FADE_IN_OUT_FAST: + self.Volume_Triggered(MenuVolume.FADE_IN_FAST, clip_ids, "Start of Clip", transaction_id=tid) + self.Volume_Triggered(MenuVolume.FADE_OUT_FAST, clip_ids, "End of Clip", transaction_id=tid) + else: + self.Volume_Triggered(MenuVolume.FADE_IN_SLOW, clip_ids, "Start of Clip", transaction_id=tid) + self.Volume_Triggered(MenuVolume.FADE_OUT_SLOW, clip_ids, "End of Clip", transaction_id=tid) return if action == MenuVolume.NONE: - # Clear all keyframes - p = openshot.Point(1, 1.0, openshot.BEZIER) - p_object = json.loads(p.Json()) - clip.data['volume'] = {"Points": [p_object]} + clip.data['volume'] = {"Points": [json.loads(openshot.Point(1, 1.0, openshot.BEZIER).Json())]} - if action in [ - MenuVolume.FADE_IN_FAST, - MenuVolume.FADE_IN_SLOW - ]: - # Add keyframes - start = openshot.Point(start_animation, 0.0, openshot.BEZIER) - start_object = json.loads(start.Json()) - end = openshot.Point(end_animation, 1.0, openshot.BEZIER) - end_object = json.loads(end.Json()) - self.AddPoint(clip.data['volume'], start_object) - self.AddPoint(clip.data['volume'], end_object) + elif action == MenuVolume.LEVEL: + # Replace entire volume curve with a flat keyframe at the chosen level + clip.data['volume'] = {"Points": [json.loads(openshot.Point(1, float(level) / 100.0, openshot.BEZIER).Json())]} - if action in [ - MenuVolume.FADE_OUT_FAST, - MenuVolume.FADE_OUT_SLOW - ]: - # Add keyframes - start = openshot.Point(start_animation, 1.0, openshot.BEZIER) - start_object = json.loads(start.Json()) - end = openshot.Point(end_animation, 0.0, openshot.BEZIER) - end_object = json.loads(end.Json()) - self.AddPoint(clip.data['volume'], start_object) - self.AddPoint(clip.data['volume'], end_object) + elif action in [MenuVolume.FADE_IN_FAST, MenuVolume.FADE_IN_SLOW]: + fade_in_zone_end = min(start_of_clip + (3.0 * fps_float), end_of_clip) + c = self.window.timeline_sync.timeline.GetClip(clip_id) + target_vol = c.volume.GetValue(int(round(fade_in_zone_end))) if c else 1.0 + self._remove_keypoints_in_range(clip.data['volume'], start_of_clip, fade_in_zone_end) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(start_animation, 0.0, openshot.BEZIER).Json())) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(end_animation, target_vol, openshot.BEZIER).Json())) - if action == MenuVolume.LEVEL: - # Add keyframes - p = openshot.Point(start_animation, float(level) / 100.0, openshot.BEZIER) - p_object = json.loads(p.Json()) - self.AddPoint(clip.data['volume'], p_object) + elif action in [MenuVolume.FADE_OUT_FAST, MenuVolume.FADE_OUT_SLOW]: + fade_out_zone_start = max(1.0, end_of_clip - (3.0 * fps_float)) + c = self.window.timeline_sync.timeline.GetClip(clip_id) + source_vol = c.volume.GetValue(int(round(fade_out_zone_start))) if c else 1.0 + self._remove_keypoints_in_range(clip.data['volume'], fade_out_zone_start, end_of_clip) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(start_animation, source_vol, openshot.BEZIER).Json())) + self.AddPoint(clip.data['volume'], json.loads(openshot.Point(end_animation, 0.0, openshot.BEZIER).Json())) # Save changes self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True, transaction_id=tid) - # Add any clips with waveforms to a list if clip.data.get("ui", {}).get("audio_data", []): clips_with_waveforms.append(clip.id) - # Update waveforms of all clips that have them if clips_with_waveforms: self.Show_Waveform_Triggered(clips_with_waveforms, transaction_id=tid) finally: - # Reset transaction id only if we created it (not if it was passed in) if not transaction_id: get_app().updates.transaction_id = None @@ -3872,8 +3847,6 @@ def ShowTransitionMenu(self, tran_id=None): # Get clipboard copied_object = ClipboardManager.from_mime(get_app().clipboard().mimeData()) - if copied_object: - print(f"Copied object found: {type(copied_object).__name__}") has_clipboard = False if copied_object and isinstance(copied_object, Transition): has_clipboard = True From a6ace4a6c09297cf0e9ab92d1ff209cb80faaab6 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Mon, 27 Apr 2026 16:41:12 -0500 Subject: [PATCH 05/23] Fixing regression with playback, so not all updates switch from playback mode to edit mode. Only user updates to transform, properties, curve editor, and color wheels should switch to edit mode (and disable video playback caching). Normal updates, like moving a clip, adding a clip should not stop video playback caching. AND... if playback is active while a property/transform is edited, do not disable caching. --- src/classes/timeline.py | 13 +++++++++++-- src/windows/video_widget.py | 15 ++++++++++++--- src/windows/views/properties_tableview.py | 16 ++++++++++++---- .../views/timeline_backend/qwidget/base.py | 7 ++++++- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/classes/timeline.py b/src/classes/timeline.py index 2568f33559..07eee59a3c 100644 --- a/src/classes/timeline.py +++ b/src/classes/timeline.py @@ -77,8 +77,17 @@ def changed(self, action): if action and len(action.key) >= 1 and action.key[0].lower() in ["files", "history", "markers", "layers", "scale", "profile", "export_settings"]: return - # Enter edit mode — disable video caching until the user seeks or plays - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False + # Enter edit mode for property updates — disable caching until the user seeks or plays. + # Only "update" actions represent manual property edits; structural changes like + # inserting/deleting clips should not interrupt caching. Also skip during playback + # so live property tweaks don't kill an in-progress cache fill. + if action and action.type == "update": + try: + is_playing = self.window.preview_thread.player.Mode() == openshot.PLAYBACK_PLAY + except Exception: + is_playing = False + if not is_playing: + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False try: proxy_service = getattr(self.window, "proxy_service", None) diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index aa0f3d45e9..3e08a23463 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -55,6 +55,12 @@ class VideoWidget(QWidget, updates.UpdateInterface): regionRectChanged = pyqtSignal() scopeRegionCancelled = pyqtSignal() + def _is_playing(self): + try: + return get_app().window.preview_thread.player.Mode() == openshot.PLAYBACK_PLAY + except Exception: + return False + def _snap_angle(self, angle_degrees, step_degrees=15.0): """Snap an angle to the nearest increment (degrees).""" step = float(step_degrees) if step_degrees else 0.0 @@ -795,7 +801,8 @@ def mousePressEvent(self, event): self.mouse_position = event.pos() self.middle_pan_active = True self.setCursor(Qt.ClosedHandCursor) - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False + if not self._is_playing(): + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False return self.mouse_pressed = True self.mouse_dragging = False @@ -821,7 +828,8 @@ def mousePressEvent(self, event): self.region_press_outside = True self.scope_region_drag_anchor = QPointF(point) self._apply_scope_region_rect(QRectF(point, point), emit_signal=True, enforce_min=False) - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False + if not self._is_playing(): + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False log.debug('mousePressEvent: Stop caching frames on timeline') return @@ -880,7 +888,8 @@ def mousePressEvent(self, event): self.original_effect_data = None # Disable video caching during drag operation (for performance reasons) - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False + if not self._is_playing(): + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False log.debug('mousePressEvent: Stop caching frames on timeline') def mouseReleaseEvent(self, event): diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index 6423631f05..8995a12653 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -728,8 +728,14 @@ def property_model_refreshed(self): self._sync_color_grade_editors_to_current_frame() + def _is_playing(self): + try: + return get_app().window.preview_thread.player.Mode() == openshot.PLAYBACK_PLAY + except Exception: + return False + def pause_live_property_caching(self): - if self.live_property_cache_paused: + if self.live_property_cache_paused or self._is_playing(): return openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False self.live_property_cache_paused = True @@ -739,7 +745,8 @@ def resume_live_property_caching(self): if not self.live_property_cache_paused: return self.live_property_cache_paused = False - log.debug("resume_live_property_caching") + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = True + log.debug("resume_live_property_caching: Resume caching frames on timeline") def _wheels_drag_started(self): """Open a per-drag undo transaction when user starts dragging a wheel control.""" @@ -979,8 +986,9 @@ def mouseMoveEvent(self, event): # Ignore undo/redo history temporarily (to avoid a huge pile of undo/redo history) get_app().updates.ignore_history = True - # Disable video caching during drag operation (for performance reasons) - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False + # Disable video caching during drag (for performance), but not during playback + if not self._is_playing(): + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False # Get the position of the cursor and % value value_column_x = self.columnViewportPosition(1) diff --git a/src/windows/views/timeline_backend/qwidget/base.py b/src/windows/views/timeline_backend/qwidget/base.py index adfc2a7e95..4efe53c811 100644 --- a/src/windows/views/timeline_backend/qwidget/base.py +++ b/src/windows/views/timeline_backend/qwidget/base.py @@ -558,7 +558,12 @@ def _buildStateMachine(self): self._sm = sm def _disable_playback_caching(self): - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False + try: + is_playing = get_app().window.preview_thread.player.Mode() == openshot.PLAYBACK_PLAY + except Exception: + is_playing = False + if not is_playing: + openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False def _event_signal(self, name): return self.events, self._event_signal_bytes(name) From dfa4eeb54dee8ef8e255474257d880f639e7164a Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 16:39:29 -0500 Subject: [PATCH 06/23] =?UTF-8?q?Overhaul=20Motion=20menu=20with=20CSS-acc?= =?UTF-8?q?urate=20animation=20presets=20-=20Replace=20old=20scale-zoom=20?= =?UTF-8?q?/=20edge-to-edge=20Motion=20menu=20with=20fully=20restructured?= =?UTF-8?q?=20In=20/=20Out=20/=20Emphasis=20/=20Camera=20/=20Credits=20sub?= =?UTF-8?q?menus=20-=20Apply=20presets=20relative=20to=20clip's=20current?= =?UTF-8?q?=20transform=20state=20(multiplicative=20for=20scale/alpha,=20a?= =?UTF-8?q?dditive=20for=20location/rotation/shear)=20-=20Refactor=20Video?= =?UTF-8?q?Widget.=5Fclip=5Fdisplay=5Frect=20=E2=86=92=20=5Fclip=5Flocatio?= =?UTF-8?q?n=5Fgeometry=20+=20=5Flocation=5Foffset=20/=20=5Flocation=5Fval?= =?UTF-8?q?ue=5Ffrom=5Foffset=20for=20correct=20drag-to-normalized-coordin?= =?UTF-8?q?ate=20conversion;=20add=20SCALE=5FNONE=20support=20-=20Add=20te?= =?UTF-8?q?sts=20for=20wipe=20mask=20contrast,=20Ken=20Burns=20keyframes,?= =?UTF-8?q?=20and=20location=20offset=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/animation_presets.py | 430 +++++++++++ src/tests/test_timeline_helpers.py | 115 +++ src/tests/test_video_widget_transform.py | 155 ++++ src/windows/video_widget.py | 106 ++- src/windows/views/timeline.py | 813 +++++++++++++------- src/windows/views/timeline_backend/enums.py | 91 ++- 6 files changed, 1397 insertions(+), 313 deletions(-) create mode 100644 src/animation_presets.py create mode 100644 src/tests/test_video_widget_transform.py diff --git a/src/animation_presets.py b/src/animation_presets.py new file mode 100644 index 0000000000..5d4e2bf591 --- /dev/null +++ b/src/animation_presets.py @@ -0,0 +1,430 @@ +""" + @file + @brief Animation keyframe presets used in Motion clip menu + @author Jonathan Thomas + + @section LICENSE + + Copyright (c) 2008-2026 OpenShot Studios, LLC + (http://www.openshotstudios.com). This file is part of + OpenShot Video Editor (http://www.openshot.org), an open-source project + dedicated to delivering high quality video editing and animation solutions + to the world. + + OpenShot Video Editor is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OpenShot Video Editor 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OpenShot Library. If not, see . + + @section THIRD-PARTY NOTICES + + Some animation keyframe data in this file is derived from Animate.css v4.1.1. + Animate.css is MIT licensed. + Copyright (c) 2020 Daniel Eden + https://animate.style/ + """ + +# Keyframe presets for the Motion menu. +# +# Format: +# PRESETS[name][property] = [(frame, value), ...] +# +# A third tuple item can be present: +# (frame, value, easing_name) +# +# Frame positions are 1-31 at 30 fps source speed and should be scaled to the +# actual clip zone at apply time. Only non-identity channels are stored. +# +# Easing values are CSS cubic-bezier(x1, y1, x2, y2) tuples. Convert them to +# libopenshot Point handles as follows: +# current.handle_right = (x1, y1) +# next.handle_left = (x2, y2) + +KEYFRAME_EASING = { + 'ease_in': (0.420, 0.000, 1.000, 1.000), + 'ease_in_quint': (0.755, 0.050, 0.855, 0.060), + 'ease_out': (0.000, 0.000, 0.580, 1.000), + 'ease_out_cubic': (0.215, 0.610, 0.355, 1.000), +} + +PRESETS = { + + # ── Attention seekers ───────────────────────────────────────────────────── + + 'bounce': { + 'scale_y': [ + (1, 1.0, 'ease_out_cubic'), (7, 1.0, 'ease_out_cubic'), + (13, 1.1, 'ease_in_quint'), (14, 1.1, 'ease_in_quint'), + (17, 1.0, 'ease_out_cubic'), + (22, 1.05, 'ease_in_quint'), (25, 0.95), (28, 1.02), (31, 1.0) + ], + 'location_y': [ + (1, 0, 'ease_out_cubic'), (7, 0, 'ease_out_cubic'), (13, -0.20, 'ease_in_quint'), + (14, -0.20, 'ease_in_quint'), (17, 0, 'ease_out_cubic'), (22, -0.10, 'ease_in_quint'), + (25, 0), (28, -0.027, 'ease_in_quint'), (31, 0) + ], + }, + + 'flash': { + 'alpha': [(1, 1), (8, 0), (16, 1), (24, 0), (31, 1)], + }, + + 'pulse': { + 'scale_x': [(1, 1), (16, 1.05), (31, 1)], + 'scale_y': [(1, 1), (16, 1.05), (31, 1)], + }, + + 'rubberBand': { + 'scale_x': [(1, 1), (10, 1.25), (13, 0.75), (16, 1.15), (20, 0.95), (24, 1.05), (31, 1)], + 'scale_y': [(1, 1), (10, 0.75), (13, 1.25), (16, 0.85), (20, 1.05), (24, 0.95), (31, 1)], + }, + + 'shakeX': { + 'location_x': [ + (1, 0), (4, -0.005208), (7, 0.005208), (10, -0.005208), (13, 0.005208), (16, -0.005208), + (19, 0.005208), (22, -0.005208), (25, 0.005208), (28, -0.005208), (31, 0) + ], + }, + + 'shakeY': { + 'location_y': [ + (1, 0), (4, -0.009259), (7, 0.009259), (10, -0.009259), (13, 0.009259), (16, -0.009259), + (19, 0.009259), (22, -0.009259), (25, 0.009259), (28, -0.009259), (31, 0) + ], + }, + + 'swing': { + 'rotation': [(7, 15), (13, -10), (19, 5), (25, -5), (31, 0)], + }, + + 'tada': { + 'scale_x': [ + (1, 1), (4, 0.9), (7, 0.9), (10, 1.1), (13, 1.1), (16, 1.1), (19, 1.1), (22, 1.1), (25, 1.1), + (28, 1.1), (31, 1) + ], + 'scale_y': [ + (1, 1), (4, 0.9), (7, 0.9), (10, 1.1), (13, 1.1), (16, 1.1), (19, 1.1), (22, 1.1), (25, 1.1), + (28, 1.1), (31, 1) + ], + 'rotation': [(4, -3), (7, -3), (10, 3), (13, -3), (16, 3), (19, -3), (22, 3), (25, -3), (28, 3)], + }, + + 'wobble': { + 'rotation': [(6, -5), (10, 3), (14, -3), (19, 2), (24, -1)], + 'location_x': [(1, 0), (6, -0.25), (10, 0.2), (14, -0.15), (19, 0.1), (24, -0.05), (31, 0)], + }, + + 'jello': { + 'shear_x': [ + (8, -0.277778), (11, 0.138889), (14, -0.069444), (18, 0.034722), (21, -0.017361), (24, 0.008681), + (28, -0.00434) + ], + 'shear_y': [ + (8, -0.277778), (11, 0.138889), (14, -0.069444), (18, 0.034722), (21, -0.017361), (24, 0.008681), + (28, -0.00434) + ], + }, + + 'heartBeat': { + 'scale_x': [(1, 1), (5, 1.3), (9, 1), (14, 1.3), (22, 1)], + 'scale_y': [(1, 1), (5, 1.3), (9, 1), (14, 1.3), (22, 1)], + }, + + + # ── Back entrances ──────────────────────────────────────────────────────── + + 'backInDown': { + 'alpha': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_x': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_y': [(1, 0.7), (25, 0.7), (31, 1)], + 'location_y': [(1, -1.5), (25, 0)], + }, + + 'backInLeft': { + 'alpha': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_x': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_y': [(1, 0.7), (25, 0.7), (31, 1)], + 'location_x': [(1, -1.5), (25, 0)], + }, + + 'backInRight': { + 'alpha': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_x': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_y': [(1, 0.7), (25, 0.7), (31, 1)], + 'location_x': [(1, 1.5), (25, 0)], + }, + + 'backInUp': { + 'alpha': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_x': [(1, 0.7), (25, 0.7), (31, 1)], + 'scale_y': [(1, 0.7), (25, 0.7), (31, 1)], + 'location_y': [(1, 1.5), (25, 0)], + }, + + + # ── Back exits ──────────────────────────────────────────────────────────── + + 'backOutDown': { + 'alpha': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_x': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_y': [(1, 1), (7, 0.7), (31, 0.7)], + 'location_y': [(7, 0), (31, 1.5)], + }, + + 'backOutLeft': { + 'alpha': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_x': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_y': [(1, 1), (7, 0.7), (31, 0.7)], + 'location_x': [(7, 0), (31, -1.5)], + }, + + 'backOutRight': { + 'alpha': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_x': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_y': [(1, 1), (7, 0.7), (31, 0.7)], + 'location_x': [(7, 0), (31, 1.5)], + }, + + 'backOutUp': { + 'alpha': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_x': [(1, 1), (7, 0.7), (31, 0.7)], + 'scale_y': [(1, 1), (7, 0.7), (31, 0.7)], + 'location_y': [(7, 0), (31, -1.5)], + }, + + + # ── Bouncing entrances ──────────────────────────────────────────────────── + + 'bounceIn': { + 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1, 'ease_out_cubic'), (31, 1)], + 'scale_x': [ + (1, 0.3, 'ease_out_cubic'), (7, 1.1, 'ease_out_cubic'), (13, 0.9, 'ease_out_cubic'), + (19, 1.03, 'ease_out_cubic'), (25, 0.97, 'ease_out_cubic'), (31, 1) + ], + 'scale_y': [ + (1, 0.3, 'ease_out_cubic'), (7, 1.1, 'ease_out_cubic'), (13, 0.9, 'ease_out_cubic'), + (19, 1.03, 'ease_out_cubic'), (25, 0.97, 'ease_out_cubic'), (31, 1) + ], + }, + + 'bounceInDown': { + 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], + 'scale_y': [(1, 3, 'ease_out_cubic'), (19, 0.9, 'ease_out_cubic'), (24, 0.95, 'ease_out_cubic'), (28, 0.985)], + 'location_y': [ + (1, -3, 'ease_out_cubic'), (19, 0.023148, 'ease_out_cubic'), (24, -0.009259, 'ease_out_cubic'), + (28, 0.00463, 'ease_out_cubic'), (31, 0) + ], + }, + + 'bounceInLeft': { + 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], + 'scale_x': [(1, 3, 'ease_out_cubic'), (19, 1, 'ease_out_cubic'), (24, 0.98, 'ease_out_cubic'), (28, 0.995)], + 'location_x': [ + (1, -3, 'ease_out_cubic'), (19, 0.013021, 'ease_out_cubic'), (24, -0.005208, 'ease_out_cubic'), + (28, 0.002604, 'ease_out_cubic'), (31, 0) + ], + }, + + 'bounceInRight': { + 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], + 'scale_x': [(1, 3, 'ease_out_cubic'), (19, 1, 'ease_out_cubic'), (24, 0.98, 'ease_out_cubic'), (28, 0.995)], + 'location_x': [ + (1, 3, 'ease_out_cubic'), (19, -0.013021, 'ease_out_cubic'), (24, 0.005208, 'ease_out_cubic'), + (28, -0.002604, 'ease_out_cubic'), (31, 0) + ], + }, + + 'bounceInUp': { + 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], + 'scale_y': [(1, 5, 'ease_out_cubic'), (19, 0.9, 'ease_out_cubic'), (24, 0.95, 'ease_out_cubic'), (28, 0.985)], + 'location_y': [ + (1, 3, 'ease_out_cubic'), (19, -0.018519, 'ease_out_cubic'), (24, 0.009259, 'ease_out_cubic'), + (28, -0.00463, 'ease_out_cubic'), (31, 0) + ], + }, + + + # ── Bouncing exits ──────────────────────────────────────────────────────── + + 'bounceOut': { + 'alpha': [(16, 1), (18, 1), (31, 0)], + 'scale_x': [(7, 0.9), (16, 1.1), (18, 1.1), (31, 0.3)], + 'scale_y': [(7, 0.9), (16, 1.1), (18, 1.1), (31, 0.3)], + }, + + 'bounceOutDown': { + 'alpha': [(13, 1), (14, 1), (31, 0)], + 'scale_y': [(7, 0.985), (13, 0.9), (14, 0.9), (31, 3)], + 'location_y': [(7, 0.009259), (13, -0.018519), (14, -0.018519), (31, 3)], + }, + + 'bounceOutLeft': { + 'alpha': [(7, 1), (31, 0)], + 'scale_x': [(7, 0.9), (31, 2)], + 'location_x': [(7, 0.010417), (31, -3)], + }, + + 'bounceOutRight': { + 'alpha': [(7, 1), (31, 0)], + 'scale_x': [(7, 0.9), (31, 2)], + 'location_x': [(7, -0.010417), (31, 3)], + }, + + 'bounceOutUp': { + 'alpha': [(13, 1), (14, 1), (31, 0)], + 'scale_y': [(7, 0.985), (13, 0.9), (14, 0.9), (31, 3)], + 'location_y': [(7, -0.009259), (13, 0.018519), (14, 0.018519), (31, -3)], + }, + + + # ── Fading entrances ────────────────────────────────────────────────────── + + 'fadeIn': { + 'alpha': [(1, 0), (31, 1)], + }, + + 'fadeInDown': { + 'alpha': [(1, 0), (31, 1)], + 'location_y': [(1, -1), (31, 0)], + }, + + 'fadeInDownBig': { + 'alpha': [(1, 0), (31, 1)], + 'location_y': [(1, -3), (31, 0)], + }, + + 'fadeInLeft': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, -1), (31, 0)], + }, + + 'fadeInLeftBig': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, -3), (31, 0)], + }, + + 'fadeInRight': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, 1), (31, 0)], + }, + + 'fadeInRightBig': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, 3), (31, 0)], + }, + + 'fadeInUp': { + 'alpha': [(1, 0), (31, 1)], + 'location_y': [(1, 1), (31, 0)], + }, + + 'fadeInUpBig': { + 'alpha': [(1, 0), (31, 1)], + 'location_y': [(1, 3), (31, 0)], + }, + + 'fadeInTopLeft': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, -1), (31, 0)], + 'location_y': [(1, -1), (31, 0)], + }, + + 'fadeInTopRight': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, 1), (31, 0)], + 'location_y': [(1, -1), (31, 0)], + }, + + 'fadeInBottomLeft': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, -1), (31, 0)], + 'location_y': [(1, 1), (31, 0)], + }, + + 'fadeInBottomRight': { + 'alpha': [(1, 0), (31, 1)], + 'location_x': [(1, 1), (31, 0)], + 'location_y': [(1, 1), (31, 0)], + }, + + + # ── Fading exits ────────────────────────────────────────────────────────── + + 'fadeOut': { + 'alpha': [(1, 1), (31, 0)], + }, + + 'fadeOutDown': { + 'alpha': [(1, 1), (31, 0)], + 'location_y': [(31, 1)], + }, + + 'fadeOutDownBig': { + 'alpha': [(1, 1), (31, 0)], + 'location_y': [(31, 3)], + }, + + 'fadeOutLeft': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(31, -1)], + }, + + 'fadeOutLeftBig': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(31, -3)], + }, + + 'fadeOutRight': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(31, 1)], + }, + + 'fadeOutRightBig': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(31, 3)], + }, + + 'fadeOutUp': { + 'alpha': [(1, 1), (31, 0)], + 'location_y': [(31, -1)], + }, + + 'fadeOutUpBig': { + 'alpha': [(1, 1), (31, 0)], + 'location_y': [(31, -3)], + }, + + 'fadeOutTopLeft': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(1, 0), (31, -1)], + 'location_y': [(1, 0), (31, -1)], + }, + + 'fadeOutTopRight': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(1, 0), (31, 1)], + 'location_y': [(1, 0), (31, -1)], + }, + + 'fadeOutBottomRight': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(1, 0), (31, 1)], + 'location_y': [(1, 0), (31, 1)], + }, + + 'fadeOutBottomLeft': { + 'alpha': [(1, 1), (31, 0)], + 'location_x': [(1, 0), (31, -1)], + 'location_y': [(1, 0), (31, 1)], + }, + + +} diff --git a/src/tests/test_timeline_helpers.py b/src/tests/test_timeline_helpers.py index 0b9ef56c08..4ff740ef44 100644 --- a/src/tests/test_timeline_helpers.py +++ b/src/tests/test_timeline_helpers.py @@ -155,6 +155,61 @@ def Show_Waveform_Triggered(self, clip_ids, transaction_id=None): return Helper() + def make_motion_helper(self): + timeline_module = self.timeline_module + + class Helper: + def __init__(self): + self.updated = [] + self.show_wait_spinner = False + self.window = types.SimpleNamespace( + timeline_sync=types.SimpleNamespace( + timeline=types.SimpleNamespace(GetClip=lambda _clip_id: None) + ), + preview_thread=types.SimpleNamespace(current_frame=None), + ) + + def get_uuid(self): + return "tx-motion-1" + + def AddPoint(self, keyframe, new_point): + return timeline_module.TimelineView.AddPoint(self, keyframe, new_point) + + def _remove_keypoints_in_range(self, points_data, frame_start, frame_end): + return timeline_module.TimelineView._remove_keypoints_in_range( + self, points_data, frame_start, frame_end) + + def update_clip_data(self, clip_data, **kwargs): + self.updated.append((copy.deepcopy(clip_data), dict(kwargs))) + + def _get_transition_reader_json(self, _path): + return {"path": "/tmp/wipe.svg", "has_single_image": True} + + return Helper() + + def make_motion_clip(self): + def kf(value): + return {"Points": [{"co": {"X": 1, "Y": value}, "interpolation": openshot.BEZIER}]} + + data = { + "id": "C1", + "start": 0.0, + "end": 3.0, + "scale": openshot.SCALE_FIT, + "scale_x": kf(1.0), + "scale_y": kf(1.0), + "location_x": kf(0.0), + "location_y": kf(0.0), + "rotation": kf(0.0), + "shear_x": kf(0.0), + "shear_y": kf(0.0), + "alpha": kf(1.0), + "origin_x": kf(0.5), + "origin_y": kf(0.5), + "effects": [], + } + return types.SimpleNamespace(id="C1", data=data) + def make_finalize_keyframe_helper(self): timeline_module = self.timeline_module @@ -1475,6 +1530,57 @@ def test_update_transition_data_reanchors_static_transition_endpoints_without_fr self.assertEqual(saved_data["brightness"]["Points"][0]["co"]["X"], 1) self.assertEqual(saved_data["brightness"]["Points"][-1]["co"]["X"], 62) + def test_motion_wipe_mask_uses_high_static_contrast(self): + helper = self.make_motion_helper() + clip = self.make_motion_clip() + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + generate_id=lambda: "FX1", + ), + ) + + with patch.object(self.timeline_module, "get_app", return_value=app), \ + patch.object(self.timeline_module.Clip, "get", return_value=clip): + self.timeline_module.TimelineView.Animate_Triggered( + helper, + self.timeline_module.MenuAnimate.WIPE_IN_LEFT, + ["C1"], + transaction_id="tx-motion-test", + ) + + self.assertEqual(len(clip.data["effects"]), 1) + effect = clip.data["effects"][0] + self.assertEqual(effect["class_name"], "Mask") + self.assertEqual(effect["contrast"]["Points"][0]["co"]["Y"], 20.0) + + def test_motion_ken_burns_direction_sets_distinct_scale_and_location(self): + helper = self.make_motion_helper() + clip = self.make_motion_clip() + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + generate_id=lambda: "FX1", + ), + ) + + with patch.object(self.timeline_module, "get_app", return_value=app), \ + patch.object(self.timeline_module.Clip, "get", return_value=clip): + self.timeline_module.TimelineView.Animate_Triggered( + helper, + self.timeline_module.MenuAnimate.KEN_BURNS_IN, + ["C1"], + transaction_id="tx-motion-test", + ) + + self.assertEqual(clip.data["scale"], openshot.SCALE_CROP) + self.assertAlmostEqual(clip.data["scale_x"]["Points"][-1]["co"]["Y"], 1.3) + self.assertAlmostEqual(clip.data["scale_y"]["Points"][-1]["co"]["Y"], 1.3) + self.assertAlmostEqual(clip.data["location_x"]["Points"][-1]["co"]["Y"], -0.08) + self.assertAlmostEqual(clip.data["location_y"]["Points"][-1]["co"]["Y"], 0.04) + def test_find_missing_transition_details_returns_overlap(self): clip_data = {"id": "B", "layer": 1, "position": 4.0, "start": 0.0, "end": 6.0} existing_clip = types.SimpleNamespace(data={"id": "A", "position": 0.0, "start": 0.0, "end": 5.0}) @@ -4201,6 +4307,9 @@ def __init__(self): def _clip_has_video(self, candidate): return timeline_module.TimelineView._clip_has_video(self, candidate) + def _clip_has_visual(self, candidate): + return timeline_module.TimelineView._clip_has_visual(self, candidate) + def _create_film_grain_effect_json(self): return {"class_name": "FilmGrain", "id": "FG-1", "seed": 77} @@ -4252,6 +4361,9 @@ def __init__(self): def _clip_has_video(self, candidate): return timeline_module.TimelineView._clip_has_video(self, candidate) + def _clip_has_visual(self, candidate): + return timeline_module.TimelineView._clip_has_visual(self, candidate) + def update_clip_data(self, clip_data, **kwargs): self.updates.append(copy.deepcopy(clip_data)) @@ -4294,6 +4406,9 @@ def __init__(self): def _clip_has_video(self, candidate): return timeline_module.TimelineView._clip_has_video(self, candidate) + def _clip_has_visual(self, candidate): + return timeline_module.TimelineView._clip_has_visual(self, candidate) + def update_clip_data(self, clip_data, **kwargs): self.updates.append((copy.deepcopy(clip_data), dict(kwargs))) diff --git a/src/tests/test_video_widget_transform.py b/src/tests/test_video_widget_transform.py new file mode 100644 index 0000000000..f8f797e4fb --- /dev/null +++ b/src/tests/test_video_widget_transform.py @@ -0,0 +1,155 @@ +""" + @file + @brief This file contains unit tests for VideoWidget transform and location geometry + @author Jonathan Thomas + + @section LICENSE + + Copyright (c) 2008-2026 OpenShot Studios, LLC + (http://www.openshotstudios.com). This file is part of + OpenShot Video Editor (http://www.openshot.org), an open-source project + dedicated to delivering high quality video editing and animation solutions + to the world. + + OpenShot Video Editor is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OpenShot Video Editor 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OpenShot Library. If not, see . + """ + +import os +import sys +import types +import unittest +from unittest.mock import patch + +import openshot +from qt_api import QApplication, QRect + + +PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +if PATH not in sys.path: + sys.path.append(PATH) + +from qt_test_app import ensure_app_state, get_or_create_app + + +class DummySettings: + def __init__(self): + self.values = {} + + def get(self, key): + return self.values.get(key, False) + + def set(self, key, value): + self.values[key] = value + + +class DummyApp(QApplication): + def __init__(self): + super().__init__([]) + self.settings = DummySettings() + + +app, _owns_app = get_or_create_app(DummyApp) +ensure_app_state(app, DummySettings, extra_attrs={"window": types.SimpleNamespace()}) + +from windows.video_widget import VideoWidget + + +def clip_with(scale_mode, gravity=openshot.GRAVITY_CENTER): + return types.SimpleNamespace(data={"scale": scale_mode, "gravity": gravity}) + + +def props(location_x=0.0, location_y=0.0, scale_x=1.0, scale_y=1.0): + return { + "scale_x": {"value": scale_x}, + "scale_y": {"value": scale_y}, + "location_x": {"value": location_x}, + "location_y": {"value": location_y}, + "parentObjectId": {"memo": ""}, + } + + +class VideoWidgetTransformTests(unittest.TestCase): + def setUp(self): + self.widget = VideoWidget.__new__(VideoWidget) + self.viewport = QRect(0, 0, 160, 90) + + def rect_for(self, scale_mode, location_x=0.0, location_y=0.0, scale_x=1.0, scale_y=1.0): + return VideoWidget._clip_display_rect( + self.widget, + 40, + 40, + clip_with(scale_mode), + props(location_x, location_y, scale_x, scale_y), + self.viewport, + ) + + def test_square_clip_location_y_endpoints_are_offscreen_for_fit_and_crop(self): + for scale_mode in (openshot.SCALE_FIT, openshot.SCALE_CROP): + with self.subTest(scale_mode=scale_mode): + top = self.rect_for(scale_mode, location_y=-1.0) + bottom = self.rect_for(scale_mode, location_y=1.0) + + self.assertLessEqual(top.y() + top.height(), 0.0) + self.assertGreaterEqual(bottom.y(), self.viewport.height()) + + def test_square_clip_location_x_endpoints_are_offscreen_for_fit_and_crop(self): + for scale_mode in (openshot.SCALE_FIT, openshot.SCALE_CROP): + with self.subTest(scale_mode=scale_mode): + left = self.rect_for(scale_mode, location_x=-1.0) + right = self.rect_for(scale_mode, location_x=1.0) + + self.assertLessEqual(left.x() + left.width(), 0.0) + self.assertGreaterEqual(right.x(), self.viewport.width()) + + def test_location_offset_inverse_round_trips_drag_motion(self): + # Crop square in a 16:9 viewport renders as 160x160, centered at y=-35. + source_w, source_h, scaled_w, scaled_h, anchor_x, anchor_y = ( + VideoWidget._clip_location_geometry( + self.widget, + 40, + 40, + clip_with(openshot.SCALE_CROP), + props(), + self.viewport, + ) + ) + self.assertEqual((source_w, source_h, scaled_w, scaled_h, anchor_x, anchor_y), + (160.0, 160.0, 160.0, 160.0, 0.0, -35.0)) + + for location in (-1.0, -0.5, 0.0, 0.5, 1.0): + with self.subTest(location=location): + offset = VideoWidget._location_offset(location, anchor_y, self.viewport.height(), scaled_h) + restored = VideoWidget._location_value_from_offset( + offset, anchor_y, self.viewport.height(), scaled_h) + self.assertAlmostEqual(restored, location, places=6) + + def test_scale_none_uses_project_to_viewport_pixel_ratio(self): + fake_app = types.SimpleNamespace( + project=types.SimpleNamespace(get=lambda key: {"width": 320, "height": 180}.get(key)) + ) + with patch("windows.video_widget.get_app", return_value=fake_app): + center = self.rect_for(openshot.SCALE_NONE) + self.assertAlmostEqual(center.width(), 20.0) + self.assertAlmostEqual(center.height(), 20.0) + self.assertAlmostEqual(center.x(), 70.0) + self.assertAlmostEqual(center.y(), 35.0) + + top = self.rect_for(openshot.SCALE_NONE, location_y=-1.0) + bottom = self.rect_for(openshot.SCALE_NONE, location_y=1.0) + self.assertLessEqual(top.y() + top.height(), 0.0) + self.assertGreaterEqual(bottom.y(), self.viewport.height()) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index 3e08a23463..b43a33a79e 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -1314,9 +1314,22 @@ def mouseMoveEvent(self, event): location_x = raw_properties.get('location_x').get('value') location_y = raw_properties.get('location_y').get('value') - # Calculate new location coordinates - location_x += x_motion / viewport_rect.width() - location_y += y_motion / viewport_rect.height() + base_w, base_h = self._clip_source_dimensions( + self.transforming_clip, self.transforming_clip_object, clip_frame_number) + _, _, scaled_w, scaled_h, anchored_x, anchored_y = self._clip_location_geometry( + base_w, base_h, self.transforming_clip, raw_properties, viewport_rect) + + # Convert from screen-pixel motion to libopenshot's normalized + # location coordinates, which are relative to the gravity anchor + # and the distance to the offscreen edge. + current_x_offset = self._location_offset( + location_x, anchored_x, viewport_rect.width(), scaled_w) + current_y_offset = self._location_offset( + location_y, anchored_y, viewport_rect.height(), scaled_h) + location_x = self._location_value_from_offset( + current_x_offset + x_motion, anchored_x, viewport_rect.width(), scaled_w) + location_y = self._location_value_from_offset( + current_y_offset + y_motion, anchored_y, viewport_rect.height(), scaled_h) # Update keyframe value (or create new one) self.updateClipProperty( @@ -2010,7 +2023,33 @@ def _clip_source_dimensions(self, clip, clip_object, frame_number, skip_effect_i return width, height - def _clip_display_rect(self, base_width, base_height, clip, raw_properties, viewport_rect): + @staticmethod + def _location_offset(location, anchored_position, canvas_size, clip_size): + """Match libopenshot normalized location semantics for one axis.""" + location = float(location) + anchored_position = float(anchored_position) + canvas_size = float(canvas_size) + clip_size = float(clip_size) + if location < 0.0: + return location * (anchored_position + clip_size) + return location * (canvas_size - anchored_position) + + @staticmethod + def _location_value_from_offset(offset, anchored_position, canvas_size, clip_size): + """Inverse of _location_offset(), used when dragging transform handles.""" + offset = float(offset) + anchored_position = float(anchored_position) + canvas_size = float(canvas_size) + clip_size = float(clip_size) + if offset < 0.0: + basis = anchored_position + clip_size + else: + basis = canvas_size - anchored_position + if abs(basis) < 0.0001: + return 0.0 + return offset / basis + + def _clip_location_geometry(self, base_width, base_height, clip, raw_properties, viewport_rect): player_width = viewport_rect.width() player_height = viewport_rect.height() @@ -2026,47 +2065,66 @@ def _clip_display_rect(self, base_width, base_height, clip, raw_properties, view source_size.scale(player_width, player_height, Qt.IgnoreAspectRatio) elif scale_mode == openshot.SCALE_CROP: source_size.scale(player_width, player_height, Qt.KeepAspectRatioByExpanding) + elif scale_mode == openshot.SCALE_NONE: + try: + project_width = float(get_app().project.get("width") or player_width) + project_height = float(get_app().project.get("height") or player_height) + except Exception: + project_width = float(player_width) + project_height = float(player_height) + if project_width > 0.0 and project_height > 0.0: + source_size = QSizeF( + source_size.width() * (player_width / project_width), + source_size.height() * (player_height / project_height)) source_width = max(source_size.width(), 0.0001) source_height = max(source_size.height(), 0.0001) - # Get per-frame scale factors sx = max(float(raw_properties.get('scale_x').get('value')), 0.001) sy = max(float(raw_properties.get('scale_y').get('value')), 0.001) - - # Scaled dimensions used for gravity and location offsets scaled_width = source_width * sx scaled_height = source_height * sy - x = viewport_rect.x() - y = viewport_rect.y() - gravity = clip.data['gravity'] + anchored_x = 0.0 + anchored_y = 0.0 if gravity == openshot.GRAVITY_TOP: - x += (player_width - scaled_width) / 2.0 + anchored_x = (player_width - scaled_width) / 2.0 elif gravity == openshot.GRAVITY_TOP_RIGHT: - x += player_width - scaled_width + anchored_x = player_width - scaled_width elif gravity == openshot.GRAVITY_LEFT: - y += (player_height - scaled_height) / 2.0 + anchored_y = (player_height - scaled_height) / 2.0 elif gravity == openshot.GRAVITY_CENTER: - x += (player_width - scaled_width) / 2.0 - y += (player_height - scaled_height) / 2.0 + anchored_x = (player_width - scaled_width) / 2.0 + anchored_y = (player_height - scaled_height) / 2.0 elif gravity == openshot.GRAVITY_RIGHT: - x += player_width - scaled_width - y += (player_height - scaled_height) / 2.0 + anchored_x = player_width - scaled_width + anchored_y = (player_height - scaled_height) / 2.0 elif gravity == openshot.GRAVITY_BOTTOM_LEFT: - y += player_height - scaled_height + anchored_y = player_height - scaled_height elif gravity == openshot.GRAVITY_BOTTOM: - x += (player_width - scaled_width) / 2.0 - y += player_height - scaled_height + anchored_x = (player_width - scaled_width) / 2.0 + anchored_y = player_height - scaled_height elif gravity == openshot.GRAVITY_BOTTOM_RIGHT: - x += player_width - scaled_width - y += player_height - scaled_height + anchored_x = player_width - scaled_width + anchored_y = player_height - scaled_height + + return source_width, source_height, scaled_width, scaled_height, anchored_x, anchored_y + + def _clip_display_rect(self, base_width, base_height, clip, raw_properties, viewport_rect): + player_width = viewport_rect.width() + player_height = viewport_rect.height() + + source_width, source_height, scaled_width, scaled_height, anchored_x, anchored_y = ( + self._clip_location_geometry(base_width, base_height, clip, raw_properties, viewport_rect)) + + x = viewport_rect.x() + anchored_x + y = viewport_rect.y() + anchored_y location_x = float(raw_properties.get('location_x', {}).get('value', 0.0)) location_y = float(raw_properties.get('location_y', {}).get('value', 0.0)) - x += player_width * location_x - y += player_height * location_y + x += self._location_offset(location_x, anchored_x, player_width, scaled_width) + y += self._location_offset(location_y, anchored_y, player_height, scaled_height) return QRectF(x, y, source_width, source_height) diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index a9a1b6aa32..187bb76274 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -35,7 +35,6 @@ import uuid from functools import partial from operator import itemgetter -from random import uniform import openshot from qt_api import pyqtSlot, Qt, QCoreApplication, QTimer, pyqtSignal, QPointF @@ -95,6 +94,58 @@ log.info("Timeline backend: QWidget (%s)", getattr(ViewClass, "__name__", "unknown")) +# ── Animation preset helpers ────────────────────────────────────────────────── + +from animation_presets import PRESETS as _ANIMATION_PRESETS, KEYFRAME_EASING as _KEYFRAME_EASING + +# JSON animation name for each MenuAnimate value +_JSON_ANIM = { + MenuAnimate.BACK_IN_DOWN: "backInDown", + MenuAnimate.BACK_IN_LEFT: "backInLeft", + MenuAnimate.BACK_IN_RIGHT: "backInRight", + MenuAnimate.BACK_IN_UP: "backInUp", + MenuAnimate.BOUNCE_IN: "bounceIn", + MenuAnimate.BOUNCE_IN_DOWN: "bounceInDown", + MenuAnimate.BOUNCE_IN_LEFT: "bounceInLeft", + MenuAnimate.BOUNCE_IN_RIGHT: "bounceInRight", + MenuAnimate.BOUNCE_IN_UP: "bounceInUp", + MenuAnimate.BACK_OUT_DOWN: "backOutDown", + MenuAnimate.BACK_OUT_LEFT: "backOutLeft", + MenuAnimate.BACK_OUT_RIGHT: "backOutRight", + MenuAnimate.BACK_OUT_UP: "backOutUp", + MenuAnimate.BOUNCE_OUT: "bounceOut", + MenuAnimate.BOUNCE_OUT_DOWN: "bounceOutDown", + MenuAnimate.BOUNCE_OUT_LEFT: "bounceOutLeft", + MenuAnimate.BOUNCE_OUT_RIGHT:"bounceOutRight", + MenuAnimate.BOUNCE_OUT_UP: "bounceOutUp", + MenuAnimate.BOUNCE: "bounce", + MenuAnimate.FLASH: "flash", + MenuAnimate.PULSE: "pulse", + MenuAnimate.RUBBER_BAND: "rubberBand", + MenuAnimate.SHAKE_X: "shakeX", + MenuAnimate.SHAKE_Y: "shakeY", + MenuAnimate.SWING: "swing", + MenuAnimate.TADA: "tada", + MenuAnimate.WOBBLE: "wobble", + MenuAnimate.JELLO: "jello", + MenuAnimate.HEART_BEAT: "heartBeat", +} + +_EMPHASIS_ACTIONS = frozenset({ + MenuAnimate.BOUNCE, MenuAnimate.FLASH, MenuAnimate.PULSE, + MenuAnimate.RUBBER_BAND, MenuAnimate.SHAKE_X, MenuAnimate.SHAKE_Y, + MenuAnimate.SWING, MenuAnimate.TADA, + MenuAnimate.WOBBLE, MenuAnimate.JELLO, MenuAnimate.HEART_BEAT, +}) + +_IN_ACTIONS = frozenset({ + MenuAnimate.BACK_IN_DOWN, MenuAnimate.BACK_IN_LEFT, + MenuAnimate.BACK_IN_RIGHT, MenuAnimate.BACK_IN_UP, + MenuAnimate.BOUNCE_IN, MenuAnimate.BOUNCE_IN_DOWN, + MenuAnimate.BOUNCE_IN_LEFT, MenuAnimate.BOUNCE_IN_RIGHT, + MenuAnimate.BOUNCE_IN_UP, +}) + def _event_posf(event): if hasattr(event, "posF"): @@ -1437,97 +1488,128 @@ def ShowClipMenu(self, clip_id=None): menu.addMenu(Fade_Menu) - # Motion Menu + # ── Motion Menu ─────────────────────────────────────────────────────── Animate_Menu = StyledContextMenu(title=_("Motion"), parent=self) Animate_None = Animate_Menu.addAction(_("No Motion")) Animate_None.triggered.connect(partial(self.Animate_Triggered, MenuAnimate.NONE, clip_ids)) Animate_Menu.addSeparator() - for position, position_label in [ - ("Start of Clip", _("Start of Clip")), - ("End of Clip", _("End of Clip")), - ("Entire Clip", _("Entire Clip")) + + def _motion_act(menu_obj, label, action): + act = menu_obj.addAction(label) + act.triggered.connect(partial(self.Animate_Triggered, action, clip_ids)) + + def _motion_sub(title, items): + sub = StyledContextMenu(title=title, parent=self) + for label, action in items: + _motion_act(sub, label, action) + return sub + + # ── In ▶ ─────────────────────────────────────────────────────────────── + In_Menu = StyledContextMenu(title=_("In"), parent=self) + In_Menu.addMenu(_motion_sub(_("Back In"), [ + (_("From Top"), MenuAnimate.BACK_IN_DOWN), + (_("From Left"), MenuAnimate.BACK_IN_LEFT), + (_("From Right"), MenuAnimate.BACK_IN_RIGHT), + (_("From Bottom"), MenuAnimate.BACK_IN_UP), + ])) + In_Menu.addMenu(_motion_sub(_("Bounce In"), [ + (_("Center"), MenuAnimate.BOUNCE_IN), + (_("From Top"), MenuAnimate.BOUNCE_IN_DOWN), + (_("From Left"), MenuAnimate.BOUNCE_IN_LEFT), + (_("From Right"), MenuAnimate.BOUNCE_IN_RIGHT), + (_("From Bottom"), MenuAnimate.BOUNCE_IN_UP), + ])) + In_Menu.addMenu(_motion_sub(_("Slide In"), [ + (_("From Left"), MenuAnimate.SLIDE_IN_LEFT), + (_("From Right"), MenuAnimate.SLIDE_IN_RIGHT), + (_("From Top"), MenuAnimate.SLIDE_IN_TOP), + (_("From Bottom"), MenuAnimate.SLIDE_IN_BOTTOM), + ])) + In_Menu.addMenu(_motion_sub(_("Wipe In"), [ + (_("Circle Expand"), MenuAnimate.WIPE_IN_CIRCLE_EXPAND), + (_("Circle Shrink"), MenuAnimate.WIPE_IN_CIRCLE_SHRINK), + (_("Fade"), MenuAnimate.WIPE_IN_FADE), + (_("From Left"), MenuAnimate.WIPE_IN_LEFT), + (_("From Right"), MenuAnimate.WIPE_IN_RIGHT), + (_("From Top"), MenuAnimate.WIPE_IN_TOP), + (_("From Bottom"), MenuAnimate.WIPE_IN_BOTTOM), + ])) + _motion_act(In_Menu, _("Blur In"), MenuAnimate.BLUR_IN) + _motion_act(In_Menu, _("Pop In"), MenuAnimate.POP_IN) + _motion_act(In_Menu, _("Spiral In"), MenuAnimate.SPIRAL_IN) + Animate_Menu.addMenu(In_Menu) + + # ── Out ▶ ────────────────────────────────────────────────────────────── + Out_Menu = StyledContextMenu(title=_("Out"), parent=self) + Out_Menu.addMenu(_motion_sub(_("Back Out"), [ + (_("To Bottom"), MenuAnimate.BACK_OUT_DOWN), + (_("To Left"), MenuAnimate.BACK_OUT_LEFT), + (_("To Right"), MenuAnimate.BACK_OUT_RIGHT), + (_("To Top"), MenuAnimate.BACK_OUT_UP), + ])) + Out_Menu.addMenu(_motion_sub(_("Bounce Out"), [ + (_("Center"), MenuAnimate.BOUNCE_OUT), + (_("To Bottom"), MenuAnimate.BOUNCE_OUT_DOWN), + (_("To Right"), MenuAnimate.BOUNCE_OUT_RIGHT), + (_("To Left"), MenuAnimate.BOUNCE_OUT_LEFT), + (_("To Top"), MenuAnimate.BOUNCE_OUT_UP), + ])) + Out_Menu.addMenu(_motion_sub(_("Slide Out"), [ + (_("To Left"), MenuAnimate.SLIDE_OUT_LEFT), + (_("To Right"), MenuAnimate.SLIDE_OUT_RIGHT), + (_("To Top"), MenuAnimate.SLIDE_OUT_TOP), + (_("To Bottom"), MenuAnimate.SLIDE_OUT_BOTTOM), + ])) + Out_Menu.addMenu(_motion_sub(_("Wipe Out"), [ + (_("Circle Expand"), MenuAnimate.WIPE_OUT_CIRCLE_EXPAND), + (_("Circle Shrink"), MenuAnimate.WIPE_OUT_CIRCLE_SHRINK), + (_("Fade"), MenuAnimate.WIPE_OUT_FADE), + (_("To Left"), MenuAnimate.WIPE_OUT_LEFT), + (_("To Right"), MenuAnimate.WIPE_OUT_RIGHT), + (_("To Top"), MenuAnimate.WIPE_OUT_TOP), + (_("To Bottom"), MenuAnimate.WIPE_OUT_BOTTOM), + ])) + _motion_act(Out_Menu, _("Blur Out"), MenuAnimate.BLUR_OUT) + _motion_act(Out_Menu, _("Pop Out"), MenuAnimate.POP_OUT) + _motion_act(Out_Menu, _("Spiral Out"), MenuAnimate.SPIRAL_OUT) + Animate_Menu.addMenu(Out_Menu) + + # ── Emphasis ▶ ───────────────────────────────────────────────────────── + Animate_Menu.addMenu(_motion_sub(_("Emphasis"), [ + (_("Bounce"), MenuAnimate.BOUNCE), + (_("Flash"), MenuAnimate.FLASH), + (_("Pulse"), MenuAnimate.PULSE), + (_("Rubber Band"), MenuAnimate.RUBBER_BAND), + (_("Shake X"), MenuAnimate.SHAKE_X), + (_("Shake Y"), MenuAnimate.SHAKE_Y), + (_("Swing"), MenuAnimate.SWING), + (_("Tada"), MenuAnimate.TADA), + (_("Wobble"), MenuAnimate.WOBBLE), + (_("Jello"), MenuAnimate.JELLO), + (_("Heartbeat"), MenuAnimate.HEART_BEAT), + ])) + + # ── Camera ▶ ─────────────────────────────────────────────────────────── + Camera_Menu = StyledContextMenu(title=_("Camera"), parent=self) + for label, action in [ + (_("Push In"), MenuAnimate.CAM_PUSH_IN), + (_("Pull Out"), MenuAnimate.CAM_PULL_OUT), + (_("Pan Left"), MenuAnimate.CAM_PAN_LEFT), + (_("Pan Right"), MenuAnimate.CAM_PAN_RIGHT), + (_("Pan Up"), MenuAnimate.CAM_PAN_UP), + (_("Pan Down"), MenuAnimate.CAM_PAN_DOWN), + (_("Ken Burns In"), MenuAnimate.KEN_BURNS_IN), + (_("Ken Burns Out"), MenuAnimate.KEN_BURNS_OUT), ]: - Position_Menu = StyledContextMenu(title=position_label, parent=self) - - # Scale - Scale_Menu = StyledContextMenu(title=_("Zoom"), parent=self) - Animate_In_50_100 = Scale_Menu.addAction(_("Zoom In (50% to 100%)")) - Animate_In_50_100.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.IN_50_100, clip_ids, position)) - Animate_In_75_100 = Scale_Menu.addAction(_("Zoom In (75% to 100%)")) - Animate_In_75_100.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.IN_75_100, clip_ids, position)) - Animate_In_100_150 = Scale_Menu.addAction(_("Zoom In (100% to 150%)")) - Animate_In_100_150.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.IN_100_150, clip_ids, position)) - Animate_Out_100_75 = Scale_Menu.addAction(_("Zoom Out (100% to 75%)")) - Animate_Out_100_75.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.OUT_100_75, clip_ids, position)) - Animate_Out_100_50 = Scale_Menu.addAction(_("Zoom Out (100% to 50%)")) - Animate_Out_100_50.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.OUT_100_50, clip_ids, position)) - Animate_Out_150_100 = Scale_Menu.addAction(_("Zoom Out (150% to 100%)")) - Animate_Out_150_100.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.OUT_150_100, clip_ids, position)) - Position_Menu.addMenu(Scale_Menu) - - # Center to Edge - Center_Edge_Menu = StyledContextMenu(title=_("Center to Edge"), parent=self) - Animate_Center_Top = Center_Edge_Menu.addAction(_("Center to Top")) - Animate_Center_Top.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.CENTER_TOP, clip_ids, position)) - Animate_Center_Left = Center_Edge_Menu.addAction(_("Center to Left")) - Animate_Center_Left.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.CENTER_LEFT, clip_ids, position)) - Animate_Center_Right = Center_Edge_Menu.addAction(_("Center to Right")) - Animate_Center_Right.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.CENTER_RIGHT, clip_ids, position)) - Animate_Center_Bottom = Center_Edge_Menu.addAction(_("Center to Bottom")) - Animate_Center_Bottom.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.CENTER_BOTTOM, clip_ids, position)) - Position_Menu.addMenu(Center_Edge_Menu) - - # Edge to Center - Edge_Center_Menu = StyledContextMenu(title=_("Edge to Center"), parent=self) - Animate_Top_Center = Edge_Center_Menu.addAction(_("Top to Center")) - Animate_Top_Center.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.TOP_CENTER, clip_ids, position)) - Animate_Left_Center = Edge_Center_Menu.addAction(_("Left to Center")) - Animate_Left_Center.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.LEFT_CENTER, clip_ids, position)) - Animate_Right_Center = Edge_Center_Menu.addAction(_("Right to Center")) - Animate_Right_Center.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.RIGHT_CENTER, clip_ids, position)) - Animate_Bottom_Center = Edge_Center_Menu.addAction(_("Bottom to Center")) - Animate_Bottom_Center.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.BOTTOM_CENTER, clip_ids, position)) - Position_Menu.addMenu(Edge_Center_Menu) - - # Edge to Edge - Edge_Edge_Menu = StyledContextMenu(title=_("Edge to Edge"), parent=self) - Animate_Top_Bottom = Edge_Edge_Menu.addAction(_("Top to Bottom")) - Animate_Top_Bottom.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.TOP_BOTTOM, clip_ids, position)) - Animate_Left_Right = Edge_Edge_Menu.addAction(_("Left to Right")) - Animate_Left_Right.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.LEFT_RIGHT, clip_ids, position)) - Animate_Right_Left = Edge_Edge_Menu.addAction(_("Right to Left")) - Animate_Right_Left.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.RIGHT_LEFT, clip_ids, position)) - Animate_Bottom_Top = Edge_Edge_Menu.addAction(_("Bottom to Top")) - Animate_Bottom_Top.triggered.connect(partial( - self.Animate_Triggered, MenuAnimate.BOTTOM_TOP, clip_ids, position)) - Position_Menu.addMenu(Edge_Edge_Menu) - - # Random Animation - Position_Menu.addSeparator() - Random = Position_Menu.addAction(_("Random")) - Random.triggered.connect(partial(self.Animate_Triggered, MenuAnimate.RANDOM, clip_ids, position)) - - # Add Sub-Menu's to Position menu - Animate_Menu.addMenu(Position_Menu) - - # Add Each position menu + _motion_act(Camera_Menu, label, action) + Animate_Menu.addMenu(Camera_Menu) + + # ── Credits ▶ ────────────────────────────────────────────────────────── + Animate_Menu.addMenu(_motion_sub(_("Credits"), [ + (_("Scroll Up"), MenuAnimate.CREDITS_UP), + (_("Scroll Down"), MenuAnimate.CREDITS_DOWN), + ])) + if clip_has_visual: menu.addMenu(Animate_Menu) @@ -2463,208 +2545,399 @@ def Layout_Triggered(self, action, clip_ids): # Save changes self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) - def Animate_Triggered(self, action, clip_ids, position="Entire Clip", transaction_id=None): - """Callback for the animate context menus""" + def Animate_Triggered(self, action, clip_ids, transaction_id=None): + """Apply one-click motion presets to selected clips. + + Each MenuAnimate action encodes the animation type and its zone: + In actions → first 1 second of the clip + Out actions → last 1 second of the clip + Continuous → entire clip duration + Pan actions → entire clip, also sets scale mode to SCALE_CROP + + Keyframe coordinates follow OpenShot conventions: + location ±1.0 ≈ one full frame dimension (offscreen) + scale 1.0 = 100% + rotation degrees (positive = clockwise) + shear dimensionless skew factor + origin 0.0–1.0 (0 = top/left edge, 0.5 = center, 1.0 = bottom/right) + """ log.debug(action) - - # Create a transaction ID for all operations in this function (if not provided) tid = transaction_id or self.get_uuid() - try: - # Set transaction ID get_app().updates.transaction_id = tid - # Loop through each selected clip for clip_id in clip_ids: - - # Get existing clip object clip = Clip.get(id=clip_id) if not clip: - # Invalid clip, skip to next item continue - # Get framerate fps = get_app().project.get("fps") fps_float = float(fps["num"]) / float(fps["den"]) - # Get existing clip object - start_of_clip = round(float(clip.data["start"]) * fps_float) + 1 - end_of_clip = round(float(clip.data["end"]) * fps_float) + 1 + # Clip frame boundaries (1-based, relative to clip content start) + s = round(float(clip.data["start"]) * fps_float) + 1 # first frame + e = round(float(clip.data["end"]) * fps_float) + 1 # last frame + dur = max(1, e - s) # total frames - # Determine the beginning and ending of this animation - # ["Start of Clip", "End of Clip", "Entire Clip"] - start_animation = start_of_clip - end_animation = end_of_clip - if position == "Start of Clip": - start_animation = start_of_clip - end_animation = min(start_of_clip + (1.0 * fps_float), end_of_clip) - elif position == "End of Clip": - start_animation = max(1.0, end_of_clip - (1.0 * fps_float)) - end_animation = end_of_clip + # 1-second enter / exit zones clamped to clip length + zone = max(1, round(fps_float)) + in_end = min(s + zone, e) # end of "In" zone + out_start = max(s, e - zone) # start of "Out" zone + + # Emphasis: fixed 1-second window at playhead (if inside clip) + try: + playhead = int(self.window.preview_thread.current_frame or 1) + except Exception: + playhead = 1 + if s <= playhead <= e: + emph_start = playhead + else: + emph_start = s + emph_end = min(emph_start + zone, e) + + # ── helpers ─────────────────────────────────────────────────── + def kf(frame, val, interp=openshot.BEZIER): + """Build a keyframe point dict.""" + return json.loads(openshot.Point(int(frame), val, interp).Json()) + + def add(key, *pts): + """Append keyframe points to a clip channel.""" + for p in pts: + self.AddPoint(clip.data[key], p) + + # Read current clip state from the live timeline BEFORE any edits + c = self.window.timeline_sync.timeline.GetClip(clip_id) + + _PROP_IDENTITY = { + 'scale_x': 1.0, 'scale_y': 1.0, + 'location_x': 0.0, 'location_y': 0.0, + 'alpha': 1.0, 'rotation': 0.0, + 'shear_x': 0.0, 'shear_y': 0.0, + } + + def _base(prop, frame): + """Return the clip's current value of prop at frame (identity fallback).""" + if c is not None: + obj = getattr(c, prop, None) + if obj is not None: + return obj.GetValue(int(round(frame))) + return _PROP_IDENTITY.get(prop, 0.0) + + def _rel(prop, preset_val, base_val): + """Adjust a preset value relative to the clip's current base value. + Scale/alpha are multiplicative; location/rotation/shear are additive.""" + if prop in ('scale_x', 'scale_y', 'alpha'): + return preset_val * base_val + return preset_val + base_val + + clip.data["gravity"] = openshot.GRAVITY_CENTER + + # ── RESET (always runs first for non-NONE actions) ───────────── + # Clears all previous motion keyframes and removes Blur/Mask effects + # added by prior motion presets so animations are never additive. + def _reset_motion(): + clip.data["scale"] = openshot.SCALE_FIT + clip.data["scale_x"] = {"Points": [kf(s, 1.0)]} + clip.data["scale_y"] = {"Points": [kf(s, 1.0)]} + clip.data["location_x"] = {"Points": [kf(s, 0.0)]} + clip.data["location_y"] = {"Points": [kf(s, 0.0)]} + clip.data["rotation"] = {"Points": [kf(s, 0.0)]} + clip.data["shear_x"] = {"Points": [kf(s, 0.0)]} + clip.data["shear_y"] = {"Points": [kf(s, 0.0)]} + clip.data["alpha"] = {"Points": [kf(s, 1.0)]} + clip.data["origin_x"] = {"Points": [kf(s, 0.5)]} + clip.data["origin_y"] = {"Points": [kf(s, 0.5)]} + effects = clip.data.get("effects", []) + clip.data["effects"] = [ + eff for eff in (effects if isinstance(effects, list) else []) + if eff.get("class_name") not in ("Blur", "Mask") + ] + + def _make_wipe_fx(svg_filename, t_start, t_end, brightness_start, brightness_end): + """Attach a Mask effect (wipe) to clip.data using the given SVG transition.""" + svg_path = os.path.join(info.PATH, "transitions", "common", svg_filename) + reader_json = self._get_transition_reader_json(svg_path) + if not reader_json: + return + effect = openshot.EffectInfo().CreateEffect("Mask") + fx = json.loads(effect.Json()) + fx["id"] = get_app().project.generate_id() + fx["mask_reader"] = deepcopy(reader_json) + fx["reader"] = deepcopy(reader_json) + fx["brightness"] = {"Points": [ + kf(t_start, brightness_start), + kf(t_end, brightness_end), + ]} + fx["contrast"] = {"Points": [kf(t_start, 20.0)]} + clip.data["effects"].append(fx) + + def _apply_preset(preset_name, t_start, t_end, resting_frame): + """Apply an animation preset scaled to [t_start, t_end] frames. + + Values are applied relative to the clip's current state at resting_frame: + scale/alpha are multiplied by the base value; location/rotation/shear + are offset by it. Easing handles from KEYFRAME_EASING are applied to + consecutive point pairs. The zone [t_start, t_end] is cleared of + existing keyframes for each touched property before insertion. + """ + preset = _ANIMATION_PRESETS.get(preset_name, {}) + if not preset: + return + src_dur = 30.0 # source frames span 1–31 + tgt_dur = max(1, t_end - t_start) + + for prop, points in preset.items(): + if prop not in clip.data: + continue + + base = _base(prop, resting_frame) + + # Clear previous keyframes in the animation zone + self._remove_keypoints_in_range(clip.data[prop], t_start, t_end) + + # Anchor keyframes at both zone boundaries so no drift occurs + # when the first/last preset frame doesn't map exactly to t_start/t_end. + # Preset keyframes that land on t_start or t_end will overwrite these. + self.AddPoint(clip.data[prop], kf(t_start, base)) + self.AddPoint(clip.data[prop], kf(t_end, base)) + + # Scale frame positions, adjust values, and record easing names + scaled = [] + for pt in points: + src_frame = pt[0] + src_val = pt[1] + easing = pt[2] if len(pt) > 2 else None + norm = (src_frame - 1) / src_dur + tgt_frame = t_start + round(norm * tgt_dur) + adj_val = _rel(prop, src_val, base) + scaled.append((tgt_frame, adj_val, easing)) + + # Build point dicts and apply cubic-bezier handles + for i, (tgt_frame, adj_val, easing) in enumerate(scaled): + p = kf(tgt_frame, adj_val) + # handle_right on current point (controls curve TO next point) + if easing and easing in _KEYFRAME_EASING: + x1, y1, x2, y2 = _KEYFRAME_EASING[easing] + p['handle_right'] = {'X': x1, 'Y': y1} + # handle_left on current point (controls curve FROM previous) + if i > 0: + prev_easing = scaled[i - 1][2] + if prev_easing and prev_easing in _KEYFRAME_EASING: + _, _, x2, y2 = _KEYFRAME_EASING[prev_easing] + p['handle_left'] = {'X': x2, 'Y': y2} + self.AddPoint(clip.data[prop], p) + + # SVG filename → enum mappings for Wipe In (brightness 1 → -1) + # and Wipe Out (brightness -1 → 1, same SVG file) + _WIPE_SVG = { + MenuAnimate.WIPE_IN_CIRCLE_EXPAND: "circle_in_to_out.svg", + MenuAnimate.WIPE_IN_CIRCLE_SHRINK: "circle_out_to_in.svg", + MenuAnimate.WIPE_IN_FADE: "fade.svg", + MenuAnimate.WIPE_IN_LEFT: "wipe_left_to_right.svg", + MenuAnimate.WIPE_IN_RIGHT: "wipe_right_to_left.svg", + MenuAnimate.WIPE_IN_TOP: "wipe_top_to_bottom.svg", + MenuAnimate.WIPE_IN_BOTTOM: "wipe_bottom_to_top.svg", + MenuAnimate.WIPE_OUT_CIRCLE_EXPAND: "circle_in_to_out.svg", + MenuAnimate.WIPE_OUT_CIRCLE_SHRINK: "circle_out_to_in.svg", + MenuAnimate.WIPE_OUT_FADE: "fade.svg", + MenuAnimate.WIPE_OUT_LEFT: "wipe_left_to_right.svg", + MenuAnimate.WIPE_OUT_RIGHT: "wipe_right_to_left.svg", + MenuAnimate.WIPE_OUT_TOP: "wipe_top_to_bottom.svg", + MenuAnimate.WIPE_OUT_BOTTOM: "wipe_bottom_to_top.svg", + } if action == MenuAnimate.NONE: - # Clear all keyframes - default_zoom = openshot.Point(start_animation, 1.0, openshot.BEZIER) - default_zoom_object = json.loads(default_zoom.Json()) - default_loc = openshot.Point(start_animation, 0.0, openshot.BEZIER) - default_loc_object = json.loads(default_loc.Json()) - default_origin = openshot.Point(start_animation, 0.5, openshot.BEZIER) - default_origin_object = json.loads(default_origin.Json()) - clip.data["gravity"] = openshot.GRAVITY_CENTER - clip.data["scale_x"] = {"Points": [default_zoom_object]} - clip.data["scale_y"] = {"Points": [default_zoom_object]} - clip.data["shear_x"] = {"Points": [default_loc_object]} - clip.data["shear_y"] = {"Points": [default_loc_object]} - clip.data["rotation"] = {"Points": [default_loc_object]} - clip.data["location_x"] = {"Points": [default_loc_object]} - clip.data["location_y"] = {"Points": [default_loc_object]} - clip.data["origin_x"] = {"Points": [default_origin_object]} - clip.data["origin_y"] = {"Points": [default_origin_object]} - - if action in [ - MenuAnimate.IN_50_100, - MenuAnimate.IN_75_100, - MenuAnimate.IN_100_150, - MenuAnimate.OUT_100_75, - MenuAnimate.OUT_100_50, - MenuAnimate.OUT_150_100 - ]: - # Scale animation - start_scale = 1.0 - end_scale = 1.0 - if action == MenuAnimate.IN_50_100: - start_scale = 0.5 - elif action == MenuAnimate.IN_75_100: - start_scale = 0.75 - elif action == MenuAnimate.IN_100_150: - end_scale = 1.5 - elif action == MenuAnimate.OUT_100_75: - end_scale = 0.75 - elif action == MenuAnimate.OUT_100_50: - end_scale = 0.5 - elif action == MenuAnimate.OUT_150_100: - start_scale = 1.5 - - # Add keyframes - start = openshot.Point(start_animation, start_scale, openshot.BEZIER) - start_object = json.loads(start.Json()) - end = openshot.Point(end_animation, end_scale, openshot.BEZIER) - end_object = json.loads(end.Json()) - clip.data["gravity"] = openshot.GRAVITY_CENTER - self.AddPoint(clip.data["scale_x"], start_object) - self.AddPoint(clip.data["scale_x"], end_object) - self.AddPoint(clip.data["scale_y"], start_object) - self.AddPoint(clip.data["scale_y"], end_object) - - if action in [ - MenuAnimate.CENTER_TOP, - MenuAnimate.CENTER_LEFT, - MenuAnimate.CENTER_RIGHT, - MenuAnimate.CENTER_BOTTOM, - MenuAnimate.TOP_CENTER, - MenuAnimate.LEFT_CENTER, - MenuAnimate.RIGHT_CENTER, - MenuAnimate.BOTTOM_CENTER, - MenuAnimate.TOP_BOTTOM, - MenuAnimate.LEFT_RIGHT, - MenuAnimate.RIGHT_LEFT, - MenuAnimate.BOTTOM_TOP - ]: - # Location animation - animate_start_x = 0.0 - animate_end_x = 0.0 - animate_start_y = 0.0 - animate_end_y = 0.0 - # Center to edge... - if action == MenuAnimate.CENTER_TOP: - animate_end_y = -1.0 - elif action == MenuAnimate.CENTER_LEFT: - animate_end_x = -1.0 - elif action == MenuAnimate.CENTER_RIGHT: - animate_end_x = 1.0 - elif action == MenuAnimate.CENTER_BOTTOM: - animate_end_y = 1.0 - - # Edge to Center - elif action == MenuAnimate.TOP_CENTER: - animate_start_y = -1.0 - elif action == MenuAnimate.LEFT_CENTER: - animate_start_x = -1.0 - elif action == MenuAnimate.RIGHT_CENTER: - animate_start_x = 1.0 - elif action == MenuAnimate.BOTTOM_CENTER: - animate_start_y = 1.0 - - # Edge to Edge - elif action == MenuAnimate.TOP_BOTTOM: - animate_start_y = -1.0 - animate_end_y = 1.0 - elif action == MenuAnimate.LEFT_RIGHT: - animate_start_x = -1.0 - animate_end_x = 1.0 - elif action == MenuAnimate.RIGHT_LEFT: - animate_start_x = 1.0 - animate_end_x = -1.0 - elif action == MenuAnimate.BOTTOM_TOP: - animate_start_y = 1.0 - animate_end_y = -1.0 - - # Add keyframes - start_x = openshot.Point(start_animation, animate_start_x, openshot.BEZIER) - start_x_object = json.loads(start_x.Json()) - end_x = openshot.Point(end_animation, animate_end_x, openshot.BEZIER) - end_x_object = json.loads(end_x.Json()) - start_y = openshot.Point(start_animation, animate_start_y, openshot.BEZIER) - start_y_object = json.loads(start_y.Json()) - end_y = openshot.Point(end_animation, animate_end_y, openshot.BEZIER) - end_y_object = json.loads(end_y.Json()) - clip.data["gravity"] = openshot.GRAVITY_CENTER - self.AddPoint(clip.data["location_x"], start_x_object) - self.AddPoint(clip.data["location_x"], end_x_object) - self.AddPoint(clip.data["location_y"], start_y_object) - self.AddPoint(clip.data["location_y"], end_y_object) - - if action == MenuAnimate.RANDOM: - # Location animation - animate_start_x = uniform(-0.5, 0.5) - animate_end_x = uniform(-0.15, 0.15) - animate_start_y = uniform(-0.5, 0.5) - animate_end_y = uniform(-0.15, 0.15) - - # Scale animation - start_scale = uniform(0.5, 1.5) - end_scale = uniform(0.85, 1.15) - - # Add keyframes - start = openshot.Point(start_animation, start_scale, openshot.BEZIER) - start_object = json.loads(start.Json()) - end = openshot.Point(end_animation, end_scale, openshot.BEZIER) - end_object = json.loads(end.Json()) - clip.data["gravity"] = openshot.GRAVITY_CENTER - self.AddPoint(clip.data["scale_x"], start_object) - self.AddPoint(clip.data["scale_x"], end_object) - self.AddPoint(clip.data["scale_y"], start_object) - self.AddPoint(clip.data["scale_y"], end_object) - - # Add keyframes - start_x = openshot.Point(start_animation, animate_start_x, openshot.BEZIER) - start_x_object = json.loads(start_x.Json()) - end_x = openshot.Point(end_animation, animate_end_x, openshot.BEZIER) - end_x_object = json.loads(end_x.Json()) - start_y = openshot.Point(start_animation, animate_start_y, openshot.BEZIER) - start_y_object = json.loads(start_y.Json()) - end_y = openshot.Point(end_animation, animate_end_y, openshot.BEZIER) - end_y_object = json.loads(end_y.Json()) - clip.data["gravity"] = openshot.GRAVITY_CENTER - self.AddPoint(clip.data["location_x"], start_x_object) - self.AddPoint(clip.data["location_x"], end_x_object) - self.AddPoint(clip.data["location_y"], start_y_object) - self.AddPoint(clip.data["location_y"], end_y_object) + _reset_motion() + + else: + # Ensure effects list exists (reset not called here) + if not isinstance(clip.data.get("effects"), list): + clip.data["effects"] = [] + + # ── SLIDE IN ────────────────────────────────────────────── + if action == MenuAnimate.SLIDE_IN_LEFT: + bx = _base('location_x', in_end) + self._remove_keypoints_in_range(clip.data["location_x"], s, in_end) + add("location_x", kf(s, bx - 1.0), kf(in_end, bx)) + elif action == MenuAnimate.SLIDE_IN_RIGHT: + bx = _base('location_x', in_end) + self._remove_keypoints_in_range(clip.data["location_x"], s, in_end) + add("location_x", kf(s, bx + 1.0), kf(in_end, bx)) + elif action == MenuAnimate.SLIDE_IN_TOP: + by = _base('location_y', in_end) + self._remove_keypoints_in_range(clip.data["location_y"], s, in_end) + add("location_y", kf(s, by - 1.0), kf(in_end, by)) + elif action == MenuAnimate.SLIDE_IN_BOTTOM: + by = _base('location_y', in_end) + self._remove_keypoints_in_range(clip.data["location_y"], s, in_end) + add("location_y", kf(s, by + 1.0), kf(in_end, by)) + + # ── SLIDE OUT ───────────────────────────────────────────── + elif action == MenuAnimate.SLIDE_OUT_LEFT: + bx = _base('location_x', out_start) + self._remove_keypoints_in_range(clip.data["location_x"], out_start, e) + add("location_x", kf(out_start, bx), kf(e, bx - 1.0)) + elif action == MenuAnimate.SLIDE_OUT_RIGHT: + bx = _base('location_x', out_start) + self._remove_keypoints_in_range(clip.data["location_x"], out_start, e) + add("location_x", kf(out_start, bx), kf(e, bx + 1.0)) + elif action == MenuAnimate.SLIDE_OUT_TOP: + by = _base('location_y', out_start) + self._remove_keypoints_in_range(clip.data["location_y"], out_start, e) + add("location_y", kf(out_start, by), kf(e, by - 1.0)) + elif action == MenuAnimate.SLIDE_OUT_BOTTOM: + by = _base('location_y', out_start) + self._remove_keypoints_in_range(clip.data["location_y"], out_start, e) + add("location_y", kf(out_start, by), kf(e, by + 1.0)) + + # ── BLUR IN — horizontal+vertical blur 50→0 + alpha fade ─── + elif action == MenuAnimate.BLUR_IN: + effect = openshot.EffectInfo().CreateEffect("Blur") + fx = json.loads(effect.Json()) + fx["id"] = get_app().project.generate_id() + fx["horizontal_radius"] = {"Points": [kf(s, 50.0), kf(in_end, 0.0)]} + fx["vertical_radius"] = {"Points": [kf(s, 50.0), kf(in_end, 0.0)]} + clip.data["effects"].append(fx) + ba = _base('alpha', in_end) + self._remove_keypoints_in_range(clip.data["alpha"], s, in_end) + add("alpha", kf(s, 0.0), kf(in_end, ba)) + + # ── BLUR OUT — alpha fade + blur grows 0→50 ─────────────── + elif action == MenuAnimate.BLUR_OUT: + effect = openshot.EffectInfo().CreateEffect("Blur") + fx = json.loads(effect.Json()) + fx["id"] = get_app().project.generate_id() + fx["horizontal_radius"] = {"Points": [kf(out_start, 0.0), kf(e, 50.0)]} + fx["vertical_radius"] = {"Points": [kf(out_start, 0.0), kf(e, 50.0)]} + clip.data["effects"].append(fx) + ba = _base('alpha', out_start) + self._remove_keypoints_in_range(clip.data["alpha"], out_start, e) + add("alpha", kf(out_start, ba), kf(e, 0.0)) + + # ── WIPE IN — Mask effect, brightness 1 → -1 ────────────── + elif action in (MenuAnimate.WIPE_IN_CIRCLE_EXPAND, + MenuAnimate.WIPE_IN_CIRCLE_SHRINK, + MenuAnimate.WIPE_IN_FADE, + MenuAnimate.WIPE_IN_LEFT, MenuAnimate.WIPE_IN_RIGHT, + MenuAnimate.WIPE_IN_TOP, MenuAnimate.WIPE_IN_BOTTOM): + _make_wipe_fx(_WIPE_SVG[action], s, in_end, 1.0, -1.0) + + # ── WIPE OUT — Mask effect, brightness -1 → 1 ───────────── + elif action in (MenuAnimate.WIPE_OUT_CIRCLE_EXPAND, + MenuAnimate.WIPE_OUT_CIRCLE_SHRINK, + MenuAnimate.WIPE_OUT_FADE, + MenuAnimate.WIPE_OUT_LEFT, MenuAnimate.WIPE_OUT_RIGHT, + MenuAnimate.WIPE_OUT_TOP, MenuAnimate.WIPE_OUT_BOTTOM): + _make_wipe_fx(_WIPE_SVG[action], out_start, e, -1.0, 1.0) + + # ── POP ─────────────────────────────────────────────────── + elif action == MenuAnimate.POP_IN: + peak = in_end - max(1, round(0.2 * (in_end - s))) + bsx = _base('scale_x', in_end) + bsy = _base('scale_y', in_end) + ba = _base('alpha', in_end) + for prop in ("scale_x", "scale_y", "alpha"): + self._remove_keypoints_in_range(clip.data[prop], s, in_end) + add("scale_x", kf(s, 0.0), kf(peak, 1.1 * bsx), kf(in_end, bsx)) + add("scale_y", kf(s, 0.0), kf(peak, 1.1 * bsy), kf(in_end, bsy)) + add("alpha", kf(s, 0.0), kf(in_end, ba)) + elif action == MenuAnimate.POP_OUT: + peak = out_start + max(1, round(0.2 * (e - out_start))) + bsx = _base('scale_x', out_start) + bsy = _base('scale_y', out_start) + ba = _base('alpha', out_start) + for prop in ("scale_x", "scale_y", "alpha"): + self._remove_keypoints_in_range(clip.data[prop], out_start, e) + add("scale_x", kf(out_start, bsx), kf(peak, 1.1 * bsx), kf(e, 0.0)) + add("scale_y", kf(out_start, bsy), kf(peak, 1.1 * bsy), kf(e, 0.0)) + add("alpha", kf(out_start, ba), kf(e, 0.0)) + + # ── SPIRAL ──────────────────────────────────────────────── + elif action == MenuAnimate.SPIRAL_IN: + br = _base('rotation', in_end) + bsx = _base('scale_x', in_end) + bsy = _base('scale_y', in_end) + ba = _base('alpha', in_end) + for prop in ("rotation", "scale_x", "scale_y", "alpha"): + self._remove_keypoints_in_range(clip.data[prop], s, in_end) + add("rotation", kf(s, -360.0 + br), kf(in_end, br)) + add("scale_x", kf(s, 0.0), kf(in_end, bsx)) + add("scale_y", kf(s, 0.0), kf(in_end, bsy)) + add("alpha", kf(s, 0.0), kf(in_end, ba)) + elif action == MenuAnimate.SPIRAL_OUT: + br = _base('rotation', out_start) + bsx = _base('scale_x', out_start) + bsy = _base('scale_y', out_start) + ba = _base('alpha', out_start) + for prop in ("rotation", "scale_x", "scale_y", "alpha"): + self._remove_keypoints_in_range(clip.data[prop], out_start, e) + add("rotation", kf(out_start, br), kf(e, 360.0 + br)) + add("scale_x", kf(out_start, bsx), kf(e, 0.0)) + add("scale_y", kf(out_start, bsy), kf(e, 0.0)) + add("alpha", kf(out_start, ba), kf(e, 0.0)) + + # ── JSON PRESETS (Back/Bounce/Flip In/Out + all Emphasis) ── + elif action in _JSON_ANIM: + if action in _EMPHASIS_ACTIONS: + _apply_preset(_JSON_ANIM[action], emph_start, emph_end, emph_start) + elif action in _IN_ACTIONS: + _apply_preset(_JSON_ANIM[action], s, in_end, in_end) + else: + _apply_preset(_JSON_ANIM[action], out_start, e, out_start) + + # ── CAMERA: PUSH IN / PULL OUT (zoom, SCALE_CROP) ────────── + elif action == MenuAnimate.CAM_PUSH_IN: + clip.data["scale"] = openshot.SCALE_CROP + add("scale_x", kf(s, 1.0), kf(e, 1.3)) + add("scale_y", kf(s, 1.0), kf(e, 1.3)) + elif action == MenuAnimate.CAM_PULL_OUT: + clip.data["scale"] = openshot.SCALE_CROP + add("scale_x", kf(s, 1.3), kf(e, 1.0)) + add("scale_y", kf(s, 1.3), kf(e, 1.0)) + + # ── CAMERA: PAN (1.3× constant, ±0.15 edge-to-edge) ──────── + elif action in (MenuAnimate.CAM_PAN_LEFT, MenuAnimate.CAM_PAN_RIGHT, + MenuAnimate.CAM_PAN_UP, MenuAnimate.CAM_PAN_DOWN): + clip.data["scale"] = openshot.SCALE_CROP + add("scale_x", kf(s, 1.3), kf(e, 1.3)) + add("scale_y", kf(s, 1.3), kf(e, 1.3)) + if action == MenuAnimate.CAM_PAN_LEFT: + add("location_x", kf(s, -0.15), kf(e, 0.15)) + elif action == MenuAnimate.CAM_PAN_RIGHT: + add("location_x", kf(s, 0.15), kf(e, -0.15)) + elif action == MenuAnimate.CAM_PAN_UP: + add("location_y", kf(s, -0.15), kf(e, 0.15)) + elif action == MenuAnimate.CAM_PAN_DOWN: + add("location_y", kf(s, 0.15), kf(e, -0.15)) + + # ── CAMERA: KEN BURNS (scale + diagonal drift) ───────────── + elif action == MenuAnimate.KEN_BURNS_IN: + clip.data["scale"] = openshot.SCALE_CROP + add("scale_x", kf(s, 1.0), kf(e, 1.3)) + add("scale_y", kf(s, 1.0), kf(e, 1.3)) + add("location_x", kf(s, 0.0), kf(e, -0.08)) + add("location_y", kf(s, 0.0), kf(e, 0.04)) + elif action == MenuAnimate.KEN_BURNS_OUT: + clip.data["scale"] = openshot.SCALE_CROP + add("scale_x", kf(s, 1.3), kf(e, 1.0)) + add("scale_y", kf(s, 1.3), kf(e, 1.0)) + add("location_x", kf(s, -0.08), kf(e, 0.0)) + add("location_y", kf(s, 0.04), kf(e, 0.0)) + + # ── CREDITS (full scroll, SCALE_CROP) ───────────────────── + elif action == MenuAnimate.CREDITS_UP: + clip.data["scale"] = openshot.SCALE_CROP + add("location_y", + kf(s, 1.5, openshot.LINEAR), + kf(e, -1.5, openshot.LINEAR)) + elif action == MenuAnimate.CREDITS_DOWN: + clip.data["scale"] = openshot.SCALE_CROP + add("location_y", + kf(s, -1.5, openshot.LINEAR), + kf(e, 1.5, openshot.LINEAR)) - # Save changes self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True, transaction_id=tid) finally: - # Reset transaction id only if we created it (not if it was passed in) if not transaction_id: get_app().updates.transaction_id = None diff --git a/src/windows/views/timeline_backend/enums.py b/src/windows/views/timeline_backend/enums.py index b0f3bc66da..1045719730 100644 --- a/src/windows/views/timeline_backend/enums.py +++ b/src/windows/views/timeline_backend/enums.py @@ -63,25 +63,78 @@ class MenuAlign(Enum): class MenuAnimate(Enum): NONE = 0 - IN_50_100 = auto() - IN_75_100 = auto() - IN_100_150 = auto() - OUT_100_75 = auto() - OUT_100_50 = auto() - OUT_150_100 = auto() - CENTER_TOP = auto() - CENTER_LEFT = auto() - CENTER_RIGHT = auto() - CENTER_BOTTOM = auto() - TOP_CENTER = auto() - LEFT_CENTER = auto() - RIGHT_CENTER = auto() - BOTTOM_CENTER = auto() - TOP_BOTTOM = auto() - LEFT_RIGHT = auto() - RIGHT_LEFT = auto() - BOTTOM_TOP = auto() - RANDOM = auto() + # ── In ─────────────────────────────────────────────────────────────────── + SLIDE_IN_LEFT = auto() + SLIDE_IN_RIGHT = auto() + SLIDE_IN_TOP = auto() + SLIDE_IN_BOTTOM = auto() + BLUR_IN = auto() + WIPE_IN_CIRCLE_EXPAND = auto() + WIPE_IN_CIRCLE_SHRINK = auto() + WIPE_IN_FADE = auto() + WIPE_IN_LEFT = auto() + WIPE_IN_RIGHT = auto() + WIPE_IN_TOP = auto() + WIPE_IN_BOTTOM = auto() + POP_IN = auto() + SPIRAL_IN = auto() + BACK_IN_DOWN = auto() + BACK_IN_LEFT = auto() + BACK_IN_RIGHT = auto() + BACK_IN_UP = auto() + BOUNCE_IN = auto() + BOUNCE_IN_DOWN = auto() + BOUNCE_IN_LEFT = auto() + BOUNCE_IN_RIGHT = auto() + BOUNCE_IN_UP = auto() + # ── Out ────────────────────────────────────────────────────────────────── + SLIDE_OUT_LEFT = auto() + SLIDE_OUT_RIGHT = auto() + SLIDE_OUT_TOP = auto() + SLIDE_OUT_BOTTOM = auto() + BLUR_OUT = auto() + WIPE_OUT_CIRCLE_EXPAND = auto() + WIPE_OUT_CIRCLE_SHRINK = auto() + WIPE_OUT_FADE = auto() + WIPE_OUT_LEFT = auto() + WIPE_OUT_RIGHT = auto() + WIPE_OUT_TOP = auto() + WIPE_OUT_BOTTOM = auto() + POP_OUT = auto() + SPIRAL_OUT = auto() + BACK_OUT_DOWN = auto() + BACK_OUT_LEFT = auto() + BACK_OUT_RIGHT = auto() + BACK_OUT_UP = auto() + BOUNCE_OUT = auto() + BOUNCE_OUT_DOWN = auto() + BOUNCE_OUT_LEFT = auto() + BOUNCE_OUT_RIGHT = auto() + BOUNCE_OUT_UP = auto() + # ── Emphasis ───────────────────────────────────────────────────────────── + BOUNCE = auto() + FLASH = auto() + PULSE = auto() + RUBBER_BAND = auto() + SHAKE_X = auto() + SHAKE_Y = auto() + SWING = auto() + TADA = auto() + WOBBLE = auto() + JELLO = auto() + HEART_BEAT = auto() + # ── Camera ─────────────────────────────────────────────────────────────── + CAM_PUSH_IN = auto() + CAM_PULL_OUT = auto() + CAM_PAN_LEFT = auto() + CAM_PAN_RIGHT = auto() + CAM_PAN_UP = auto() + CAM_PAN_DOWN = auto() + KEN_BURNS_IN = auto() + KEN_BURNS_OUT = auto() + # ── Credits ────────────────────────────────────────────────────────────── + CREDITS_UP = auto() + CREDITS_DOWN = auto() class MenuVolume(Enum): From 48b9ba17e33e23e873ca26a33e5e14bdf35a0fcb Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 16:51:39 -0500 Subject: [PATCH 07/23] KeyframeScaler now recursively scales any nested Points keyframe data inside clip/effect/transition properties. That covers the new ColorGrade wheel and curve editor structures, while preserving the existing special time behavior where both X and Y frame values are scaled. - Also added new unit tests to validate change --- src/classes/keyframe_scaler.py | 42 ++++---- src/tests/test_keyframe_scaler.py | 172 ++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 src/tests/test_keyframe_scaler.py diff --git a/src/classes/keyframe_scaler.py b/src/classes/keyframe_scaler.py index 50210e18b7..bb96aef73d 100644 --- a/src/classes/keyframe_scaler.py +++ b/src/classes/keyframe_scaler.py @@ -39,32 +39,34 @@ def _scale_value(self, value: float) -> int: # Round to nearest INT return round(value * self._scale_factor) - def _update_prop(self, prop: dict, scale_y = False): + def _scale_points(self, prop: dict, scale_y = False): """To keep keyframes at the same time in video, update frame numbers to the new framerate. scale_y: if the y coordinate also represents a frame number, this flag will scale both x and y. """ - # Create a list of lists of keyframe points for this prop - if "red" in prop: - # It's a color, one list of points for each channel - keyframes = [prop[color].get("Points", []) for color in prop] - else: - # Not a color, just a single list of points - keyframes = [prop.get("Points", [])] - for k in keyframes: - if (scale_y): - # Y represents a frame number. Scale it too - [point["co"].update({ - "X": self._scale_value(point["co"].get("X", 0.0)), - "Y": self._scale_value(point["co"].get("Y", 0.0)) - }) for point in k if "co" in point] - else: - # Scale the X coordinate (frame #) by the stored factor - [point["co"].update({ - "X": self._scale_value(point["co"].get("X", 0.0)), - }) for point in k if "co" in point] + keyframes = prop.get("Points", []) + for point in keyframes: + if "co" not in point: + continue + point["co"]["X"] = self._scale_value(point["co"].get("X", 0.0)) + if scale_y: + point["co"]["Y"] = self._scale_value(point["co"].get("Y", 0.0)) + + def _update_prop(self, prop: dict, scale_y = False): + """Scale keyframe points in a property, including nested property data.""" + if "Points" in prop: + self._scale_points(prop, scale_y=scale_y) + return + + for value in prop.values(): + if isinstance(value, dict): + self._update_prop(value, scale_y=scale_y) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + self._update_prop(item, scale_y=scale_y) def _process_item(self, item: dict): """Process all the dict sub-members of the current dict""" diff --git a/src/tests/test_keyframe_scaler.py b/src/tests/test_keyframe_scaler.py new file mode 100644 index 0000000000..0ff21bfb55 --- /dev/null +++ b/src/tests/test_keyframe_scaler.py @@ -0,0 +1,172 @@ +""" + @file + @brief Unit tests for project keyframe frame-number scaling + @author Jonathan Thomas + + @section LICENSE + + Copyright (c) 2008-2026 OpenShot Studios, LLC + (http://www.openshotstudios.com). This file is part of + OpenShot Video Editor (http://www.openshot.org), an open-source project + dedicated to delivering high quality video editing and animation solutions + to the world. + + OpenShot Video Editor is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OpenShot Video Editor 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OpenShot Library. If not, see . + """ + +import os +import sys +import unittest + + +PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +if PATH not in sys.path: + sys.path.append(PATH) + +from classes.keyframe_scaler import KeyframeScaler # noqa: E402 + + +def keyframe(*frames): + return { + "Points": [ + {"co": {"X": float(frame), "Y": float(index)}, "interpolation": 0} + for index, frame in enumerate(frames) + ] + } + + +def frame_numbers(data): + return [point["co"]["X"] for point in data["Points"]] + + +class KeyframeScalerTests(unittest.TestCase): + def test_scales_clip_effect_transition_colorgrade_and_time_keyframes(self): + data = { + "clips": [{ + "id": "clip-1", + "alpha": keyframe(1, 30), + "location": { + "red": keyframe(1, 30), + "green": keyframe(1, 30), + "blue": keyframe(1, 30), + "alpha": keyframe(1, 30), + }, + "time": { + "Points": [ + {"co": {"X": 1.0, "Y": 1.0}, "interpolation": 0}, + {"co": {"X": 30.0, "Y": 30.0}, "interpolation": 0}, + ] + }, + "effects": [{ + "class_name": "ColorGrade", + "wheels": { + "enabled_keyframes": keyframe(1, 30), + "global": { + "color_keyframes": { + "red": keyframe(1, 30), + "green": keyframe(1, 30), + "blue": keyframe(1, 30), + "alpha": keyframe(1, 30), + }, + "amount_keyframes": keyframe(1, 30), + "luma_keyframes": keyframe(1, 30), + }, + }, + "curve": { + "enabled": keyframe(1, 30), + "nodes": [{ + "id": 1, + "x": keyframe(1, 30), + "y": { + "Points": [ + {"co": {"X": 1.0, "Y": 0.2}, "interpolation": 0}, + {"co": {"X": 30.0, "Y": 0.8}, "interpolation": 0}, + ] + }, + "left_handle_x": keyframe(1, 30), + "right_handle_y": keyframe(1, 30), + }], + }, + }], + }], + "effects": [{ + "id": "transition-1", + "brightness": keyframe(1, 30), + }], + } + + KeyframeScaler(2.0)(data) + + clip = data["clips"][0] + self.assertEqual(frame_numbers(clip["alpha"]), [1.0, 60]) + self.assertEqual(frame_numbers(clip["location"]["red"]), [1.0, 60]) + self.assertEqual(frame_numbers(clip["time"]), [1.0, 60]) + self.assertEqual([point["co"]["Y"] for point in clip["time"]["Points"]], [1.0, 60]) + + effect = clip["effects"][0] + wheels = effect["wheels"] + self.assertEqual(frame_numbers(wheels["enabled_keyframes"]), [1.0, 60]) + self.assertEqual(frame_numbers(wheels["global"]["color_keyframes"]["red"]), [1.0, 60]) + self.assertEqual(frame_numbers(wheels["global"]["amount_keyframes"]), [1.0, 60]) + self.assertEqual(frame_numbers(wheels["global"]["luma_keyframes"]), [1.0, 60]) + + curve = effect["curve"] + node = curve["nodes"][0] + self.assertEqual(frame_numbers(curve["enabled"]), [1.0, 60]) + self.assertEqual(frame_numbers(node["x"]), [1.0, 60]) + self.assertEqual(frame_numbers(node["y"]), [1.0, 60]) + self.assertEqual([point["co"]["Y"] for point in node["y"]["Points"]], [0.2, 0.8]) + self.assertEqual(frame_numbers(node["left_handle_x"]), [1.0, 60]) + self.assertEqual(frame_numbers(node["right_handle_y"]), [1.0, 60]) + + self.assertEqual(frame_numbers(data["effects"][0]["brightness"]), [1.0, 60]) + + def test_scales_nested_colorgrade_keyframes_without_color_channel_shortcut(self): + data = { + "clips": [{ + "effects": [{ + "wheels": { + "highlights": { + "color_keyframes": { + "red": keyframe(1, 30), + "green": keyframe(1, 30), + "blue": keyframe(1, 30), + "alpha": keyframe(1, 30), + }, + }, + }, + "curve": { + "nodes": [ + {"id": 0, "right_handle_x": keyframe(1, 30)}, + {"id": 1, "left_handle_y": keyframe(1, 30)}, + ], + }, + }], + }], + } + + KeyframeScaler(2.0)(data) + + effect = data["clips"][0]["effects"][0] + color = effect["wheels"]["highlights"]["color_keyframes"] + self.assertEqual(frame_numbers(color["red"]), [1.0, 60]) + self.assertEqual(frame_numbers(color["green"]), [1.0, 60]) + self.assertEqual(frame_numbers(color["blue"]), [1.0, 60]) + self.assertEqual(frame_numbers(color["alpha"]), [1.0, 60]) + self.assertEqual(frame_numbers(effect["curve"]["nodes"][0]["right_handle_x"]), [1.0, 60]) + self.assertEqual(frame_numbers(effect["curve"]["nodes"][1]["left_handle_y"]), [1.0, 60]) + + +if __name__ == "__main__": + unittest.main() From c4b900dc5906d99074f3a936e2334c84d8317148 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 17:42:38 -0500 Subject: [PATCH 08/23] Improve Camera motion presets with smart framing - Add pure camera motion helper for crop-aware zoom, pan, and zoom+pan keyframes - Compute safe pan ranges from project and source aspect ratios to avoid black bars - Use natural crop room for wide/tall media and only add extra zoom when needed - Add Auto Direction support for Pan and Zoom & Pan presets - Reorganize Camera menu into Zoom, Pan, and Zoom & Pan groups - Add directional Zoom & Pan presets with translatable labels - Add unit coverage for smart camera framing and update timeline integration tests --- src/classes/camera_motion.py | 229 ++++++++++++++++++++ src/tests/test_camera_motion.py | 140 ++++++++++++ src/tests/test_timeline_helpers.py | 16 +- src/windows/views/timeline.py | 154 +++++++++---- src/windows/views/timeline_backend/enums.py | 9 + 5 files changed, 499 insertions(+), 49 deletions(-) create mode 100644 src/classes/camera_motion.py create mode 100644 src/tests/test_camera_motion.py diff --git a/src/classes/camera_motion.py b/src/classes/camera_motion.py new file mode 100644 index 0000000000..7fa84b3f5c --- /dev/null +++ b/src/classes/camera_motion.py @@ -0,0 +1,229 @@ +""" + @file + @brief Camera motion framing helpers for clip motion presets + @author OpenShot Studios + + @section LICENSE + + Copyright (c) 2008-2026 OpenShot Studios, LLC + (http://www.openshotstudios.com). This file is part of + OpenShot Video Editor (http://www.openshot.org), an open-source project + dedicated to delivering high quality video editing and animation solutions + to the world. + + OpenShot Video Editor is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + """ + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + + +PAN_AUTO = "auto" +PAN_LEFT_TO_RIGHT = "left_to_right" +PAN_RIGHT_TO_LEFT = "right_to_left" +PAN_TOP_TO_BOTTOM = "top_to_bottom" +PAN_BOTTOM_TO_TOP = "bottom_to_top" +PAN_LEFT = "pan_left" +PAN_RIGHT = "pan_right" +PAN_UP = "pan_up" +PAN_DOWN = "pan_down" + +KEN_BURNS_AUTO = "auto" +KEN_BURNS_LEFT_TO_RIGHT = "left_to_right" +KEN_BURNS_RIGHT_TO_LEFT = "right_to_left" +KEN_BURNS_TOP_TO_BOTTOM = "top_to_bottom" +KEN_BURNS_BOTTOM_TO_TOP = "bottom_to_top" + + +@dataclass(frozen=True) +class CameraKeyframes: + """Plain keyframe values returned by camera motion helpers.""" + + scale_x: Tuple[float, float] + scale_y: Tuple[float, float] + location_x: Tuple[float, float] = (0.0, 0.0) + location_y: Tuple[float, float] = (0.0, 0.0) + + +def _positive_float(value, fallback: float) -> float: + try: + value = float(value) + except (TypeError, ValueError): + return fallback + return value if value > 0.0 else fallback + + +def _crop_base_size( + project_width, + project_height, + source_width, + source_height, +) -> Tuple[float, float]: + """Return source size after SCALE_CROP with scale_x/scale_y at 1.0.""" + pw = _positive_float(project_width, 1920.0) + ph = _positive_float(project_height, 1080.0) + sw = _positive_float(source_width, pw) + sh = _positive_float(source_height, ph) + + project_aspect = pw / ph + source_aspect = sw / sh + if source_aspect >= project_aspect: + return ph * source_aspect, ph + return pw, pw / source_aspect + + +def _safe_location(canvas_size: float, base_size: float, scale: float) -> float: + """Largest normalized location value that keeps SCALE_CROP edges covered.""" + scaled_size = max(0.0001, float(base_size) * max(0.001, float(scale))) + canvas_size = max(0.0001, float(canvas_size)) + if scaled_size <= canvas_size: + return 0.0 + return (scaled_size - canvas_size) / (scaled_size + canvas_size) + + +def _scale_for_safe_location(canvas_size: float, base_size: float, target: float) -> float: + """Return minimum scale needed for target normalized pan room on one axis.""" + target = max(0.0, min(float(target), 0.9)) + canvas_size = max(0.0001, float(canvas_size)) + base_size = max(0.0001, float(base_size)) + return (canvas_size * (1.0 + target)) / (base_size * (1.0 - target)) + + +def _axis_for_direction(direction: str) -> str: + if direction in ( + PAN_LEFT, PAN_RIGHT, PAN_LEFT_TO_RIGHT, PAN_RIGHT_TO_LEFT, + KEN_BURNS_LEFT_TO_RIGHT, KEN_BURNS_RIGHT_TO_LEFT): + return "x" + if direction in ( + PAN_UP, PAN_DOWN, PAN_TOP_TO_BOTTOM, PAN_BOTTOM_TO_TOP, + KEN_BURNS_TOP_TO_BOTTOM, KEN_BURNS_BOTTOM_TO_TOP): + return "y" + return "auto" + + +def _auto_ken_burns_direction( + project_width, + project_height, + source_width, + source_height, +) -> str: + """Choose the strongest natural crop axis for a Ken Burns drift.""" + pw = _positive_float(project_width, 1920.0) + ph = _positive_float(project_height, 1080.0) + bw, bh = _crop_base_size(pw, ph, source_width, source_height) + x_room = _safe_location(pw, bw, 1.0) + y_room = _safe_location(ph, bh, 1.0) + + if x_room > y_room + 0.03: + return KEN_BURNS_LEFT_TO_RIGHT + if y_room > x_room + 0.03: + return KEN_BURNS_BOTTOM_TO_TOP + return KEN_BURNS_LEFT_TO_RIGHT + + +def _pan_endpoints(direction: str, magnitude: float) -> Tuple[float, float]: + magnitude = round(max(0.0, float(magnitude)), 6) + if direction in (PAN_LEFT, PAN_RIGHT_TO_LEFT, KEN_BURNS_RIGHT_TO_LEFT): + return -magnitude, magnitude + if direction in (PAN_RIGHT, PAN_LEFT_TO_RIGHT, KEN_BURNS_LEFT_TO_RIGHT): + return magnitude, -magnitude + if direction in (PAN_UP, PAN_BOTTOM_TO_TOP, KEN_BURNS_BOTTOM_TO_TOP): + return -magnitude, magnitude + if direction in (PAN_DOWN, PAN_TOP_TO_BOTTOM, KEN_BURNS_TOP_TO_BOTTOM): + return magnitude, -magnitude + return 0.0, 0.0 + + +def camera_pan_keyframes( + direction: str, + project_width, + project_height, + source_width, + source_height, + *, + target_pan: float = 0.18, + edge_margin: float = 0.995, +) -> CameraKeyframes: + """Return smart SCALE_CROP pan values for a requested camera pan direction.""" + pw = _positive_float(project_width, 1920.0) + ph = _positive_float(project_height, 1080.0) + if direction == PAN_AUTO: + direction = _auto_ken_burns_direction(pw, ph, source_width, source_height) + bw, bh = _crop_base_size(pw, ph, source_width, source_height) + axis = _axis_for_direction(direction) + canvas = pw if axis == "x" else ph + base = bw if axis == "x" else bh + + natural_room = _safe_location(canvas, base, 1.0) + if natural_room >= target_pan: + scale = 1.0 + magnitude = natural_room * edge_margin + else: + scale = max(1.0, _scale_for_safe_location(canvas, base, target_pan)) + magnitude = _safe_location(canvas, base, scale) * edge_margin + + start, end = _pan_endpoints(direction, magnitude) + if axis == "x": + return CameraKeyframes((scale, scale), (scale, scale), (start, end), (0.0, 0.0)) + return CameraKeyframes((scale, scale), (scale, scale), (0.0, 0.0), (start, end)) + + +def push_pull_keyframes(zoom_in: bool, *, zoom: float = 1.2) -> CameraKeyframes: + """Return centered push-in or pull-out camera zoom keyframes.""" + start, end = (1.0, zoom) if zoom_in else (zoom, 1.0) + return CameraKeyframes((start, end), (start, end)) + + +def ken_burns_keyframes( + zoom_in: bool, + direction: str, + project_width, + project_height, + source_width, + source_height, + *, + zoom: float = 1.22, + target_pan: float = 0.10, + max_pan: float = 0.24, +) -> CameraKeyframes: + """Return smart Ken Burns zoom and drift values.""" + pw = _positive_float(project_width, 1920.0) + ph = _positive_float(project_height, 1080.0) + bw, bh = _crop_base_size(pw, ph, source_width, source_height) + + if direction == KEN_BURNS_AUTO: + direction = _auto_ken_burns_direction(pw, ph, source_width, source_height) + + axis = _axis_for_direction(direction) + canvas = pw if axis == "x" else ph + base = bw if axis == "x" else bh + zoom = max(float(zoom), _scale_for_safe_location(canvas, base, target_pan)) + + start_scale, end_scale = (1.0, zoom) if zoom_in else (zoom, 1.0) + start_safe = min(_safe_location(canvas, base, start_scale) * 0.82, max_pan) + end_safe = min(_safe_location(canvas, base, end_scale) * 0.82, max_pan) + start_magnitude = max(start_safe, target_pan if start_safe else 0.0) + end_magnitude = max(end_safe, target_pan if end_safe else 0.0) + start_mag = _pan_endpoints(direction, min(start_magnitude, max_pan))[0] + end_mag = _pan_endpoints(direction, min(end_magnitude, max_pan))[1] + + if axis == "x": + return CameraKeyframes((start_scale, end_scale), (start_scale, end_scale), (start_mag, end_mag), (0.0, 0.0)) + return CameraKeyframes((start_scale, end_scale), (start_scale, end_scale), (0.0, 0.0), (start_mag, end_mag)) + + +def source_dimensions_from_reader(reader: Optional[Dict]) -> Tuple[Optional[float], Optional[float]]: + """Extract media dimensions from reader metadata.""" + if not isinstance(reader, dict): + return None, None + width = reader.get("width") or reader.get("display_width") + height = reader.get("height") or reader.get("display_height") + try: + return float(width), float(height) + except (TypeError, ValueError): + return None, None diff --git a/src/tests/test_camera_motion.py b/src/tests/test_camera_motion.py new file mode 100644 index 0000000000..f4906e77b5 --- /dev/null +++ b/src/tests/test_camera_motion.py @@ -0,0 +1,140 @@ +""" + @file + @brief Unit tests for camera motion framing helpers + @author OpenShot Studios + + @section LICENSE + + Copyright (c) 2008-2026 OpenShot Studios, LLC + (http://www.openshotstudios.com). This file is part of + OpenShot Video Editor (http://www.openshot.org), an open-source project + dedicated to delivering high quality video editing and animation solutions + to the world. + + OpenShot Video Editor is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + """ + +import os +import sys +import unittest + + +PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +if PATH not in sys.path: + sys.path.append(PATH) + +from classes.camera_motion import ( + KEN_BURNS_AUTO, + KEN_BURNS_BOTTOM_TO_TOP, + KEN_BURNS_LEFT_TO_RIGHT, + KEN_BURNS_TOP_TO_BOTTOM, + PAN_AUTO, + PAN_BOTTOM_TO_TOP, + PAN_RIGHT, + PAN_LEFT_TO_RIGHT, + PAN_UP, + camera_pan_keyframes, + ken_burns_keyframes, + push_pull_keyframes, + source_dimensions_from_reader, +) + + +class CameraMotionTests(unittest.TestCase): + def test_wide_media_pans_horizontally_without_extra_zoom(self): + values = camera_pan_keyframes(PAN_LEFT_TO_RIGHT, 1920, 1080, 3840, 1080) + + self.assertEqual(values.scale_x, (1.0, 1.0)) + self.assertEqual(values.scale_y, (1.0, 1.0)) + self.assertGreater(values.location_x[0], 0.0) + self.assertLess(values.location_x[1], 0.0) + self.assertEqual(values.location_y, (0.0, 0.0)) + + def test_tall_media_pans_vertically_without_extra_zoom(self): + values = camera_pan_keyframes(PAN_UP, 1920, 1080, 1080, 3840) + + self.assertEqual(values.scale_x, (1.0, 1.0)) + self.assertEqual(values.scale_y, (1.0, 1.0)) + self.assertLess(values.location_y[0], 0.0) + self.assertGreater(values.location_y[1], 0.0) + self.assertGreater(abs(values.location_y[0]), 0.4) + self.assertEqual(values.location_x, (0.0, 0.0)) + + def test_tall_two_by_three_image_pans_to_crop_edges(self): + values = camera_pan_keyframes(PAN_BOTTOM_TO_TOP, 1920, 1080, 1024, 1536) + + self.assertEqual(values.scale_y, (1.0, 1.0)) + self.assertAlmostEqual(abs(values.location_y[0]), 0.452272, places=5) + self.assertAlmostEqual(abs(values.location_y[1]), 0.452272, places=5) + + def test_auto_pan_chooses_natural_direction(self): + values = camera_pan_keyframes(PAN_AUTO, 1920, 1080, 1024, 1536) + + self.assertEqual(values.location_x, (0.0, 0.0)) + self.assertLess(values.location_y[0], 0.0) + self.assertGreater(values.location_y[1], 0.0) + + def test_cross_axis_pan_adds_only_needed_zoom(self): + values = camera_pan_keyframes(PAN_RIGHT, 1920, 1080, 1080, 1920) + + self.assertGreater(values.scale_x[0], 1.0) + self.assertEqual(values.scale_x, values.scale_y) + self.assertGreater(values.location_x[0], 0.0) + self.assertLess(values.location_x[1], 0.0) + self.assertEqual(values.location_y, (0.0, 0.0)) + + def test_push_pull_are_centered_zoom_only(self): + push = push_pull_keyframes(zoom_in=True) + pull = push_pull_keyframes(zoom_in=False) + + self.assertEqual(push.scale_x[0], 1.0) + self.assertGreater(push.scale_x[1], 1.0) + self.assertGreater(pull.scale_x[0], 1.0) + self.assertEqual(pull.scale_x[1], 1.0) + self.assertEqual(push.location_x, (0.0, 0.0)) + self.assertEqual(pull.location_y, (0.0, 0.0)) + + def test_auto_ken_burns_chooses_wide_axis(self): + values = ken_burns_keyframes(True, KEN_BURNS_AUTO, 1920, 1080, 3840, 1080) + + self.assertEqual(values.scale_x[0], 1.0) + self.assertGreater(values.scale_x[1], 1.0) + self.assertGreater(values.location_x[0], 0.0) + self.assertLess(values.location_x[1], 0.0) + self.assertEqual(values.location_y, (0.0, 0.0)) + + def test_auto_ken_burns_chooses_tall_axis(self): + values = ken_burns_keyframes(True, KEN_BURNS_AUTO, 1920, 1080, 1080, 3840) + + self.assertEqual(values.location_x, (0.0, 0.0)) + self.assertLess(values.location_y[0], 0.0) + self.assertGreater(values.location_y[1], 0.0) + + def test_forced_ken_burns_direction_uses_requested_axis(self): + values = ken_burns_keyframes(True, KEN_BURNS_TOP_TO_BOTTOM, 1920, 1080, 1080, 3840) + + self.assertEqual(values.location_x, (0.0, 0.0)) + self.assertGreater(values.location_y[0], 0.0) + self.assertLess(values.location_y[1], 0.0) + + def test_ken_burns_out_reverses_zoom_but_keeps_requested_travel(self): + values = ken_burns_keyframes(False, KEN_BURNS_LEFT_TO_RIGHT, 1920, 1080, 3840, 1080) + + self.assertGreater(values.scale_x[0], 1.0) + self.assertEqual(values.scale_x[1], 1.0) + self.assertGreater(values.location_x[0], 0.0) + self.assertLess(values.location_x[1], 0.0) + + def test_source_dimensions_from_reader_accepts_display_dimensions(self): + self.assertEqual( + source_dimensions_from_reader({"display_width": 800, "display_height": 600}), + (800.0, 600.0), + ) + self.assertEqual(source_dimensions_from_reader({}), (None, None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/tests/test_timeline_helpers.py b/src/tests/test_timeline_helpers.py index 4ff740ef44..b38d57051a 100644 --- a/src/tests/test_timeline_helpers.py +++ b/src/tests/test_timeline_helpers.py @@ -1558,10 +1558,15 @@ def test_motion_wipe_mask_uses_high_static_contrast(self): def test_motion_ken_burns_direction_sets_distinct_scale_and_location(self): helper = self.make_motion_helper() clip = self.make_motion_clip() + clip.data["reader"] = {"width": 3840, "height": 1080} app = types.SimpleNamespace( updates=types.SimpleNamespace(transaction_id=None), project=types.SimpleNamespace( - get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + get=lambda key: { + "fps": {"num": 30, "den": 1}, + "width": 1920, + "height": 1080, + }.get(key), generate_id=lambda: "FX1", ), ) @@ -1576,10 +1581,11 @@ def test_motion_ken_burns_direction_sets_distinct_scale_and_location(self): ) self.assertEqual(clip.data["scale"], openshot.SCALE_CROP) - self.assertAlmostEqual(clip.data["scale_x"]["Points"][-1]["co"]["Y"], 1.3) - self.assertAlmostEqual(clip.data["scale_y"]["Points"][-1]["co"]["Y"], 1.3) - self.assertAlmostEqual(clip.data["location_x"]["Points"][-1]["co"]["Y"], -0.08) - self.assertAlmostEqual(clip.data["location_y"]["Points"][-1]["co"]["Y"], 0.04) + self.assertAlmostEqual(clip.data["scale_x"]["Points"][-1]["co"]["Y"], 1.22) + self.assertAlmostEqual(clip.data["scale_y"]["Points"][-1]["co"]["Y"], 1.22) + self.assertGreater(clip.data["location_x"]["Points"][0]["co"]["Y"], 0.0) + self.assertLess(clip.data["location_x"]["Points"][-1]["co"]["Y"], 0.0) + self.assertAlmostEqual(clip.data["location_y"]["Points"][-1]["co"]["Y"], 0.0) def test_find_missing_transition_details_returns_overlap(self): clip_data = {"id": "B", "layer": 1, "position": 4.0, "start": 0.0, "end": 6.0} diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index 187bb76274..6c1a670e1e 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -66,6 +66,26 @@ apply_film_grain_preset, is_film_grain_effect, ) +from classes.camera_motion import ( + KEN_BURNS_AUTO, + KEN_BURNS_BOTTOM_TO_TOP, + KEN_BURNS_LEFT_TO_RIGHT, + KEN_BURNS_RIGHT_TO_LEFT, + KEN_BURNS_TOP_TO_BOTTOM, + PAN_AUTO, + PAN_DOWN, + PAN_LEFT, + PAN_LEFT_TO_RIGHT, + PAN_RIGHT, + PAN_RIGHT_TO_LEFT, + PAN_TOP_TO_BOTTOM, + PAN_BOTTOM_TO_TOP, + PAN_UP, + camera_pan_keyframes, + ken_burns_keyframes, + push_pull_keyframes, + source_dimensions_from_reader, +) from classes.effect_init import effect_options from classes.logger import log from classes.query import File, Clip, Transition, Track, Effect @@ -1591,17 +1611,33 @@ def _motion_sub(title, items): # ── Camera ▶ ─────────────────────────────────────────────────────────── Camera_Menu = StyledContextMenu(title=_("Camera"), parent=self) - for label, action in [ - (_("Push In"), MenuAnimate.CAM_PUSH_IN), - (_("Pull Out"), MenuAnimate.CAM_PULL_OUT), - (_("Pan Left"), MenuAnimate.CAM_PAN_LEFT), - (_("Pan Right"), MenuAnimate.CAM_PAN_RIGHT), - (_("Pan Up"), MenuAnimate.CAM_PAN_UP), - (_("Pan Down"), MenuAnimate.CAM_PAN_DOWN), - (_("Ken Burns In"), MenuAnimate.KEN_BURNS_IN), - (_("Ken Burns Out"), MenuAnimate.KEN_BURNS_OUT), - ]: - _motion_act(Camera_Menu, label, action) + Camera_Menu.addMenu(_motion_sub(_("Zoom"), [ + (_("In"), MenuAnimate.CAM_PUSH_IN), + (_("Out"), MenuAnimate.CAM_PULL_OUT), + ])) + Camera_Menu.addMenu(_motion_sub(_("Pan"), [ + (_("Auto Direction"), MenuAnimate.CAM_PAN_AUTO), + (_("Left to Right"), MenuAnimate.CAM_PAN_RIGHT), + (_("Right to Left"), MenuAnimate.CAM_PAN_LEFT), + (_("Top to Bottom"), MenuAnimate.CAM_PAN_DOWN), + (_("Bottom to Top"), MenuAnimate.CAM_PAN_UP), + ])) + Zoom_Pan_Menu = StyledContextMenu(title=_("Zoom & Pan").replace("&", "&&"), parent=self) + Zoom_Pan_Menu.addMenu(_motion_sub(_("In"), [ + (_("Auto Direction"), MenuAnimate.KEN_BURNS_IN), + (_("Left to Right"), MenuAnimate.KEN_BURNS_IN_LEFT_TO_RIGHT), + (_("Right to Left"), MenuAnimate.KEN_BURNS_IN_RIGHT_TO_LEFT), + (_("Top to Bottom"), MenuAnimate.KEN_BURNS_IN_TOP_TO_BOTTOM), + (_("Bottom to Top"), MenuAnimate.KEN_BURNS_IN_BOTTOM_TO_TOP), + ])) + Zoom_Pan_Menu.addMenu(_motion_sub(_("Out"), [ + (_("Auto Direction"), MenuAnimate.KEN_BURNS_OUT), + (_("Left to Right"), MenuAnimate.KEN_BURNS_OUT_LEFT_TO_RIGHT), + (_("Right to Left"), MenuAnimate.KEN_BURNS_OUT_RIGHT_TO_LEFT), + (_("Top to Bottom"), MenuAnimate.KEN_BURNS_OUT_TOP_TO_BOTTOM), + (_("Bottom to Top"), MenuAnimate.KEN_BURNS_OUT_BOTTOM_TO_TOP), + ])) + Camera_Menu.addMenu(Zoom_Pan_Menu) Animate_Menu.addMenu(Camera_Menu) # ── Credits ▶ ────────────────────────────────────────────────────────── @@ -2746,6 +2782,25 @@ def _apply_preset(preset_name, t_start, t_end, resting_frame): MenuAnimate.WIPE_OUT_BOTTOM: "wipe_bottom_to_top.svg", } + def _camera_context(): + reader = clip.data.get("reader", {}) if isinstance(clip.data, dict) else {} + source_width, source_height = source_dimensions_from_reader(reader) + try: + project_width = get_app().project.get("width") + project_height = get_app().project.get("height") + except Exception: + project_width, project_height = None, None + return project_width, project_height, source_width, source_height + + def _apply_camera_motion(values): + clip.data["scale"] = openshot.SCALE_CROP + for prop in ("scale_x", "scale_y", "location_x", "location_y"): + self._remove_keypoints_in_range(clip.data[prop], s, e) + add("scale_x", kf(s, values.scale_x[0]), kf(e, values.scale_x[1])) + add("scale_y", kf(s, values.scale_y[0]), kf(e, values.scale_y[1])) + add("location_x", kf(s, values.location_x[0]), kf(e, values.location_x[1])) + add("location_y", kf(s, values.location_y[0]), kf(e, values.location_y[1])) + if action == MenuAnimate.NONE: _reset_motion() @@ -2887,42 +2942,53 @@ def _apply_preset(preset_name, t_start, t_end, resting_frame): # ── CAMERA: PUSH IN / PULL OUT (zoom, SCALE_CROP) ────────── elif action == MenuAnimate.CAM_PUSH_IN: - clip.data["scale"] = openshot.SCALE_CROP - add("scale_x", kf(s, 1.0), kf(e, 1.3)) - add("scale_y", kf(s, 1.0), kf(e, 1.3)) + _apply_camera_motion(push_pull_keyframes(zoom_in=True)) elif action == MenuAnimate.CAM_PULL_OUT: - clip.data["scale"] = openshot.SCALE_CROP - add("scale_x", kf(s, 1.3), kf(e, 1.0)) - add("scale_y", kf(s, 1.3), kf(e, 1.0)) + _apply_camera_motion(push_pull_keyframes(zoom_in=False)) - # ── CAMERA: PAN (1.3× constant, ±0.15 edge-to-edge) ──────── - elif action in (MenuAnimate.CAM_PAN_LEFT, MenuAnimate.CAM_PAN_RIGHT, + # ── CAMERA: PAN (axis-aware SCALE_CROP framing) ─────────── + elif action in (MenuAnimate.CAM_PAN_AUTO, + MenuAnimate.CAM_PAN_LEFT, MenuAnimate.CAM_PAN_RIGHT, MenuAnimate.CAM_PAN_UP, MenuAnimate.CAM_PAN_DOWN): - clip.data["scale"] = openshot.SCALE_CROP - add("scale_x", kf(s, 1.3), kf(e, 1.3)) - add("scale_y", kf(s, 1.3), kf(e, 1.3)) - if action == MenuAnimate.CAM_PAN_LEFT: - add("location_x", kf(s, -0.15), kf(e, 0.15)) - elif action == MenuAnimate.CAM_PAN_RIGHT: - add("location_x", kf(s, 0.15), kf(e, -0.15)) - elif action == MenuAnimate.CAM_PAN_UP: - add("location_y", kf(s, -0.15), kf(e, 0.15)) - elif action == MenuAnimate.CAM_PAN_DOWN: - add("location_y", kf(s, 0.15), kf(e, -0.15)) - - # ── CAMERA: KEN BURNS (scale + diagonal drift) ───────────── - elif action == MenuAnimate.KEN_BURNS_IN: - clip.data["scale"] = openshot.SCALE_CROP - add("scale_x", kf(s, 1.0), kf(e, 1.3)) - add("scale_y", kf(s, 1.0), kf(e, 1.3)) - add("location_x", kf(s, 0.0), kf(e, -0.08)) - add("location_y", kf(s, 0.0), kf(e, 0.04)) - elif action == MenuAnimate.KEN_BURNS_OUT: - clip.data["scale"] = openshot.SCALE_CROP - add("scale_x", kf(s, 1.3), kf(e, 1.0)) - add("scale_y", kf(s, 1.3), kf(e, 1.0)) - add("location_x", kf(s, -0.08), kf(e, 0.0)) - add("location_y", kf(s, 0.04), kf(e, 0.0)) + pan_direction = { + MenuAnimate.CAM_PAN_AUTO: PAN_AUTO, + MenuAnimate.CAM_PAN_LEFT: PAN_LEFT, + MenuAnimate.CAM_PAN_RIGHT: PAN_RIGHT, + MenuAnimate.CAM_PAN_UP: PAN_UP, + MenuAnimate.CAM_PAN_DOWN: PAN_DOWN, + }[action] + _apply_camera_motion(camera_pan_keyframes(pan_direction, *_camera_context())) + + # ── CAMERA: KEN BURNS (axis-aware zoom + drift) ─────────── + elif action in ( + MenuAnimate.KEN_BURNS_IN, MenuAnimate.KEN_BURNS_OUT, + MenuAnimate.KEN_BURNS_IN_LEFT_TO_RIGHT, + MenuAnimate.KEN_BURNS_IN_RIGHT_TO_LEFT, + MenuAnimate.KEN_BURNS_IN_TOP_TO_BOTTOM, + MenuAnimate.KEN_BURNS_IN_BOTTOM_TO_TOP, + MenuAnimate.KEN_BURNS_OUT_LEFT_TO_RIGHT, + MenuAnimate.KEN_BURNS_OUT_RIGHT_TO_LEFT, + MenuAnimate.KEN_BURNS_OUT_TOP_TO_BOTTOM, + MenuAnimate.KEN_BURNS_OUT_BOTTOM_TO_TOP): + direction = { + MenuAnimate.KEN_BURNS_IN: KEN_BURNS_AUTO, + MenuAnimate.KEN_BURNS_OUT: KEN_BURNS_AUTO, + MenuAnimate.KEN_BURNS_IN_LEFT_TO_RIGHT: KEN_BURNS_LEFT_TO_RIGHT, + MenuAnimate.KEN_BURNS_IN_RIGHT_TO_LEFT: KEN_BURNS_RIGHT_TO_LEFT, + MenuAnimate.KEN_BURNS_IN_TOP_TO_BOTTOM: KEN_BURNS_TOP_TO_BOTTOM, + MenuAnimate.KEN_BURNS_IN_BOTTOM_TO_TOP: KEN_BURNS_BOTTOM_TO_TOP, + MenuAnimate.KEN_BURNS_OUT_LEFT_TO_RIGHT: KEN_BURNS_LEFT_TO_RIGHT, + MenuAnimate.KEN_BURNS_OUT_RIGHT_TO_LEFT: KEN_BURNS_RIGHT_TO_LEFT, + MenuAnimate.KEN_BURNS_OUT_TOP_TO_BOTTOM: KEN_BURNS_TOP_TO_BOTTOM, + MenuAnimate.KEN_BURNS_OUT_BOTTOM_TO_TOP: KEN_BURNS_BOTTOM_TO_TOP, + }[action] + zoom_in = action in ( + MenuAnimate.KEN_BURNS_IN, + MenuAnimate.KEN_BURNS_IN_LEFT_TO_RIGHT, + MenuAnimate.KEN_BURNS_IN_RIGHT_TO_LEFT, + MenuAnimate.KEN_BURNS_IN_TOP_TO_BOTTOM, + MenuAnimate.KEN_BURNS_IN_BOTTOM_TO_TOP) + _apply_camera_motion(ken_burns_keyframes(zoom_in, direction, *_camera_context())) # ── CREDITS (full scroll, SCALE_CROP) ───────────────────── elif action == MenuAnimate.CREDITS_UP: diff --git a/src/windows/views/timeline_backend/enums.py b/src/windows/views/timeline_backend/enums.py index 1045719730..7f81abdd82 100644 --- a/src/windows/views/timeline_backend/enums.py +++ b/src/windows/views/timeline_backend/enums.py @@ -126,12 +126,21 @@ class MenuAnimate(Enum): # ── Camera ─────────────────────────────────────────────────────────────── CAM_PUSH_IN = auto() CAM_PULL_OUT = auto() + CAM_PAN_AUTO = auto() CAM_PAN_LEFT = auto() CAM_PAN_RIGHT = auto() CAM_PAN_UP = auto() CAM_PAN_DOWN = auto() KEN_BURNS_IN = auto() KEN_BURNS_OUT = auto() + KEN_BURNS_IN_LEFT_TO_RIGHT = auto() + KEN_BURNS_IN_RIGHT_TO_LEFT = auto() + KEN_BURNS_IN_TOP_TO_BOTTOM = auto() + KEN_BURNS_IN_BOTTOM_TO_TOP = auto() + KEN_BURNS_OUT_LEFT_TO_RIGHT = auto() + KEN_BURNS_OUT_RIGHT_TO_LEFT = auto() + KEN_BURNS_OUT_TOP_TO_BOTTOM = auto() + KEN_BURNS_OUT_BOTTOM_TO_TOP = auto() # ── Credits ────────────────────────────────────────────────────────────── CREDITS_UP = auto() CREDITS_DOWN = auto() From 90c79754b5b3039367be4f9068781e2acf241500 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 18:13:16 -0500 Subject: [PATCH 09/23] Fix retime support for nested color grade keyframes - Recursively scale nested Points keyframes during timeline retime operations - Cover ColorGrade color wheel and curve editor keyframes in retime regression tests - Refresh the Color Wheels dock from current model data after external edits - Reset the dock to neutral disabled wheels when no ColorGrade effect is selected or bound - Render color wheel controls and Amount/Luma sliders in muted gray while disabled --- src/tests/test_retime.py | 141 ++++++++++++++++++++++ src/windows/color_grade_editor.py | 45 +++++-- src/windows/views/properties_tableview.py | 49 +++++++- src/windows/views/retime.py | 33 +++-- 4 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 src/tests/test_retime.py diff --git a/src/tests/test_retime.py b/src/tests/test_retime.py new file mode 100644 index 0000000000..bc19af3d98 --- /dev/null +++ b/src/tests/test_retime.py @@ -0,0 +1,141 @@ +""" + @file + @brief Unit tests for timeline retime keyframe scaling + @author Jonathan Thomas + + @section LICENSE + + Copyright (c) 2008-2026 OpenShot Studios, LLC + (http://www.openshotstudios.com). This file is part of + OpenShot Video Editor (http://www.openshot.org), an open-source project + dedicated to delivering high quality video editing and animation solutions + to the world. + + OpenShot Video Editor is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OpenShot Video Editor 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OpenShot Library. If not, see . + """ + +import os +import sys +import types +import unittest +from unittest.mock import patch + +try: + import openshot +except ModuleNotFoundError: + openshot = types.SimpleNamespace(LINEAR=1) + sys.modules["openshot"] = openshot + +classes_app = types.ModuleType("classes.app") +classes_app.get_app = lambda: None +sys.modules.setdefault("classes.app", classes_app) + + +PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +if PATH not in sys.path: + sys.path.append(PATH) + +from windows.views.retime import retime_clip # noqa: E402 + + +def keyframe(*frames): + return { + "Points": [ + {"co": {"X": float(frame), "Y": float(index)}, "interpolation": openshot.LINEAR} + for index, frame in enumerate(frames) + ] + } + + +def frame_numbers(data): + return [point["co"]["X"] for point in data["Points"]] + + +class DummyClip: + def __init__(self, data): + self.data = data + + +class RetimeTests(unittest.TestCase): + def test_retime_scales_nested_colorgrade_wheel_and_curve_keyframes(self): + clip = DummyClip({ + "id": "clip-1", + "position": 0.0, + "start": 0.0, + "end": 2.0, + "duration": 2.0, + "alpha": keyframe(1, 30, 60), + "time": keyframe(1, 60), + "effects": [{ + "class_name": "ColorGrade", + "wheels": { + "enabled_keyframes": keyframe(1, 30, 60), + "global": { + "color_keyframes": { + "red": keyframe(1, 30, 60), + "green": keyframe(1, 30, 60), + "blue": keyframe(1, 30, 60), + "alpha": keyframe(1, 30, 60), + }, + "amount_keyframes": keyframe(1, 30, 60), + "luma_keyframes": keyframe(1, 30, 60), + }, + }, + "curve": { + "enabled": keyframe(1, 30, 60), + "nodes": [{ + "id": 1, + "x": keyframe(1, 30, 60), + "y": { + "Points": [ + {"co": {"X": 1.0, "Y": 0.2}, "interpolation": openshot.LINEAR}, + {"co": {"X": 30.0, "Y": 0.4}, "interpolation": openshot.LINEAR}, + {"co": {"X": 60.0, "Y": 0.8}, "interpolation": openshot.LINEAR}, + ] + }, + "left_handle_x": keyframe(1, 30, 60), + "right_handle_y": keyframe(1, 30, 60), + }], + }, + }], + }) + + with patch("windows.views.retime._project_fps_float", return_value=30.0): + self.assertTrue(retime_clip(clip, 4.0, 0.0, direction=1)) + + self.assertEqual(frame_numbers(clip.data["alpha"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(clip.data["time"]), [1, 121]) + + effect = clip.data["effects"][0] + wheels = effect["wheels"] + self.assertEqual(frame_numbers(wheels["enabled_keyframes"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(wheels["global"]["color_keyframes"]["red"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(wheels["global"]["color_keyframes"]["green"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(wheels["global"]["amount_keyframes"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(wheels["global"]["luma_keyframes"]), [1.0, 60, 121]) + + curve = effect["curve"] + node = curve["nodes"][0] + self.assertEqual(frame_numbers(curve["enabled"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(node["x"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(node["y"]), [1.0, 60, 121]) + self.assertEqual([point["co"]["Y"] for point in node["y"]["Points"]], [0.2, 0.4, 0.8]) + self.assertEqual(frame_numbers(node["left_handle_x"]), [1.0, 60, 121]) + self.assertEqual(frame_numbers(node["right_handle_y"]), [1.0, 60, 121]) + self.assertEqual(clip.data["end"], 4.0) + self.assertEqual(clip.data["duration"], 4.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/windows/color_grade_editor.py b/src/windows/color_grade_editor.py index 31345bdaf6..f09237e63b 100644 --- a/src/windows/color_grade_editor.py +++ b/src/windows/color_grade_editor.py @@ -586,6 +586,9 @@ def _update_from_position(self, pos): self.changed.emit() def mousePressEvent(self, event): + if not self.isEnabled(): + super().mousePressEvent(event) + return if event.button() != Qt.LeftButton: super().mousePressEvent(event) return @@ -596,7 +599,7 @@ def mousePressEvent(self, event): super().mousePressEvent(event) def mouseMoveEvent(self, event): - if not self._dragging: + if not self._dragging or not self.isEnabled(): return pos = event.position() if hasattr(event, "position") else QPointF(event.pos()) self._update_from_position(pos) @@ -609,6 +612,9 @@ def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) def mouseDoubleClickEvent(self, event): + if not self.isEnabled(): + super().mouseDoubleClickEvent(event) + return self._data["amount"] = 0.0 self._data["color"] = NEUTRAL_WHEEL_COLOR self.update() @@ -620,7 +626,7 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.Antialiasing) center, radius = self._center_and_radius() - color = display_wheel_color(self._data) + enabled = self.isEnabled() ring_rect = QRectF(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0) ring_width = max(6.0, radius * 0.16) @@ -630,10 +636,15 @@ def paintEvent(self, event): inner_radius = radius - ring_width inner_path.addEllipse(QRectF(center.x() - inner_radius, center.y() - inner_radius, inner_radius * 2.0, inner_radius * 2.0)) ring_path = ring_path.subtracted(inner_path) - painter.save() - painter.setClipPath(ring_path) - draw_broadcast_hue_ring(painter, center, radius - (ring_width * 0.5), ring_width + 1.0) - painter.restore() + if enabled: + painter.save() + painter.setClipPath(ring_path) + draw_broadcast_hue_ring(painter, center, radius - (ring_width * 0.5), ring_width + 1.0) + painter.restore() + else: + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(self.palette().mid().color())) + painter.drawPath(ring_path) painter.setPen(QPen(self.palette().mid().color(), 1.0)) painter.setBrush(QBrush(self.palette().base())) @@ -644,15 +655,15 @@ def paintEvent(self, event): painter.drawLine(QPointF(center.x(), center.y() - inner_radius), QPointF(center.x(), center.y() + inner_radius)) puck = self._puck_position() - painter.setPen(QPen(Qt.white, 1.0)) - painter.setBrush(QBrush(puck_display_color(self._data))) + painter.setPen(QPen(Qt.white if enabled else self.palette().mid().color(), 1.0)) + painter.setBrush(QBrush(puck_display_color(self._data) if enabled else self.palette().mid().color())) painter.drawEllipse(puck, 5.0, 5.0) if self._title: font = painter.font() font.setBold(True) painter.setFont(font) - painter.setPen(QPen(Qt.white)) + painter.setPen(QPen(Qt.white if enabled else self.palette().mid().color())) text_rect = QRectF(center.x() - radius, center.y() - radius, radius * 2.0, ring_width) painter.drawText(text_rect, Qt.AlignCenter, self._title) @@ -1282,6 +1293,9 @@ def paintEvent(self, event): if theme: bg = theme.get_color(".property_value", "background-color") fg = theme.get_color(".property_value", "foreground-color") + if not self.isEnabled(): + bg = self.palette().base().color() + fg = self.palette().mid().color() path = QPainterPath() path.addRoundedRect(rect, 6, 6) @@ -1307,7 +1321,7 @@ def paintEvent(self, event): self._curve_pixmaps.get(self._interpolation, self._curve_pixmaps[openshot.LINEAR])) text_rect.adjust(0.0, 0.0, -24.0, 0.0) - painter.setPen(QPen(Qt.white)) + painter.setPen(QPen(Qt.white if self.isEnabled() else self.palette().mid().color())) painter.drawText(text_rect, Qt.AlignCenter, self._fmt(self._value)) painter.end() @@ -1320,6 +1334,9 @@ def _x_to_value(self, x): return self._min + pct * (self._max - self._min) def mousePressEvent(self, event): + if not self.isEnabled(): + super().mousePressEvent(event) + return if event.button() == Qt.LeftButton: self._drag_active = True self.dragStarted.emit() @@ -1328,7 +1345,7 @@ def mousePressEvent(self, event): self.update() def mouseMoveEvent(self, event): - if not self._drag_active: + if not self._drag_active or not self.isEnabled(): return self.setValue(self._x_to_value(event.x())) self.valueChanged.emit(self._value) @@ -1340,10 +1357,16 @@ def mouseReleaseEvent(self, event): self.dragFinished.emit() def mouseDoubleClickEvent(self, event): + if not self.isEnabled(): + super().mouseDoubleClickEvent(event) + return if event.button() == Qt.LeftButton: self._enter_edit_mode() def keyPressEvent(self, event): + if not self.isEnabled(): + super().keyPressEvent(event) + return text = event.text() if text and (text.isdigit() or text in ('.', ',', '-')): self._enter_edit_mode(text) diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index 8995a12653..ee0695d918 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -699,6 +699,7 @@ def _sync_color_grade_editors_to_current_frame(self): dialog.curve_widget().blockSignals(False) elif property_type == "colorgrade_wheels": self._update_color_grade_preview_meta(property_meta) + self._sync_color_grade_wheels_dock_from_model() self.viewport().update() def property_model_refreshed(self): @@ -792,7 +793,10 @@ def _selection_is_color_grade(self, selection): def _update_color_grade_wheels_enabled(self, selection=None): if selection is None: selection = getattr(self, "current_selection", []) - self.color_grade_wheels_panel.setEnabled(self._selection_is_color_grade(selection)) + if self._selection_is_color_grade(selection): + self.color_grade_wheels_panel.setEnabled(True) + else: + self._set_color_grade_wheels_unbound() def _find_color_grade_wheels_item(self): model = self.clip_properties_model.model @@ -808,6 +812,49 @@ def _find_color_grade_wheels_item(self): return value_item, cur_property[0], normalize_wheels_data(cur_property[1].get("wheels")) return None, None, None + def _sync_color_grade_wheels_dock_from_model(self): + """Refresh the visible wheels dock from current model data after external edits.""" + if not hasattr(self, "color_grade_wheels_dock") or not self.color_grade_wheels_dock.isVisible(): + return + if not self._selection_is_color_grade(getattr(self, "current_selection", [])): + self._set_color_grade_wheels_unbound() + return + + item, property_key, wheels_data = self._find_color_grade_wheels_item() + if not item or not property_key: + self._set_color_grade_wheels_unbound() + return + + self.selected_item = item + self.color_grade_wheels_panel.setEnabled(True) + self.color_grade_wheels_panel.set_frame_number(self.clip_properties_model.frame_number) + self.color_grade_wheels_panel.blockSignals(True) + self.color_grade_wheels_panel.set_wheels_data(wheels_data) + self.color_grade_wheels_panel.blockSignals(False) + + session = self.live_property_session or {} + if session.get("property_type") == "colorgrade_wheels": + session["item"] = item + session["item_data"] = copy.deepcopy(item.data()) + session["property_key"] = property_key + + def _disabled_color_grade_wheels_data(self): + data = default_wheels_data() + points = data.get("enabled_keyframes", {}).get("Points") + if points: + points[0].setdefault("co", {})["Y"] = 0.0 + return data + + def _set_color_grade_wheels_unbound(self): + """Show neutral disabled wheels when no editable ColorGrade effect is bound.""" + if not hasattr(self, "color_grade_wheels_panel"): + return + self.color_grade_wheels_panel.blockSignals(True) + self.color_grade_wheels_panel.set_frame_number(self.clip_properties_model.frame_number) + self.color_grade_wheels_panel.set_wheels_data(self._disabled_color_grade_wheels_data()) + self.color_grade_wheels_panel.setEnabled(False) + self.color_grade_wheels_panel.blockSignals(False) + def _activate_color_grade_wheels_session(self, item, property_key, wheels_data): session = self.live_property_session or {} if session.get("property_type") == "colorgrade_wheels": diff --git a/src/windows/views/retime.py b/src/windows/views/retime.py index c1d9bdd06b..9f0c4a3758 100644 --- a/src/windows/views/retime.py +++ b/src/windows/views/retime.py @@ -67,30 +67,29 @@ def _calculate_retime_metrics(clip, new_end, pfps): } -def _iterate_keyframe_lists(clip_dict): - for value in clip_dict.values(): - if isinstance(value, dict) and isinstance(value.get("Points"), list): - yield value["Points"] - objects = clip_dict.get("objects") or {} - for obj in objects.values(): - if not isinstance(obj, dict): - continue - for value in obj.values(): - if isinstance(value, dict) and isinstance(value.get("Points"), list): - yield value["Points"] - for eff in clip_dict.get("effects", []) or []: - if not isinstance(eff, dict): - continue - for value in eff.values(): - if isinstance(value, dict) and isinstance(value.get("Points"), list): - yield value["Points"] +def _iterate_keyframe_lists(value): + """Yield every keyframe Points list nested anywhere inside a clip payload.""" + if isinstance(value, dict): + points = value.get("Points") + if isinstance(points, list): + yield points + return + for child in value.values(): + yield from _iterate_keyframe_lists(child) + elif isinstance(value, list): + for child in value: + yield from _iterate_keyframe_lists(child) def _scale_points(points, start_x, new_end_x, scale): if not isinstance(points, list): return for point in points: + if not isinstance(point, dict): + continue co = point.get("co", {}) + if not isinstance(co, dict): + continue x = co.get("X") if x is None or x < start_x: continue From f178484a3f9066eb6bb889ac462a8fb9f17bed56 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 21:06:55 -0500 Subject: [PATCH 10/23] Improve bounce motion presets and menu ordering - Increase Bounce In/Out and Emphasis bounce offsets so rebounds are visibly frame-relative - Fix Emphasis presets to apply at the playhead by converting timeline frames to clip-local frames - Alphabetize Motion > In, Out, and Emphasis preset menus and submenu entries - Add regression coverage for bounce keyframe values and playhead-based Emphasis placement --- src/animation_presets.py | 30 +++---- src/tests/test_timeline_helpers.py | 123 +++++++++++++++++++++++++++++ src/windows/views/timeline.py | 56 +++++++------ 3 files changed, 170 insertions(+), 39 deletions(-) diff --git a/src/animation_presets.py b/src/animation_presets.py index 5d4e2bf591..254e5dedb9 100644 --- a/src/animation_presets.py +++ b/src/animation_presets.py @@ -67,9 +67,9 @@ (22, 1.05, 'ease_in_quint'), (25, 0.95), (28, 1.02), (31, 1.0) ], 'location_y': [ - (1, 0, 'ease_out_cubic'), (7, 0, 'ease_out_cubic'), (13, -0.20, 'ease_in_quint'), - (14, -0.20, 'ease_in_quint'), (17, 0, 'ease_out_cubic'), (22, -0.10, 'ease_in_quint'), - (25, 0), (28, -0.027, 'ease_in_quint'), (31, 0) + (1, 0, 'ease_out_cubic'), (7, 0, 'ease_out_cubic'), (13, -0.25, 'ease_in_quint'), + (14, -0.25, 'ease_in_quint'), (17, 0, 'ease_out_cubic'), (22, -0.125, 'ease_in_quint'), + (25, 0), (28, -0.033333, 'ease_in_quint'), (31, 0) ], }, @@ -219,8 +219,8 @@ 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], 'scale_y': [(1, 3, 'ease_out_cubic'), (19, 0.9, 'ease_out_cubic'), (24, 0.95, 'ease_out_cubic'), (28, 0.985)], 'location_y': [ - (1, -3, 'ease_out_cubic'), (19, 0.023148, 'ease_out_cubic'), (24, -0.009259, 'ease_out_cubic'), - (28, 0.00463, 'ease_out_cubic'), (31, 0) + (1, -3, 'ease_out_cubic'), (19, 0.25, 'ease_out_cubic'), (24, -0.10, 'ease_out_cubic'), + (28, 0.05, 'ease_out_cubic'), (31, 0) ], }, @@ -228,8 +228,8 @@ 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], 'scale_x': [(1, 3, 'ease_out_cubic'), (19, 1, 'ease_out_cubic'), (24, 0.98, 'ease_out_cubic'), (28, 0.995)], 'location_x': [ - (1, -3, 'ease_out_cubic'), (19, 0.013021, 'ease_out_cubic'), (24, -0.005208, 'ease_out_cubic'), - (28, 0.002604, 'ease_out_cubic'), (31, 0) + (1, -3, 'ease_out_cubic'), (19, 0.25, 'ease_out_cubic'), (24, -0.10, 'ease_out_cubic'), + (28, 0.05, 'ease_out_cubic'), (31, 0) ], }, @@ -237,8 +237,8 @@ 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], 'scale_x': [(1, 3, 'ease_out_cubic'), (19, 1, 'ease_out_cubic'), (24, 0.98, 'ease_out_cubic'), (28, 0.995)], 'location_x': [ - (1, 3, 'ease_out_cubic'), (19, -0.013021, 'ease_out_cubic'), (24, 0.005208, 'ease_out_cubic'), - (28, -0.002604, 'ease_out_cubic'), (31, 0) + (1, 3, 'ease_out_cubic'), (19, -0.25, 'ease_out_cubic'), (24, 0.10, 'ease_out_cubic'), + (28, -0.05, 'ease_out_cubic'), (31, 0) ], }, @@ -246,8 +246,8 @@ 'alpha': [(1, 0, 'ease_out_cubic'), (19, 1)], 'scale_y': [(1, 5, 'ease_out_cubic'), (19, 0.9, 'ease_out_cubic'), (24, 0.95, 'ease_out_cubic'), (28, 0.985)], 'location_y': [ - (1, 3, 'ease_out_cubic'), (19, -0.018519, 'ease_out_cubic'), (24, 0.009259, 'ease_out_cubic'), - (28, -0.00463, 'ease_out_cubic'), (31, 0) + (1, 3, 'ease_out_cubic'), (19, -0.25, 'ease_out_cubic'), (24, 0.10, 'ease_out_cubic'), + (28, -0.05, 'ease_out_cubic'), (31, 0) ], }, @@ -263,25 +263,25 @@ 'bounceOutDown': { 'alpha': [(13, 1), (14, 1), (31, 0)], 'scale_y': [(7, 0.985), (13, 0.9), (14, 0.9), (31, 3)], - 'location_y': [(7, 0.009259), (13, -0.018519), (14, -0.018519), (31, 3)], + 'location_y': [(7, 0.125), (13, -0.25), (14, -0.25), (31, 3)], }, 'bounceOutLeft': { 'alpha': [(7, 1), (31, 0)], 'scale_x': [(7, 0.9), (31, 2)], - 'location_x': [(7, 0.010417), (31, -3)], + 'location_x': [(7, 0.25), (31, -3)], }, 'bounceOutRight': { 'alpha': [(7, 1), (31, 0)], 'scale_x': [(7, 0.9), (31, 2)], - 'location_x': [(7, -0.010417), (31, 3)], + 'location_x': [(7, -0.25), (31, 3)], }, 'bounceOutUp': { 'alpha': [(13, 1), (14, 1), (31, 0)], 'scale_y': [(7, 0.985), (13, 0.9), (14, 0.9), (31, 3)], - 'location_y': [(7, -0.009259), (13, 0.018519), (14, 0.018519), (31, -3)], + 'location_y': [(7, -0.125), (13, 0.25), (14, 0.25), (31, -3)], }, diff --git a/src/tests/test_timeline_helpers.py b/src/tests/test_timeline_helpers.py index b38d57051a..88af03f5d3 100644 --- a/src/tests/test_timeline_helpers.py +++ b/src/tests/test_timeline_helpers.py @@ -193,6 +193,7 @@ def kf(value): data = { "id": "C1", + "position": 0.0, "start": 0.0, "end": 3.0, "scale": openshot.SCALE_FIT, @@ -1555,6 +1556,128 @@ def test_motion_wipe_mask_uses_high_static_contrast(self): self.assertEqual(effect["class_name"], "Mask") self.assertEqual(effect["contrast"]["Points"][0]["co"]["Y"], 20.0) + def test_motion_bounce_emphasis_uses_frame_relative_offsets(self): + helper = self.make_motion_helper() + clip = self.make_motion_clip() + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + generate_id=lambda: "FX1", + ), + ) + + with patch.object(self.timeline_module, "get_app", return_value=app), \ + patch.object(self.timeline_module.Clip, "get", return_value=clip): + self.timeline_module.TimelineView.Animate_Triggered( + helper, + self.timeline_module.MenuAnimate.BOUNCE, + ["C1"], + transaction_id="tx-motion-test", + ) + + points = { + point["co"]["X"]: point["co"]["Y"] + for point in clip.data["location_y"]["Points"] + } + self.assertAlmostEqual(points[13], -0.25) + self.assertAlmostEqual(points[14], -0.25) + self.assertAlmostEqual(points[22], -0.125) + self.assertAlmostEqual(points[28], -0.033333, places=6) + self.assertAlmostEqual(points[31], 0.0) + + def test_motion_emphasis_uses_playhead_in_clip_local_frame_space(self): + helper = self.make_motion_helper() + helper.window.preview_thread.current_frame = 331 + clip = self.make_motion_clip() + clip.data["position"] = 10.0 + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + generate_id=lambda: "FX1", + ), + ) + + with patch.object(self.timeline_module, "get_app", return_value=app), \ + patch.object(self.timeline_module.Clip, "get", return_value=clip): + self.timeline_module.TimelineView.Animate_Triggered( + helper, + self.timeline_module.MenuAnimate.BOUNCE, + ["C1"], + transaction_id="tx-motion-test", + ) + + points = { + point["co"]["X"]: point["co"]["Y"] + for point in clip.data["location_y"]["Points"] + } + self.assertIn(31, points) + self.assertIn(43, points) + self.assertIn(61, points) + self.assertNotIn(13, points) + self.assertAlmostEqual(points[43], -0.25) + self.assertAlmostEqual(points[61], 0.0) + + def test_motion_bounce_in_down_uses_frame_relative_rebound_offsets(self): + helper = self.make_motion_helper() + clip = self.make_motion_clip() + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + generate_id=lambda: "FX1", + ), + ) + + with patch.object(self.timeline_module, "get_app", return_value=app), \ + patch.object(self.timeline_module.Clip, "get", return_value=clip): + self.timeline_module.TimelineView.Animate_Triggered( + helper, + self.timeline_module.MenuAnimate.BOUNCE_IN_DOWN, + ["C1"], + transaction_id="tx-motion-test", + ) + + points = { + point["co"]["X"]: point["co"]["Y"] + for point in clip.data["location_y"]["Points"] + } + self.assertAlmostEqual(points[1], -3.0) + self.assertAlmostEqual(points[19], 0.25) + self.assertAlmostEqual(points[24], -0.1) + self.assertAlmostEqual(points[28], 0.05) + self.assertAlmostEqual(points[31], 0.0) + + def test_motion_bounce_out_up_uses_frame_relative_rebound_offsets(self): + helper = self.make_motion_helper() + clip = self.make_motion_clip() + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: {"num": 30, "den": 1} if key == "fps" else None, + generate_id=lambda: "FX1", + ), + ) + + with patch.object(self.timeline_module, "get_app", return_value=app), \ + patch.object(self.timeline_module.Clip, "get", return_value=clip): + self.timeline_module.TimelineView.Animate_Triggered( + helper, + self.timeline_module.MenuAnimate.BOUNCE_OUT_UP, + ["C1"], + transaction_id="tx-motion-test", + ) + + points = { + point["co"]["X"]: point["co"]["Y"] + for point in clip.data["location_y"]["Points"] + } + self.assertAlmostEqual(points[67], -0.125) + self.assertAlmostEqual(points[73], 0.25) + self.assertAlmostEqual(points[74], 0.25) + self.assertAlmostEqual(points[91], -3.0) + def test_motion_ken_burns_direction_sets_distinct_scale_and_location(self): helper = self.make_motion_helper() clip = self.make_motion_clip() diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index 6c1a670e1e..35b517846d 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -1527,36 +1527,35 @@ def _motion_sub(title, items): # ── In ▶ ─────────────────────────────────────────────────────────────── In_Menu = StyledContextMenu(title=_("In"), parent=self) In_Menu.addMenu(_motion_sub(_("Back In"), [ - (_("From Top"), MenuAnimate.BACK_IN_DOWN), + (_("From Bottom"), MenuAnimate.BACK_IN_UP), (_("From Left"), MenuAnimate.BACK_IN_LEFT), (_("From Right"), MenuAnimate.BACK_IN_RIGHT), - (_("From Bottom"), MenuAnimate.BACK_IN_UP), + (_("From Top"), MenuAnimate.BACK_IN_DOWN), ])) + _motion_act(In_Menu, _("Blur In"), MenuAnimate.BLUR_IN) In_Menu.addMenu(_motion_sub(_("Bounce In"), [ (_("Center"), MenuAnimate.BOUNCE_IN), - (_("From Top"), MenuAnimate.BOUNCE_IN_DOWN), + (_("From Bottom"), MenuAnimate.BOUNCE_IN_UP), (_("From Left"), MenuAnimate.BOUNCE_IN_LEFT), (_("From Right"), MenuAnimate.BOUNCE_IN_RIGHT), - (_("From Bottom"), MenuAnimate.BOUNCE_IN_UP), + (_("From Top"), MenuAnimate.BOUNCE_IN_DOWN), ])) + _motion_act(In_Menu, _("Pop In"), MenuAnimate.POP_IN) In_Menu.addMenu(_motion_sub(_("Slide In"), [ + (_("From Bottom"), MenuAnimate.SLIDE_IN_BOTTOM), (_("From Left"), MenuAnimate.SLIDE_IN_LEFT), (_("From Right"), MenuAnimate.SLIDE_IN_RIGHT), (_("From Top"), MenuAnimate.SLIDE_IN_TOP), - (_("From Bottom"), MenuAnimate.SLIDE_IN_BOTTOM), ])) + _motion_act(In_Menu, _("Spiral In"), MenuAnimate.SPIRAL_IN) In_Menu.addMenu(_motion_sub(_("Wipe In"), [ (_("Circle Expand"), MenuAnimate.WIPE_IN_CIRCLE_EXPAND), (_("Circle Shrink"), MenuAnimate.WIPE_IN_CIRCLE_SHRINK), - (_("Fade"), MenuAnimate.WIPE_IN_FADE), + (_("From Bottom"), MenuAnimate.WIPE_IN_BOTTOM), (_("From Left"), MenuAnimate.WIPE_IN_LEFT), (_("From Right"), MenuAnimate.WIPE_IN_RIGHT), (_("From Top"), MenuAnimate.WIPE_IN_TOP), - (_("From Bottom"), MenuAnimate.WIPE_IN_BOTTOM), ])) - _motion_act(In_Menu, _("Blur In"), MenuAnimate.BLUR_IN) - _motion_act(In_Menu, _("Pop In"), MenuAnimate.POP_IN) - _motion_act(In_Menu, _("Spiral In"), MenuAnimate.SPIRAL_IN) Animate_Menu.addMenu(In_Menu) # ── Out ▶ ────────────────────────────────────────────────────────────── @@ -1567,37 +1566,38 @@ def _motion_sub(title, items): (_("To Right"), MenuAnimate.BACK_OUT_RIGHT), (_("To Top"), MenuAnimate.BACK_OUT_UP), ])) + _motion_act(Out_Menu, _("Blur Out"), MenuAnimate.BLUR_OUT) Out_Menu.addMenu(_motion_sub(_("Bounce Out"), [ (_("Center"), MenuAnimate.BOUNCE_OUT), (_("To Bottom"), MenuAnimate.BOUNCE_OUT_DOWN), - (_("To Right"), MenuAnimate.BOUNCE_OUT_RIGHT), (_("To Left"), MenuAnimate.BOUNCE_OUT_LEFT), + (_("To Right"), MenuAnimate.BOUNCE_OUT_RIGHT), (_("To Top"), MenuAnimate.BOUNCE_OUT_UP), ])) + _motion_act(Out_Menu, _("Pop Out"), MenuAnimate.POP_OUT) Out_Menu.addMenu(_motion_sub(_("Slide Out"), [ + (_("To Bottom"), MenuAnimate.SLIDE_OUT_BOTTOM), (_("To Left"), MenuAnimate.SLIDE_OUT_LEFT), (_("To Right"), MenuAnimate.SLIDE_OUT_RIGHT), (_("To Top"), MenuAnimate.SLIDE_OUT_TOP), - (_("To Bottom"), MenuAnimate.SLIDE_OUT_BOTTOM), ])) + _motion_act(Out_Menu, _("Spiral Out"), MenuAnimate.SPIRAL_OUT) Out_Menu.addMenu(_motion_sub(_("Wipe Out"), [ (_("Circle Expand"), MenuAnimate.WIPE_OUT_CIRCLE_EXPAND), (_("Circle Shrink"), MenuAnimate.WIPE_OUT_CIRCLE_SHRINK), - (_("Fade"), MenuAnimate.WIPE_OUT_FADE), + (_("To Bottom"), MenuAnimate.WIPE_OUT_BOTTOM), (_("To Left"), MenuAnimate.WIPE_OUT_LEFT), (_("To Right"), MenuAnimate.WIPE_OUT_RIGHT), (_("To Top"), MenuAnimate.WIPE_OUT_TOP), - (_("To Bottom"), MenuAnimate.WIPE_OUT_BOTTOM), ])) - _motion_act(Out_Menu, _("Blur Out"), MenuAnimate.BLUR_OUT) - _motion_act(Out_Menu, _("Pop Out"), MenuAnimate.POP_OUT) - _motion_act(Out_Menu, _("Spiral Out"), MenuAnimate.SPIRAL_OUT) Animate_Menu.addMenu(Out_Menu) # ── Emphasis ▶ ───────────────────────────────────────────────────────── Animate_Menu.addMenu(_motion_sub(_("Emphasis"), [ (_("Bounce"), MenuAnimate.BOUNCE), (_("Flash"), MenuAnimate.FLASH), + (_("Heartbeat"), MenuAnimate.HEART_BEAT), + (_("Jello"), MenuAnimate.JELLO), (_("Pulse"), MenuAnimate.PULSE), (_("Rubber Band"), MenuAnimate.RUBBER_BAND), (_("Shake X"), MenuAnimate.SHAKE_X), @@ -1605,8 +1605,6 @@ def _motion_sub(title, items): (_("Swing"), MenuAnimate.SWING), (_("Tada"), MenuAnimate.TADA), (_("Wobble"), MenuAnimate.WOBBLE), - (_("Jello"), MenuAnimate.JELLO), - (_("Heartbeat"), MenuAnimate.HEART_BEAT), ])) # ── Camera ▶ ─────────────────────────────────────────────────────────── @@ -2620,13 +2618,23 @@ def Animate_Triggered(self, action, clip_ids, transaction_id=None): in_end = min(s + zone, e) # end of "In" zone out_start = max(s, e - zone) # start of "Out" zone - # Emphasis: fixed 1-second window at playhead (if inside clip) + # Emphasis: fixed 1-second window at playhead (if inside clip). + # preview_thread.current_frame is timeline-global, while clip + # keyframes are stored in the clip's local/source frame space. + try: + timeline_frame = int(self.window.preview_thread.current_frame or 1) + except Exception: + timeline_frame = 1 try: - playhead = int(self.window.preview_thread.current_frame or 1) + timeline_seconds = max(0.0, (timeline_frame - 1) / fps_float) + clip_position = float(clip.data.get("position", 0.0)) + clip_playhead = round( + (float(clip.data["start"]) + timeline_seconds - clip_position) * fps_float + ) + 1 except Exception: - playhead = 1 - if s <= playhead <= e: - emph_start = playhead + clip_playhead = s + if s <= clip_playhead <= e: + emph_start = clip_playhead else: emph_start = s emph_end = min(emph_start + zone, e) From 6268011fce15d0012afe5d06720a68d42c51db95 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 22:22:00 -0500 Subject: [PATCH 11/23] Fix Color Grade nested keyframe handling - Add dedicated insert/remove support for Color Grade curve and wheel properties - Prevent Properties table Insert Keyframe from replacing rich wheel payloads - Remove nested Color Grade keyframes without clearing unrelated frames - Rebind open Curve Editor callbacks after Properties model refreshes - Make newly added curve nodes interpolate linearly across animated frames - Enable Color Wheels dock Remove Keyframe between keyframes - Darken disabled Color Wheels dock styling using theme-adaptive palette colors - Add regression tests for nested Color Grade keyframe insert/remove and curve interpolation --- src/tests/test_color_grade_editor.py | 128 +++++++++++++++++ src/windows/color_grade_editor.py | 79 ++++++----- src/windows/models/properties_model.py | 165 ++++++++++++++++++++++ src/windows/views/properties_tableview.py | 42 +++++- 4 files changed, 376 insertions(+), 38 deletions(-) diff --git a/src/tests/test_color_grade_editor.py b/src/tests/test_color_grade_editor.py index 86bdfd8eac..8d4bb52133 100644 --- a/src/tests/test_color_grade_editor.py +++ b/src/tests/test_color_grade_editor.py @@ -36,6 +36,8 @@ from windows.color_grade_editor import ( # noqa: E402 _set_color_value, _set_keyframe_value, + _default_curve_node, + WheelRow, colorgrade_keyframe_frames, curve_enabled_at_frame, curve_nodes_at_frame, @@ -59,6 +61,24 @@ def test_default_curve_data_uses_linear_nodes(self): self.assertEqual(len(curve["nodes"]), 2) self.assertEqual(curve["nodes"][0]["interpolation"], openshot.LINEAR) self.assertEqual(curve["nodes"][1]["interpolation"], openshot.LINEAR) + self.assertEqual(curve["nodes"][0]["x"]["Points"][0]["interpolation"], openshot.LINEAR) + + def test_new_curve_node_interpolates_after_frame_one_shape_is_added(self): + node = _default_curve_node(2, 0.5, 0.8, frame_number=24) + node["y"] = _set_keyframe_value(node["y"], 1, 0.2) + curve = normalize_curve_data({ + "enabled": {"Points": [{"co": {"X": 1.0, "Y": 1.0}, "interpolation": openshot.LINEAR}]}, + "nodes": [ + _default_curve_node(0, 0.0, 0.0), + node, + _default_curve_node(1, 1.0, 1.0), + ], + }) + + evaluated = {node["id"]: node for node in curve_nodes_at_frame(curve, 12)} + + self.assertGreater(evaluated[2]["y"], 0.2) + self.assertLess(evaluated[2]["y"], 0.8) def test_normalize_curve_data_falls_back_to_default(self): self.assertEqual(normalize_curve_data({}), default_curve_data()) @@ -195,6 +215,114 @@ def test_properties_model_applies_interpolation_to_colorgrade_curve_frame(self): self.assertEqual(updated["nodes"][0]["y"]["Points"][1]["interpolation"], openshot.CONSTANT) self.assertEqual(updated["nodes"][1]["x"]["Points"][1]["interpolation"], openshot.CONSTANT) + def test_properties_model_inserts_colorgrade_wheel_keyframe_without_resetting_payload(self): + wheels = default_wheels_data() + wheels["global"]["amount_keyframes"]["Points"].append({ + "co": {"X": 24.0, "Y": 0.8}, + "interpolation": openshot.LINEAR, + }) + wheels["shadows"]["luma_keyframes"]["Points"].append({ + "co": {"X": 24.0, "Y": -0.4}, + "interpolation": openshot.LINEAR, + }) + + helper = PropertiesModel.__new__(PropertiesModel) + updated = helper._insert_colorgrade_keyframe(wheels, "colorgrade_wheels", 12) + + self.assertIn(12, colorgrade_keyframe_frames(updated, "colorgrade_wheels")) + self.assertIn(24, colorgrade_keyframe_frames(updated, "colorgrade_wheels")) + self.assertEqual(updated["global"]["amount_keyframes"]["Points"][1]["interpolation"], openshot.LINEAR) + self.assertEqual(updated["global"]["amount_keyframes"]["Points"][-1]["co"]["X"], 24.0) + self.assertEqual(updated["shadows"]["luma_keyframes"]["Points"][-1]["co"]["X"], 24.0) + + def test_properties_model_removes_colorgrade_wheel_frame_without_clearing_other_frames(self): + wheels = default_wheels_data() + for keyframe in ( + wheels["global"]["color_keyframes"]["red"], + wheels["global"]["color_keyframes"]["green"], + wheels["global"]["amount_keyframes"], + wheels["highlights"]["luma_keyframes"], + ): + keyframe["Points"].append({ + "co": {"X": 24.0, "Y": 0.5}, + "interpolation": openshot.LINEAR, + }) + + helper = PropertiesModel.__new__(PropertiesModel) + updated, changed = helper._remove_colorgrade_keyframe(wheels, "colorgrade_wheels", 24) + + self.assertTrue(changed) + self.assertEqual(colorgrade_keyframe_frames(updated, "colorgrade_wheels"), {1}) + + def test_wheel_row_remove_keyframe_targets_active_interpolated_frame(self): + row = WheelRow.__new__(WheelRow) + row._frame_number = 12 + row._data = normalize_wheels_data({ + "global": { + "color_keyframes": { + "red": {"Points": [ + {"co": {"X": 1.0, "Y": 255.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": 64.0}, "interpolation": openshot.LINEAR}, + ]}, + "green": {"Points": [ + {"co": {"X": 1.0, "Y": 255.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": 128.0}, "interpolation": openshot.LINEAR}, + ]}, + "blue": {"Points": [ + {"co": {"X": 1.0, "Y": 255.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": 192.0}, "interpolation": openshot.LINEAR}, + ]}, + "alpha": {"Points": [ + {"co": {"X": 1.0, "Y": 255.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": 255.0}, "interpolation": openshot.LINEAR}, + ]}, + }, + "amount_keyframes": {"Points": [ + {"co": {"X": 1.0, "Y": 0.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": 0.8}, "interpolation": openshot.LINEAR}, + ]}, + } + })["global"] + row.dragStarted = type("Signal", (), {"emit": lambda self: None})() + row.dragFinished = type("Signal", (), {"emit": lambda self: None})() + row.changed = type("Signal", (), {"emit": lambda self: None})() + row._apply_data = lambda: None + + row._remove_keyframe() + + self.assertEqual(row._frame_set(), {1}) + + def test_wheel_row_remove_slider_keyframe_targets_active_interpolated_frame(self): + row = WheelRow.__new__(WheelRow) + row._frame_number = 12 + row._data = normalize_wheels_data({ + "global": { + "amount_keyframes": {"Points": [ + {"co": {"X": 1.0, "Y": 0.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": 0.8}, "interpolation": openshot.LINEAR}, + ]}, + "luma_keyframes": {"Points": [ + {"co": {"X": 1.0, "Y": 0.0}, "interpolation": openshot.LINEAR}, + {"co": {"X": 24.0, "Y": -0.4}, "interpolation": openshot.LINEAR}, + ]}, + } + })["global"] + row.dragStarted = type("Signal", (), {"emit": lambda self: None})() + row.dragFinished = type("Signal", (), {"emit": lambda self: None})() + row.changed = type("Signal", (), {"emit": lambda self: None})() + row._apply_data = lambda: None + + row._remove_slider_keyframe("amount") + + self.assertEqual( + [point["co"]["X"] for point in row._data["amount_keyframes"]["Points"]], + [1.0], + ) + self.assertEqual( + [point["co"]["X"] for point in row._data["luma_keyframes"]["Points"]], + [1.0, 24.0], + ) + def test_achromatic_color_detection_treats_white_as_neutral(self): self.assertTrue(is_achromatic_color(QColor("#ffffff"))) self.assertTrue(is_achromatic_color(QColor("#808080"))) diff --git a/src/windows/color_grade_editor.py b/src/windows/color_grade_editor.py index f09237e63b..6731e1d295 100644 --- a/src/windows/color_grade_editor.py +++ b/src/windows/color_grade_editor.py @@ -238,25 +238,25 @@ def _evaluate_color(data, frame_number, default_color="#ffffff"): ) -def _set_color_value(data, frame_number, color): +def _set_color_value(data, frame_number, color, interpolation=openshot.BEZIER): current = _normalize_color_data(data) return { - "red": _set_keyframe_value(current["red"], frame_number, color.red()), - "green": _set_keyframe_value(current["green"], frame_number, color.green()), - "blue": _set_keyframe_value(current["blue"], frame_number, color.blue()), - "alpha": _set_keyframe_value(current["alpha"], frame_number, color.alpha()), + "red": _set_keyframe_value(current["red"], frame_number, color.red(), interpolation), + "green": _set_keyframe_value(current["green"], frame_number, color.green(), interpolation), + "blue": _set_keyframe_value(current["blue"], frame_number, color.blue(), interpolation), + "alpha": _set_keyframe_value(current["alpha"], frame_number, color.alpha(), interpolation), } def _default_curve_node(node_id, x_value, y_value, frame_number=1): return { "id": int(node_id), - "x": _keyframe_value(frame_number=frame_number, value=x_value), - "y": _keyframe_value(frame_number=frame_number, value=y_value), - "left_handle_x": _keyframe_value(frame_number=frame_number, value=0.5), - "left_handle_y": _keyframe_value(frame_number=frame_number, value=1.0), - "right_handle_x": _keyframe_value(frame_number=frame_number, value=0.5), - "right_handle_y": _keyframe_value(frame_number=frame_number, value=0.0), + "x": _keyframe_value(frame_number=frame_number, value=x_value, interpolation=openshot.LINEAR), + "y": _keyframe_value(frame_number=frame_number, value=y_value, interpolation=openshot.LINEAR), + "left_handle_x": _keyframe_value(frame_number=frame_number, value=0.5, interpolation=openshot.LINEAR), + "left_handle_y": _keyframe_value(frame_number=frame_number, value=1.0, interpolation=openshot.LINEAR), + "right_handle_x": _keyframe_value(frame_number=frame_number, value=0.5, interpolation=openshot.LINEAR), + "right_handle_y": _keyframe_value(frame_number=frame_number, value=0.0, interpolation=openshot.LINEAR), "interpolation": int(openshot.LINEAR), "handle_type": int(openshot.AUTO), } @@ -346,6 +346,26 @@ def default_wheels_data(): ACHROMATIC_SATURATION_THRESHOLD = 0.02 +def _mix_color(first, second, ratio): + ratio = max(0.0, min(1.0, float(ratio))) + return QColor( + int(round(first.red() + ((second.red() - first.red()) * ratio))), + int(round(first.green() + ((second.green() - first.green()) * ratio))), + int(round(first.blue() + ((second.blue() - first.blue()) * ratio))), + int(round(first.alpha() + ((second.alpha() - first.alpha()) * ratio))), + ) + + +def disabled_control_color(widget, text=False): + palette = widget.palette() + base = palette.base().color() + mid = palette.mid().color() + text_color = palette.text().color() + if text: + return _mix_color(mid, text_color, 0.32) + return _mix_color(base, mid, 0.36) + + def is_neutral_wheel(data): try: return float((data or {}).get("amount", 0.0)) <= 0.0001 @@ -627,6 +647,8 @@ def paintEvent(self, event): center, radius = self._center_and_radius() enabled = self.isEnabled() + disabled_color = disabled_control_color(self) + disabled_text_color = disabled_control_color(self, text=True) ring_rect = QRectF(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0) ring_width = max(6.0, radius * 0.16) @@ -643,27 +665,28 @@ def paintEvent(self, event): painter.restore() else: painter.setPen(Qt.NoPen) - painter.setBrush(QBrush(self.palette().mid().color())) + painter.setBrush(QBrush(disabled_color)) painter.drawPath(ring_path) - painter.setPen(QPen(self.palette().mid().color(), 1.0)) + outline_color = disabled_color if not enabled else self.palette().mid().color() + painter.setPen(QPen(outline_color, 1.0)) painter.setBrush(QBrush(self.palette().base())) painter.drawEllipse(center, inner_radius - 1.0, inner_radius - 1.0) - painter.setPen(QPen(self.palette().mid().color(), 1.0, Qt.DashLine)) + painter.setPen(QPen(outline_color, 1.0, Qt.DashLine)) painter.drawLine(QPointF(center.x() - inner_radius, center.y()), QPointF(center.x() + inner_radius, center.y())) painter.drawLine(QPointF(center.x(), center.y() - inner_radius), QPointF(center.x(), center.y() + inner_radius)) puck = self._puck_position() - painter.setPen(QPen(Qt.white if enabled else self.palette().mid().color(), 1.0)) - painter.setBrush(QBrush(puck_display_color(self._data) if enabled else self.palette().mid().color())) + painter.setPen(QPen(Qt.white if enabled else disabled_text_color, 1.0)) + painter.setBrush(QBrush(puck_display_color(self._data) if enabled else disabled_color)) painter.drawEllipse(puck, 5.0, 5.0) if self._title: font = painter.font() font.setBold(True) painter.setFont(font) - painter.setPen(QPen(Qt.white if enabled else self.palette().mid().color())) + painter.setPen(QPen(Qt.white if enabled else disabled_text_color)) text_rect = QRectF(center.x() - radius, center.y() - radius, radius * 2.0, ring_width) painter.drawText(text_rect, Qt.AlignCenter, self._title) @@ -1295,7 +1318,7 @@ def paintEvent(self, event): fg = theme.get_color(".property_value", "foreground-color") if not self.isEnabled(): bg = self.palette().base().color() - fg = self.palette().mid().color() + fg = disabled_control_color(self) path = QPainterPath() path.addRoundedRect(rect, 6, 6) @@ -1321,7 +1344,7 @@ def paintEvent(self, event): self._curve_pixmaps.get(self._interpolation, self._curve_pixmaps[openshot.LINEAR])) text_rect.adjust(0.0, 0.0, -24.0, 0.0) - painter.setPen(QPen(Qt.white if self.isEnabled() else self.palette().mid().color())) + painter.setPen(QPen(Qt.white if self.isEnabled() else disabled_control_color(self, text=True))) painter.drawText(text_rect, Qt.AlignCenter, self._fmt(self._value)) painter.end() @@ -1574,16 +1597,6 @@ def _keyframe_status(self, kf_data): continue return max(1, len(points)), interpolation - def _has_keyframe_at(self, kf_data, frame_number): - target = int(round(frame_number)) - for point in self._keyframe_points(kf_data): - try: - if int(round(float(point["co"]["X"]))) == target: - return True - except (KeyError, TypeError, ValueError): - continue - return False - def _interpolation_target_frame(self): frames = sorted(self._frame_set()) if not frames: @@ -1741,7 +1754,7 @@ def _insert_keyframe(self): self.dragFinished.emit() def _remove_keyframe(self): - target = int(round(self._frame_number)) + target = self._interpolation_target_frame() changed = False def _remove(kf_data): @@ -1787,7 +1800,7 @@ def _insert_slider_keyframe(self, key): def _remove_slider_keyframe(self, key): key_name = f"{key}_keyframes" - target = int(round(self._frame_number)) + target = self._interpolation_target_frame_for(self._data.get(key_name)) points = self._keyframe_points(self._data.get(key_name)) if len(points) <= 1: return @@ -1825,7 +1838,7 @@ def _show_slider_menu(self, key, pos, source_widget): insert_action.triggered.connect(lambda: self._insert_slider_keyframe(key)) menu.addAction(insert_action) remove_action = QAction(_("Remove Keyframe"), self) - remove_action.setEnabled(self._has_keyframe_at(self._data.get(f"{key}_keyframes"), self._frame_number)) + remove_action.setEnabled(len(self._keyframe_points(self._data.get(f"{key}_keyframes"))) > 1) remove_action.triggered.connect(lambda: self._remove_slider_keyframe(key)) menu.addAction(remove_action) menu.exec_(source_widget.mapToGlobal(pos)) @@ -1851,7 +1864,7 @@ def _show_wheel_menu(self, pos, source_widget=None): insert_action.triggered.connect(self._insert_keyframe) menu.addAction(insert_action) remove_action = QAction(_("Remove Keyframe"), self) - remove_action.setEnabled(int(round(self._frame_number)) in self._frame_set()) + remove_action.setEnabled(len(self._frame_set()) > 1) remove_action.triggered.connect(self._remove_keyframe) menu.addAction(remove_action) menu.addSeparator() diff --git a/src/windows/models/properties_model.py b/src/windows/models/properties_model.py index 820ed4d6e3..e2910434bb 100644 --- a/src/windows/models/properties_model.py +++ b/src/windows/models/properties_model.py @@ -67,6 +67,167 @@ def mimeData(self, indexes): class PropertiesModel(updates.UpdateInterface): + def _insert_colorgrade_keyframe(self, data, property_type, frame_number): + from windows.color_grade_editor import ( + _set_color_value, + _set_keyframe_value, + curve_enabled_at_frame, + curve_nodes_at_frame, + normalize_curve_data, + normalize_wheels_data, + wheels_enabled_at_frame, + wheels_snapshot, + ) + + frame_number = int(round(frame_number)) + if property_type == "colorgrade_curve": + updated = normalize_curve_data(data) + enabled = 1.0 if curve_enabled_at_frame(updated, frame_number) else 0.0 + updated["enabled"] = _set_keyframe_value( + updated.get("enabled"), frame_number, enabled, openshot.LINEAR) + nodes_at_frame = { + node["id"]: node + for node in curve_nodes_at_frame(updated, frame_number) + } + for node in updated.get("nodes", []): + snapshot = nodes_at_frame.get(node.get("id")) + if not snapshot: + continue + for key in ("x", "y", "left_handle_x", "left_handle_y", "right_handle_x", "right_handle_y"): + node[key] = _set_keyframe_value( + node.get(key), frame_number, snapshot.get(key, 0.0), openshot.LINEAR) + return normalize_curve_data(updated) + + updated = normalize_wheels_data(data) + snapshot = wheels_snapshot(updated, frame_number) + enabled = 1.0 if wheels_enabled_at_frame(updated, frame_number) else 0.0 + updated["enabled_keyframes"] = _set_keyframe_value( + updated.get("enabled_keyframes"), frame_number, enabled, openshot.LINEAR) + for name in ("global", "shadows", "midtones", "highlights"): + wheel = updated.get(name, {}) + wheel_snapshot = snapshot.get(name, {}) + wheel["color_keyframes"] = _set_color_value( + wheel.get("color_keyframes"), frame_number, QColor(wheel_snapshot.get("color", "#ffffff")), + openshot.LINEAR) + wheel["amount_keyframes"] = _set_keyframe_value( + wheel.get("amount_keyframes"), frame_number, wheel_snapshot.get("amount", 0.0), openshot.LINEAR) + wheel["luma_keyframes"] = _set_keyframe_value( + wheel.get("luma_keyframes"), frame_number, wheel_snapshot.get("luma", 0.0), openshot.LINEAR) + return normalize_wheels_data(updated) + + def _remove_colorgrade_keyframe(self, data, property_type, frame_number): + from windows.color_grade_editor import normalize_curve_data, normalize_wheels_data + + frame_number = int(round(frame_number)) + if property_type == "colorgrade_curve": + updated = normalize_curve_data(data) + else: + updated = normalize_wheels_data(data) + + changed = False + + def _remove_from_keyframe(kf_data): + nonlocal changed + points = kf_data.get("Points") if isinstance(kf_data, dict) else None + if not isinstance(points, list) or len(points) <= 1: + return + filtered = [] + for point in points: + try: + keep = int(round(float(point.get("co", {}).get("X")))) != frame_number + except (TypeError, ValueError): + keep = True + if keep: + filtered.append(point) + if len(filtered) != len(points): + kf_data["Points"] = filtered + changed = True + + def _walk(value): + if isinstance(value, dict): + if isinstance(value.get("Points"), list): + _remove_from_keyframe(value) + return + for child in value.values(): + _walk(child) + elif isinstance(value, list): + for child in value: + _walk(child) + + _walk(updated) + if property_type == "colorgrade_curve": + return normalize_curve_data(updated), changed + return normalize_wheels_data(updated), changed + + def _save_colorgrade_keyframe_update(self, item, operation): + property = self.model.item(item.row(), 0).data() + property_type = property[1]["type"] + property_key = property[0] + object_id = property[1]["object_id"] + item_data = item.data() + any_updated = False + + for item_id, item_type in item_data: + clip_updated = False + c = None + if item_type == "clip": + c = Clip.get(id=item_id) + elif item_type == "transition": + c = Transition.get(id=item_id) + elif item_type == "effect": + c = Effect.get(id=item_id) + if not c or not c.data: + continue + + clip_data = c.data + objects = {} + if object_id: + objects = c.data.get('objects', {}) + clip_data = objects.pop(object_id, {}) + if not clip_data: + log.debug("No clip data found for this object id") + continue + if property_key not in clip_data: + continue + + if operation == "insert": + clip_data[property_key] = self._insert_colorgrade_keyframe( + clip_data[property_key], property_type, self.frame_number) + clip_updated = True + else: + clip_data[property_key], clip_updated = self._remove_colorgrade_keyframe( + clip_data[property_key], property_type, self.frame_number) + + if not clip_updated: + continue + if not object_id: + clip_data = {property_key: clip_data.get(property_key)} + else: + objects[object_id] = clip_data + clip_data = {'objects': objects} + c.data = clip_data + c.save() + any_updated = True + + if any_updated: + if not self._trim_preview_mode: + get_app().window.refreshFrameSignal.emit() + + current_row = self.parent.currentIndex().row() + self.parent.clearSelection() + if current_row >= 0: + self.parent.setCurrentIndex(self.model.index(current_row, 0)) + + def insert_keyframe(self, item): + property = self.model.item(item.row(), 0).data() + property_type = property[1]["type"] + if property_type in ("colorgrade_curve", "colorgrade_wheels"): + self._save_colorgrade_keyframe_update(item, "insert") + return + + value = QLocale().system().toDouble(item.text())[0] + self.value_updated(item, value=value) + def _resolve_reader_source_path(self, value): """Resolve a reader property value to a filesystem path when possible.""" if value in (None, ""): @@ -312,6 +473,10 @@ def remove_keyframe(self, item): object_id = property[1]["object_id"] item_data = item.data() + if property_type in ("colorgrade_curve", "colorgrade_wheels"): + self._save_colorgrade_keyframe_update(item, "remove") + return + for item_id, item_type in item_data: # Find this clip c = None diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index ee0695d918..ae168cd284 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -557,7 +557,33 @@ def preview_live_property_value(self, value): self._update_live_property_preview(value) get_app().updates.ignore_history = True - def preview_curve_property_value(self, item, property_key, value): + def _resolve_live_property_item(self, item, property_key, property_type, item_data=None): + if item: + try: + if not isdeleted(item): + row = item.row() + label_item = self.clip_properties_model.model.item(row, 0) + if label_item: + cur_property = label_item.data() + if ( + isinstance(cur_property, tuple) + and len(cur_property) == 2 + and cur_property[0] == property_key + and cur_property[1].get("type") == property_type + ): + return item + except RuntimeError: + pass + + resolved_item, _property_meta = self._find_property_value_item( + property_key, + property_type=property_type, + item_data=item_data, + ) + return resolved_item + + def preview_curve_property_value(self, item, property_key, value, item_data=None): + item = self._resolve_live_property_item(item, property_key, "colorgrade_curve", item_data) if not item: return self.update_in_progress = True @@ -942,6 +968,10 @@ def start_property_change(self, item): self.start_transaction(item) get_app().updates.ignore_history = True + def start_curve_property_change(self, item, property_key, item_data=None): + item = self._resolve_live_property_item(item, property_key, "colorgrade_curve", item_data) + self.start_property_change(item) + def finish_live_property_change(self): self.finish_property_change() @@ -1251,9 +1281,12 @@ def _open_curve_editor(self, cur_property, model_index): self.color_grade_curve_dialogs.add(dialog) dialog.destroyed.connect(lambda *_args, dlg=dialog: self.color_grade_curve_dialogs.discard(dlg)) dialog.curve_widget().curveChanged.connect( - lambda value, item=item, key=property_key: self.preview_curve_property_value(item, key, value) + lambda value, item=item, key=property_key, dlg=dialog: self.preview_curve_property_value( + item, key, value, getattr(dlg, "_item_data", None)) ) - dialog.changeStarted.connect(lambda item=item: self.start_property_change(item)) + dialog.changeStarted.connect( + lambda item=item, key=property_key, dlg=dialog: self.start_curve_property_change( + item, key, getattr(dlg, "_item_data", None))) dialog.changeStarted.connect(self.pause_live_property_caching) dialog.changeFinished.connect(self.resume_live_property_caching) dialog.changeFinished.connect(self.finish_property_change) @@ -1848,8 +1881,7 @@ def Insert_Action_Triggered(self): self.selected_item = None if self.selected_item: - current_value = QLocale().system().toDouble(self.selected_item.text())[0] - self.clip_properties_model.value_updated(self.selected_item, value=current_value) + self.clip_properties_model.insert_keyframe(self.selected_item) def Remove_Action_Triggered(self): log.info("Remove_Action_Triggered") From 28fb89c30de4c0b7b07e68e8e4197059209e7b55 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 13:50:29 -0500 Subject: [PATCH 12/23] Reorganize clip Look menu presets - Group Look options into Color, Film, Focus, and Lighting - Add Look presets for Analog Tape, Sharpen, Blur, Shadow, and Glow - Preserve Film Grain presets under the new Film submenu - Add Reset Look support for Color Grade, Film Grain, and Look-managed effects - Tag Look-created generic effects with ui-menu metadata to avoid clobbering manual or motion effects (ui-menu) - Handle animated RGBA color properties for Shadow and Glow presets --- src/windows/views/timeline.py | 313 +++++++++++++++++++++++++++++++++- 1 file changed, 306 insertions(+), 7 deletions(-) diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index 35b517846d..26885fb5a7 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -66,6 +66,70 @@ apply_film_grain_preset, is_film_grain_effect, ) + +LOOK_EFFECT_UI_MENU = "look" + +LOOK_RESET_EFFECT_CLASSES = { + COLOR_GRADE_CLASS_NAME, + FILM_GRAIN_CLASS_NAME, +} + +LOOK_EFFECT_PRESETS = { + "AnalogTape": { + "none": {}, + "subtle": { + "bleed": 0.25, + "noise": 0.18, + "softness": 0.15, + "static_bands": 0.05, + "stripe": 0.06, + "tracking": 0.20, + }, + "vhs": { + "bleed": 0.55, + "noise": 0.35, + "softness": 0.35, + "static_bands": 0.18, + "stripe": 0.20, + "tracking": 0.45, + }, + "heavy": { + "bleed": 0.85, + "noise": 0.60, + "softness": 0.55, + "static_bands": 0.35, + "stripe": 0.40, + "tracking": 0.75, + }, + }, + "Blur": { + "none": {}, + "soft_focus": {"horizontal_radius": 3.0, "vertical_radius": 3.0, "sigma": 1.5, "iterations": 2.0}, + "medium": {"horizontal_radius": 8.0, "vertical_radius": 8.0, "sigma": 4.0, "iterations": 3.0}, + "heavy": {"horizontal_radius": 20.0, "vertical_radius": 20.0, "sigma": 8.0, "iterations": 4.0}, + }, + "Glow": { + "none": {}, + "soft_white": {"mode": 0, "opacity": 0.35, "blur_radius": 18.0, "spread": 0.15, "color": "#ffffffff"}, + "warm": {"mode": 0, "opacity": 0.45, "blur_radius": 24.0, "spread": 0.20, "color": "#ffd28cff"}, + "neon": {"mode": 0, "opacity": 0.65, "blur_radius": 16.0, "spread": 0.35, "color": "#35d7ffff"}, + "inner": {"mode": 1, "opacity": 0.45, "blur_radius": 12.0, "spread": 0.25, "color": "#ffffffff"}, + }, + "Shadow": { + "none": {}, + "subtle": {"opacity": 0.30, "blur_radius": 12.0, "spread": 0.05, "distance": 8.0, "angle": 135.0, "color": "#000000ff"}, + "soft": {"opacity": 0.45, "blur_radius": 28.0, "spread": 0.10, "distance": 14.0, "angle": 135.0, "color": "#000000ff"}, + "strong": {"opacity": 0.70, "blur_radius": 18.0, "spread": 0.25, "distance": 16.0, "angle": 135.0, "color": "#000000ff"}, + "long": {"opacity": 0.45, "blur_radius": 24.0, "spread": 0.12, "distance": 44.0, "angle": 135.0, "color": "#000000ff"}, + }, + "Sharpen": { + "none": {}, + "subtle": {"amount": 4.0, "radius": 1.5, "threshold": 0.0}, + "medium": {"amount": 9.0, "radius": 2.5, "threshold": 0.0}, + "strong": {"amount": 16.0, "radius": 3.5, "threshold": 0.0}, + }, +} + from classes.camera_motion import ( KEN_BURNS_AUTO, KEN_BURNS_BOTTOM_TO_TOP, @@ -1712,13 +1776,10 @@ def _motion_sub(title, items): menu.addMenu(Transform_Menu) if clip_has_visual: - # Look Menu (Color + Film Grain) + # Look Menu (color, film, focus, and lighting presets) Look_Menu = StyledContextMenu(title=_("Look"), parent=self) Reset_Look = Look_Menu.addAction(_("Reset Look")) - Reset_Look.triggered.connect(lambda: ( - self.Color_Triggered(COLOR_PRESET_RESET, clip_ids), - self.Film_Grain_Triggered(FILM_GRAIN_PRESET_NONE, clip_ids) - )) + Reset_Look.triggered.connect(partial(self.Reset_Look_Triggered, clip_ids)) Look_Menu.addSeparator() Color_Menu = StyledContextMenu(title=_("Color"), parent=self) @@ -1732,6 +1793,7 @@ def _motion_sub(title, items): Boost_Color.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_BOOST_COLOR, clip_ids)) Look_Menu.addMenu(Color_Menu) + Film_Menu = StyledContextMenu(title=_("Film"), parent=self) Film_Grain_Menu = StyledContextMenu(title=_("Film Grain"), parent=self) Film_Grain_None = Film_Grain_Menu.addAction(_("No Film Grain")) Film_Grain_None.triggered.connect(partial( @@ -1755,7 +1817,82 @@ def _motion_sub(title, items): Film_Grain_High_ISO = Film_Grain_Menu.addAction(_("High ISO")) Film_Grain_High_ISO.triggered.connect(partial( self.Film_Grain_Triggered, FILM_GRAIN_PRESET_HIGH_ISO, clip_ids)) - Look_Menu.addMenu(Film_Grain_Menu) + Film_Menu.addMenu(Film_Grain_Menu) + + self._add_effect_preset_menu( + Film_Menu, + _("Analog Tape"), + "AnalogTape", + _("No Analog Tape"), + [ + (_("Subtle"), "subtle"), + (_("VHS"), "vhs"), + (_("Heavy"), "heavy"), + ], + clip_ids, + ) + + Look_Menu.addMenu(Film_Menu) + + Focus_Menu = StyledContextMenu(title=_("Focus"), parent=self) + self._add_effect_preset_menu( + Focus_Menu, + _("Sharpen"), + "Sharpen", + _("No Sharpen"), + [ + (_("Subtle"), "subtle"), + (_("Medium"), "medium"), + (_("Strong"), "strong"), + ], + clip_ids, + ) + self._add_effect_preset_menu( + Focus_Menu, + _("Blur"), + "Blur", + _("No Blur"), + [ + (_("Soft Focus"), "soft_focus"), + (_("Medium"), "medium"), + (_("Heavy"), "heavy"), + ], + clip_ids, + ) + + if Focus_Menu.actions(): + Look_Menu.addMenu(Focus_Menu) + + Lighting_Menu = StyledContextMenu(title=_("Lighting"), parent=self) + self._add_effect_preset_menu( + Lighting_Menu, + _("Shadow"), + "Shadow", + _("No Shadow"), + [ + (_("Subtle"), "subtle"), + (_("Soft"), "soft"), + (_("Strong"), "strong"), + (_("Long"), "long"), + ], + clip_ids, + ) + self._add_effect_preset_menu( + Lighting_Menu, + _("Glow"), + "Glow", + _("No Glow"), + [ + (_("Soft White"), "soft_white"), + (_("Warm"), "warm"), + (_("Neon"), "neon"), + (_("Inner Glow"), "inner"), + ], + clip_ids, + ) + + if Lighting_Menu.actions(): + Look_Menu.addMenu(Lighting_Menu) Look_Menu.addSeparator() Adjust_Colors = Look_Menu.addAction( @@ -2364,6 +2501,164 @@ def _create_film_grain_effect_json(self): effect.Id(get_app().project.generate_id()) return json.loads(effect.Json()) + def _can_create_effect(self, class_name): + return openshot.EffectInfo().CreateEffect(class_name) is not None + + def _add_effect_preset_menu(self, parent_menu, title, class_name, reset_label, preset_items, clip_ids): + if not self._can_create_effect(class_name): + return None + + preset_menu = StyledContextMenu(title=title, parent=self) + reset_action = preset_menu.addAction(reset_label) + reset_action.triggered.connect(partial( + self._apply_effect_preset, class_name, "none", clip_ids)) + preset_menu.addSeparator() + + for label, preset_name in preset_items: + preset_action = preset_menu.addAction(label) + preset_action.triggered.connect(partial( + self._apply_effect_preset, class_name, preset_name, clip_ids)) + + parent_menu.addMenu(preset_menu) + return preset_menu + + def _create_effect_json(self, class_name): + effect = openshot.EffectInfo().CreateEffect(class_name) + if effect is None: + raise RuntimeError("Unable to create {} effect".format(class_name)) + effect.Id(get_app().project.generate_id()) + return json.loads(effect.Json()) + + def _is_look_managed_effect(self, effect_json, class_name=None): + if not isinstance(effect_json, dict): + return False + if effect_json.get("ui-menu") != LOOK_EFFECT_UI_MENU: + return False + return class_name is None or effect_json.get("class_name") == class_name + + def _parse_effect_color(self, value): + if not isinstance(value, str): + return None + color = value.strip() + if color.startswith("#"): + color = color[1:] + if len(color) not in (6, 8): + return None + try: + red = int(color[0:2], 16) + green = int(color[2:4], 16) + blue = int(color[4:6], 16) + alpha = int(color[6:8], 16) if len(color) == 8 else 255 + except ValueError: + return None + return { + "red": red, + "green": green, + "blue": blue, + "alpha": alpha, + } + + def _set_effect_property_value(self, effect_json, property_name, value): + property_data = effect_json.get(property_name) + color_channels = self._parse_effect_color(value) + if color_channels and isinstance(property_data, dict): + for channel, channel_value in color_channels.items(): + channel_data = property_data.get(channel) + if isinstance(channel_data, dict) and isinstance(channel_data.get("Points"), list): + channel_data["Points"] = [ + json.loads(openshot.Point(1, float(channel_value), openshot.BEZIER).Json()) + ] + elif isinstance(property_data, dict) and isinstance(property_data.get("Points"), list): + property_data["Points"] = [json.loads(openshot.Point(1, float(value), openshot.BEZIER).Json())] + elif property_name in effect_json: + effect_json[property_name] = value + + def _apply_effect_preset(self, class_name, preset_name, clip_ids): + """Apply a simple Look effect preset, or remove the effect for the none preset.""" + presets = LOOK_EFFECT_PRESETS.get(class_name, {}) + if preset_name not in presets: + return + + for clip_id in clip_ids: + clip = Clip.get(id=clip_id) + if not clip or not self._clip_has_visual(clip): + continue + + original_clip_data = json.loads(json.dumps(clip.data)) + effects = clip.data.get("effects") + if not isinstance(effects, list): + effects = list(effects) if effects else [] + clip.data["effects"] = effects + + matching_indexes = [ + index for index, effect_json in enumerate(effects) + if self._is_look_managed_effect(effect_json, class_name) + ] + + if preset_name == "none": + if not matching_indexes: + continue + clip.data["effects"] = [ + effect_json for effect_json in effects + if not self._is_look_managed_effect(effect_json, class_name) + ] + self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) + get_app().updates.apply_last_action_to_history(original_clip_data) + continue + + try: + preset_effect = self._create_effect_json(class_name) + except RuntimeError: + continue + preset_effect["ui-menu"] = LOOK_EFFECT_UI_MENU + + if matching_indexes: + existing_effect = effects[matching_indexes[0]] + if existing_effect.get("id"): + preset_effect["id"] = existing_effect["id"] + if "order" in existing_effect: + preset_effect["order"] = existing_effect["order"] + + for property_name, value in presets[preset_name].items(): + self._set_effect_property_value(preset_effect, property_name, value) + + if matching_indexes: + effects[matching_indexes[0]] = preset_effect + for index in reversed(matching_indexes[1:]): + del effects[index] + else: + effects.append(preset_effect) + + self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) + get_app().updates.apply_last_action_to_history(original_clip_data) + + def Reset_Look_Triggered(self, clip_ids): + """Remove all effects managed by the clip Look menu.""" + for clip_id in clip_ids: + clip = Clip.get(id=clip_id) + if not clip or not self._clip_has_visual(clip): + continue + + effects = clip.data.get("effects") + if not isinstance(effects, list): + continue + + filtered_effects = [ + effect_json for effect_json in effects + if not isinstance(effect_json, dict) + or ( + effect_json.get("class_name") not in LOOK_RESET_EFFECT_CLASSES + and not self._is_look_managed_effect(effect_json) + ) + ] + if len(filtered_effects) == len(effects): + continue + + original_clip_data = json.loads(json.dumps(clip.data)) + clip.data["effects"] = filtered_effects + self.update_clip_data(clip.data, only_basic_props=False, ignore_reader=True) + get_app().updates.apply_last_action_to_history(original_clip_data) + def _ensure_color_grade_effect(self, clip): if not clip or not self._clip_has_visual(clip): return None, False @@ -2694,7 +2989,11 @@ def _reset_motion(): effects = clip.data.get("effects", []) clip.data["effects"] = [ eff for eff in (effects if isinstance(effects, list) else []) - if eff.get("class_name") not in ("Blur", "Mask") + if not isinstance(eff, dict) + or ( + eff.get("class_name") not in ("Blur", "Mask") + or eff.get("ui-menu") == LOOK_EFFECT_UI_MENU + ) ] def _make_wipe_fx(svg_filename, t_start, t_end, brightness_start, brightness_end): From 7fa9748e435e60f38063540daa0a47872e1689e0 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 14:13:25 -0500 Subject: [PATCH 13/23] Updating docs / user-guide with updated clip menu names and functionality (renaming/reoganized menus). --- doc/animation.rst | 4 +- doc/clips.rst | 276 +++++++++++++++++++++++++++++++--------------- doc/color.rst | 6 +- doc/effects.rst | 2 +- 4 files changed, 195 insertions(+), 93 deletions(-) diff --git a/doc/animation.rst b/doc/animation.rst index d8823cf61f..c55f5d4120 100644 --- a/doc/animation.rst +++ b/doc/animation.rst @@ -77,14 +77,14 @@ Timing ------ Changing how fast a clip plays is done with the :guilabel:`Time` property and the :guilabel:`Timing` tool. -- The :guilabel:`Time` menu offers presets such as normal, fast, slow, freeze, and reverse. See details in :ref:`clip_time_ref`. +- The :guilabel:`Speed` menu offers presets such as normal, fast, slow, freeze, reverse, and repeat. See details in :ref:`clip_time_ref`. - The :guilabel:`Timing` tool lets you drag a clip’s edges to speed it up or slow it down. OpenShot adds the needed Time keyframes and **scales your other keyframes** so your animations stay aligned. Shorter clips play faster, longer clips play slower. See more: :ref:`clip_time_ref`. Repeating --------- -To play a clip multiple times, use :guilabel:`Right-Click → Time → Repeat`. +To play a clip multiple times, use :guilabel:`Right-Click → Speed → Repeat`. - :guilabel:`Loop` repeats in one direction (forward or reverse). - :guilabel:`Ping-Pong` alternates direction (forward then backward, etc.). diff --git a/doc/clips.rst b/doc/clips.rst index 7cf7326d52..b68a5989c1 100644 --- a/doc/clips.rst +++ b/doc/clips.rst @@ -132,98 +132,144 @@ revealing the context menu. A preset sets one (or more) clip properties for the to manually set the key-frame clip properties. See :ref:`clip_properties_ref`. Some presets allow the user to target either the start, end, or entire clip, and most presets allow -the user to reset a specific clip property. For example, when using the ``Volume`` preset, the user has -the following menu options: +the user to reset a specific clip property. For example, when using the :guilabel:`Audio → Volume` presets, +the user has the following menu options: -- **Reset** - This will reset the volume to the original level. -- **Start of Clip** - Your volume level selection will apply at the Beginning of the clip. -- **End of Clip** - Your volume level selection will apply to the End of the clip. -- **Entire Clip** - Your volume level selection will apply to the Entire clip. +- **Reset Volume** — resets the volume to the original level. +- **Start of Clip** — the volume change applies at the beginning of the clip. +- **End of Clip** — the volume change applies at the end of the clip. +- **Entire Clip** — the volume change applies to the entire clip. .. image:: images/clip-presets.jpg .. table:: :widths: 20 80 - + ================== ============ Preset Name Description ================== ============ Copy / Cut / Paste Copy selected clip data, cut selected clips, or paste copied clip data. Copy supports full clips, effects, and keyframe groups. - Align Align the left or right edge of multiple selected clips and transitions. - Fade Fade the image in or out (often easier than using a transition) - Animate Zoom and slide a clip - Rotate Rotate or flip a clip - Crop Crop the clip with or without resizing the visible result back to the project frame. - Color Apply quick color presets, reset color changes, or open the video scopes. - Layout Make a video smaller or larger, and snap to any corner - Time Reverse, repeat, and speed up or slow down video - Volume Fade in or out the volume, reduce or increase the volume of a clip, mute it, or open the Audio Levels dock - Separate Audio Separate the audio from a clip. This preset can either create a single detached audio clip (positioned on a layer below the original clip), or multiple detached audio clips (one per audio track, positioned on multiple layers below the original clip) - Slice Cut the clip at the play-head position, with options to keep either side, both sides, or ripple-delete one side. - Display Switch selected clips between audio waveform and thumbnail display styles. - Properties Show the properties panel for a clip - Remove Clip Remove a clip from the timeline + Align Align the left or right edge of multiple selected clips and transitions (only shown when multiple clips are selected). + Fade Fade the clip in or out — automatically fades video (alpha) and/or audio (volume) based on what the clip contains. + Motion Add animated motion to a clip: slide in/out, bounce, blur, zoom, emphasis effects at the playhead, camera movements, and scrolling credits. Only shown for visual clips. + Transform Apply geometric presets to a clip: rotate/flip, crop, or snap to a corner layout. Also provides a **No Transform** reset. Only shown for visual clips. + Look Apply visual style presets: color grades, film grain, analog tape, sharpen, blur, shadow, and glow effects. Includes **Adjust Colors** (Color Wheels editor) and **Analyze Colors** (video scopes). Only shown for visual clips. + Speed Reverse, repeat, speed up, or slow down video. Includes Freeze and Freeze & Zoom options. + Audio Control audio properties: adjust volume levels, fade volume in/out, separate audio channels, show/hide the timeline waveform, or open the Audio Levels scope. + Slice Cut the clip at the play-head position, with options to keep either side, both sides, or ripple-delete one side. Only shown when the playhead overlaps the clip. + Properties Show the properties panel for a clip. + Remove Clip Remove a clip from the timeline. ================== ============ Fade """" -The :guilabel:`Fade` preset enables smooth transitions by gradually increasing or decreasing the clip's opacity. It -creates a fade-in or fade-out of the clip image, ideal for introducing or concluding clips. -See :ref:`clip_alpha_ref` key-frame. +The :guilabel:`Fade` preset creates a smooth fade-in or fade-out on the selected clip. It automatically +fades **video** (alpha/opacity) and/or **audio** (volume) depending on what the clip contains — a video clip +gets both, an audio-only clip gets only a volume fade, and an image clip gets only an alpha fade. + +- :guilabel:`No Fade` — removes all fade keyframes. +- :guilabel:`Fade In` → :guilabel:`Fast` / :guilabel:`Slow` — fades from transparent/silent at the start of the clip. +- :guilabel:`Fade Out` → :guilabel:`Fast` / :guilabel:`Slow` — fades to transparent/silent at the end of the clip. +- :guilabel:`Fade In and Out` → :guilabel:`Fast` / :guilabel:`Slow` — applies both a fade-in at the start and a fade-out at the end. + +Fast fades span approximately 1 second; Slow fades span approximately 3 seconds. +See :ref:`clip_alpha_ref` and :ref:`clip_volume_ref` key-frames. - **Usage Example:** Applying a fade-out to a video clip to gently conclude a scene. -- **Tip:** Adjust the duration of the fade effect (slow or fast) to control its timing and intensity. +- **Tip:** Fade and Volume fade are independent — use :guilabel:`Fade` for a combined video+audio fade, or use :guilabel:`Audio → Volume` to fade only the audio. -Animate -""""""" -The :guilabel:`Animate` preset adds dynamic motion to clips, combining zooming and sliding animations. It -animates a clip by zooming in or out while sliding across the screen. It can **slide** in many specific -directions, or slide and zoom to a **random** location. See :ref:`clip_location_x_ref` and -:ref:`clip_scale_x_ref` key-frames. +Motion +"""""" +The :guilabel:`Motion` menu adds animated movement to visual clips using keyframe presets. It is organized into +several submenus. See :ref:`clip_location_x_ref` and :ref:`clip_scale_x_ref` key-frames. + +- :guilabel:`No Motion` — removes all motion keyframes from the clip. +- :guilabel:`In` — entrance animations applied at the start of the clip: + + - **Back In** (From Bottom / Left / Right / Top) — clip overshoots and springs back into position. + - **Blur In** — clip fades in from a motion blur. + - **Bounce In** (Center / From Bottom / Left / Right / Top) — clip bounces as it enters. + - **Pop In** — clip scales up quickly from nothing. + - **Slide In** (From Bottom / Left / Right / Top) — clip slides onto the screen. + - **Spiral In** — clip rotates in while scaling up. + - **Wipe In** (Circle Expand / Circle Shrink / From Bottom / Left / Right / Top) — clip is revealed by a wipe. + +- :guilabel:`Out` — exit animations applied at the end of the clip (mirrors the In options: Back Out, Blur Out, Bounce Out, Pop Out, Slide Out, Spiral Out, Wipe Out). + +- :guilabel:`Emphasis` — short attention-grabbing animations **inserted at the current playhead position** (not applied across the whole clip). Useful for mid-clip highlights: **Bounce**, **Flash**, **Heartbeat**, **Jello**, **Pulse**, **Rubber Band**, **Shake X**, **Shake Y**, **Swing**, **Tada**, **Wobble**. + +- :guilabel:`Camera` — simulates camera movement on the clip by animating scale and position: + + - **Zoom** → In / Out — a simple push-in or pull-out. + - **Pan** → Auto Direction / Left to Right / Right to Left / Top to Bottom / Bottom to Top — a lateral camera pan. *Auto Direction* picks the best direction based on the clip's aspect ratio. + - **Zoom & Pan** → In or Out, with directional variants — combines a zoom with a pan (Ken Burns style). *Auto Direction* picks the best direction based on the clip's aspect ratio. -- **Usage Example:** Using the animate preset to simulate a camera movement across a landscape shot. -- **Tip:** Experiment with different animation speeds and directions for diverse visual effects. +- :guilabel:`Credits` — scrolling credit animations: **Scroll Up** and **Scroll Down**. + +- **Tip:** Emphasis presets are placed at the current playhead position, so position the playhead over the moment you want to highlight before applying one. +- **Tip:** Camera presets use *Auto Direction* by default, which picks the optimal pan direction for wide or tall media to avoid black bars. + +.. _clip_presets_rotate_ref: Rotate """""" -The :guilabel:`Rotate` preset introduces easy rotation and flipping of clips, enhancing their visual appeal. It -enables orientation adjustment, by rotating and flipping a clip for creative visual transformations. See :ref:`clip_rotation_ref` key-frame. +The :guilabel:`Transform → Rotate` submenu introduces easy rotation and flipping of clips. It +enables orientation adjustment by rotating and flipping a clip for creative visual transformations. +See :ref:`clip_rotation_ref` key-frame. + +- :guilabel:`No Rotation` — removes any rotation keyframes. +- :guilabel:`Rotate 90 (Right)` — rotates the clip 90 degrees clockwise. +- :guilabel:`Rotate 90 (Left)` — rotates the clip 90 degrees counterclockwise. +- :guilabel:`Rotate 180 (Flip)` — flips the clip upside down. -- **Usage Example:** Rotating a photo or video by 90 degree (a portrait video to a landscape) -- **Usage Example:** If your video is oriented sideways (90 degrees), you can rotate it clockwise or counterclockwise by 90 degrees to bring it to the correct orientation. This can be useful when you accidentally recorded a video in portrait mode when you intended it to be landscape. -- **Usage Example:** If your video is upside down, you can rotate it by 180 degrees to flip it to the correct orientation. This can happen if you accidentally held your camera the wrong way while recording. +- **Usage Example:** Rotating a photo or video by 90 degrees (a portrait video to a landscape). +- **Usage Example:** If your video is upside down, rotate it by 180 degrees to correct the orientation. + +.. _clip_presets_layout_ref: Layout """""" -The :guilabel:`Layout` preset adjusts the size of a clip and snaps it to a chosen corner of the screen. It -resizes a clip and anchors it to a corner or the center, useful for picture-in-picture or watermark effects. +The :guilabel:`Transform → Layout` submenu adjusts the size of a clip and snaps it to a chosen corner of the +screen, useful for picture-in-picture or watermark effects. See :ref:`clip_location_x_ref` and :ref:`clip_scale_x_ref` key-frames. +- :guilabel:`Reset Layout` — removes any layout keyframes. +- :guilabel:`1/4 Size` — positions the clip at 1/4 of the screen in the Center, Top Left, Top Right, Bottom Left, or Bottom Right corner. +- :guilabel:`Show All (Maintain Ratio)` — fits the entire clip frame within the screen while preserving its aspect ratio. +- :guilabel:`Show All (Distort)` — stretches the entire clip frame to fill the screen, ignoring aspect ratio. + - **Usage Example:** Placing a logo in the corner of a video using the layout preset. - **Tip:** Combine with animation presets for dynamic transitions involving resizing and repositioning. -Time -"""" -The :guilabel:`Time` preset manipulates clip playback speed, allowing for reverse playback or time-lapse effects. It -alters the speed and direction of a clip's playback, enhancing visual storytelling. -See :ref:`clip_time_ref` key-frame. +Speed +""""" +The :guilabel:`Speed` menu manipulates clip playback speed, allowing for reverse playback, time-lapse, +slow-motion, freezes, and looping effects. See :ref:`clip_time_ref` key-frame. + +- :guilabel:`Reset` — restores the clip to normal 1× speed. +- :guilabel:`Reverse` — plays the clip backwards at normal speed. +- :guilabel:`Speed Up` → Forward / Backward → 2×, 4×, 8×, 16× — speeds up playback in either direction. +- :guilabel:`Slow Down` → Forward / Backward → 1/2×, 1/4×, 1/8×, 1/16× — slows down playback in either direction. +- :guilabel:`Repeat` — see below. +- :guilabel:`Freeze` — freezes on the frame at the current playhead position for 2, 4, 6, 8, 10, 20, or 30 seconds. +- :guilabel:`Freeze && Zoom` — freezes and simultaneously zooms in on that frozen frame. - **Usage Example:** Creating a slow-motion effect to emphasize a specific action. -- **Tip:** Use time presets to creatively manipulate the pacing of your video. +- **Tip:** Use the :guilabel:`Timing` toolbar tool to change speed interactively by dragging a clip's edges. .. _clip_time_repeat_ref: Repeat """""" -Use :guilabel:`Time → Repeat` to play a clip multiple times, without building the +Use :guilabel:`Speed → Repeat` to play a clip multiple times, without building the time curve by hand. OpenShot writes the needed :guilabel:`Time` keyframes for you (you can edit them later). **Menu path** -- :guilabel:`Time → Repeat → Loop → Forward` – plays left to right, then starts again from the beginning -- :guilabel:`Time → Repeat → Loop → Reverse` – plays right to left, then starts again from the end -- :guilabel:`Time → Repeat → Ping-Pong → Forward` – forward, then backward, then forward… -- :guilabel:`Time → Repeat → Ping-Pong → Reverse` – backward, then forward, then backward… +- :guilabel:`Speed → Repeat → Loop → Forward` – plays left to right, then starts again from the beginning +- :guilabel:`Speed → Repeat → Loop → Reverse` – plays right to left, then starts again from the end +- :guilabel:`Speed → Repeat → Ping-Pong → Forward` – forward, then backward, then forward… +- :guilabel:`Speed → Repeat → Ping-Pong → Reverse` – backward, then forward, then backward… - :guilabel:`Custom…` – opens a dialog for extra options (see below) Counts are **finite** (2x, 3x, 4x, 5x, 8x, 10x, or a custom number). @@ -253,7 +299,7 @@ Example: “Forward then Back and stop” = :guilabel:`Ping-Pong → Forward → **Reset** -- :guilabel:`Time → Reset Time` completely removes any Time curve (including Repeat) and restores the clip to its +- :guilabel:`Speed → Reset` completely removes any Time curve (including Repeat) and restores the clip to its original playback, **without deleting your original non-Time keyframes**. Timing Tool @@ -262,37 +308,79 @@ Another way to change a clip's speed is with the :guilabel:`Timing` tool on the icon and drag a clip's edges. Lengthening the clip slows playback, while shotening it speeds the clip up. All keyframes on the clip and its effects are scaled so their relative positions remain intact. -Color +Look +"""" +The :guilabel:`Look` menu applies one-click visual style presets to clips. All presets work by adding or +updating effects on the clip — you can inspect or animate them further in the Properties dock. The menu is +organized into four submenus plus two direct actions at the bottom. + +- :guilabel:`Reset Look` — removes all Look-managed effects (Color Grade, Film Grain, Analog Tape, Sharpen, + Blur, Shadow, Glow) from the selected clips in a single step. + +**Color** — applies a :guilabel:`Color Grade` effect with one of four quick presets: + +- **Auto Contrast** — boosts contrast by lifting shadows and pulling highlights. +- **Lift Shadows** — brightens the dark areas of the image. +- **Warm Up** — shifts the color balance toward warm orange/amber tones. +- **Boost Color** — increases color saturation. + +**Film** — cinematic film simulation: + +- :guilabel:`Film Grain` → **No Film Grain**, then: **35mm Fine**, **35mm Classic**, **35mm Gritty**, + **16mm Classic**, **Super 8**, **High ISO** — adds photographic grain at various strengths and sizes. +- :guilabel:`Analog Tape` → **No Analog Tape**, then: **Subtle**, **VHS**, **Heavy** — adds tape + noise and color degradation for a retro video look. + +**Focus** — sharpness adjustments: + +- :guilabel:`Sharpen` → **No Sharpen**, then: **Subtle**, **Medium**, **Strong** — sharpens fine detail. +- :guilabel:`Blur` → **No Blur**, then: **Soft Focus**, **Medium**, **Heavy** — softens the image. + +**Lighting** — light and shadow overlays: + +- :guilabel:`Shadow` → **No Shadow**, then: **Subtle**, **Soft**, **Strong**, **Long** — casts a drop + shadow across the clip. +- :guilabel:`Glow` → **No Glow**, then: **Soft White**, **Warm**, **Neon**, **Inner Glow** — adds a + soft halo or glow effect. + +At the bottom of the Look menu: + +- :guilabel:`Adjust Colors` — opens the :guilabel:`Color Wheels` dock for full manual color grading with + lift/gamma/gain wheels, curves, and an Amount/Luma blend slider. +- :guilabel:`Analyze Colors` — opens the :guilabel:`Luma Waveform` and :guilabel:`Histogram` video scopes + on the right side of the window, tabified together. + +- **Tip:** Each Look submenu has a "No …" option at the top to remove just that single effect without affecting + the others. +- **Tip:** After applying a Look preset, double-click the effect badge on the clip (or use Properties) to + fine-tune every parameter or animate it over time. + +Audio """"" -The :guilabel:`Color` preset menu includes quick grading presets, :guilabel:`Reset Color`, and -:guilabel:`Analyze Colors`. Choosing :guilabel:`Analyze Colors` opens the :guilabel:`Luma Waveform` -and :guilabel:`Histogram` docks on the right side of the window, tabified together when needed. +The :guilabel:`Audio` menu groups all audio-related clip actions in one place. -If you select multiple clips and/or transitions that share the same left edge or -right edge, you can retime that shared edge together in one drag. +**Volume** — controls the clip's audio level. See :ref:`clip_volume_ref` key-frame. -Volume -"""""" -The :guilabel:`Volume` preset controls audio properties, facilitating smooth volume adjustments. It -manages audio volume, including fading in/out, reducing/increasing volume, or muting. -See :ref:`clip_volume_ref` key-frame. +- :guilabel:`Reset Volume` — removes all volume keyframes, returning to full (1×) volume. +- :guilabel:`Level` — sets a fixed volume percentage (0 % to 130 %) for the entire clip. +- :guilabel:`Fade In` → :guilabel:`Fast` / :guilabel:`Slow` — fades volume from silence at the start of the clip. +- :guilabel:`Fade Out` → :guilabel:`Fast` / :guilabel:`Slow` — fades volume to silence at the end of the clip. +- :guilabel:`Fade In and Out` → :guilabel:`Fast` / :guilabel:`Slow` — applies both a volume fade-in and fade-out. -The same menu also includes :guilabel:`Audio Levels`, which opens the :guilabel:`Audio Levels` dock on the right -side of the window. If the scope docks are already open, OpenShot tabifies :guilabel:`Audio Levels` with the -other scopes. +**Separate** — splits the audio track out of a clip: -- **Usage Example:** Applying a gradual volume fade-out to transition between scenes. -- **Tip:** Utilize volume presets for quickly lowering or raising volume levels. +- :guilabel:`Single Clip (all channels)` — creates one detached audio clip on a layer below the original. +- :guilabel:`Multiple Clips (each channel)` — creates separate detached audio clips, one per audio channel, on multiple layers below the original. -Separate Audio -"""""""""""""" -The :guilabel:`Separate Audio` preset splits the audio from a clip, creating detached audio clips positioned -below the original clip on the timeline. This preset can either create a **single** detached audio clip -(positioned on a layer below the original clip) or **multiple** detached audio clips -(one per audio track, positioned on multiple layers below the original clip). +**Show Waveform / Hide Waveform** — toggles the audio waveform visualization on the timeline for audio-only clips. +Only shown for clips that do not have a video track. -- **Usage Example:** Extracting background music from a video clip for independent control. -- **Tip:** Use this preset to fine-tune audio elements separately from the visual content. +**Analyze Levels** — opens the :guilabel:`Audio Levels` scope dock. If other scope docks are already open, +OpenShot tabifies :guilabel:`Audio Levels` with them. + +- **Usage Example:** Applying a gradual volume fade-out to transition between scenes. +- **Usage Example:** Extracting background music from a video clip for independent volume control. +- **Tip:** Use :guilabel:`Audio → Volume → Fade In/Out` for audio-only fades. Use the top-level :guilabel:`Fade` menu for a combined video+audio fade. Slice """"" @@ -321,6 +409,17 @@ For a complete guide to slicing and all available keyboard shortcuts, see the :r Transform """"""""" +The :guilabel:`Transform` context menu provides geometric preset actions for visual clips: + +- :guilabel:`No Transform` — removes all Rotate, Layout, and crop presets from the selected clips in one step. +- :guilabel:`Rotate` — rotate or flip the clip. See the :ref:`Rotate ` section above. +- :guilabel:`Crop` — add or remove a crop effect. See the :ref:`Crop ` section below. +- :guilabel:`Layout` — resize and snap the clip to a corner or fit position. See the :ref:`Layout ` section above. + +.. _clip_transform_tool_ref: + +Transform Tool +^^^^^^^^^^^^^^ The **Transform Tool** lets you quickly adjust a clip directly in the preview window, instead of changing location, scale, rotation, shear, and rotation origin values one property at a time. OpenShot shows the Transform Tool @@ -347,9 +446,11 @@ of your clip. You can also manually adjust these same clip properties in the pro - **Usage Example:** Use the transform handles to resize and reposition a clip for a picture-in-picture effect. - **Tip:** Use these handles to precisely control a clip's appearance. +.. _clip_presets_crop_ref: + Crop """"" -The :guilabel:`Crop` preset adds a crop effect to the selected clip and displays +The :guilabel:`Transform → Crop` submenu adds a crop effect to the selected clip and displays interactive crop handles in the video preview. The submenu offers: - :guilabel:`No Crop` – remove any existing crop effect. @@ -359,13 +460,14 @@ interactive crop handles in the video preview. The submenu offers: Drag the blue handles to adjust the crop boundaries, move the cropped area around, or move the center handle to reposition the image inside the cropped area. -Display -""""""" -The :guilabel:`Display` preset toggles the display mode of a clip on the timeline, showing either its -waveform or thumbnail. +Waveform +"""""""" +The :guilabel:`Audio → Show Waveform` / :guilabel:`Hide Waveform` action toggles whether an audio-only clip +shows its waveform visualization on the timeline instead of the default thumbnail. This option only appears +for clips that have no video track. -- **Usage Example:** Displaying the audio waveform for precise audio editing. -- **Tip:** Use this preset to focus on specific aspects of a clip's audio during editing. +- **Usage Example:** Displaying the audio waveform for precise audio editing and alignment. +- **Tip:** Use this to visually spot loud or quiet sections in a music clip without opening the audio scopes. Properties """""""""" @@ -662,7 +764,7 @@ To avoid distortion, OpenShot might need to reduce the volume levels in overlapp - **Average** - Automatically divide the volume of each clip based on the # of overlapping clips. For example, 2 overlapping clips would each have 50% volume, 3 overlapping clips would each have 33% volume, etc... - **Reduce** - Automatically reduce overlapping clips volume by 20%, which reduces the likelihood of becoming too loud, but does not always prevent audio distortion. For example, if you have 10 loud clips overlapping, each with a 20% reduction in volume, it might still exceed the max allowable volume and exhibit audio distortion. -For quickly adjusting the volume of a clip, you can use the simple :guilabel:`Volume Preset` menu. See :ref:`clip_presets_ref`. +For quickly adjusting the volume of a clip, you can use the :guilabel:`Audio → Volume` menu. See :ref:`clip_presets_ref`. For precise control over the volume of a clip, you can manually set the :guilabel:`Volume Key-frame`. See :ref:`clip_volume_ref`. Origin X and Origin Y @@ -757,8 +859,8 @@ Changing this property will impact the :guilabel:`Duration` clip property. Time """" The :guilabel:`Time` property is a key-frame curve that represents frames played over time, affecting the speed and direction of the video. -You can use one of the available presets (`normal, fast, slow, freeze, freeze & zoom, forward, backward`), by right clicking -on a Clip and choosing the :guilabel:`Time` menu. Many presets are available in this menu for reversing, +You can use one of the available presets (normal, fast, slow, freeze, freeze & zoom, forward, backward), by right-clicking +on a clip and choosing the :guilabel:`Speed` menu. Many presets are available in this menu for reversing, speeding up, and slowing down a video clip, see :ref:`clip_presets_ref`. The same adjustments can be made interactively with the :guilabel:`Timing` toolbar button by dragging a clip's edges; OpenShot adds the necessary time keyframes and scales all other keyframes automatically. @@ -787,7 +889,7 @@ For automatic adjustment of volume, see :ref:`clip_volume_mixing_ref`. - **Usage Example:** Gradually fading out background music as dialogue becomes more prominent, or increasing or lowering the volume of a clip. - **Tip:** Combine multiple volume key-frames for nuanced audio adjustments, such as ducking the level of the music when dialog is spoken. -- **Tip:** For **quickly** adjusting the volume of a clip you can use the simple :guilabel:`Volume Preset` menu. See :ref:`clip_presets_ref`. +- **Tip:** For **quickly** adjusting the volume of a clip you can use the :guilabel:`Audio → Volume` menu. See :ref:`clip_presets_ref`. Wave Color """""""""" diff --git a/doc/color.rst b/doc/color.rst index cfee3aeb9b..dd4aa64844 100644 --- a/doc/color.rst +++ b/doc/color.rst @@ -100,16 +100,16 @@ Getting Started OpenShot offers several ways to open its color tools, depending on what you need: -**Right-click a clip → Color → Adjust Colors** +**Right-click a clip → Look → Adjust Colors** The quickest all-in-one setup. OpenShot adds the :guilabel:`Color Grade` effect to the clip, selects it, opens the :guilabel:`Properties` panel, and shows the :guilabel:`Color Wheels` dock and all three video scopes — ready to grade immediately. -**Right-click a clip → Color → [preset]** *(Auto Contrast, Lift Shadows, Warm Up, Boost Color…)* +**Right-click a clip → Look → Color → [preset]** *(Auto Contrast, Lift Shadows, Warm Up, Boost Color…)* Adds the Color Grade effect with a useful preset already applied. The Color Wheels and scopes are not opened automatically — open them any time from :guilabel:`View → Docks`. -**Right-click a clip → Color → Analyze Colors** +**Right-click a clip → Look → Analyze Colors** Opens all three scopes (Luma Waveform, Histogram, and Vectorscope, tabbed together on the right) without adding any Color Grade effect. Use this to evaluate footage before deciding whether it needs grading, or simply to monitor levels during playback. diff --git a/doc/effects.rst b/doc/effects.rst index 487c899060..ddb385ab74 100644 --- a/doc/effects.rst +++ b/doc/effects.rst @@ -552,7 +552,7 @@ Color Grade """"""""""" The Color Grade effect combines primary correction, tonal wheels, RGB curves, and LUT support into one fully animated effect. Use it for **color correction** (white balance, exposure, contrast) and -**color grading** (building a stylized look). Right-click a clip and use :guilabel:`Color` presets to +**color grading** (building a stylized look). Right-click a clip and use :guilabel:`Look → Color` presets to apply it instantly, or switch to :guilabel:`View → Views → Color View` for a dedicated grading workspace. .. seealso:: From 5d0d338df362e272aa02c4978fdf5a2d0947dd10 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 14:19:14 -0500 Subject: [PATCH 14/23] Updating docs / user-guide for recently added effects: Film Grain, Glow, Shadow, and Color Grade (updating menu names, and making some small improvements) --- doc/effects.rst | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/doc/effects.rst b/doc/effects.rst index ddb385ab74..78f8fbfd1a 100644 --- a/doc/effects.rst +++ b/doc/effects.rst @@ -949,14 +949,15 @@ itself. Stronger settings can be useful for vintage looks, music videos, horror or footage that should feel like older 16mm or Super 8 film. You can add Film Grain from the :guilabel:`Effects` tab, or right-click a clip and choose -:guilabel:`Film Grain` to start with a preset such as :guilabel:`35mm Fine`, -:guilabel:`35mm Classic`, :guilabel:`16mm Classic`, :guilabel:`Super 8`, or +:guilabel:`Look → Film → Film Grain` to start with a preset: :guilabel:`35mm Fine`, +:guilabel:`35mm Classic`, :guilabel:`35mm Gritty`, :guilabel:`16mm Classic`, :guilabel:`Super 8`, or :guilabel:`High ISO`. Presets only set the properties for you; all controls remain visible and editable. Simple starting points: - **Clean cinematic texture**: try :guilabel:`35mm Fine`, then lower ``amount`` if it feels too visible. - **Classic film look**: try :guilabel:`35mm Classic` for a balanced grain pattern. +- **Punchy, gritty film look**: try :guilabel:`35mm Gritty` for heavier, more visible grain. - **Older home-movie style**: try :guilabel:`Super 8`, which uses larger, more active grain. - **Low-light camera noise style**: try :guilabel:`High ISO`, then adjust ``color_amount`` to control how colorful the grain feels. @@ -997,17 +998,23 @@ The Glow effect creates a soft halo from the clip's visible pixels. It can rende for a classic outer glow, or along the inside edges for an inner glow. The effect uses the source alpha channel, so transparent PNGs, text, logos, and masked clips work especially well. +You can add Glow from the :guilabel:`Effects` tab, or right-click a clip and choose +:guilabel:`Look → Lighting → Glow` to start with a preset: :guilabel:`Soft White` (a gentle neutral +halo), :guilabel:`Warm` (a warm amber glow), :guilabel:`Neon` (a vivid colored glow), or +:guilabel:`Inner Glow` (glow drawn inside the subject's edges). Presets only set the properties for +you; all controls remain visible and editable. + .. table:: :widths: 26 80 ========================== ============ Property Name Description ========================== ============ - mode ``(int, choices: ['Outer', 'Inner'])`` Choose whether the glow appears outside the visible pixels or just inside their edges. + mode ``(int, choices: ['Outer', 'Inner'])`` Choose whether the glow appears outside the visible pixels (Outer) or just inside their edges (Inner). opacity ``(float, 0 to 1)`` Overall glow strength and transparency. - blur_radius ``(float, 0 to 100)`` Blur radius in pixels used to soften the glow. - spread ``(float, 0 to 1)`` Expands and strengthens the source alpha before blurring for a denser glow. - color ``(color)`` Tint color of the glow, including alpha. + blur_radius ``(float, 0 to 100)`` Blur radius in pixels used to soften the glow. Larger values create a wider, softer halo. + spread ``(float, 0 to 1)`` Expands and strengthens the source alpha before blurring for a denser, more filled-in glow. + color ``(color)`` Tint color of the glow, including alpha. Use the alpha channel to control the glow's maximum opacity independently of the ``opacity`` property. ========================== ============ Hue @@ -1195,6 +1202,12 @@ The Shadow effect creates a soft drop shadow from the clip's visible pixels. It blurs that silhouette, and offsets it by a distance and angle before drawing the original image on top. This is useful for giving text, logos, overlays, and cut-out subjects more separation from the background. +You can add Shadow from the :guilabel:`Effects` tab, or right-click a clip and choose +:guilabel:`Look → Lighting → Shadow` to start with a preset: :guilabel:`Subtle` (a light near shadow), +:guilabel:`Soft` (a diffused medium shadow), :guilabel:`Strong` (a bold, high-opacity shadow), or +:guilabel:`Long` (a distant, elongated shadow). Presets only set the properties for you; all controls +remain visible and editable. + .. table:: :widths: 26 80 @@ -1202,11 +1215,11 @@ useful for giving text, logos, overlays, and cut-out subjects more separation fr Property Name Description ========================== ============ opacity ``(float, 0 to 1)`` Overall shadow strength and transparency. - blur_radius ``(float, 0 to 100)`` Blur radius in pixels used to soften the shadow. - spread ``(float, 0 to 1)`` Expands and strengthens the source alpha before blur for a fuller shadow shape. - distance ``(float, -500 to 500)`` Offset distance of the shadow in pixels. - angle ``(float, -360 to 360)`` Direction of the shadow offset in degrees. - color ``(color)`` Shadow tint color, including alpha. + blur_radius ``(float, 0 to 100)`` Blur radius in pixels used to soften the shadow edges. Higher values produce softer, more diffused shadows. + spread ``(float, 0 to 1)`` Expands and strengthens the source alpha before blurring for a fuller, heavier shadow shape. + distance ``(float, -500 to 500)`` Offset distance of the shadow in pixels. Negative values move the shadow in the opposite direction. + angle ``(float, -360 to 360)`` Direction of the shadow offset in degrees. 0° is right, 90° is down, 180° is left, 270° is up. + color ``(color)`` Shadow tint color, including alpha. The default is semi-transparent black. ========================== ============ Shift From 88cc3ebef275ece150f0225b4d4d343f933c2522 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 16:14:52 -0500 Subject: [PATCH 15/23] Updating user-guide, and expanding many sections, adding additional features to the introduction, and including common search phrases, new chromakey instructions, and more. Updating copyright year on all *.rst files. Removing some old export presets that are SD and no longer relevant. Also adding many new export presets for modern, popular social networks, such as Snapchat, Facebook, Instagram, TikTok, and LinkedIn, and renaming some -HD suffixes that are not used anymore. --- doc/animation.rst | 12 ++++++- doc/clips.rst | 8 +++-- doc/contributing.rst | 2 +- doc/developers.rst | 2 +- doc/effects.rst | 10 +++++- doc/export.rst | 56 ++++++++++++++++++++++++++++++- doc/files.rst | 2 +- doc/getting_started.rst | 2 +- doc/glossary.rst | 12 ++++++- doc/import_export.rst | 2 +- doc/index.rst | 4 +-- doc/installation.rst | 2 +- doc/introduction.rst | 21 +++++++----- doc/learn_more.rst | 2 +- doc/main_window.rst | 2 +- doc/playback.rst | 2 +- doc/preferences.rst | 2 +- doc/profiles.rst | 14 ++++---- doc/quick_tutorial.rst | 2 +- doc/titles.rst | 4 +-- doc/transitions.rst | 4 +-- doc/troubleshoot.rst | 2 +- src/presets/apple_tv.xml | 27 +++++++++++---- src/presets/facebook.xml | 30 +++++++++++++++++ src/presets/flickr_HD.xml | 2 +- src/presets/format_mov_prores.xml | 36 ++++++++++++++++++++ src/presets/instagram.xml | 2 ++ src/presets/instagram_reels.xml | 28 ++++++++++++++++ src/presets/linkedin.xml | 30 +++++++++++++++++ src/presets/snapchat.xml | 25 ++++++++++++++ src/presets/tiktok.xml | 29 ++++++++++++++++ src/presets/twitter.xml | 2 +- src/presets/vimeo.xml | 22 ------------ src/presets/vimeo_HD.xml | 2 +- src/presets/youtube.xml | 38 --------------------- src/presets/youtube_2K.xml | 2 +- src/presets/youtube_4K.xml | 2 +- src/presets/youtube_8K.xml | 2 +- src/presets/youtube_HD.xml | 2 +- src/presets/youtube_shorts.xml | 30 +++++++++++++++++ 40 files changed, 366 insertions(+), 114 deletions(-) create mode 100644 src/presets/facebook.xml create mode 100644 src/presets/format_mov_prores.xml create mode 100644 src/presets/instagram_reels.xml create mode 100644 src/presets/linkedin.xml create mode 100644 src/presets/snapchat.xml create mode 100644 src/presets/tiktok.xml delete mode 100644 src/presets/vimeo.xml delete mode 100644 src/presets/youtube.xml create mode 100644 src/presets/youtube_shorts.xml diff --git a/doc/animation.rst b/doc/animation.rst index c55f5d4120..19ed29246b 100644 --- a/doc/animation.rst +++ b/doc/animation.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -104,6 +104,16 @@ To choose a curve preset, right click on the small graph icon next to a key fram .. image:: images/curve-presets.jpg +.. _animation_ken_burns_ref: + +Ken Burns Effect +---------------- +The **Ken Burns effect** is a pan-and-zoom animation technique — named after documentary filmmaker Ken Burns — +that brings still images or video clips to life with slow, deliberate camera movement. In OpenShot, use +:guilabel:`Right-Click → Motion → Camera` presets for one-click Ken Burns animations +(Zoom In, Zoom Out, Pan, Zoom & Pan with Auto Direction), or set keyframes on :guilabel:`Location X/Y` +and :guilabel:`Scale X/Y` manually for full control. See :ref:`clip_presets_ref`. + .. _animation_image_seq_ref: Image Sequences diff --git a/doc/clips.rst b/doc/clips.rst index b68a5989c1..044d9d00e8 100644 --- a/doc/clips.rst +++ b/doc/clips.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -254,8 +254,10 @@ slow-motion, freezes, and looping effects. See :ref:`clip_time_ref` key-frame. - :guilabel:`Freeze` — freezes on the frame at the current playhead position for 2, 4, 6, 8, 10, 20, or 30 seconds. - :guilabel:`Freeze && Zoom` — freezes and simultaneously zooms in on that frozen frame. -- **Usage Example:** Creating a slow-motion effect to emphasize a specific action. -- **Tip:** Use the :guilabel:`Timing` toolbar tool to change speed interactively by dragging a clip's edges. +- **Slow motion** — :guilabel:`Slow Down → 1/2×` or :guilabel:`1/4×` for dreamy or dramatic footage. +- **Time-lapse** — :guilabel:`Speed Up → 8×` or :guilabel:`16×` to compress hours into seconds. +- **Speed ramp** — use the :guilabel:`Timing` tool to drag clip edges and create a natural-feeling speed change; OpenShot scales all keyframes automatically. +- **Tip:** Combine :guilabel:`Freeze` with :guilabel:`Speed Up` for an impact-freeze-then-fast-forward effect. .. _clip_time_repeat_ref: diff --git a/doc/contributing.rst b/doc/contributing.rst index 43e218a13b..d66c07a13d 100644 --- a/doc/contributing.rst +++ b/doc/contributing.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2018 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/developers.rst b/doc/developers.rst index 147b9f3ae1..90f376a998 100644 --- a/doc/developers.rst +++ b/doc/developers.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/effects.rst b/doc/effects.rst index 78f8fbfd1a..2ca430f3e4 100644 --- a/doc/effects.rst +++ b/doc/effects.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -534,6 +534,14 @@ with transparency, allowing for the compositing of the video over a different ba in film and television production for creating visual effects and placing subjects in settings that would be otherwise impossible or impractical to shoot in. +**Green Screen Workflow** + +1. Place your background clip on a lower track and your green-screen footage on the track directly above it. +2. Drag and drop the :guilabel:`Chroma Key (Greenscreen)` effect from the **Effects** panel onto your green-screen clip. +3. Double-click the :guilabel:`color` button in the Properties panel to open the color picker, then select the green or blue background color. +4. Raise the :guilabel:`threshold` slider until the background turns fully transparent. +5. Fine-tune :guilabel:`halo` to remove any residual color fringe around the subject edges. + .. table:: :widths: 26 80 diff --git a/doc/export.rst b/doc/export.rst index 16943ff6da..30692bebe4 100644 --- a/doc/export.rst +++ b/doc/export.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -151,3 +151,57 @@ Audio Settings Channel Layout The number and layout of audio channels (``Stereo``, ``Mono``, ``Surround``, etc...) Bit Rate / Quality The bitrate to use for audio encoding. Accepts the following formats: ``96 kb/s``, ``128 kb/s``, ``192 kb/s``, etc... ================== ============ + +.. _export_social_media_ref: + +Social Media Quick Reference +----------------------------- + +The table below shows the recommended :guilabel:`Target` and :guilabel:`Video Profile` settings for common +social media platforms. Select these in the **Simple** tab of the Export dialog. + +.. table:: + :widths: 20 22 30 28 + + ============================ ======================= ============================== ====================================== + Platform Target Video Profile Notes + ============================ ======================= ============================== ====================================== + YouTube (landscape) ``YouTube`` ``FHD 1080p 30 fps`` Use ``YouTube (4K)`` for 4K + YouTube Shorts (vertical) ``YouTube Shorts`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps + TikTok (vertical) ``TikTok`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps + Instagram Reels (vertical) ``Instagram Reels`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps + Instagram (landscape/square) ``Instagram`` ``FHD 1080p 30 fps`` Square (1:1) also available + Snapchat (vertical) ``Snapchat`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps + Facebook ``Facebook`` ``FHD 1080p 30 fps`` Square and vertical also available + LinkedIn (landscape) ``LinkedIn`` ``FHD 1080p 30 fps`` Square and 4:5 portrait also available + Twitter / X ``Twitter / X`` ``FHD 1080p 30 fps`` Vertical also available + Vimeo ``Vimeo`` ``FHD 1080p 30 fps`` Use High quality setting + ============================ ======================= ============================== ====================================== + +.. _export_hardware_accel_ref: + +Hardware-Accelerated Export +---------------------------- + +OpenShot supports GPU-accelerated video encoding on supported hardware, dramatically reducing export times. +Hardware-accelerated targets are shown with a badge in the :guilabel:`Target` dropdown. Select the +appropriate target for your hardware: + +.. table:: + :widths: 35 20 45 + + ===================================== ============ ============================================= + Target (Export Dialog) Badge Requires + ===================================== ============ ============================================= + ``MP4 (h.264 nv)`` NVENC NVIDIA GPU (Kepler or newer) + ``MP4 (h.264 va)`` VA-API Linux with AMD or Intel GPU (VAAPI driver) + ``MP4 (h.264 qsv)`` QSV Intel GPU with Quick Sync Video + ``MP4 (h.264 videotoolbox)`` VideoToolbox macOS with Apple or Intel GPU + ``MP4 (h.264 dx)`` DirectX Windows with DirectX-compatible GPU + ``MP4 (HEVC va)`` VA-API Linux VA-API — produces smaller HEVC files + ===================================== ============ ============================================= + +MKV variants (``MKV (h.264 nv)``, ``MKV (h.264 va)``, etc.) are also available for each accelerator. +If none of these targets appear or export fails, your system either lacks the required driver or the +hardware encoder is not supported — fall back to the standard ``MP4 (h.264 + AAC)`` target, which uses +the CPU-based ``libx264`` encoder and works on all systems. diff --git a/doc/files.rst b/doc/files.rst index e845622eec..62f3a56683 100644 --- a/doc/files.rst +++ b/doc/files.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 00bb33b728..f505dbd9c3 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2020 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/glossary.rst b/doc/glossary.rst index 9b8ca9a252..175f04d088 100644 --- a/doc/glossary.rst +++ b/doc/glossary.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -123,6 +123,8 @@ Codec: Codec is a video compression technology used to compress data in a video file. Codec stands for "Compression Decompression." An example of a popular codec is H.264. Color Correction: The process of altering the color of a video, especially one shot under less than ideal conditions, such as low light. +Color Grade / Color Grading: + The process of stylizing or enhancing the look and mood of a video beyond technical correction. Color grading adjusts tonal balance, hue, saturation, and contrast to achieve a deliberate visual style (for example, a warm cinematic look or a cold, desaturated tone). See also Color Correction. Compositing: Construction of a composite image by combining multiple images and other elements. Coverage: @@ -263,6 +265,8 @@ Jump Cut: -K- ~~~ +Ken Burns Effect: + A pan-and-zoom animation technique applied to still images or video clips to create the illusion of camera movement. Named after documentary filmmaker Ken Burns, who popularized the approach. In OpenShot, use :guilabel:`Right-Click → Motion → Camera` presets for one-click Ken Burns animations. Key: A method for creating transparency, such as a bluescreen key or a chroma key. Keyframe: @@ -284,6 +288,10 @@ Lossless: A compression scheme that results in no loss of data from decompressing the file. Lossless files are generally quite large (but still smaller than uncompressed versions) and sometimes require considerable processing power to decode the data. Lossy: Lossy compression is a compression scheme that degrades quality. Lossy algorithms compress digital data by eliminating the data least sensitive to the human eye and offer the highest compression rates available. +Lower Third: + A text or graphic overlay positioned in the lower third of the screen, commonly used to display a speaker's name, title, or other identifying information. Lower thirds are a staple of news, documentaries, and corporate video. In OpenShot, create them using the Title Editor and place the title clip on a track above your video. +LUT (Look-Up Table): + A file that maps one set of colors to another, used to apply a specific color grade or look to a video clip in one step. LUTs can replicate film stock emulations, broadcast standards, or custom styles. OpenShot's Color Grade effect supports LUT files directly. .. _letter_M_ref: @@ -377,6 +385,8 @@ RGB: Monitors, cameras, and digital projectors use the primary colors of light (Red, Green, and Blue) to make images. RGBA: A file containing an RGB image plus an alpha channel for transparency information. +Ripple Edit: + An edit where trimming a clip automatically shifts all subsequent clips on the timeline forward or backward by the same amount, closing or opening a gap. This preserves sync between clips that follow the edit point. Also called a ripple trim. Roll: Roll is a text effect commonly seen in end credits, where text typically moves from the bottom to the top of the screen. Rough cut: diff --git a/doc/import_export.rst b/doc/import_export.rst index fba0cf2175..9e04e2a0fd 100644 --- a/doc/import_export.rst +++ b/doc/import_export.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/index.rst b/doc/index.rst index ffd7c3f9ca..fbc1d0134e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -20,7 +20,7 @@ OpenShot User Guide =================== -OpenShot Video Editor is an award-winning, open-source video editor, available on +OpenShot Video Editor is a free, award-winning, open-source video editor, available on Linux, Mac, Chrome OS, and Windows. OpenShot can create stunning videos, films, and animations with an easy-to-use interface and rich set of features. diff --git a/doc/installation.rst b/doc/installation.rst index 523d366853..02dec68778 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2020 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/introduction.rst b/doc/introduction.rst index 4246596ec7..8a31109a9b 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2020 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -21,7 +21,7 @@ Introduction ============ OpenShot Video Editor is an award-winning, open-source video editor, available on -Linux, Mac, and Windows. OpenShot can create stunning videos, films, and animations with an +Linux, macOS, Chrome OS, and Windows. OpenShot can create stunning videos, films, and animations with an easy-to-use interface and rich feature-set. .. image:: images/openshot-banner.jpg @@ -29,26 +29,29 @@ easy-to-use interface and rich feature-set. Features -------- - **Free & open-source** (licensed under GPLv3) -- **Cross-platform** (Linux, OS X, Chrome OS, and Windows) +- **Cross-platform** (Linux, macOS, Chrome OS, and Windows) - **Easy-to-use UI** (beginner-friendly, built-in tutorial) - **Supports most formats** (video, audio, images - FFmpeg-based) -- **70+ video profiles & presets** (including YouTube HD) +- **70+ export targets & profiles** (YouTube, TikTok, Reels, Shorts, and more) - **Advanced timeline** (drag-drop, scroll, zoom, snap) - **Advanced clips** (trim, alpha, scale, rotate, shear, transform) - **Real-time preview** (multi-threaded, performance-optimized) - **Simple & advanced views** (customizable) -- **Keyframe animations** (`linear`, `Bézier`, `constant` interpolation) +- **Keyframe animations & panel** (`linear`, `bézier`, `constant` interpolation, visual keyframe panel) +- **One-click presets** (motion, fade, look, camera, color, and more) - **Compositing, overlays, watermarks, transparency** - **Unlimited tracks / layers** (for complex projects) - **Transitions, masks, wipes** (grayscale images, animated masks) -- **Video & audio effects** (brightness, hue, chroma key, and more) +- **Video effects** (chroma key, blur, brightness & contrast, film grain, stabilizer, and more) +- **Audio effects** (compressor, expander, noise removal, echo, and more) +- **Color grading** (color wheels, RGB curves, LUT support, video scopes & analysis) - **Image sequences & 2D animations** - **Blender 3D integration** (animated 3D title templates) -- **Vector file support & editing** (SVG for titles) +- **Vector file support & editing** (SVG for titles, lower thirds, text overlays) - **Audio mixing, waveform, editing** - **Emojis** (open-source stickers & artwork) - **Frame accuracy** (per-frame navigation) -- **Time re-mapping & speed changes** (slow/fast, forward/backward) +- **Time re-mapping & speed changes** (slow motion, repeat, loop, ping-pong, forward/backward) - **Built-in AI** (motion tracking, object detection, stabilization) - **Advanced AI** (see :ref:`ai_ref`) @@ -86,7 +89,7 @@ Most computers manufactured after 2017 will run OpenShot Minimum Specifications ^^^^^^^^^^^^^^^^^^^^^^ -- 64-bit Operating System (*Linux, OS X, Chrome OS, Windows 7/8/10/11*) +- 64-bit Operating System (*Linux, macOS, Chrome OS, Windows 7/8/10/11*) - Multi-core processor with 64-bit support - Minimum cores: 2 (*recommended: 6+ cores*) - Minimum threads: 4 (*recommended: 6+ threads*) diff --git a/doc/learn_more.rst b/doc/learn_more.rst index 4e692127b3..153cd8c9ee 100644 --- a/doc/learn_more.rst +++ b/doc/learn_more.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2020 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/main_window.rst b/doc/main_window.rst index d246370e54..7cbf409f25 100644 --- a/doc/main_window.rst +++ b/doc/main_window.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/playback.rst b/doc/playback.rst index ca60536e3c..780c3ecd4a 100644 --- a/doc/playback.rst +++ b/doc/playback.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2023 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/preferences.rst b/doc/preferences.rst index 9ee2faaf22..b9b0ac2d9e 100644 --- a/doc/preferences.rst +++ b/doc/preferences.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2020 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/profiles.rst b/doc/profiles.rst index 834d81f1b1..db833b9280 100644 --- a/doc/profiles.rst +++ b/doc/profiles.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2023 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -1087,7 +1087,7 @@ Xbox 360 Web ^^^ -Flickr-HD +Flickr ~~~~~~~~~ .. table:: @@ -1249,7 +1249,7 @@ Vimeo | NTSC SD Wide FWVGA 480p 29.97 fps ======================= ============ -Vimeo-HD +Vimeo ~~~~~~~~ .. table:: @@ -1306,7 +1306,7 @@ Wikipedia Profiles | NTSC SD 1/4 QVGA 240p 29.97 fps ======================= ============ -YouTube HD +YouTube ~~~~~~~~~~ .. table:: @@ -1345,7 +1345,7 @@ YouTube HD | FHD Vertical 1080p 60 fps ======================= ============ -YouTube HD (2K) +YouTube (2K) ~~~~~~~~~~~~~~~ .. table:: @@ -1376,7 +1376,7 @@ YouTube HD (2K) | 2.5K WQHD 1440p 60 fps ======================= ============ -YouTube HD (4K) +YouTube (4K) ~~~~~~~~~~~~~~~ .. table:: @@ -1407,7 +1407,7 @@ YouTube HD (4K) | 4K UHD 2160p 60 fps ======================= ============ -YouTube HD (8K) +YouTube (8K) ~~~~~~~~~~~~~~~ .. table:: diff --git a/doc/quick_tutorial.rst b/doc/quick_tutorial.rst index 22470d1409..5ca48831bc 100644 --- a/doc/quick_tutorial.rst +++ b/doc/quick_tutorial.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/doc/titles.rst b/doc/titles.rst index afdeb4b5c5..d41adf5348 100644 --- a/doc/titles.rst +++ b/doc/titles.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -22,7 +22,7 @@ Text & Titles ============= -Adding text and titles is an important aspect of video editing, and OpenShot comes with an easy-to-use Title Editor. Use +Adding text overlays, lower thirds, and titles is an important aspect of video editing, and OpenShot comes with an easy-to-use Title Editor. Use the Title menu (located in the main menu of OpenShot) to launch the Title Editor. You can also use the keyboard shortcut :kbd:`Ctrl+T`. diff --git a/doc/transitions.rst b/doc/transitions.rst index 09152301b0..ac085f731e 100644 --- a/doc/transitions.rst +++ b/doc/transitions.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2016 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions @@ -22,7 +22,7 @@ Transitions =========== -A transition is used to gradually fade (or wipe) between two clip images. In OpenShot, +A transition is used to gradually fade (also called a **cross dissolve** or **crossfade**) or wipe between two clip images. In OpenShot, transitions are represented by blue, rounded rectangles on the timeline. They are automatically created when you overlap two clips, and can be added manually by dragging one onto the timeline from the **Transitions** panel. A transition must be placed on top of a clip (overlapping it), with the most common location being the beginning or end diff --git a/doc/troubleshoot.rst b/doc/troubleshoot.rst index 40fdafc27b..512c747d79 100644 --- a/doc/troubleshoot.rst +++ b/doc/troubleshoot.rst @@ -1,4 +1,4 @@ -.. Copyright (c) 2008-2024 OpenShot Studios, LLC +.. Copyright (c) 2008-2026 OpenShot Studios, LLC (http://www.openshotstudios.com). This file is part of OpenShot Video Editor (http://www.openshot.org), an open-source project dedicated to delivering high quality video editing and animation solutions diff --git a/src/presets/apple_tv.xml b/src/presets/apple_tv.xml index ec7176d80b..6e230bf5e7 100644 --- a/src/presets/apple_tv.xml +++ b/src/presets/apple_tv.xml @@ -9,13 +9,28 @@ 2 3 + low="8 Mb/s" + med="20 Mb/s" + high="40 Mb/s"> 48000 - HD 720p 30 fps + FHD 1080p 23.98 fps + FHD 1080p 24 fps + FHD PAL 1080p 25 fps + FHD 1080p 29.97 fps + FHD 1080p 30 fps + FHD PAL 1080p 50 fps + FHD 1080p 59.94 fps + FHD 1080p 60 fps + 4K UHD 2160p 23.98 fps + 4K UHD 2160p 24 fps + 4K UHD 2160p 25 fps + 4K UHD 2160p 29.97 fps + 4K UHD 2160p 30 fps + 4K UHD 2160p 50 fps + 4K UHD 2160p 59.94 fps + 4K UHD 2160p 60 fps diff --git a/src/presets/facebook.xml b/src/presets/facebook.xml new file mode 100644 index 0000000000..20f0263097 --- /dev/null +++ b/src/presets/facebook.xml @@ -0,0 +1,30 @@ + + + + Web + Facebook + mp4 + libx264 + aac + 2 + 3 + + + 48000 + HD 720p 25 fps + HD 720p 30 fps + FHD PAL 1080p 25 fps + FHD 1080p 30 fps + HD Square 1080p 25 fps + HD Square 1080p 30 fps + FHD Vertical 1080p 25 fps + FHD Vertical 1080p 29.97 fps + FHD Vertical 1080p 30 fps + FHD Vertical 1080p 60 fps + diff --git a/src/presets/flickr_HD.xml b/src/presets/flickr_HD.xml index 360e7afd0d..205706b3ae 100644 --- a/src/presets/flickr_HD.xml +++ b/src/presets/flickr_HD.xml @@ -2,7 +2,7 @@ Web - Flickr-HD + Flickr mov libx264 aac diff --git a/src/presets/format_mov_prores.xml b/src/presets/format_mov_prores.xml new file mode 100644 index 0000000000..79864cff99 --- /dev/null +++ b/src/presets/format_mov_prores.xml @@ -0,0 +1,36 @@ + + + + All Formats + MOV (ProRes 422) + mov + prores_ks + pcm_s16le + 2 + 3 + + + 48000 + FHD 1080p 23.98 fps + FHD 1080p 24 fps + FHD PAL 1080p 25 fps + FHD 1080p 29.97 fps + FHD 1080p 30 fps + FHD PAL 1080p 50 fps + FHD 1080p 59.94 fps + FHD 1080p 60 fps + 4K UHD 2160p 23.98 fps + 4K UHD 2160p 24 fps + 4K UHD 2160p 25 fps + 4K UHD 2160p 29.97 fps + 4K UHD 2160p 30 fps + 4K UHD 2160p 50 fps + 4K UHD 2160p 59.94 fps + 4K UHD 2160p 60 fps + diff --git a/src/presets/instagram.xml b/src/presets/instagram.xml index e38adc88b4..27a4e3794f 100644 --- a/src/presets/instagram.xml +++ b/src/presets/instagram.xml @@ -21,6 +21,8 @@ HD 720p 30 fps FHD PAL 1080p 25 fps FHD 1080p 30 fps + HD Square 1080p 25 fps + HD Square 1080p 30 fps HD Vertical 720p 25 fps HD Vertical 720p 30 fps FHD Vertical 1080p 25 fps diff --git a/src/presets/instagram_reels.xml b/src/presets/instagram_reels.xml new file mode 100644 index 0000000000..eff1e16b9c --- /dev/null +++ b/src/presets/instagram_reels.xml @@ -0,0 +1,28 @@ + + + + Web + Instagram Reels + mp4 + libx264 + aac + 2 + 3 + + + 48000 + FHD Vertical 1080p 25 fps + FHD Vertical 1080p 29.97 fps + FHD Vertical 1080p 30 fps + FHD Vertical 1080p 50 fps + FHD Vertical 1080p 59.94 fps + FHD Vertical 1080p 60 fps + HD Vertical 720p 25 fps + HD Vertical 720p 30 fps + diff --git a/src/presets/linkedin.xml b/src/presets/linkedin.xml new file mode 100644 index 0000000000..0a6935c227 --- /dev/null +++ b/src/presets/linkedin.xml @@ -0,0 +1,30 @@ + + + + Web + LinkedIn + mp4 + libx264 + aac + 2 + 3 + + + 48000 + FHD 1080p 23.98 fps + FHD 1080p 24 fps + FHD PAL 1080p 25 fps + FHD 1080p 29.97 fps + FHD 1080p 30 fps + HD Square 1080p 25 fps + HD Square 1080p 30 fps + HD Vertical 1080p 24 fps + HD Vertical 1080p 25 fps + HD Vertical 1080p 30 fps + diff --git a/src/presets/snapchat.xml b/src/presets/snapchat.xml new file mode 100644 index 0000000000..544e2d430e --- /dev/null +++ b/src/presets/snapchat.xml @@ -0,0 +1,25 @@ + + + + Web + Snapchat + mp4 + libx264 + aac + 2 + 3 + + + 48000 + FHD Vertical 1080p 25 fps + FHD Vertical 1080p 29.97 fps + FHD Vertical 1080p 30 fps + FHD Vertical 1080p 59.94 fps + FHD Vertical 1080p 60 fps + diff --git a/src/presets/tiktok.xml b/src/presets/tiktok.xml new file mode 100644 index 0000000000..9d7b112119 --- /dev/null +++ b/src/presets/tiktok.xml @@ -0,0 +1,29 @@ + + + + Web + TikTok + mp4 + libx264 + aac + 2 + 3 + + + 44100 + FHD Vertical 1080p 23.98 fps + FHD Vertical 1080p 25 fps + FHD Vertical 1080p 29.97 fps + FHD Vertical 1080p 30 fps + FHD Vertical 1080p 50 fps + FHD Vertical 1080p 59.94 fps + FHD Vertical 1080p 60 fps + HD Vertical 720p 25 fps + HD Vertical 720p 30 fps + diff --git a/src/presets/twitter.xml b/src/presets/twitter.xml index 9a4c62c921..c09c7f0d25 100644 --- a/src/presets/twitter.xml +++ b/src/presets/twitter.xml @@ -2,7 +2,7 @@ Web - Twitter + Twitter / X mp4 libx264 aac diff --git a/src/presets/vimeo.xml b/src/presets/vimeo.xml deleted file mode 100644 index 286617014a..0000000000 --- a/src/presets/vimeo.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Web - Vimeo - mp4 - libx264 - aac - 2 - 3 - - - 48000 - NTSC SD SQ VGA 480p 29.97 fps - NTSC SD Wide FWVGA 480p 29.97 fps - diff --git a/src/presets/vimeo_HD.xml b/src/presets/vimeo_HD.xml index fdaa387f9f..d508040a6e 100644 --- a/src/presets/vimeo_HD.xml +++ b/src/presets/vimeo_HD.xml @@ -2,7 +2,7 @@ Web - Vimeo-HD + Vimeo mp4 libx264 aac diff --git a/src/presets/youtube.xml b/src/presets/youtube.xml deleted file mode 100644 index 9e6ff9a33d..0000000000 --- a/src/presets/youtube.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - Web - YouTube Standard - mp4 - libx264 - aac - 2 - 3 - - - 48000 - NTSC SD SQ VGA 480p 29.97 fps - NTSC SD Wide FWVGA 480p 29.97 fps - HD 720p 23.98 fps - HD 720p 24 fps - HD 720p 25 fps - HD 720p 29.97 fps - HD 720p 30 fps - PAL HD 720p 50 fps - HD 720p 59.94 fps - HD 720p 60 fps - HD Vertical 720p 23.98 fps - HD Vertical 720p 24 fps - HD Vertical 720p 25 fps - HD Vertical 720p 29.97 fps - HD Vertical 720p 30 fps - HD Vertical 720p 50 fps - HD Vertical 720p 59.94 fps - HD Vertical 720p 60 fps - diff --git a/src/presets/youtube_2K.xml b/src/presets/youtube_2K.xml index f4356856b8..8c4e5fb8aa 100644 --- a/src/presets/youtube_2K.xml +++ b/src/presets/youtube_2K.xml @@ -2,7 +2,7 @@ Web - YouTube HD (2K) + YouTube (2K) mp4 libx264 aac diff --git a/src/presets/youtube_4K.xml b/src/presets/youtube_4K.xml index 090e529d78..b8f055c635 100644 --- a/src/presets/youtube_4K.xml +++ b/src/presets/youtube_4K.xml @@ -2,7 +2,7 @@ Web - YouTube HD (4K) + YouTube (4K) mp4 libx264 aac diff --git a/src/presets/youtube_8K.xml b/src/presets/youtube_8K.xml index c2db2e59d2..d65479a20a 100644 --- a/src/presets/youtube_8K.xml +++ b/src/presets/youtube_8K.xml @@ -2,7 +2,7 @@ Web - YouTube HD (8K) + YouTube (8K) mp4 libx264 aac diff --git a/src/presets/youtube_HD.xml b/src/presets/youtube_HD.xml index c66bf3c7a1..7edaa7e382 100644 --- a/src/presets/youtube_HD.xml +++ b/src/presets/youtube_HD.xml @@ -2,7 +2,7 @@ Web - YouTube HD + YouTube mp4 libx264 aac diff --git a/src/presets/youtube_shorts.xml b/src/presets/youtube_shorts.xml new file mode 100644 index 0000000000..fdb25e8051 --- /dev/null +++ b/src/presets/youtube_shorts.xml @@ -0,0 +1,30 @@ + + + + Web + YouTube Shorts + mp4 + libx264 + aac + 2 + 3 + + + 48000 + FHD Vertical 1080p 23.98 fps + FHD Vertical 1080p 24 fps + FHD Vertical 1080p 25 fps + FHD Vertical 1080p 29.97 fps + FHD Vertical 1080p 30 fps + FHD Vertical 1080p 50 fps + FHD Vertical 1080p 59.94 fps + FHD Vertical 1080p 60 fps + HD Vertical 720p 25 fps + HD Vertical 720p 30 fps + From 2e93dfc92d9015b92c93c0309e230691263444f6 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 16:27:54 -0500 Subject: [PATCH 16/23] Replacing old docs image of our clip context menu (options are reorganized and renamed), and fixing a syntax error in the *.rst export file --- doc/export.rst | 63 +++++++++++++++++++++++++++--------- doc/images/clip-presets.jpg | Bin 50829 -> 51630 bytes 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/doc/export.rst b/doc/export.rst index 30692bebe4..f6eb3c0615 100644 --- a/doc/export.rst +++ b/doc/export.rst @@ -160,23 +160,54 @@ Social Media Quick Reference The table below shows the recommended :guilabel:`Target` and :guilabel:`Video Profile` settings for common social media platforms. Select these in the **Simple** tab of the Export dialog. -.. table:: +.. list-table:: :widths: 20 22 30 28 - - ============================ ======================= ============================== ====================================== - Platform Target Video Profile Notes - ============================ ======================= ============================== ====================================== - YouTube (landscape) ``YouTube`` ``FHD 1080p 30 fps`` Use ``YouTube (4K)`` for 4K - YouTube Shorts (vertical) ``YouTube Shorts`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps - TikTok (vertical) ``TikTok`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps - Instagram Reels (vertical) ``Instagram Reels`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps - Instagram (landscape/square) ``Instagram`` ``FHD 1080p 30 fps`` Square (1:1) also available - Snapchat (vertical) ``Snapchat`` ``FHD Vertical 1080p 30 fps`` Up to 60 fps - Facebook ``Facebook`` ``FHD 1080p 30 fps`` Square and vertical also available - LinkedIn (landscape) ``LinkedIn`` ``FHD 1080p 30 fps`` Square and 4:5 portrait also available - Twitter / X ``Twitter / X`` ``FHD 1080p 30 fps`` Vertical also available - Vimeo ``Vimeo`` ``FHD 1080p 30 fps`` Use High quality setting - ============================ ======================= ============================== ====================================== + :header-rows: 1 + + * - Platform + - Target + - Video Profile + - Notes + * - YouTube (landscape) + - ``YouTube`` + - ``FHD 1080p 30 fps`` + - Use ``YouTube (4K)`` for 4K + * - YouTube Shorts (vertical) + - ``YouTube Shorts`` + - ``FHD Vertical 1080p 30 fps`` + - Up to 60 fps + * - TikTok (vertical) + - ``TikTok`` + - ``FHD Vertical 1080p 30 fps`` + - Up to 60 fps + * - Instagram Reels (vertical) + - ``Instagram Reels`` + - ``FHD Vertical 1080p 30 fps`` + - Up to 60 fps + * - Instagram (landscape/square) + - ``Instagram`` + - ``FHD 1080p 30 fps`` + - Square (1:1) also available + * - Snapchat (vertical) + - ``Snapchat`` + - ``FHD Vertical 1080p 30 fps`` + - Up to 60 fps + * - Facebook + - ``Facebook`` + - ``FHD 1080p 30 fps`` + - Square and vertical also available + * - LinkedIn (landscape) + - ``LinkedIn`` + - ``FHD 1080p 30 fps`` + - Square and 4:5 portrait also available + * - Twitter / X + - ``Twitter / X`` + - ``FHD 1080p 30 fps`` + - Vertical also available + * - Vimeo + - ``Vimeo`` + - ``FHD 1080p 30 fps`` + - Use High quality setting .. _export_hardware_accel_ref: diff --git a/doc/images/clip-presets.jpg b/doc/images/clip-presets.jpg index e437f3d27371086b77a9ef55e760b6b99283a397..b1e31048381183f8d777ef5a2fde5d6ab259f983 100644 GIT binary patch literal 51630 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%0Aw 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 b)r7xFR86G#f?{=j!d~FKI+;}RZQ_3bk>>?1 literal 50829 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 C`=^os From eb84a357fa47cdc8fa5db84659a996d07afdf5ff Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 17:36:56 -0500 Subject: [PATCH 17/23] Reorganize View menu and dock controls - Promote Simple, Color, and Advanced View actions to the top-level View menu - Move Toolbar and Fullscreen into a new Window submenu - Keep Docks as a top-level View submenu, excluding Tutorial and scope docks - Add a top-level Scopes submenu with scope dock toggles and Show All Scopes - Rename Freeze/Un-Freeze View to Lock/Unlock Docks - Move Lock/Unlock Docks and Show All Docks into the Docks menu - Make Show All Docks skip the Tutorial dock - Add middle-click-to-close support for dock title bars and tabified dock tabs - Fix Cosmic Dusk checked menu item alignment --- doc/color.rst | 6 +-- doc/effects.rst | 2 +- doc/main_window.rst | 21 ++++---- src/classes/title_bar.py | 23 ++++++++- src/settings/_default.settings | 4 +- src/themes/cosmic/theme.py | 4 +- src/windows/main_window.py | 89 ++++++++++++++++++++++++++++++++-- src/windows/ui/main-window.ui | 27 +++++------ 8 files changed, 136 insertions(+), 40 deletions(-) diff --git a/doc/color.rst b/doc/color.rst index dd4aa64844..c8b91840e3 100644 --- a/doc/color.rst +++ b/doc/color.rst @@ -64,7 +64,7 @@ Your monitor is not a reliable measuring tool — room lighting, screen brightne displays all affect what you see. **Video scopes** display the actual pixel values in your image as precise graphs. They never lie, even if your monitor does. -OpenShot includes three scopes, all accessible from :guilabel:`View → Docks` or opened automatically +OpenShot includes three scopes, all accessible from :guilabel:`View → Scopes` or opened automatically by the clip menu options described in :ref:`getting_started_ref`: - **Luma Waveform** — shows brightness across the frame, column by column. Instantly reveals @@ -107,14 +107,14 @@ OpenShot offers several ways to open its color tools, depending on what you need **Right-click a clip → Look → Color → [preset]** *(Auto Contrast, Lift Shadows, Warm Up, Boost Color…)* Adds the Color Grade effect with a useful preset already applied. The Color Wheels and scopes - are not opened automatically — open them any time from :guilabel:`View → Docks`. + are not opened automatically — open them any time from :guilabel:`View → Scopes`. **Right-click a clip → Look → Analyze Colors** Opens all three scopes (Luma Waveform, Histogram, and Vectorscope, tabbed together on the right) without adding any Color Grade effect. Use this to evaluate footage before deciding whether it needs grading, or simply to monitor levels during playback. -**View → Views → Color View** *(optional immersive mode)* +**View → Color View** *(optional immersive mode)* Switches the entire interface into a dedicated color grading layout: the video preview is maximized in the center, non-color docks are hidden, the Color Wheels dock appears on the right, and the scopes appear below. Switch back to your normal layout from the same menu when done. diff --git a/doc/effects.rst b/doc/effects.rst index 2ca430f3e4..f742ea8a72 100644 --- a/doc/effects.rst +++ b/doc/effects.rst @@ -561,7 +561,7 @@ Color Grade The Color Grade effect combines primary correction, tonal wheels, RGB curves, and LUT support into one fully animated effect. Use it for **color correction** (white balance, exposure, contrast) and **color grading** (building a stylized look). Right-click a clip and use :guilabel:`Look → Color` presets to -apply it instantly, or switch to :guilabel:`View → Views → Color View` for a dedicated grading workspace. +apply it instantly, or switch to :guilabel:`View → Color View` for a dedicated grading workspace. .. seealso:: diff --git a/doc/main_window.rst b/doc/main_window.rst index 7cbf409f25..8b61422ac8 100644 --- a/doc/main_window.rst +++ b/doc/main_window.rst @@ -165,7 +165,7 @@ Learning a few of these shortcuts can save you a bunch of time! Export Video / Media :kbd:`Ctrl+E` :kbd:`Ctrl+M` Fast Forward :kbd:`L` File Properties :kbd:`Alt+I` :kbd:`Ctrl+Double Click` - Freeze View :kbd:`Ctrl+F` + Lock Docks :kbd:`Ctrl+F` Fullscreen :kbd:`F11` Import Files... :kbd:`Ctrl+I` Insert Keyframe :kbd:`Alt+Shift+K` @@ -217,7 +217,7 @@ Learning a few of these shortcuts can save you a bunch of time! Timing Toggle :kbd:`T` Title :kbd:`Ctrl+T` Translate this Application... :kbd:`F6` - Un-Freeze View :kbd:`Ctrl+Shift+F` + Unlock Docks :kbd:`Ctrl+Shift+F` Undo :kbd:`Ctrl+Z` View Toolbar :kbd:`Ctrl+Shift+B` Zoom In :kbd:`=` :kbd:`Ctrl+=` @@ -262,10 +262,10 @@ are renamed and/or rearranged. - :guilabel:`Animated Title` Add an animated title to the project. See :ref:`animated_titles_ref`. * - View - - - :guilabel:`Toolbar` Show or hide the main window toolbar. - - :guilabel:`Fullscreen` Toggle fullscreen mode. - - :guilabel:`Views` Switch or reset the main window layout (*Simple, Color, Advanced, Freeze, Show All*). - - :guilabel:`Docks` Show or hide various dockable panels (*Audio Levels, Captions, Color Wheels, Effects, Emojis, Histogram, Luma Waveform, Project Files, Properties, Transitions, Video Preview*). + - :guilabel:`Simple View`, :guilabel:`Color View`, and :guilabel:`Advanced View` switch or reset the main window layout. + - :guilabel:`Docks` Show or hide various dockable panels. + - :guilabel:`Scopes` Show or hide scope docks, or open all scopes at once. + - :guilabel:`Window` Show or hide the main window toolbar, or toggle fullscreen mode. * - Help - - :guilabel:`Contents` Open the user guide online. @@ -288,7 +288,7 @@ Simple View This is the **default** view, and is designed to be easy-to-use, especially for first-time users. It contains :guilabel:`Project Files` on the top left, :guilabel:`Preview Window` on the top right, and :guilabel:`Timeline` on the bottom. If you accidentally close or move a dock, you can quickly reset all the docks back to their default -location using the :guilabel:`View->Views->Simple View` menu at the top of the screen. +location using the :guilabel:`View->Simple View` menu at the top of the screen. Advanced View ^^^^^^^^^^^^^ @@ -306,7 +306,8 @@ Docks ^^^^^ Each widget on the OpenShot main window is contained in a **dock**. These docks can be dragged and snapped around the main window, and even grouped together (into tabs). OpenShot will always save your main window dock layout when you -exit the program. Re-launching OpenShot will restore your custom dock layout automatically. +exit the program. Re-launching OpenShot will restore your custom dock layout automatically. Scope docks are grouped +under :guilabel:`View->Scopes`. .. list-table:: :widths: 20 80 @@ -338,5 +339,5 @@ exit the program. Re-launching OpenShot will restore your custom dock layout aut - Preview the current state of your video project. Allows you to play back and review your edits in real-time. See :ref:`playback_ref`. If you have accidentally closed or moved a dock and can no longer find it, there are a couple easy solutions. -First, you can use the :guilabel:`View->Views->Simple View` menu option at the top of the screen, to restore the view back to its -default. Or you can use the :guilabel:`View->Views->Docks->...` menu to show or hide specific dock widgets on the main window. +First, you can use the :guilabel:`View->Simple View` menu option at the top of the screen, to restore the view back to its +default. Or you can use the :guilabel:`View->Docks->...` menu to show or hide specific dock widgets on the main window. diff --git a/src/classes/title_bar.py b/src/classes/title_bar.py index 03c87d723f..b3801a121e 100644 --- a/src/classes/title_bar.py +++ b/src/classes/title_bar.py @@ -25,7 +25,7 @@ along with OpenShot Library. If not, see . """ -from qt_api import Qt, QWidget, QHBoxLayout, QPushButton, QLabel +from qt_api import Qt, QEvent, QWidget, QDockWidget, QHBoxLayout, QPushButton, QLabel from classes.app import get_app @@ -46,6 +46,7 @@ def __init__(self, dock_widget, title_text="", show_buttons=True): # Add a QLabel for the title (optional, based on title_text) self.title_label = QLabel(title_text) self.title_label.setFocusPolicy(Qt.NoFocus) + self.title_label.installEventFilter(self) if title_text: self.title_label.setObjectName("dock-title-label") else: @@ -111,6 +112,26 @@ def _update_accessible_labels(self): float_label = _("Float") self.undock_button.setAccessibleName(float_label) + def _close_on_middle_click(self, event): + if event.button() != Qt.MiddleButton: + return False + if not (self.dock_widget.features() & QDockWidget.DockWidgetClosable): + return False + self.dock_widget.close() + event.accept() + return True + + def eventFilter(self, obj, event): + if obj is self.title_label and event.type() == QEvent.MouseButtonRelease: + if self._close_on_middle_click(event): + return True + return super().eventFilter(obj, event) + + def mouseReleaseEvent(self, event): + if self._close_on_middle_click(event): + return + super().mouseReleaseEvent(event) + def toggle_dock_state(self): """Toggle between docked and floating states.""" if self.dock_widget.isFloating(): diff --git a/src/settings/_default.settings b/src/settings/_default.settings index 890adb5f9a..0ef9460af0 100644 --- a/src/settings/_default.settings +++ b/src/settings/_default.settings @@ -1291,7 +1291,7 @@ }, { "category": "Keyboard", - "title": "Freeze View", + "title": "Lock Docks", "restart": false, "setting": "actionFreeze_View", "value": "Ctrl+F", @@ -1299,7 +1299,7 @@ }, { "category": "Keyboard", - "title": "Un-Freeze View", + "title": "Unlock Docks", "restart": false, "setting": "actionUn_Freeze_View", "value": "Ctrl+Shift+F", diff --git a/src/themes/cosmic/theme.py b/src/themes/cosmic/theme.py index 7329b8109d..f5aa64e7a8 100644 --- a/src/themes/cosmic/theme.py +++ b/src/themes/cosmic/theme.py @@ -128,8 +128,8 @@ def __init__(self, app): padding: 6px 14px 6px 10px; } -QMenu::item::checked { - padding: 6px 12px 6px 20px; +QMenu::item:checked { + padding: 6px 14px 6px 10px; } QMenu::item:selected { diff --git a/src/windows/main_window.py b/src/windows/main_window.py index 17094cf9a2..5fe207bf03 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -1516,6 +1516,21 @@ def show_scope_audio_dock(self): """Show Audio Levels dock, anchoring to right if needed.""" self._anchor_and_show_scope_dock(self.dockAudio) + def _scope_docks(self): + """Return docks that display video/audio scope data.""" + return [ + self.dockAudio, + self.dockHistogram, + self.dockLumaWaveform, + self.dockVectorscope, + ] + + def show_all_scope_docks(self): + """Show all scope docks, anchoring them to the right if needed.""" + for dock in self._scope_docks(): + self._anchor_and_show_scope_dock(dock) + self.dockLumaWaveform.raise_() + def actionSaveFrame_trigger(self, checked=True): log.info("actionSaveFrame_trigger") @@ -2888,23 +2903,48 @@ def freezeMainToolBar(self, frozen=None): self.toolBar.setMovable(not frozen) def addViewDocksMenu(self): - """ Insert a Docks submenu into the View menu, rebuilt dynamically on each open """ + """Insert dynamic Docks and Scopes submenus into the View menu.""" _ = get_app()._tr - self.docks_menu = self.menuView.addMenu(_("Docks")) + self.docks_menu = QMenu(_("Docks"), self.menuView) + self.menuView.insertMenu(self.menuWindow.menuAction(), self.docks_menu) self.docks_menu.aboutToShow.connect(self._rebuild_docks_menu) + self.scopes_menu = QMenu(_("Scopes"), self.menuView) + self.menuView.insertMenu(self.menuWindow.menuAction(), self.scopes_menu) + self.scopes_menu.aboutToShow.connect(self._rebuild_scopes_menu) def _rebuild_docks_menu(self): """Repopulate the Docks menu so late-created docks (e.g. Color Wheels) are included.""" self.docks_menu.clear() + scope_dock_names = {dock.objectName() for dock in self._scope_docks()} for dock in sorted(self.getDocks(), key=lambda d: d.windowTitle()): - if dock.features() & QDockWidget.DockWidgetClosable: + if (dock.features() & QDockWidget.DockWidgetClosable + and dock.objectName() != "dockTutorial" + and dock.objectName() not in scope_dock_names): self.docks_menu.addAction(dock.toggleViewAction()) + self.docks_menu.addSeparator() + self.docks_menu.addAction(self.actionFreeze_View) + self.docks_menu.addAction(self.actionUn_Freeze_View) + self.docks_menu.addAction(self.actionShow_All) + + def _rebuild_scopes_menu(self): + """Repopulate the Scopes menu with scope docks and scope recovery actions.""" + self.scopes_menu.clear() + _ = get_app()._tr + for dock in sorted(self._scope_docks(), key=lambda d: d.windowTitle()): + if dock.features() & QDockWidget.DockWidgetClosable: + self.scopes_menu.addAction(dock.toggleViewAction()) + self.scopes_menu.addSeparator() + show_all_scopes = QAction(self.actionShow_All.icon(), _("Show All Scopes"), self.scopes_menu) + show_all_scopes.triggered.connect(self.show_all_scope_docks) + self.scopes_menu.addAction(show_all_scopes) + def createPopupMenu(self): """Override Qt's right-click context menu to include all closable docks.""" menu = QMenu(self) for dock in sorted(self.getDocks(), key=lambda d: d.windowTitle()): - if dock.features() & QDockWidget.DockWidgetClosable: + if (dock.features() & QDockWidget.DockWidgetClosable + and dock.objectName() != "dockTutorial"): menu.addAction(dock.toggleViewAction()) menu.addSeparator() menu.addAction(self.actionView_Toolbar) @@ -3060,7 +3100,8 @@ def actionUn_Freeze_View_trigger(self): def actionShow_All_trigger(self): """ Show all dockable widgets """ - self.showDocks(self.getDocks()) + self.showDocks([dock for dock in self.getDocks() + if dock.objectName() != "dockTutorial"]) def actionTutorial_trigger(self): """ Show tutorial again """ @@ -4356,6 +4397,12 @@ def nudgeRightBig(self): def eventFilter(self, obj, event): """Filter out specific QActions/QShortcuts when certain docks have focus.""" + if (isinstance(obj, QTabBar) + and event.type() == QEvent.MouseButtonRelease + and event.button() == Qt.MiddleButton): + if self._close_dock_tab_from_middle_click(obj, event): + return True + # List of QAction names to ignore when non-timeline dock widgets have focus ignored_actions = [ "seekPreviousFrame", @@ -4424,6 +4471,35 @@ def eventFilter(self, obj, event): # Allow all other events to propagate normally return super(MainWindow, self).eventFilter(obj, event) + def _close_dock_tab_from_middle_click(self, tab_bar, event): + """Close a dock when its tabified dock tab is middle-clicked.""" + tab_index = tab_bar.tabAt(event.pos()) + if tab_index < 0: + return False + + tabified_docks = [ + dock + for dock in self.getDocks() + if dock.isVisible() and self.tabifiedDockWidgets(dock) + ] + if not tabified_docks: + return False + + tab_title = tab_bar.tabText(tab_index) + tab_titles = {tab_bar.tabText(index) for index in range(tab_bar.count())} + dock_titles = {dock.windowTitle() for dock in tabified_docks} + if len(tab_titles & dock_titles) < 2: + return False + + for dock in tabified_docks: + if (dock.windowTitle() == tab_title + and dock.objectName() != "dockTutorial" + and dock.features() & QDockWidget.DockWidgetClosable): + dock.close() + event.accept() + return True + return False + def _blocks_timeline_shortcuts(self, widget): """Return True when focus should block timeline shortcuts like seek/play.""" if widget is None: @@ -4622,6 +4698,9 @@ def set_tab_drawbase(self): # Loop through all QTabBar objects tab_bars = self.findChildren(QTabBar) for tab_bar in tab_bars: + if not tab_bar.property("_openshot_middle_click_filter"): + tab_bar.installEventFilter(self) + tab_bar.setProperty("_openshot_middle_click_filter", True) if draw_base is None: tab_bar.setProperty("drawBase", True) else: diff --git a/src/windows/ui/main-window.ui b/src/windows/ui/main-window.ui index 7ec102028d..c1df060a94 100644 --- a/src/windows/ui/main-window.ui +++ b/src/windows/ui/main-window.ui @@ -167,23 +167,18 @@ View - + - Views + Window - - - - - - - - + + - - + + + - + @@ -1259,7 +1254,7 @@ :/icons/Humanity/actions/16/locked.svg:/icons/Humanity/actions/16/locked.svg - Freeze View + Lock Docks @@ -1268,7 +1263,7 @@ :/icons/Humanity/actions/16/locked.svg:/icons/Humanity/actions/16/locked.svg - Un-Freeze View + Unlock Docks false @@ -1280,7 +1275,7 @@ :/icons/Humanity/actions/16/zoom-in.png:/icons/Humanity/actions/16/zoom-in.png - Show All + Show All Docks From e679559167935180249d5587df13d69b021922ae Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 21:21:42 -0500 Subject: [PATCH 18/23] Show the menu option for "Hide/Show waveform" based on if a clip has audio or not. Also, hide the "Audio" sub-menu in the clip menu for image-only files (i.e. no audio data) --- src/tests/test_timeline_helpers.py | 12 ++++++++++++ src/windows/views/timeline.py | 13 +++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/tests/test_timeline_helpers.py b/src/tests/test_timeline_helpers.py index 88af03f5d3..3e9fed1ac5 100644 --- a/src/tests/test_timeline_helpers.py +++ b/src/tests/test_timeline_helpers.py @@ -4421,6 +4421,18 @@ def test_keyframe_bezier_presets_returns_28_entries(self): self.assertEqual(len(preset), 5) self.assertIsInstance(preset[4], str) + def test_video_clip_with_audio_can_toggle_waveform(self): + helper = types.SimpleNamespace() + clip = types.SimpleNamespace(data={"reader": {"has_video": True, "has_audio": True}}) + + self.assertTrue(self.timeline_module.TimelineView._clip_has_audio(helper, clip)) + + def test_video_clip_without_audio_cannot_toggle_waveform(self): + helper = types.SimpleNamespace() + clip = types.SimpleNamespace(data={"reader": {"has_video": True, "has_audio": False}}) + + self.assertFalse(self.timeline_module.TimelineView._clip_has_audio(helper, clip)) + def test_film_grain_trigger_adds_preset_effect(self): timeline_module = self.timeline_module clip = types.SimpleNamespace(id="C1", data={ diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index 26885fb5a7..e823d4f769 100644 --- a/src/windows/views/timeline.py +++ b/src/windows/views/timeline.py @@ -2029,7 +2029,7 @@ def _motion_sub(title, items): if clip_has_audio: Audio_Menu.addSeparator() - if not self._clip_has_video(clip): + if self._clip_has_audio(clip): if self._clip_has_visible_waveform(clip): ToggleWaveform = Audio_Menu.addAction( QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-waveform-flat.svg")), @@ -2040,12 +2040,13 @@ def _motion_sub(title, items): QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-waveform.svg")), _("Show Waveform")) ToggleWaveform.triggered.connect(partial(self.Show_Waveform_Triggered, clip_ids)) - Analyze_Levels = Audio_Menu.addAction( - QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-analysis.svg")), - _("Analyze Levels")) - Analyze_Levels.triggered.connect(lambda: get_app().window.show_scope_audio_dock()) + if clip_has_audio: + Analyze_Levels = Audio_Menu.addAction( + QIcon(os.path.join(info.PATH, "themes/cosmic/images/view-analysis.svg")), + _("Analyze Levels")) + Analyze_Levels.triggered.connect(lambda: get_app().window.show_scope_audio_dock()) - menu.addMenu(Audio_Menu) + menu.addMenu(Audio_Menu) # If Playhead overlapping clip if clip: From 60b9f286a570099c1b5abc1bf13e4f48abb12dfc Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 29 Apr 2026 23:46:15 -0500 Subject: [PATCH 19/23] Add dynamic dock/scope bulk actions and scope locking - Add dynamic Show All / Close All actions for Docks and Scopes - Add Lock Scopes / Unlock Scopes with persisted scopes_frozen state - Keep locked dock/scope menu toggles clickable while hiding dock chrome controls - Apply dock/scope lock state consistently when docks are added or shown - Include Color Wheels in Docks bulk actions and lock handling - Keep live color grade edit mode from re-enabling preview caching until seek/play - Add regression tests for dock lock-state sync and live-property cache behavior --- src/settings/_default.settings | 7 + src/tests/test_main_window.py | 51 +++++- src/windows/main_window.py | 188 ++++++++++++++++++---- src/windows/views/properties_tableview.py | 9 +- 4 files changed, 216 insertions(+), 39 deletions(-) diff --git a/src/settings/_default.settings b/src/settings/_default.settings index 0ef9460af0..958b9ece90 100644 --- a/src/settings/_default.settings +++ b/src/settings/_default.settings @@ -166,6 +166,13 @@ "category": "Qt", "setting": "docks_frozen" }, + { + "value": false, + "title": "", + "type": "hidden", + "category": "Qt", + "setting": "scopes_frozen" + }, { "value": 0, "title": "", diff --git a/src/tests/test_main_window.py b/src/tests/test_main_window.py index 55c2e3fdd3..526d6d863f 100644 --- a/src/tests/test_main_window.py +++ b/src/tests/test_main_window.py @@ -37,13 +37,14 @@ from datetime import datetime, timedelta from unittest.mock import patch +import openshot PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) if PATH not in sys.path: sys.path.append(PATH) from qt_api import QCoreApplication, Qt -from qt_api import QApplication +from qt_api import QApplication, QDockWidget, QMainWindow from classes.project_data import ProjectDataStore from classes.updates import UpdateManager @@ -121,7 +122,9 @@ def setUpClass(cls): sys.modules["classes.metrics"] = metrics sys.modules.pop("windows.views.timeline", None) sys.modules.pop("windows.main_window", None) + sys.modules.pop("windows.views.properties_tableview", None) cls.main_window_module = importlib.import_module("windows.main_window") + cls.properties_tableview_module = importlib.import_module("windows.views.properties_tableview") @classmethod def tearDownClass(cls): @@ -754,6 +757,52 @@ def test_delete_item_removes_selected_effects(self): self.assertEqual(refreshed.calls, [()]) self.assertIsNone(self.app.updates.transaction_id) + def test_add_and_show_docks_apply_current_lock_state(self): + fake_window = QMainWindow() + normal_dock = QDockWidget("Normal", fake_window) + normal_dock.setObjectName("dockNormal") + scope_dock = QDockWidget("Scope", fake_window) + scope_dock.setObjectName("dockLumaWaveform") + fake_window.dockAudio = QDockWidget("Audio", fake_window) + fake_window.dockHistogram = QDockWidget("Histogram", fake_window) + fake_window.dockLumaWaveform = scope_dock + fake_window.dockTimeline = QDockWidget("Timeline", fake_window) + fake_window.dockVectorscope = QDockWidget("Vectorscope", fake_window) + fake_window.docks_frozen = True + fake_window.scopes_frozen = False + fake_window._scope_docks = lambda: self.main_window_module.MainWindow._scope_docks(fake_window) + fake_window.freezeDock = lambda dock, frozen=True: self.main_window_module.MainWindow.freezeDock( + fake_window, dock, frozen=frozen) + fake_window.applyDockLockState = lambda dock: self.main_window_module.MainWindow.applyDockLockState( + fake_window, dock) + + self.main_window_module.MainWindow.addDocks(fake_window, [normal_dock], Qt.RightDockWidgetArea) + self.assertEqual(normal_dock.features(), QDockWidget.NoDockWidgetFeatures) + + fake_window.removeDockWidget(normal_dock) + fake_window.docks_frozen = False + self.main_window_module.MainWindow.addDocks(fake_window, [normal_dock], Qt.RightDockWidgetArea) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetClosable) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetFloatable) + + fake_window.scopes_frozen = True + self.main_window_module.MainWindow.addDocks(fake_window, [scope_dock], Qt.RightDockWidgetArea) + self.assertEqual(scope_dock.features(), QDockWidget.NoDockWidgetFeatures) + + def test_live_property_resume_keeps_cache_disabled_until_seek_or_play(self): + settings = openshot.Settings.Instance() + previous = settings.ENABLE_PLAYBACK_CACHING + try: + settings.ENABLE_PLAYBACK_CACHING = False + fake_view = types.SimpleNamespace(live_property_cache_paused=True) + + self.properties_tableview_module.PropertiesTableView.resume_live_property_caching(fake_view) + + self.assertFalse(fake_view.live_property_cache_paused) + self.assertFalse(settings.ENABLE_PLAYBACK_CACHING) + finally: + settings.ENABLE_PLAYBACK_CACHING = previous + def test_ripple_delete_gap_shifts_only_later_items_on_same_layer(self): saved = [] clips = [ diff --git a/src/windows/main_window.py b/src/windows/main_window.py index 5fe207bf03..5f62bb35de 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -150,6 +150,7 @@ class MainWindow(updates.UpdateWatcher, QMainWindow): # Docks are closable, movable and floatable docks_frozen = False + scopes_frozen = False # Save window settings on close def closeEvent(self, event): @@ -1473,7 +1474,7 @@ def _anchor_and_show_scope_dock(self, dock): scope_docks = [self.dockLumaWaveform, self.dockHistogram, self.dockVectorscope, self.dockAudio] if self.dockWidgetArea(dock) == Qt.NoDockWidgetArea: - self.addDockWidget(Qt.RightDockWidgetArea, dock) + self.addDocks([dock], Qt.RightDockWidgetArea) anchored = [d for d in scope_docks if d is not dock and self.dockWidgetArea(d) != Qt.NoDockWidgetArea and d.isVisible()] @@ -1525,6 +1526,53 @@ def _scope_docks(self): self.dockVectorscope, ] + def _scope_dock_names(self): + """Return object names for all scope docks.""" + return {dock.objectName() for dock in self._scope_docks()} + + def _view_menu_docks(self): + """Return non-scope docks managed by the View > Docks menu.""" + scope_dock_names = self._scope_dock_names() + docks = [ + dock for dock in self.getDocks() + if (dock.objectName() not in scope_dock_names + and dock.objectName() not in {"dockTimeline", "dockTutorial"}) + ] + color_grade_dock = getattr( + getattr(self, "propertyTableView", None), "color_grade_wheels_dock", None) + if color_grade_dock and color_grade_dock not in docks: + docks.append(color_grade_dock) + return docks + + def _dock_is_open(self, dock): + """Return True when a dock is attached and visible.""" + return (self.dockWidgetArea(dock) != Qt.NoDockWidgetArea + and dock.toggleViewAction().isChecked()) + + def _add_dock_visibility_actions( + self, menu, docks, show_text, close_text, + show_callback=None): + """Add bulk show/close actions when they are valid for the current dock state.""" + if not docks: + return + + open_docks = [dock for dock in docks if self._dock_is_open(dock)] + closed_docks = [dock for dock in docks if dock not in open_docks] + if not open_docks and not closed_docks: + return + + menu.addSeparator() + if closed_docks: + show_action = QAction(self.actionShow_All.icon(), show_text, menu) + show_action.triggered.connect( + lambda _=False, _callback=show_callback, _docks=docks: + _callback() if _callback else self.showDocks(_docks)) + menu.addAction(show_action) + if open_docks: + close_action = QAction(self.actionShow_All.icon(), close_text, menu) + close_action.triggered.connect(lambda _=False, _docks=open_docks: self.closeDocks(_docks)) + menu.addAction(close_action) + def show_all_scope_docks(self): """Show all scope docks, anchoring them to the right if needed.""" for dock in self._scope_docks(): @@ -2858,6 +2906,7 @@ def addDocks(self, docks, area): """ Add all dockable widgets to the same dock area on the main screen """ for dock in docks: self.addDockWidget(area, dock) + self.applyDockLockState(dock) def floatDocks(self, is_floating): """ Float or Un-Float all dockable widgets above main screen """ @@ -2867,15 +2916,26 @@ def floatDocks(self, is_floating): def showDocks(self, docks): """ Show all dockable widgets on the main screen """ + property_view = getattr(self, "propertyTableView", None) + color_grade_dock = getattr(property_view, "color_grade_wheels_dock", None) for dock in docks: + if dock is color_grade_dock and hasattr(property_view, "_ensure_color_grade_wheels_dock_attached"): + property_view._ensure_color_grade_wheels_dock_attached() if self.dockWidgetArea(dock) != Qt.NoDockWidgetArea: + self.applyDockLockState(dock) # Only show correctly docked widgets dock.show() + def closeDocks(self, docks): + """Close dockable widgets.""" + for dock in docks: + if self._dock_is_open(dock): + dock.hide() + def freezeDock(self, dock, frozen=True): """ Freeze/unfreeze a dock widget on the main screen.""" - if self.dockWidgetArea(dock) == Qt.NoDockWidgetArea: - # Don't freeze undockable widgets + if self.dockWidgetArea(dock) == Qt.NoDockWidgetArea and not dock.isFloating(): + # Don't freeze removed/undockable widgets return if frozen: dock.setFeatures(QDockWidget.NoDockWidgetFeatures) @@ -2886,6 +2946,16 @@ def freezeDock(self, dock, frozen=True): if dock is not self.dockTimeline: features |= QDockWidget.DockWidgetClosable dock.setFeatures(features) + dock.toggleViewAction().setEnabled(True) + + def applyDockLockState(self, dock): + """Apply the current Docks/Scopes lock state to a dock.""" + if all(hasattr(self, name) for name in ( + "dockAudio", "dockHistogram", "dockLumaWaveform", "dockVectorscope" + )) and dock in self._scope_docks(): + self.freezeDock(dock, frozen=self.scopes_frozen) + else: + self.freezeDock(dock, frozen=self.docks_frozen) @pyqtSlot() def freezeMainToolBar(self, frozen=None): @@ -2915,37 +2985,51 @@ def addViewDocksMenu(self): def _rebuild_docks_menu(self): """Repopulate the Docks menu so late-created docks (e.g. Color Wheels) are included.""" self.docks_menu.clear() - scope_dock_names = {dock.objectName() for dock in self._scope_docks()} - for dock in sorted(self.getDocks(), key=lambda d: d.windowTitle()): - if (dock.features() & QDockWidget.DockWidgetClosable - and dock.objectName() != "dockTutorial" - and dock.objectName() not in scope_dock_names): - self.docks_menu.addAction(dock.toggleViewAction()) + docks = sorted(self._view_menu_docks(), key=lambda d: d.windowTitle()) + for dock in docks: + action = dock.toggleViewAction() + action.setEnabled(True) + self.docks_menu.addAction(action) self.docks_menu.addSeparator() - self.docks_menu.addAction(self.actionFreeze_View) - self.docks_menu.addAction(self.actionUn_Freeze_View) - self.docks_menu.addAction(self.actionShow_All) + if self.docks_frozen: + self.docks_menu.addAction(self.actionUn_Freeze_View) + else: + self.docks_menu.addAction(self.actionFreeze_View) + _ = get_app()._tr + self._add_dock_visibility_actions( + self.docks_menu, docks, _("Show All Docks"), _("Close All Docks"), + show_callback=self.actionShow_All_trigger) def _rebuild_scopes_menu(self): """Repopulate the Scopes menu with scope docks and scope recovery actions.""" self.scopes_menu.clear() _ = get_app()._tr - for dock in sorted(self._scope_docks(), key=lambda d: d.windowTitle()): - if dock.features() & QDockWidget.DockWidgetClosable: - self.scopes_menu.addAction(dock.toggleViewAction()) + docks = sorted(self._scope_docks(), key=lambda d: d.windowTitle()) + for dock in docks: + action = dock.toggleViewAction() + action.setEnabled(True) + self.scopes_menu.addAction(action) self.scopes_menu.addSeparator() - show_all_scopes = QAction(self.actionShow_All.icon(), _("Show All Scopes"), self.scopes_menu) - show_all_scopes.triggered.connect(self.show_all_scope_docks) - self.scopes_menu.addAction(show_all_scopes) + lock_text = _("Unlock Scopes") if self.scopes_frozen else _("Lock Scopes") + lock_scopes = QAction(self.actionFreeze_View.icon(), lock_text, self.scopes_menu) + lock_scopes.triggered.connect( + self.actionUn_Freeze_Scopes_trigger if self.scopes_frozen + else self.actionFreeze_Scopes_trigger) + self.scopes_menu.addAction(lock_scopes) + self._add_dock_visibility_actions( + self.scopes_menu, docks, _("Show All Scopes"), _("Close All Scopes"), + show_callback=self.show_all_scope_docks) def createPopupMenu(self): """Override Qt's right-click context menu to include all closable docks.""" menu = QMenu(self) for dock in sorted(self.getDocks(), key=lambda d: d.windowTitle()): - if (dock.features() & QDockWidget.DockWidgetClosable - and dock.objectName() != "dockTutorial"): - menu.addAction(dock.toggleViewAction()) + if dock.objectName() in {"dockTimeline", "dockTutorial"}: + continue + action = dock.toggleViewAction() + action.setEnabled(True) + menu.addAction(action) menu.addSeparator() menu.addAction(self.actionView_Toolbar) return menu @@ -3081,27 +3165,42 @@ def _resize_right_column(): QTimer.singleShot(0, _resize_right_column) def actionFreeze_View_trigger(self): - """ Freeze all dockable widgets on the main screen """ - for dock in self.getDocks(): + """Freeze dock widgets managed by the Docks menu.""" + for dock in self._view_menu_docks(): self.freezeDock(dock, frozen=True) self.freezeMainToolBar(frozen=True) self.actionFreeze_View.setVisible(False) self.actionUn_Freeze_View.setVisible(True) self.docks_frozen = True + self._schedule_dock_style_update(theme_changed=True, delay=0) def actionUn_Freeze_View_trigger(self): - """ Un-Freeze all dockable widgets on the main screen """ - for dock in self.getDocks(): + """Un-freeze dock widgets managed by the Docks menu.""" + for dock in self._view_menu_docks(): self.freezeDock(dock, frozen=False) self.freezeMainToolBar(frozen=False) self.actionFreeze_View.setVisible(True) self.actionUn_Freeze_View.setVisible(False) self.docks_frozen = False + self._schedule_dock_style_update(theme_changed=True, delay=0) + + def actionFreeze_Scopes_trigger(self): + """Freeze scope dock widgets.""" + for dock in self._scope_docks(): + self.freezeDock(dock, frozen=True) + self.scopes_frozen = True + self._schedule_dock_style_update(theme_changed=True, delay=0) + + def actionUn_Freeze_Scopes_trigger(self): + """Un-freeze scope dock widgets.""" + for dock in self._scope_docks(): + self.freezeDock(dock, frozen=False) + self.scopes_frozen = False + self._schedule_dock_style_update(theme_changed=True, delay=0) def actionShow_All_trigger(self): - """ Show all dockable widgets """ - self.showDocks([dock for dock in self.getDocks() - if dock.objectName() != "dockTutorial"]) + """Show dock widgets managed by the Docks menu.""" + self.showDocks(self._view_menu_docks()) def actionTutorial_trigger(self): """ Show tutorial again """ @@ -3391,6 +3490,7 @@ def save_settings(self): s.set('window_state_v2', qt_types.bytes_to_str(self.saveState())) s.set('window_geometry_v2', qt_types.bytes_to_str(self.saveGeometry())) s.set('docks_frozen', self.docks_frozen) + s.set('scopes_frozen', self.scopes_frozen) # Qt's saveState() does not capture docks removed via removeDockWidget(); save them explicitly. hidden = [d.objectName() for d in self.getDocks() if self.dockWidgetArea(d) == Qt.NoDockWidgetArea] @@ -3413,6 +3513,16 @@ def load_settings(self): self.actionFreeze_View_trigger() else: self.actionUn_Freeze_View_trigger() + scopes_frozen = s.get('scopes_frozen') + if (not scopes_frozen + and hasattr(s, "has_user_value") + and not s.has_user_value('scopes_frozen') + and s.get('docks_frozen')): + scopes_frozen = True + if scopes_frozen: + self.actionFreeze_Scopes_trigger() + else: + self.actionUn_Freeze_Scopes_trigger() timeline_height = s.get('timeline_height') if timeline_height: try: @@ -4602,15 +4712,23 @@ def style_dock_widgets(self, theme_changed=False): # entire Qt focus chain. When dockLocationChanged fires repeatedly during # a drag this becomes O(n²) and freezes the UI. Skip the call when the # state hasn't actually changed. + feature_state = ":".join([ + "close" if dock_widget.features() & QDockWidget.DockWidgetClosable else "no-close", + "float" if dock_widget.features() & QDockWidget.DockWidgetFloatable else "no-float", + ]) + show_titlebar_buttons = bool( + dock_widget.features() & ( + QDockWidget.DockWidgetClosable + | QDockWidget.DockWidgetFloatable)) if dock_widget.objectName() == "dockTimeline": required_state = "timeline" elif theme and theme.name == ThemeName.COSMIC.value: if tabified_widgets: - required_state = "tabbed" + required_state = f"tabbed:{feature_state}" elif dock_widget.isFloating(): required_state = "floating" else: - required_state = f"docked:{dock_widget.windowTitle()}" + required_state = f"docked:{dock_widget.windowTitle()}:{feature_state}" else: required_state = "system" @@ -4621,12 +4739,16 @@ def style_dock_widgets(self, theme_changed=False): if required_state == "timeline": dock_widget.setTitleBarWidget(QWidget()) - elif required_state == "tabbed": - dock_widget.setTitleBarWidget(HiddenTitleBar(dock_widget, "", show_buttons=True)) + elif required_state.startswith("tabbed:"): + dock_widget.setTitleBarWidget( + HiddenTitleBar(dock_widget, "", show_buttons=show_titlebar_buttons)) elif required_state == "floating" or required_state == "system": dock_widget.setTitleBarWidget(None) else: # "docked:" - dock_widget.setTitleBarWidget(HiddenTitleBar(dock_widget, dock_widget.windowTitle())) + dock_widget.setTitleBarWidget( + HiddenTitleBar( + dock_widget, dock_widget.windowTitle(), + show_buttons=show_titlebar_buttons)) # Set tab drawBase property self.set_tab_drawbase() diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index ae168cd284..c052ec4ea7 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -772,8 +772,7 @@ def resume_live_property_caching(self): if not self.live_property_cache_paused: return self.live_property_cache_paused = False - openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = True - log.debug("resume_live_property_caching: Resume caching frames on timeline") + log.debug("resume_live_property_caching: Keep caching disabled until seek/play") def _wheels_drag_started(self): """Open a per-drag undo transaction when user starts dragging a wheel control.""" @@ -2018,7 +2017,7 @@ def __init__(self, *args): self.color_grade_wheels_dock.setWidget(self.color_grade_wheels_scroll) self.color_grade_wheels_panel.setEnabled(False) self.color_grade_wheels_dock.hide() - self.win.addDockWidget(Qt.RightDockWidgetArea, self.color_grade_wheels_dock) + self.win.addDocks([self.color_grade_wheels_dock], Qt.RightDockWidgetArea) self.color_grade_wheels_panel.wheelsChanged.connect(self.preview_live_property_value) self.color_grade_wheels_panel.dragStarted.connect(self._wheels_drag_started) self.color_grade_wheels_panel.dragFinished.connect(self._wheels_drag_finished) @@ -2034,7 +2033,7 @@ def _show_scope_docks_if_hidden(self): def _ensure_color_grade_wheels_dock_attached(self): if self.win.dockWidgetArea(self.color_grade_wheels_dock) == Qt.NoDockWidgetArea: - self.win.addDockWidget(Qt.RightDockWidgetArea, self.color_grade_wheels_dock) + self.win.addDocks([self.color_grade_wheels_dock], Qt.RightDockWidgetArea) if self.color_grade_wheels_dock.isFloating(): # Only call setFloating(False) when actually floating — calling it # unconditionally triggers setWindowFlags → reparentFocusWidgets over @@ -2044,7 +2043,7 @@ def _ensure_color_grade_wheels_dock_attached(self): def _color_grade_wheels_visibility_changed(self, visible): if visible: if self.win.dockWidgetArea(self.color_grade_wheels_dock) == Qt.NoDockWidgetArea: - self.win.addDockWidget(Qt.RightDockWidgetArea, self.color_grade_wheels_dock) + self.win.addDocks([self.color_grade_wheels_dock], Qt.RightDockWidgetArea) # If scope docks are already at bottom-right, split so Wheels sits above them scope_docks = [self.win.dockLumaWaveform, self.win.dockHistogram, From 3c6719db05d541f373532162052a635b9acdfc54 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas <jonathan@openshot.org> Date: Thu, 30 Apr 2026 14:32:43 -0500 Subject: [PATCH 20/23] Fixing "insert keyframe" on a Color property bug, where it was not inserting any keyframes. Now sure if this was a regression or an existing bug. --- src/tests/test_main_window.py | 64 +++++++++++++++++++++++++- src/windows/models/properties_model.py | 10 ++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/tests/test_main_window.py b/src/tests/test_main_window.py index 526d6d863f..36276a0371 100644 --- a/src/tests/test_main_window.py +++ b/src/tests/test_main_window.py @@ -44,7 +44,7 @@ sys.path.append(PATH) from qt_api import QCoreApplication, Qt -from qt_api import QApplication, QDockWidget, QMainWindow +from qt_api import QApplication, QDockWidget, QMainWindow, QStandardItem, QStandardItemModel from classes.project_data import ProjectDataStore from classes.updates import UpdateManager @@ -123,8 +123,10 @@ def setUpClass(cls): sys.modules.pop("windows.views.timeline", None) sys.modules.pop("windows.main_window", None) sys.modules.pop("windows.views.properties_tableview", None) + sys.modules.pop("windows.models.properties_model", None) cls.main_window_module = importlib.import_module("windows.main_window") cls.properties_tableview_module = importlib.import_module("windows.views.properties_tableview") + cls.properties_model_module = importlib.import_module("windows.models.properties_model") @classmethod def tearDownClass(cls): @@ -803,6 +805,66 @@ def test_live_property_resume_keeps_cache_disabled_until_seek_or_play(self): finally: settings.ENABLE_PLAYBACK_CACHING = previous + def test_insert_keyframe_adds_current_color_property_frame(self): + saved = [] + refreshed = SignalRecorder() + self.app.window = types.SimpleNamespace(refreshFrameSignal=refreshed) + + effect = types.SimpleNamespace( + data={ + "wave_color": { + "red": {"Points": [{"co": {"X": 1.0, "Y": 0.0}, "interpolation": openshot.LINEAR}]}, + "green": {"Points": [{"co": {"X": 1.0, "Y": 123.0}, "interpolation": openshot.LINEAR}]}, + "blue": {"Points": [{"co": {"X": 1.0, "Y": 255.0}, "interpolation": openshot.LINEAR}]}, + "alpha": {"Points": [{"co": {"X": 1.0, "Y": 255.0}, "interpolation": openshot.LINEAR}]}, + } + }, + ) + effect.save = lambda: saved.append(effect.data) + + model = QStandardItemModel() + label = QStandardItem("Wave Color") + label.setData(( + "wave_color", + { + "type": "color", + "red": {"value": 0}, + "green": {"value": 123}, + "blue": {"value": 255}, + "alpha": {"value": 255}, + "closest_point_x": 1, + "previous_point_x": 1, + "object_id": None, + "max": 255.0, + }, + )) + value = QStandardItem("") + value.setData([("effect-1", "effect")]) + model.appendRow([label, value]) + + parent = types.SimpleNamespace( + currentIndex=lambda: model.index(0, 0), + clearSelection=lambda: None, + setCurrentIndex=lambda index: None, + ) + helper = self.properties_model_module.PropertiesModel.__new__( + self.properties_model_module.PropertiesModel) + helper.model = model + helper.parent = parent + helper.frame_number = 30 + helper._trim_preview_mode = False + + with patch.object(self.properties_model_module.Effect, "get", return_value=effect): + helper.insert_keyframe(value) + + self.assertEqual(len(saved), 1) + color = effect.data["wave_color"] + self.assertIn(30, [point["co"]["X"] for point in color["red"]["Points"]]) + self.assertIn(30, [point["co"]["X"] for point in color["green"]["Points"]]) + self.assertIn(30, [point["co"]["X"] for point in color["blue"]["Points"]]) + self.assertIn(30, [point["co"]["X"] for point in color["alpha"]["Points"]]) + self.assertEqual(refreshed.calls, [()]) + def test_ripple_delete_gap_shifts_only_later_items_on_same_layer(self): saved = [] clips = [ diff --git a/src/windows/models/properties_model.py b/src/windows/models/properties_model.py index e2910434bb..15771dec5d 100644 --- a/src/windows/models/properties_model.py +++ b/src/windows/models/properties_model.py @@ -224,6 +224,16 @@ def insert_keyframe(self, item): if property_type in ("colorgrade_curve", "colorgrade_wheels"): self._save_colorgrade_keyframe_update(item, "insert") return + if property_type == "color": + property_data = property[1] + current_color = QColor( + int(property_data["red"]["value"]), + int(property_data["green"]["value"]), + int(property_data["blue"]["value"]), + int(property_data.get("alpha", {}).get("value", property_data.get("max", 255.0))), + ) + self.color_update(item, current_color) + return value = QLocale().system().toDouble(item.text())[0] self.value_updated(item, value=value) From f81d8239bd4314ec0289a49dddb90a30ea81aafd Mon Sep 17 00:00:00 2001 From: Jonathan Thomas <jonathan@openshot.org> Date: Thu, 30 Apr 2026 14:48:08 -0500 Subject: [PATCH 21/23] Removing many view options that are confusing to most users and were never really fully baked: locking/unlocking docks (legacy feature inherited from original Qt implementation). Show / Hide all docks options (we already have Views that are more useful for hiding/showing and positioning docks). - Removed Dock/Scope locking behavior and saved lock state handling from src/windows/main_window.py. - Removed Lock Docks, Unlock Docks, and Show All Docks actions from src/windows/ui/main-window.ui. - Removed related default shortcut/settings entries from src/settings/_default.settings. - Kept Show All Scopes / Close All Scopes as conditional actions under Scopes. - Updated focused tests in src/tests/test_main_window.py. - Removed obsolete shortcuts from doc/main_window.rst. --- doc/main_window.rst | 3 - src/language/OpenShot/OpenShot.pot | 15 ---- src/settings/_default.settings | 38 --------- src/tests/test_main_window.py | 73 +++++++++++------ src/windows/main_window.py | 125 +---------------------------- src/windows/ui/main-window.ui | 30 ------- 6 files changed, 52 insertions(+), 232 deletions(-) diff --git a/doc/main_window.rst b/doc/main_window.rst index 8b61422ac8..3b2b8e68ec 100644 --- a/doc/main_window.rst +++ b/doc/main_window.rst @@ -165,7 +165,6 @@ Learning a few of these shortcuts can save you a bunch of time! Export Video / Media :kbd:`Ctrl+E` :kbd:`Ctrl+M` Fast Forward :kbd:`L` File Properties :kbd:`Alt+I` :kbd:`Ctrl+Double Click` - Lock Docks :kbd:`Ctrl+F` Fullscreen :kbd:`F11` Import Files... :kbd:`Ctrl+I` Insert Keyframe :kbd:`Alt+Shift+K` @@ -201,7 +200,6 @@ Learning a few of these shortcuts can save you a bunch of time! Select All :kbd:`Ctrl+A` Select Item (Ripple) :kbd:`Alt+A` :kbd:`Alt+Click` Select None :kbd:`Ctrl+Shift+A` - Show All Docks :kbd:`Ctrl+Shift+D` Simple View :kbd:`Alt+Shift+0` Slice All: Keep Both Sides :kbd:`Ctrl+Shift+K` Slice All: Keep Left Side :kbd:`Ctrl+Shift+J` @@ -217,7 +215,6 @@ Learning a few of these shortcuts can save you a bunch of time! Timing Toggle :kbd:`T` Title :kbd:`Ctrl+T` Translate this Application... :kbd:`F6` - Unlock Docks :kbd:`Ctrl+Shift+F` Undo :kbd:`Ctrl+Z` View Toolbar :kbd:`Ctrl+Shift+B` Zoom In :kbd:`=` :kbd:`Ctrl+=` diff --git a/src/language/OpenShot/OpenShot.pot b/src/language/OpenShot/OpenShot.pot index 22f873b916..1ffe542870 100644 --- a/src/language/OpenShot/OpenShot.pot +++ b/src/language/OpenShot/OpenShot.pot @@ -4276,20 +4276,6 @@ msgstr "" msgid "Advanced View" msgstr "" -#: Settings for actionFreeze_View -#: /home/jonathan/apps/openshot-qt/src/windows/ui/main-window.ui:1253 -msgid "Freeze View" -msgstr "" - -#: Settings for actionUn_Freeze_View -#: /home/jonathan/apps/openshot-qt/src/windows/ui/main-window.ui:1262 -msgid "Un-Freeze View" -msgstr "" - -#: Settings for actionShow_All -msgid "Show All Docks" -msgstr "" - #: Settings for actionView_Toolbar #: /home/jonathan/apps/openshot-qt/src/windows/ui/main-window.ui:1130 msgid "View Toolbar" @@ -5050,4 +5036,3 @@ msgstr "" #: /home/jonathan/apps/openshot-qt/src/windows/ui/title-editor.ui:14 msgid "Titles" msgstr "" - diff --git a/src/settings/_default.settings b/src/settings/_default.settings index 958b9ece90..b28d9ecb68 100644 --- a/src/settings/_default.settings +++ b/src/settings/_default.settings @@ -159,20 +159,6 @@ "category": "Qt", "setting": "window_geometry_v2" }, - { - "value": false, - "title": "", - "type": "hidden", - "category": "Qt", - "setting": "docks_frozen" - }, - { - "value": false, - "title": "", - "type": "hidden", - "category": "Qt", - "setting": "scopes_frozen" - }, { "value": 0, "title": "", @@ -1296,30 +1282,6 @@ "value": "Alt+Shift+1", "type": "text" }, - { - "category": "Keyboard", - "title": "Lock Docks", - "restart": false, - "setting": "actionFreeze_View", - "value": "Ctrl+F", - "type": "text" - }, - { - "category": "Keyboard", - "title": "Unlock Docks", - "restart": false, - "setting": "actionUn_Freeze_View", - "value": "Ctrl+Shift+F", - "type": "text" - }, - { - "category": "Keyboard", - "title": "Show All Docks", - "restart": false, - "setting": "actionShow_All", - "value": "Ctrl+Shift+D", - "type": "text" - }, { "category": "Keyboard", "title": "View Toolbar", diff --git a/src/tests/test_main_window.py b/src/tests/test_main_window.py index 36276a0371..1519cc5005 100644 --- a/src/tests/test_main_window.py +++ b/src/tests/test_main_window.py @@ -44,7 +44,7 @@ sys.path.append(PATH) from qt_api import QCoreApplication, Qt -from qt_api import QApplication, QDockWidget, QMainWindow, QStandardItem, QStandardItemModel +from qt_api import QApplication, QDockWidget, QMainWindow, QMenu, QStandardItem, QStandardItemModel from classes.project_data import ProjectDataStore from classes.updates import UpdateManager @@ -759,37 +759,64 @@ def test_delete_item_removes_selected_effects(self): self.assertEqual(refreshed.calls, [()]) self.assertIsNone(self.app.updates.transaction_id) - def test_add_and_show_docks_apply_current_lock_state(self): + def test_add_and_show_docks_keep_default_dock_features(self): fake_window = QMainWindow() normal_dock = QDockWidget("Normal", fake_window) normal_dock.setObjectName("dockNormal") - scope_dock = QDockWidget("Scope", fake_window) - scope_dock.setObjectName("dockLumaWaveform") - fake_window.dockAudio = QDockWidget("Audio", fake_window) - fake_window.dockHistogram = QDockWidget("Histogram", fake_window) - fake_window.dockLumaWaveform = scope_dock - fake_window.dockTimeline = QDockWidget("Timeline", fake_window) - fake_window.dockVectorscope = QDockWidget("Vectorscope", fake_window) - fake_window.docks_frozen = True - fake_window.scopes_frozen = False - fake_window._scope_docks = lambda: self.main_window_module.MainWindow._scope_docks(fake_window) - fake_window.freezeDock = lambda dock, frozen=True: self.main_window_module.MainWindow.freezeDock( - fake_window, dock, frozen=frozen) - fake_window.applyDockLockState = lambda dock: self.main_window_module.MainWindow.applyDockLockState( - fake_window, dock) self.main_window_module.MainWindow.addDocks(fake_window, [normal_dock], Qt.RightDockWidgetArea) - self.assertEqual(normal_dock.features(), QDockWidget.NoDockWidgetFeatures) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetClosable) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetMovable) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetFloatable) - fake_window.removeDockWidget(normal_dock) - fake_window.docks_frozen = False - self.main_window_module.MainWindow.addDocks(fake_window, [normal_dock], Qt.RightDockWidgetArea) + normal_dock.hide() + fake_window.showDocks = lambda docks: self.main_window_module.MainWindow.showDocks(fake_window, docks) + self.main_window_module.MainWindow.showDocks(fake_window, [normal_dock]) self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetClosable) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetMovable) self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetFloatable) - fake_window.scopes_frozen = True - self.main_window_module.MainWindow.addDocks(fake_window, [scope_dock], Qt.RightDockWidgetArea) - self.assertEqual(scope_dock.features(), QDockWidget.NoDockWidgetFeatures) + def test_scope_menu_keeps_conditional_show_and_close_all_actions(self): + fake_window = QMainWindow() + fake_window.scopes_menu = QMenu(fake_window) + fake_window.dockAudio = QDockWidget("Audio Levels", fake_window) + fake_window.dockAudio.setObjectName("dockAudio") + fake_window.dockHistogram = QDockWidget("Histogram", fake_window) + fake_window.dockHistogram.setObjectName("dockHistogram") + fake_window.dockLumaWaveform = QDockWidget("Luma Waveform", fake_window) + fake_window.dockLumaWaveform.setObjectName("dockLumaWaveform") + fake_window.dockVectorscope = QDockWidget("Vectorscope", fake_window) + fake_window.dockVectorscope.setObjectName("dockVectorscope") + for dock in [ + fake_window.dockAudio, + fake_window.dockHistogram, + fake_window.dockLumaWaveform, + fake_window.dockVectorscope]: + fake_window.addDockWidget(Qt.RightDockWidgetArea, dock) + dock.hide() + + open_docks = set() + fake_window._scope_docks = lambda: self.main_window_module.MainWindow._scope_docks(fake_window) + fake_window._dock_is_open = lambda dock: dock in open_docks + fake_window.closeDocks = lambda docks: self.main_window_module.MainWindow.closeDocks(fake_window, docks) + fake_window.show_all_scope_docks = lambda: None + fake_window._add_dock_visibility_actions = ( + lambda menu, docks, show_text, close_text, show_callback=None: + self.main_window_module.MainWindow._add_dock_visibility_actions( + fake_window, menu, docks, show_text, close_text, show_callback)) + + self.main_window_module.MainWindow._rebuild_scopes_menu(fake_window) + action_texts = [action.text() for action in fake_window.scopes_menu.actions() if not action.isSeparator()] + self.assertIn("Show All Scopes", action_texts) + self.assertNotIn("Close All Scopes", action_texts) + self.assertNotIn("Lock Scopes", action_texts) + + open_docks.add(fake_window.dockAudio) + self.main_window_module.MainWindow._rebuild_scopes_menu(fake_window) + action_texts = [action.text() for action in fake_window.scopes_menu.actions() if not action.isSeparator()] + self.assertIn("Show All Scopes", action_texts) + self.assertIn("Close All Scopes", action_texts) + self.assertNotIn("Unlock Scopes", action_texts) def test_live_property_resume_keeps_cache_disabled_until_seek_or_play(self): settings = openshot.Settings.Instance() diff --git a/src/windows/main_window.py b/src/windows/main_window.py index 5f62bb35de..a4b40f7fea 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -148,10 +148,6 @@ class MainWindow(updates.UpdateWatcher, QMainWindow): ProjectSaved = pyqtSignal(str) ProjectSaveFailed = pyqtSignal(str, str) - # Docks are closable, movable and floatable - docks_frozen = False - scopes_frozen = False - # Save window settings on close def closeEvent(self, event): app = get_app() @@ -1563,13 +1559,13 @@ def _add_dock_visibility_actions( menu.addSeparator() if closed_docks: - show_action = QAction(self.actionShow_All.icon(), show_text, menu) + show_action = QAction(show_text, menu) show_action.triggered.connect( lambda _=False, _callback=show_callback, _docks=docks: _callback() if _callback else self.showDocks(_docks)) menu.addAction(show_action) if open_docks: - close_action = QAction(self.actionShow_All.icon(), close_text, menu) + close_action = QAction(close_text, menu) close_action.triggered.connect(lambda _=False, _docks=open_docks: self.closeDocks(_docks)) menu.addAction(close_action) @@ -2906,7 +2902,6 @@ def addDocks(self, docks, area): """ Add all dockable widgets to the same dock area on the main screen """ for dock in docks: self.addDockWidget(area, dock) - self.applyDockLockState(dock) def floatDocks(self, is_floating): """ Float or Un-Float all dockable widgets above main screen """ @@ -2922,7 +2917,6 @@ def showDocks(self, docks): if dock is color_grade_dock and hasattr(property_view, "_ensure_color_grade_wheels_dock_attached"): property_view._ensure_color_grade_wheels_dock_attached() if self.dockWidgetArea(dock) != Qt.NoDockWidgetArea: - self.applyDockLockState(dock) # Only show correctly docked widgets dock.show() @@ -2932,46 +2926,6 @@ def closeDocks(self, docks): if self._dock_is_open(dock): dock.hide() - def freezeDock(self, dock, frozen=True): - """ Freeze/unfreeze a dock widget on the main screen.""" - if self.dockWidgetArea(dock) == Qt.NoDockWidgetArea and not dock.isFloating(): - # Don't freeze removed/undockable widgets - return - if frozen: - dock.setFeatures(QDockWidget.NoDockWidgetFeatures) - else: - features = ( - QDockWidget.DockWidgetFloatable - | QDockWidget.DockWidgetMovable) - if dock is not self.dockTimeline: - features |= QDockWidget.DockWidgetClosable - dock.setFeatures(features) - dock.toggleViewAction().setEnabled(True) - - def applyDockLockState(self, dock): - """Apply the current Docks/Scopes lock state to a dock.""" - if all(hasattr(self, name) for name in ( - "dockAudio", "dockHistogram", "dockLumaWaveform", "dockVectorscope" - )) and dock in self._scope_docks(): - self.freezeDock(dock, frozen=self.scopes_frozen) - else: - self.freezeDock(dock, frozen=self.docks_frozen) - - @pyqtSlot() - def freezeMainToolBar(self, frozen=None): - """Freeze/unfreeze the toolbar if it's attached to the window.""" - if frozen is None: - frozen = self.docks_frozen - floating = self.toolBar.isFloating() - log.debug( - "%s main toolbar%s", - "freezing" if frozen and not floating else "unfreezing", - " (floating)" if floating else "") - if floating: - self.toolBar.setMovable(True) - else: - self.toolBar.setMovable(not frozen) - def addViewDocksMenu(self): """Insert dynamic Docks and Scopes submenus into the View menu.""" _ = get_app()._tr @@ -2991,16 +2945,6 @@ def _rebuild_docks_menu(self): action.setEnabled(True) self.docks_menu.addAction(action) - self.docks_menu.addSeparator() - if self.docks_frozen: - self.docks_menu.addAction(self.actionUn_Freeze_View) - else: - self.docks_menu.addAction(self.actionFreeze_View) - _ = get_app()._tr - self._add_dock_visibility_actions( - self.docks_menu, docks, _("Show All Docks"), _("Close All Docks"), - show_callback=self.actionShow_All_trigger) - def _rebuild_scopes_menu(self): """Repopulate the Scopes menu with scope docks and scope recovery actions.""" self.scopes_menu.clear() @@ -3010,13 +2954,6 @@ def _rebuild_scopes_menu(self): action = dock.toggleViewAction() action.setEnabled(True) self.scopes_menu.addAction(action) - self.scopes_menu.addSeparator() - lock_text = _("Unlock Scopes") if self.scopes_frozen else _("Lock Scopes") - lock_scopes = QAction(self.actionFreeze_View.icon(), lock_text, self.scopes_menu) - lock_scopes.triggered.connect( - self.actionUn_Freeze_Scopes_trigger if self.scopes_frozen - else self.actionFreeze_Scopes_trigger) - self.scopes_menu.addAction(lock_scopes) self._add_dock_visibility_actions( self.scopes_menu, docks, _("Show All Scopes"), _("Close All Scopes"), show_callback=self.show_all_scope_docks) @@ -3164,44 +3101,6 @@ def _resize_right_column(): ) QTimer.singleShot(0, _resize_right_column) - def actionFreeze_View_trigger(self): - """Freeze dock widgets managed by the Docks menu.""" - for dock in self._view_menu_docks(): - self.freezeDock(dock, frozen=True) - self.freezeMainToolBar(frozen=True) - self.actionFreeze_View.setVisible(False) - self.actionUn_Freeze_View.setVisible(True) - self.docks_frozen = True - self._schedule_dock_style_update(theme_changed=True, delay=0) - - def actionUn_Freeze_View_trigger(self): - """Un-freeze dock widgets managed by the Docks menu.""" - for dock in self._view_menu_docks(): - self.freezeDock(dock, frozen=False) - self.freezeMainToolBar(frozen=False) - self.actionFreeze_View.setVisible(True) - self.actionUn_Freeze_View.setVisible(False) - self.docks_frozen = False - self._schedule_dock_style_update(theme_changed=True, delay=0) - - def actionFreeze_Scopes_trigger(self): - """Freeze scope dock widgets.""" - for dock in self._scope_docks(): - self.freezeDock(dock, frozen=True) - self.scopes_frozen = True - self._schedule_dock_style_update(theme_changed=True, delay=0) - - def actionUn_Freeze_Scopes_trigger(self): - """Un-freeze scope dock widgets.""" - for dock in self._scope_docks(): - self.freezeDock(dock, frozen=False) - self.scopes_frozen = False - self._schedule_dock_style_update(theme_changed=True, delay=0) - - def actionShow_All_trigger(self): - """Show dock widgets managed by the Docks menu.""" - self.showDocks(self._view_menu_docks()) - def actionTutorial_trigger(self): """ Show tutorial again """ s = get_app().get_settings() @@ -3489,8 +3388,6 @@ def save_settings(self): # Save window state and geometry (saves toolbar and dock locations) s.set('window_state_v2', qt_types.bytes_to_str(self.saveState())) s.set('window_geometry_v2', qt_types.bytes_to_str(self.saveGeometry())) - s.set('docks_frozen', self.docks_frozen) - s.set('scopes_frozen', self.scopes_frozen) # Qt's saveState() does not capture docks removed via removeDockWidget(); save them explicitly. hidden = [d.objectName() for d in self.getDocks() if self.dockWidgetArea(d) == Qt.NoDockWidgetArea] @@ -3509,20 +3406,6 @@ def load_settings(self): self.saved_geometry = qt_types.str_to_bytes(s.get('window_geometry_v2')) if s.get('window_state_v2'): self.saved_state = qt_types.str_to_bytes(s.get('window_state_v2')) - if s.get('docks_frozen'): - self.actionFreeze_View_trigger() - else: - self.actionUn_Freeze_View_trigger() - scopes_frozen = s.get('scopes_frozen') - if (not scopes_frozen - and hasattr(s, "has_user_value") - and not s.has_user_value('scopes_frozen') - and s.get('docks_frozen')): - scopes_frozen = True - if scopes_frozen: - self.actionFreeze_Scopes_trigger() - else: - self.actionUn_Freeze_Scopes_trigger() timeline_height = s.get('timeline_height') if timeline_height: try: @@ -5197,10 +5080,6 @@ def __init__(self, *args): for _dock in [self.dockLumaWaveform, self.dockHistogram, self.dockVectorscope]: _dock.visibilityChanged.connect(self._on_video_scope_visibility_changed) - # Ensure toolbar is movable when floated (even with docks frozen) - self.toolBar.topLevelChanged.connect( - functools.partial(self.freezeMainToolBar, None)) - # Create tutorial manager self.tutorial_manager = TutorialManager(self) diff --git a/src/windows/ui/main-window.ui b/src/windows/ui/main-window.ui index c1df060a94..8beb6695b2 100644 --- a/src/windows/ui/main-window.ui +++ b/src/windows/ui/main-window.ui @@ -1248,36 +1248,6 @@ <string>Alt+Shift+2</string> </property> </action> - <action name="actionFreeze_View"> - <property name="icon"> - <iconset theme="locked" resource="../../../images/openshot.qrc"> - <normaloff>:/icons/Humanity/actions/16/locked.svg</normaloff>:/icons/Humanity/actions/16/locked.svg</iconset> - </property> - <property name="text"> - <string>Lock Docks</string> - </property> - </action> - <action name="actionUn_Freeze_View"> - <property name="icon"> - <iconset theme="locked" resource="../../../images/openshot.qrc"> - <normaloff>:/icons/Humanity/actions/16/locked.svg</normaloff>:/icons/Humanity/actions/16/locked.svg</iconset> - </property> - <property name="text"> - <string>Unlock Docks</string> - </property> - <property name="visible"> - <bool>false</bool> - </property> - </action> - <action name="actionShow_All"> - <property name="icon"> - <iconset theme="zoom-in" resource="../../../images/openshot.qrc"> - <normaloff>:/icons/Humanity/actions/16/zoom-in.png</normaloff>:/icons/Humanity/actions/16/zoom-in.png</iconset> - </property> - <property name="text"> - <string>Show All Docks</string> - </property> - </action> <action name="actionRecoveryProjects"> <property name="checkable"> <bool>false</bool> From 4143ecb52b79b9116fdd3f0e2e5a0ed5e7d866a4 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas <jonathan@openshot.org> Date: Thu, 30 Apr 2026 16:00:19 -0500 Subject: [PATCH 22/23] Added a new "My Views" menu to the "View" top menu, where a user can save the current view, choose custom views, or update/delete custom views. It's purposely kept simple but very easy to use for customizing OpenShot for specific user work-flows. Also updated docs. --- doc/introduction.rst | 2 +- doc/main_window.rst | 40 +++++- src/classes/settings.py | 5 +- src/settings/_default.settings | 14 +++ src/tests/test_main_window.py | 15 +++ src/windows/main_window.py | 223 +++++++++++++++++++++++++++++++-- 6 files changed, 289 insertions(+), 10 deletions(-) diff --git a/doc/introduction.rst b/doc/introduction.rst index 8a31109a9b..3db4bd6e80 100644 --- a/doc/introduction.rst +++ b/doc/introduction.rst @@ -36,7 +36,7 @@ Features - **Advanced timeline** (drag-drop, scroll, zoom, snap) - **Advanced clips** (trim, alpha, scale, rotate, shear, transform) - **Real-time preview** (multi-threaded, performance-optimized) -- **Simple & advanced views** (customizable) +- **Multiple workspace modes** (Simple, Advanced, Color views — plus save your own) - **Keyframe animations & panel** (`linear`, `bézier`, `constant` interpolation, visual keyframe panel) - **One-click presets** (motion, fade, look, camera, color, and more) - **Compositing, overlays, watermarks, transparency** diff --git a/doc/main_window.rst b/doc/main_window.rst index 3b2b8e68ec..e4da2b4e39 100644 --- a/doc/main_window.rst +++ b/doc/main_window.rst @@ -260,6 +260,7 @@ are renamed and/or rearranged. * - View - - :guilabel:`Simple View`, :guilabel:`Color View`, and :guilabel:`Advanced View` switch or reset the main window layout. + - :guilabel:`My Views` Save, load, update, and delete your own named layouts. See :ref:`my_views_ref`. - :guilabel:`Docks` Show or hide various dockable panels. - :guilabel:`Scopes` Show or hide scope docks, or open all scopes at once. - :guilabel:`Window` Show or hide the main window toolbar, or toggle fullscreen mode. @@ -273,12 +274,14 @@ are renamed and/or rearranged. - :guilabel:`Donate` Make a donation to support the project. - :guilabel:`About` View information about the software (version, contributors, translators, changelog, and supporters). +.. _views_ref: + Views ----- The OpenShot main window is composed of multiple **docks**. These **docks** are arranged and snapped together into a grouping that we call a **View**. OpenShot includes :guilabel:`Simple View`, :guilabel:`Advanced View`, -and :guilabel:`Color View`. +:guilabel:`Color View`, and :guilabel:`My Views` (user-defined layouts). Simple View ^^^^^^^^^^^ @@ -299,6 +302,41 @@ This view is focused on color correction and scopes. It enlarges the video previ keeps the timeline and properties visible, places the :guilabel:`Color Wheels` dock on the right, and tabifies the :guilabel:`Luma Waveform` and :guilabel:`Histogram` docks together below it. +.. _my_views_ref: + +My Views +^^^^^^^^ +**My Views** lets you save any dock arrangement as a named layout and recall it instantly. This is ideal for +workflows that require switching between different editing modes — for example, a detailed audio mix layout and +a focused color grading layout — without manually repositioning docks each time. + +Each saved view captures the position, size, and visibility of every dock, as well as the timeline height. +Saved views are stored in your project settings and persist across sessions. + +**View → My Views** menu options: + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Menu Item + - Description + * - List of user-defined views + - Click to restore that layout. The currently active view shows a checkmark. + * - Update "*[name]*" + - Save your current dock arrangement over the active view, replacing it. + * - Delete "*[name]*" + - Remove the active view (asks for confirmation first). + * - Save Current View As... + - Name and save your current dock arrangement as a new view. + +**Typical workflow:** + +1. Arrange your docks exactly how you like them. +2. Open :guilabel:`View → My Views → Save Current View As...` and enter a name (e.g. *"Audio Mix"*). +3. Later, open :guilabel:`View → My Views` and click the view name to restore that layout instantly. +4. If you adjust the layout and want to keep the changes, choose :guilabel:`Update "Audio Mix"` to overwrite it. + Docks ^^^^^ Each widget on the OpenShot main window is contained in a **dock**. These docks can be dragged and snapped around the diff --git a/src/classes/settings.py b/src/classes/settings.py index 9521175e55..3b7ddc393d 100644 --- a/src/classes/settings.py +++ b/src/classes/settings.py @@ -166,7 +166,10 @@ def restore(self, category_filter=None): Return True if any settings with 'restart: True' are changed. """ log.info(f"Restoring defaults for category: {category_filter or 'all categories'}") - preserve_keys = ['unique_install_id', 'tutorial_ids', 'tutorial_enabled', 'send_metrics', 'recent_projects'] + preserve_keys = [ + 'unique_install_id', 'tutorial_ids', 'tutorial_enabled', 'send_metrics', + 'recent_projects', 'custom_views', 'active_custom_view', + ] requires_restart = False # Track if any setting requires a restart diff --git a/src/settings/_default.settings b/src/settings/_default.settings index b28d9ecb68..25e3f8ae96 100644 --- a/src/settings/_default.settings +++ b/src/settings/_default.settings @@ -173,6 +173,20 @@ "category": "Qt", "setting": "hidden_docks" }, + { + "value": [], + "title": "", + "type": "hidden", + "category": "Qt", + "setting": "custom_views" + }, + { + "value": "", + "title": "", + "type": "hidden", + "category": "Qt", + "setting": "active_custom_view" + }, { "value": "thumbnail", "title": "", diff --git a/src/tests/test_main_window.py b/src/tests/test_main_window.py index 1519cc5005..2ca8e3c525 100644 --- a/src/tests/test_main_window.py +++ b/src/tests/test_main_window.py @@ -227,6 +227,21 @@ def test_dock_top_level_change_marks_interaction_and_restyles_immediately(self): self.assertEqual(calls, ["interaction", ("style", {"delay": 0})]) + def test_active_custom_view_setter_does_not_shadow_reader(self): + fake_window = types.SimpleNamespace() + fake_window._active_custom_view_id = types.MethodType( + self.main_window_module.MainWindow._active_custom_view_id, + fake_window) + fake_window._set_active_custom_view_id = types.MethodType( + self.main_window_module.MainWindow._set_active_custom_view_id, + fake_window) + + fake_window._set_active_custom_view_id("view-1") + + self.assertTrue(callable(fake_window._active_custom_view_id)) + self.assertEqual(fake_window._active_custom_view_id(), "view-1") + self.assertEqual(self.app.settings.values["active_custom_view"], "view-1") + def test_scheduled_dock_style_update_waits_for_mouse_release(self): starts = [] styles = [] diff --git a/src/windows/main_window.py b/src/windows/main_window.py index a4b40f7fea..7fd3d5ed1d 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -2927,8 +2927,18 @@ def closeDocks(self, docks): dock.hide() def addViewDocksMenu(self): - """Insert dynamic Docks and Scopes submenus into the View menu.""" + """Insert dynamic Custom Views, Docks, and Scopes submenus into the View menu.""" _ = get_app()._tr + self.custom_views_menu = QMenu(_("My Views"), self.menuView) + separator_after_views = self.menuWindow.menuAction() + advanced_index = self.menuView.actions().index(self.actionAdvanced_View) + for action in self.menuView.actions()[advanced_index + 1:]: + if action.isSeparator(): + separator_after_views = action + break + self.menuView.insertSeparator(separator_after_views) + self.menuView.insertMenu(separator_after_views, self.custom_views_menu) + self.custom_views_menu.aboutToShow.connect(self._rebuild_custom_views_menu) self.docks_menu = QMenu(_("Docks"), self.menuView) self.menuView.insertMenu(self.menuWindow.menuAction(), self.docks_menu) self.docks_menu.aboutToShow.connect(self._rebuild_docks_menu) @@ -2936,6 +2946,102 @@ def addViewDocksMenu(self): self.menuView.insertMenu(self.menuWindow.menuAction(), self.scopes_menu) self.scopes_menu.aboutToShow.connect(self._rebuild_scopes_menu) + def _custom_views(self): + """Return saved custom views from settings.""" + views = get_app().get_settings().get("custom_views") or [] + if not isinstance(views, list): + return [] + valid_views = [] + for view in views: + if not isinstance(view, dict): + continue + if not view.get("id") or not view.get("name") or not view.get("state"): + continue + valid_views.append(view) + return valid_views + + def _set_custom_views(self, views): + """Persist the custom view list.""" + s = get_app().get_settings() + s.set("custom_views", views) + if hasattr(s, "save"): + s.save() + + def _active_custom_view_id(self): + return ( + getattr(self, "_active_custom_view_id_value", "") + or get_app().get_settings().get("active_custom_view") + or "" + ) + + def _set_active_custom_view_id(self, view_id): + self._active_custom_view_id_value = view_id or "" + s = get_app().get_settings() + s.set("active_custom_view", self._active_custom_view_id_value) + if hasattr(s, "save"): + s.save() + + def _active_custom_view(self): + active_id = self._active_custom_view_id() + for view in self._custom_views(): + if view.get("id") == active_id: + return view + return None + + def _current_custom_view_data(self, view_id, name): + """Capture the current dock layout as a custom view.""" + dock = getattr(self, "dockTimeline", None) + hidden = [ + d.objectName() for d in self.getDocks() + if self.dockWidgetArea(d) == Qt.NoDockWidgetArea + ] + return { + "id": view_id, + "name": name, + "state": qt_types.bytes_to_str(self.saveState()), + "hidden_docks": hidden, + "timeline_height": dock.height() if dock else 0, + } + + def _rebuild_custom_views_menu(self): + """Repopulate the Custom Views menu.""" + self.custom_views_menu.clear() + _ = get_app()._tr + views = sorted(self._custom_views(), key=lambda view: view.get("name", "").lower()) + active_id = self._active_custom_view_id() + + if views: + view_group = QActionGroup(self.custom_views_menu) + for view in views: + action = QAction(view.get("name", ""), self.custom_views_menu) + is_active = view.get("id") == active_id + action.setCheckable(True) + action.setChecked(is_active) + action.triggered.connect( + functools.partial(self.apply_custom_view, view.get("id"))) + view_group.addAction(action) + self.custom_views_menu.addAction(action) + self.custom_views_menu.addSeparator() + + active_view = self._active_custom_view() + if active_view: + update_action = QAction( + _('Update "%s"') % active_view.get("name", ""), + self.custom_views_menu) + update_action.triggered.connect(self.update_active_custom_view) + self.custom_views_menu.addAction(update_action) + + delete_action = QAction( + _('Delete "%s"') % active_view.get("name", ""), + self.custom_views_menu) + delete_action.triggered.connect(self.delete_active_custom_view) + self.custom_views_menu.addAction(delete_action) + self.custom_views_menu.addSeparator() + + save_as_action = QAction(_("Save Current View As..."), self.custom_views_menu) + save_as_action.triggered.connect(self.save_current_view_as) + self.custom_views_menu.addAction(save_as_action) + def _rebuild_docks_menu(self): """Repopulate the Docks menu so late-created docks (e.g. Color Wheels) are included.""" self.docks_menu.clear() @@ -2971,8 +3077,114 @@ def createPopupMenu(self): menu.addAction(self.actionView_Toolbar) return menu + def _restore_hidden_docks(self, hidden_names): + """Remove docks hidden by a saved layout.""" + if not hidden_names: + return + name_to_dock = {d.objectName(): d for d in self.getDocks()} + for name in hidden_names: + dock = name_to_dock.get(name) + if dock: + self.removeDockWidget(dock) + + def _prepare_docks_for_state_restore(self): + """Attach removed docks so restoreState can place them.""" + for dock in self.getDocks(): + if self.dockWidgetArea(dock) == Qt.NoDockWidgetArea: + self.addDockWidget(Qt.TopDockWidgetArea, dock) + + def apply_custom_view(self, view_id, checked=True): + """Apply a saved custom view by id.""" + view = None + for custom_view in self._custom_views(): + if custom_view.get("id") == view_id: + view = custom_view + break + if not view: + return + + self._prepare_docks_for_state_restore() + self.restoreState(qt_types.str_to_bytes(view.get("state", ""))) + self._restore_hidden_docks(view.get("hidden_docks") or []) + timeline_height = view.get("timeline_height") + if timeline_height: + try: + self.saved_timeline_height = int(timeline_height) + except (TypeError, ValueError): + self.saved_timeline_height = None + self._apply_saved_timeline_height() + self._set_active_custom_view_id(view_id) + QCoreApplication.processEvents() + self.style_dock_widgets() + + def save_current_view_as(self): + """Prompt for a name and save the current layout as a custom view.""" + _ = get_app()._tr + name, ok = QInputDialog.getText( + self, + _("Save Current View"), + _("View Name:")) + if not ok: + return + name = name.strip() + if not name: + return + + views = self._custom_views() + if any(view.get("name", "").lower() == name.lower() for view in views): + QMessageBox.warning( + self, + _("Custom View Exists"), + _('A custom view named "%s" already exists.') % name) + return + + view_id = str(uuid.uuid4()) + views.append(self._current_custom_view_data(view_id, name)) + self._set_custom_views(views) + self._set_active_custom_view_id(view_id) + + def update_active_custom_view(self): + """Overwrite the active custom view with the current layout.""" + active_view = self._active_custom_view() + if not active_view: + return + views = self._custom_views() + updated = self._current_custom_view_data( + active_view.get("id"), + active_view.get("name", "")) + views = [ + updated if view.get("id") == active_view.get("id") else view + for view in views + ] + self._set_custom_views(views) + + def delete_active_custom_view(self): + """Delete the active custom view after confirmation.""" + active_view = self._active_custom_view() + if not active_view: + return + + _ = get_app()._tr + name = active_view.get("name", "") + ret = QMessageBox.question( + self, + _("Delete Custom View"), + _('Delete "%s"?') % name, + QMessageBox.No | QMessageBox.Yes, + QMessageBox.No) + if ret != QMessageBox.Yes: + return + + views = [ + view for view in self._custom_views() + if view.get("id") != active_view.get("id") + ] + self._set_custom_views(views) + self._set_active_custom_view_id("") + def actionSimple_View_trigger(self): """ Switch to the default / simple view """ + self._set_active_custom_view_id("") self.removeDocks() # Add Docks @@ -3005,6 +3217,7 @@ def actionSimple_View_trigger(self): def actionAdvanced_View_trigger(self): """ Switch to an alternative view """ + self._set_active_custom_view_id("") self.removeDocks() # Add Docks @@ -3047,6 +3260,7 @@ def actionAdvanced_View_trigger(self): def actionColor_Grade_View_trigger(self): """Switch to a color grading focused view.""" + self._set_active_custom_view_id("") self.removeDocks() color_grade_dock = getattr(getattr(self, "propertyTableView", None), "color_grade_wheels_dock", None) @@ -3866,12 +4080,7 @@ def _restore_state_and_timeline(self): self.restoreState(self.saved_state) # Re-apply removed-dock state that Qt's saveState/restoreState doesn't preserve. hidden_names = get_app().get_settings().get('hidden_docks') or [] - if hidden_names: - name_to_dock = {d.objectName(): d for d in self.getDocks()} - for name in hidden_names: - dock = name_to_dock.get(name) - if dock: - self.removeDockWidget(dock) + self._restore_hidden_docks(hidden_names) self._apply_saved_timeline_height() def _apply_saved_timeline_height(self): From 09b715882b16e9b2d7fb1d2e9a9730bd5f3a8c12 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas <jonathan@openshot.org> Date: Fri, 1 May 2026 23:09:26 -0500 Subject: [PATCH 23/23] Fixing 10 Codacy nitpicks, minor cleanup, some exception handling logs, etc... --- src/tests/test_camera_motion.py | 1 - src/tests/test_video_widget_transform.py | 2 +- src/windows/color_grade_editor.py | 6 ++---- src/windows/preview_thread.py | 12 ++++++------ src/windows/scope_panel.py | 5 +++-- src/windows/video_widget.py | 1 - src/windows/views/properties_tableview.py | 1 - 7 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/tests/test_camera_motion.py b/src/tests/test_camera_motion.py index f4906e77b5..6676a67549 100644 --- a/src/tests/test_camera_motion.py +++ b/src/tests/test_camera_motion.py @@ -28,7 +28,6 @@ from classes.camera_motion import ( KEN_BURNS_AUTO, - KEN_BURNS_BOTTOM_TO_TOP, KEN_BURNS_LEFT_TO_RIGHT, KEN_BURNS_TOP_TO_BOTTOM, PAN_AUTO, diff --git a/src/tests/test_video_widget_transform.py b/src/tests/test_video_widget_transform.py index f8f797e4fb..f5503cd6f4 100644 --- a/src/tests/test_video_widget_transform.py +++ b/src/tests/test_video_widget_transform.py @@ -136,7 +136,7 @@ def test_location_offset_inverse_round_trips_drag_motion(self): def test_scale_none_uses_project_to_viewport_pixel_ratio(self): fake_app = types.SimpleNamespace( - project=types.SimpleNamespace(get=lambda key: {"width": 320, "height": 180}.get(key)) + project=types.SimpleNamespace(get={"width": 320, "height": 180}.get) ) with patch("windows.video_widget.get_app", return_value=fake_app): center = self.rect_for(openshot.SCALE_NONE) diff --git a/src/windows/color_grade_editor.py b/src/windows/color_grade_editor.py index 6731e1d295..9d391ac51a 100644 --- a/src/windows/color_grade_editor.py +++ b/src/windows/color_grade_editor.py @@ -26,13 +26,12 @@ """ import copy -import json import math -from qt_api import Qt, QPointF, QRectF, QSize, pyqtSignal, QShortcut, QKeySequence, QTimer +from qt_api import Qt, QPointF, QRectF, QSize, pyqtSignal, QShortcut, QKeySequence from qt_api import QColor, QPainter, QPen, QBrush, QPainterPath, QPixmap, QIcon from qt_api import QWidget, QDialog, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QAction -from qt_api import QDialogButtonBox, QFrame +from qt_api import QDialogButtonBox from qt_api import QFontMetrics, QSizePolicy from qt_api import QLineEdit, QEvent, QLinearGradient @@ -849,7 +848,6 @@ def _set_handle_from_position(self, node_id, side, pos, modifiers): handle_y = max(-2.0, min(2.0, handle_y)) self._set_handle_value(node, side, handle_x, handle_y) - opposite_side = "right" if side == "left" else "left" if modifiers & Qt.ShiftModifier: opposite_node = node if side == "left": diff --git a/src/windows/preview_thread.py b/src/windows/preview_thread.py index 20f1f9c1a7..9e4ed5bc95 100644 --- a/src/windows/preview_thread.py +++ b/src/windows/preview_thread.py @@ -428,8 +428,8 @@ def run_scope_analysis(self, frame_number, need_waveform, need_histogram, need_v waveform_settings = waveform_render if isinstance(waveform_render, dict) else {} try: scope.SetWaveformColumns(max(32, int(waveform_settings.get("columns", 256) or 256))) - except Exception: - pass + except Exception as ex: + log.debug("Unable to set waveform column count: %s", ex) if need_vectorscope: is_playing = False try: @@ -483,8 +483,8 @@ def run_scope_analysis(self, frame_number, need_waveform, need_histogram, need_v try: root = json.loads(scope.Json()) vectorscope = root.get("video", {}).get("vectorscope", vectorscope) - except Exception: - pass + except Exception as ex: + log.debug("Unable to read vectorscope data from scope JSON: %s", ex) try: vectorscope["image"] = build_vectorscope_image( vectorscope.get("density", []), @@ -492,8 +492,8 @@ def run_scope_analysis(self, frame_number, need_waveform, need_histogram, need_v zoom_factor, display, ) - except Exception: - pass + except Exception as ex: + log.debug("Unable to build vectorscope image: %s", ex) video["vectorscope"] = vectorscope video["summary"] = { "avg_luma": scope.GetVideoAverageLuma(), diff --git a/src/windows/scope_panel.py b/src/windows/scope_panel.py index 1498efe91b..3fddd7a646 100644 --- a/src/windows/scope_panel.py +++ b/src/windows/scope_panel.py @@ -35,6 +35,7 @@ QComboBox, QToolButton, QRect, QPainterPath, QPointF, ) from classes import info +from classes.logger import log from windows.color_grade_editor import draw_broadcast_hue_ring # ─── Persistent settings keys ──────────────────────────────────────────────── @@ -79,8 +80,8 @@ def _set(key, value): if s is not None: try: s.set(key, value) - except Exception: - pass + except Exception as ex: + log.debug("Unable to save scope setting %s: %s", key, ex) def _scope_region_icon(size=16): diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index b43a33a79e..47e3ea3e86 100644 --- a/src/windows/video_widget.py +++ b/src/windows/video_widget.py @@ -1181,7 +1181,6 @@ def mouseMoveEvent(self, event): last_point = self._clamp_region_point(self.region_transform_inverted.map(self.mouse_position)) diff_x = point.x() - last_point.x() diff_y = point.y() - last_point.y() - current_rect = self._scope_region_rect() or QRectF(point, point) if self.region_mode == "draw": anchor = self.scope_region_drag_anchor or QPointF(point) diff --git a/src/windows/views/properties_tableview.py b/src/windows/views/properties_tableview.py index c052ec4ea7..bbf3400060 100644 --- a/src/windows/views/properties_tableview.py +++ b/src/windows/views/properties_tableview.py @@ -68,7 +68,6 @@ normalize_wheels_data, puck_display_color, scope_angle_for_display_hue, - wheels_enabled_at_frame, wheels_snapshot, wheels_summary, )