diff --git a/doc/animation.rst b/doc/animation.rst index d8823cf61f..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 @@ -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.). @@ -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 7cf7326d52..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 @@ -132,98 +132,146 @@ 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. - -- **Usage Example:** Creating a slow-motion effect to emphasize a specific action. -- **Tip:** Use time presets to creatively manipulate the pacing of your video. +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. + +- **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: 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 +301,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 +310,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 +411,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 +448,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 +462,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 +766,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 +861,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 +891,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..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 @@ -100,21 +100,21 @@ 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`. + are not opened automatically — open them any time from :guilabel:`View → Scopes`. -**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. -**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/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 85fde94db1..f742ea8a72 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 @@ -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. @@ -529,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 @@ -547,8 +560,8 @@ 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 -apply it instantly, or switch to :guilabel:`View → Views → Color View` for a dedicated grading workspace. +**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 → Color View` for a dedicated grading workspace. .. seealso:: @@ -932,23 +945,84 @@ 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:`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. + +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 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 @@ -1136,6 +1210,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 @@ -1143,11 +1223,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 diff --git a/doc/export.rst b/doc/export.rst index 16943ff6da..f6eb3c0615 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,88 @@ 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. + +.. list-table:: + :widths: 20 22 30 28 + :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: + +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/images/clip-presets.jpg b/doc/images/clip-presets.jpg index e437f3d273..b1e3104838 100644 Binary files a/doc/images/clip-presets.jpg and b/doc/images/clip-presets.jpg differ 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..3db4bd6e80 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) +- **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** - **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..e4da2b4e39 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 @@ -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` - Freeze View :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` - Un-Freeze View :kbd:`Ctrl+Shift+F` Undo :kbd:`Ctrl+Z` View Toolbar :kbd:`Ctrl+Shift+B` Zoom In :kbd:`=` :kbd:`Ctrl+=` @@ -262,10 +259,11 @@ 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:`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. * - Help - - :guilabel:`Contents` Open the user guide online. @@ -276,19 +274,21 @@ 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 ^^^^^^^^^^^ 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 ^^^^^^^^^^^^^ @@ -302,11 +302,47 @@ 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 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 +374,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/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/animation_presets.py b/src/animation_presets.py new file mode 100644 index 0000000000..254e5dedb9 --- /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.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) + ], + }, + + '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.25, 'ease_out_cubic'), (24, -0.10, 'ease_out_cubic'), + (28, 0.05, '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.25, 'ease_out_cubic'), (24, -0.10, 'ease_out_cubic'), + (28, 0.05, '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.25, 'ease_out_cubic'), (24, 0.10, 'ease_out_cubic'), + (28, -0.05, '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.25, 'ease_out_cubic'), (24, 0.10, 'ease_out_cubic'), + (28, -0.05, '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.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.25), (31, -3)], + }, + + 'bounceOutRight': { + 'alpha': [(7, 1), (31, 0)], + 'scale_x': [(7, 0.9), (31, 2)], + '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.125), (13, 0.25), (14, 0.25), (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/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/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/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/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/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/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/effects/icons/filmgrain.png b/src/effects/icons/filmgrain.png new file mode 100644 index 0000000000..17d1663204 Binary files /dev/null and b/src/effects/icons/filmgrain.png differ diff --git a/src/effects/icons/filmgrain@2x.png b/src/effects/icons/filmgrain@2x.png new file mode 100644 index 0000000000..cd2706535b Binary files /dev/null and b/src/effects/icons/filmgrain@2x.png differ 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/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 + diff --git a/src/settings/_default.settings b/src/settings/_default.settings index 890adb5f9a..25e3f8ae96 100644 --- a/src/settings/_default.settings +++ b/src/settings/_default.settings @@ -160,25 +160,32 @@ "setting": "window_geometry_v2" }, { - "value": false, + "value": 0, "title": "", "type": "hidden", "category": "Qt", - "setting": "docks_frozen" + "setting": "timeline_height" }, { - "value": 0, + "value": [], "title": "", "type": "hidden", "category": "Qt", - "setting": "timeline_height" + "setting": "hidden_docks" }, { "value": [], "title": "", "type": "hidden", "category": "Qt", - "setting": "hidden_docks" + "setting": "custom_views" + }, + { + "value": "", + "title": "", + "type": "hidden", + "category": "Qt", + "setting": "active_custom_view" }, { "value": "thumbnail", @@ -1289,30 +1296,6 @@ "value": "Alt+Shift+1", "type": "text" }, - { - "category": "Keyboard", - "title": "Freeze View", - "restart": false, - "setting": "actionFreeze_View", - "value": "Ctrl+F", - "type": "text" - }, - { - "category": "Keyboard", - "title": "Un-Freeze View", - "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_camera_motion.py b/src/tests/test_camera_motion.py new file mode 100644 index 0000000000..6676a67549 --- /dev/null +++ b/src/tests/test_camera_motion.py @@ -0,0 +1,139 @@ +""" + @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_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_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/tests/test_film_grain_presets.py b/src/tests/test_film_grain_presets.py new file mode 100644 index 0000000000..9676e0dc18 --- /dev/null +++ b/src/tests/test_film_grain_presets.py @@ -0,0 +1,80 @@ +""" + @file + @brief Unit tests for Film Grain preset helpers +""" + +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.film_grain_presets import ( # noqa: E402 + 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_SUPER_8, + apply_film_grain_preset, + is_film_grain_effect, +) + + +class FilmGrainPresetTests(unittest.TestCase): + def test_presets_initialize_all_visible_keyframe_controls(self): + for preset in ( + FILM_GRAIN_PRESET_35MM_FINE, + FILM_GRAIN_PRESET_35MM_CLASSIC, + FILM_GRAIN_PRESET_35MM_GRITTY, + FILM_GRAIN_PRESET_16MM_CLASSIC, + FILM_GRAIN_PRESET_SUPER_8, + FILM_GRAIN_PRESET_HIGH_ISO, + ): + payload = apply_film_grain_preset({"class_name": "FilmGrain"}, preset) + for key in ( + "amount", + "size", + "softness", + "clump", + "shadows", + "midtones", + "highlights", + "color_amount", + "color_variation", + "evolution", + "coherence", + ): + self.assertIn("Points", payload[key]) + self.assertEqual(payload[key]["Points"][0]["co"]["X"], 1.0) + self.assertEqual(payload["seed"], 1) + + def test_presets_get_progressively_stronger_and_coarser(self): + fine = apply_film_grain_preset({}, FILM_GRAIN_PRESET_35MM_FINE) + classic = apply_film_grain_preset({}, FILM_GRAIN_PRESET_35MM_CLASSIC) + gritty = apply_film_grain_preset({}, FILM_GRAIN_PRESET_35MM_GRITTY) + sixteen = apply_film_grain_preset({}, FILM_GRAIN_PRESET_16MM_CLASSIC) + super8 = apply_film_grain_preset({}, FILM_GRAIN_PRESET_SUPER_8) + + self.assertLess(fine["amount"]["Points"][0]["co"]["Y"], classic["amount"]["Points"][0]["co"]["Y"]) + self.assertLess(classic["amount"]["Points"][0]["co"]["Y"], gritty["amount"]["Points"][0]["co"]["Y"]) + self.assertLess(gritty["amount"]["Points"][0]["co"]["Y"], sixteen["amount"]["Points"][0]["co"]["Y"]) + self.assertLess(sixteen["amount"]["Points"][0]["co"]["Y"], super8["amount"]["Points"][0]["co"]["Y"]) + self.assertLess(fine["size"]["Points"][0]["co"]["Y"], super8["size"]["Points"][0]["co"]["Y"]) + self.assertLess(fine["clump"]["Points"][0]["co"]["Y"], super8["clump"]["Points"][0]["co"]["Y"]) + + def test_seed_is_plain_int_and_preserved_when_existing(self): + payload = apply_film_grain_preset({"class_name": "FilmGrain", "seed": 1234}, FILM_GRAIN_PRESET_16MM_CLASSIC) + self.assertEqual(payload["seed"], 1234) + self.assertIsInstance(payload["seed"], int) + + def test_is_film_grain_effect(self): + self.assertTrue(is_film_grain_effect({"class_name": "FilmGrain"})) + self.assertFalse(is_film_grain_effect({"class_name": "ColorGrade"})) + self.assertFalse(is_film_grain_effect(None)) + + +if __name__ == "__main__": + unittest.main() 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() diff --git a/src/tests/test_main_window.py b/src/tests/test_main_window.py index 55c2e3fdd3..2ca8e3c525 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, QMenu, QStandardItem, QStandardItemModel from classes.project_data import ProjectDataStore from classes.updates import UpdateManager @@ -121,7 +122,11 @@ 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) + 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): @@ -222,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 = [] @@ -754,6 +774,139 @@ 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_keep_default_dock_features(self): + fake_window = QMainWindow() + normal_dock = QDockWidget("Normal", fake_window) + normal_dock.setObjectName("dockNormal") + + 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.DockWidgetMovable) + self.assertTrue(normal_dock.features() & QDockWidget.DockWidgetFloatable) + + 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) + + 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() + 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_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/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/tests/test_timeline_helpers.py b/src/tests/test_timeline_helpers.py index 99ec204fa8..3e9fed1ac5 100644 --- a/src/tests/test_timeline_helpers.py +++ b/src/tests/test_timeline_helpers.py @@ -155,6 +155,62 @@ 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", + "position": 0.0, + "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 +1531,185 @@ 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_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() + clip.data["reader"] = {"width": 3840, "height": 1080} + app = types.SimpleNamespace( + updates=types.SimpleNamespace(transaction_id=None), + project=types.SimpleNamespace( + get=lambda key: { + "fps": {"num": 30, "den": 1}, + "width": 1920, + "height": 1080, + }.get(key), + 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.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} existing_clip = types.SimpleNamespace(data={"id": "A", "position": 0.0, "start": 0.0, "end": 5.0}) @@ -4185,3 +4420,155 @@ def test_keyframe_bezier_presets_returns_28_entries(self): for preset in presets: 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={ + "id": "C1", + "reader": {"has_video": True}, + "effects": [], + }) + + class Helper: + def __init__(self): + self.updates = [] + + 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} + + def update_clip_data(self, clip_data, **kwargs): + self.updates.append((copy.deepcopy(clip_data), dict(kwargs))) + + history = [] + fake_app = types.SimpleNamespace( + updates=types.SimpleNamespace( + apply_last_action_to_history=lambda original: history.append(copy.deepcopy(original)) + ) + ) + helper = Helper() + + with patch.object(timeline_module.Clip, "get", return_value=clip), \ + patch.object(timeline_module, "get_app", return_value=fake_app): + timeline_module.TimelineView.Film_Grain_Triggered( + helper, + timeline_module.FILM_GRAIN_PRESET_SUPER_8, + ["C1"], + ) + + self.assertEqual(len(clip.data["effects"]), 1) + effect = clip.data["effects"][0] + self.assertEqual(effect["class_name"], "FilmGrain") + self.assertEqual(effect["id"], "FG-1") + self.assertEqual(effect["seed"], 77) + self.assertEqual(effect["amount"]["Points"][0]["co"]["Y"], 0.62) + self.assertEqual(effect["size"]["Points"][0]["co"]["Y"], 0.72) + self.assertEqual(len(helper.updates), 1) + self.assertEqual(helper.updates[0][1], {"only_basic_props": False, "ignore_reader": True}) + self.assertEqual(len(history), 1) + + def test_film_grain_trigger_replaces_duplicate_existing_effects(self): + timeline_module = self.timeline_module + clip = types.SimpleNamespace(id="C1", data={ + "id": "C1", + "reader": {"has_video": True}, + "effects": [ + {"class_name": "FilmGrain", "id": "KEEP", "order": 3, "seed": 222}, + {"class_name": "FilmGrain", "id": "DROP", "seed": 333}, + ], + }) + + class Helper: + def __init__(self): + self.updates = [] + + 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)) + + fake_app = types.SimpleNamespace( + updates=types.SimpleNamespace(apply_last_action_to_history=lambda _original: None) + ) + helper = Helper() + + with patch.object(timeline_module.Clip, "get", return_value=clip), \ + patch.object(timeline_module, "get_app", return_value=fake_app): + timeline_module.TimelineView.Film_Grain_Triggered( + helper, + timeline_module.FILM_GRAIN_PRESET_35MM_FINE, + ["C1"], + ) + + self.assertEqual(len(clip.data["effects"]), 1) + effect = clip.data["effects"][0] + self.assertEqual(effect["id"], "KEEP") + self.assertEqual(effect["order"], 3) + self.assertEqual(effect["seed"], 222) + self.assertEqual(effect["amount"]["Points"][0]["co"]["Y"], 0.14) + + def test_film_grain_trigger_none_removes_existing_effects(self): + timeline_module = self.timeline_module + clip = types.SimpleNamespace(id="C1", data={ + "id": "C1", + "reader": {"has_video": True}, + "effects": [ + {"class_name": "FilmGrain", "id": "FG-1"}, + {"class_name": "Brightness", "id": "B-1"}, + {"class_name": "FilmGrain", "id": "FG-2"}, + ], + }) + + class Helper: + def __init__(self): + self.updates = [] + + 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))) + + history = [] + fake_app = types.SimpleNamespace( + updates=types.SimpleNamespace( + apply_last_action_to_history=lambda original: history.append(copy.deepcopy(original)) + ) + ) + helper = Helper() + + with patch.object(timeline_module.Clip, "get", return_value=clip), \ + patch.object(timeline_module, "get_app", return_value=fake_app): + timeline_module.TimelineView.Film_Grain_Triggered( + helper, + timeline_module.FILM_GRAIN_PRESET_NONE, + ["C1"], + ) + + self.assertEqual(clip.data["effects"], [{"class_name": "Brightness", "id": "B-1"}]) + self.assertEqual(len(helper.updates), 1) + self.assertEqual(len(history), 1) diff --git a/src/tests/test_video_widget_transform.py b/src/tests/test_video_widget_transform.py new file mode 100644 index 0000000000..f5503cd6f4 --- /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={"width": 320, "height": 180}.get) + ) + 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/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/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/color_grade_editor.py b/src/windows/color_grade_editor.py index 31345bdaf6..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 @@ -238,25 +237,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 +345,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 @@ -586,6 +605,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 +618,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 +631,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 +645,9 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.Antialiasing) center, radius = self._center_and_radius() - color = display_wheel_color(self._data) + 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) @@ -630,29 +657,35 @@ 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(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, 1.0)) - painter.setBrush(QBrush(puck_display_color(self._data))) + 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)) + 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) @@ -815,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": @@ -1282,6 +1314,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 = disabled_control_color(self) path = QPainterPath() path.addRoundedRect(rect, 6, 6) @@ -1307,7 +1342,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 disabled_control_color(self, text=True))) painter.drawText(text_rect, Qt.AlignCenter, self._fmt(self._value)) painter.end() @@ -1320,6 +1355,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 +1366,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 +1378,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) @@ -1551,16 +1595,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: @@ -1718,7 +1752,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): @@ -1764,7 +1798,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 @@ -1802,7 +1836,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)) @@ -1828,7 +1862,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/main_window.py b/src/windows/main_window.py index 17094cf9a2..7fd3d5ed1d 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -148,9 +148,6 @@ class MainWindow(updates.UpdateWatcher, QMainWindow): ProjectSaved = pyqtSignal(str) ProjectSaveFailed = pyqtSignal(str, str) - # Docks are closable, movable and floatable - docks_frozen = False - # Save window settings on close def closeEvent(self, event): app = get_app() @@ -1473,7 +1470,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()] @@ -1516,6 +1513,68 @@ 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 _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(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(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(): + self._anchor_and_show_scope_dock(dock) + self.dockLumaWaveform.raise_() + def actionSaveFrame_trigger(self, checked=True): log.info("actionSaveFrame_trigger") @@ -2852,66 +2911,280 @@ 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: # Only show correctly docked widgets dock.show() - 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 - 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) - - @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 closeDocks(self, docks): + """Close dockable widgets.""" + for dock in docks: + if self._dock_is_open(dock): + dock.hide() def addViewDocksMenu(self): - """ Insert a Docks submenu into the View menu, rebuilt dynamically on each open """ + """Insert dynamic Custom Views, Docks, and Scopes submenus into the View menu.""" _ = get_app()._tr - self.docks_menu = self.menuView.addMenu(_("Docks")) + 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) + 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 _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() - for dock in sorted(self.getDocks(), key=lambda d: d.windowTitle()): - if dock.features() & QDockWidget.DockWidgetClosable: - 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) + + def _rebuild_scopes_menu(self): + """Repopulate the Scopes menu with scope docks and scope recovery actions.""" + self.scopes_menu.clear() + _ = get_app()._tr + 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._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: - 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 + 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 @@ -2944,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 @@ -2986,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) @@ -3040,28 +3315,6 @@ 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(): - self.freezeDock(dock, frozen=True) - self.freezeMainToolBar(frozen=True) - self.actionFreeze_View.setVisible(False) - self.actionUn_Freeze_View.setVisible(True) - self.docks_frozen = True - - def actionUn_Freeze_View_trigger(self): - """ Un-Freeze all dockable widgets on the main screen """ - for dock in self.getDocks(): - self.freezeDock(dock, frozen=False) - self.freezeMainToolBar(frozen=False) - self.actionFreeze_View.setVisible(True) - self.actionUn_Freeze_View.setVisible(False) - self.docks_frozen = False - - def actionShow_All_trigger(self): - """ Show all dockable widgets """ - self.showDocks(self.getDocks()) - def actionTutorial_trigger(self): """ Show tutorial again """ s = get_app().get_settings() @@ -3349,7 +3602,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) # 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] @@ -3368,10 +3620,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() timeline_height = s.get('timeline_height') if timeline_height: try: @@ -3832,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): @@ -4356,6 +4599,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 +4673,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: @@ -4526,15 +4804,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" @@ -4545,12 +4831,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() @@ -4622,6 +4912,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: @@ -4996,10 +5289,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/models/properties_model.py b/src/windows/models/properties_model.py index 820ed4d6e3..15771dec5d 100644 --- a/src/windows/models/properties_model.py +++ b/src/windows/models/properties_model.py @@ -67,6 +67,177 @@ 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 + 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) + def _resolve_reader_source_path(self, value): """Resolve a reader property value to a filesystem path when possible.""" if value in (None, ""): @@ -312,6 +483,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/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/ui/main-window.ui b/src/windows/ui/main-window.ui index 7ec102028d..8beb6695b2 100644 --- a/src/windows/ui/main-window.ui +++ b/src/windows/ui/main-window.ui @@ -167,23 +167,18 @@ <property name="title"> <string>View</string> </property> - <widget class="QMenu" name="menuViews"> + <widget class="QMenu" name="menuWindow"> <property name="title"> - <string>Views</string> + <string>Window</string> </property> - <addaction name="actionSimple_View"/> - <addaction name="actionColor_Grade_View"/> - <addaction name="actionAdvanced_View"/> - <addaction name="separator"/> - <addaction name="actionFreeze_View"/> - <addaction name="actionUn_Freeze_View"/> - <addaction name="separator"/> - <addaction name="actionShow_All"/> + <addaction name="actionView_Toolbar"/> + <addaction name="actionFullscreen"/> </widget> - <addaction name="actionView_Toolbar"/> - <addaction name="actionFullscreen"/> + <addaction name="actionSimple_View"/> + <addaction name="actionColor_Grade_View"/> + <addaction name="actionAdvanced_View"/> <addaction name="separator"/> - <addaction name="menuViews"/> + <addaction name="menuWindow"/> </widget> <widget class="QMenu" name="menuHelp"> <property name="title"> @@ -1253,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>Freeze View</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>Un-Freeze View</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</string> - </property> - </action> <action name="actionRecoveryProjects"> <property name="checkable"> <bool>false</bool> diff --git a/src/windows/video_widget.py b/src/windows/video_widget.py index aa0f3d45e9..47e3ea3e86 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): @@ -1172,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) @@ -1305,9 +1313,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( @@ -2001,7 +2022,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() @@ -2017,47 +2064,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/properties_tableview.py b/src/windows/views/properties_tableview.py index dbd82bb7fa..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, ) @@ -557,7 +556,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 @@ -699,6 +724,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): @@ -728,8 +754,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 +771,7 @@ 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") + 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.""" @@ -785,7 +817,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 @@ -801,6 +836,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": @@ -888,6 +966,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() @@ -979,9 +1061,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 - log.debug('mouseMoveEvent: Stop caching frames on timeline') + # 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) @@ -1051,21 +1133,26 @@ def mouseMoveEvent(self, event): # range is unreasonably long (such as position, start, end, etc.... which can be huge #'s) # Get the current value and apply fixed adjustments in response to motion - self.new_value = QLocale().system().toDouble(self.selected_item.text())[0] + if self.new_value is None: + self.new_value = QLocale().system().toDouble(self.selected_item.text())[0] + step = 1.0 if property_type == "int" else 0.50 if drag_diff > 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) @@ -1099,6 +1186,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): @@ -1191,9 +1279,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) @@ -1788,8 +1879,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") @@ -1926,7 +2016,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) @@ -1942,7 +2032,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 @@ -1952,7 +2042,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, 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 diff --git a/src/windows/views/timeline.py b/src/windows/views/timeline.py index 7f9abcd853..e823d4f769 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 @@ -55,6 +54,102 @@ 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, +) + +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, + 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 @@ -83,6 +178,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"): @@ -1203,8 +1350,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 @@ -1304,13 +1449,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 @@ -1395,146 +1543,180 @@ 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)) - 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) + 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) + 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 [ - ("Start of Clip", _("Start of Clip")), - ("End of Clip", _("End of Clip")), - ("Entire Clip", _("Entire Clip")) - ]: - 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 - menu.addMenu(Animate_Menu) - - # Rotate Menu + + 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 Bottom"), MenuAnimate.BACK_IN_UP), + (_("From Left"), MenuAnimate.BACK_IN_LEFT), + (_("From Right"), MenuAnimate.BACK_IN_RIGHT), + (_("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 Bottom"), MenuAnimate.BOUNCE_IN_UP), + (_("From Left"), MenuAnimate.BOUNCE_IN_LEFT), + (_("From Right"), MenuAnimate.BOUNCE_IN_RIGHT), + (_("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), + ])) + _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), + (_("From Bottom"), MenuAnimate.WIPE_IN_BOTTOM), + (_("From Left"), MenuAnimate.WIPE_IN_LEFT), + (_("From Right"), MenuAnimate.WIPE_IN_RIGHT), + (_("From Top"), MenuAnimate.WIPE_IN_TOP), + ])) + 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), + ])) + _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 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), + ])) + _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), + (_("To Bottom"), MenuAnimate.WIPE_OUT_BOTTOM), + (_("To Left"), MenuAnimate.WIPE_OUT_LEFT), + (_("To Right"), MenuAnimate.WIPE_OUT_RIGHT), + (_("To Top"), MenuAnimate.WIPE_OUT_TOP), + ])) + 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), + (_("Shake Y"), MenuAnimate.SHAKE_Y), + (_("Swing"), MenuAnimate.SWING), + (_("Tada"), MenuAnimate.TADA), + (_("Wobble"), MenuAnimate.WOBBLE), + ])) + + # ── Camera ▶ ─────────────────────────────────────────────────────────── + Camera_Menu = StyledContextMenu(title=_("Camera"), parent=self) + 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 ▶ ────────────────────────────────────────────────────────── + Animate_Menu.addMenu(_motion_sub(_("Credits"), [ + (_("Scroll Up"), MenuAnimate.CREDITS_UP), + (_("Scroll Down"), MenuAnimate.CREDITS_DOWN), + ])) + + if clip_has_visual: + menu.addMenu(Animate_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( @@ -1549,7 +1731,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")) @@ -1559,33 +1741,8 @@ 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) - if self._clip_has_video(clip): - 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")) - Lift_Shadows.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_LIFT_SHADOWS, clip_ids)) - Warm_Up = Color_Menu.addAction(_("Warm Up")) - 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) - - # Layout Menu Layout_Menu = StyledContextMenu(title=_("Layout"), parent=self) Layout_None = Layout_Menu.addAction(_("Reset Layout")) Layout_None.triggered.connect(partial( @@ -1613,11 +1770,145 @@ def ShowClipMenu(self, clip_id=None): 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) + Transform_Menu.addMenu(Layout_Menu) + + if clip_has_visual: + menu.addMenu(Transform_Menu) + + if clip_has_visual: + # 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(partial(self.Reset_Look_Triggered, clip_ids)) + Look_Menu.addSeparator() + + Color_Menu = StyledContextMenu(title=_("Color"), parent=self) + 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")) + Lift_Shadows.triggered.connect(partial(self.Color_Triggered, COLOR_PRESET_LIFT_SHADOWS, clip_ids)) + Warm_Up = Color_Menu.addAction(_("Warm Up")) + 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)) + 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( + 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)) + 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( + 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()) - # Time Menu - Time_Menu = StyledContextMenu(title=_("Time"), parent=self) - Time_None = Time_Menu.addAction(_("Reset Time")) + menu.addMenu(Look_Menu) + + # 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() @@ -1628,8 +1919,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) @@ -1686,74 +1977,76 @@ 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)) 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() - - # Volume levels menu optinos - 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) + 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_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) - - # Add separate audio menu - Split_Audio_Channels_Menu = StyledContextMenu(title=_("Separate Audio"), parent=self) + + 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)")) 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) + if clip_has_audio: + Audio_Menu.addMenu(Split_Audio_Channels_Menu) + + if clip_has_audio: + Audio_Menu.addSeparator() + 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")), + _("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)) + 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) # If Playhead overlapping clip if clip: @@ -1787,16 +2080,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 @@ -2186,6 +2471,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: @@ -2193,8 +2495,173 @@ 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 _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_video(clip): + if not clip or not self._clip_has_visual(clip): return None, False effects = clip.data.get("effects") @@ -2214,7 +2681,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)) @@ -2259,13 +2726,64 @@ 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_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 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 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)) @@ -2357,208 +2875,443 @@ 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). + # 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: + 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: + clip_playhead = s + if s <= clip_playhead <= e: + emph_start = clip_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 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): + """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", + } + + 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: - # 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: + _apply_camera_motion(push_pull_keyframes(zoom_in=True)) + elif action == MenuAnimate.CAM_PULL_OUT: + _apply_camera_motion(push_pull_keyframes(zoom_in=False)) + + # ── 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): + 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: + 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 @@ -2576,6 +3329,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""" @@ -2897,12 +3657,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() @@ -2917,7 +3678,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 @@ -2940,9 +3700,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) @@ -2951,32 +3710,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 + 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 == 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) - - 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) + 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: @@ -3196,132 +3990,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]} - - 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) - - 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) - - 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) + clip.data['volume'] = {"Points": [json.loads(openshot.Point(1, 1.0, openshot.BEZIER).Json())]} + + 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())]} + + 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())) + + 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 @@ -3365,6 +4104,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) @@ -3744,8 +4494,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 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", diff --git a/src/windows/views/timeline_backend/enums.py b/src/windows/views/timeline_backend/enums.py index b0f3bc66da..7f81abdd82 100644 --- a/src/windows/views/timeline_backend/enums.py +++ b/src/windows/views/timeline_backend/enums.py @@ -63,25 +63,87 @@ 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_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() class MenuVolume(Enum): 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)