Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ For more information, please visit <http://www.openshot.org/>.
set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/Modules")

################ PROJECT VERSION ####################
set(PROJECT_VERSION_FULL "1.0.0")
set(PROJECT_SO_VERSION 31)
set(PROJECT_VERSION_FULL "1.0.1")
set(PROJECT_SO_VERSION 32)

# Remove the dash and anything following, to get the #.#.# version for project()
STRING(REGEX REPLACE "\-.*$" "" VERSION_NUM "${PROJECT_VERSION_FULL}")
Expand Down
50 changes: 50 additions & 0 deletions doc/JSON-KEYFRAME-BENCHMARK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: 2026 OpenShot Studios, LLC
SPDX-License-Identifier: LGPL-3.0-or-later
-->

# JSON keyframe edit benchmark

Build `openshot-benchmark`, then run from the repository root:

```sh
cmake --build build --target openshot-benchmark -j2
QT_QPA_PLATFORM=offscreen build/tests/openshot-benchmark --test 'Timeline JSON transforms (0 existing keys)'
QT_QPA_PLATFORM=offscreen build/tests/openshot-benchmark --test 'Timeline JSON transforms (1000 existing keys)'
```

Each case uses an image clip on layer 5 of a 1280×720, 24 fps timeline. It
starts with the specified number of keys **per axis**, then adds 100 successive
`location_x` / `location_y` samples with Bezier interpolation and handles. Each
JSON diff contains the complete growing curves, matching the editor's transform
update format. Both axes are sent in one diff. The result verifies the final
point counts and values so a discarded update cannot appear faster.

Payload generation, serialization, initial population, and clip/timeline setup
are outside the timer. The measured operation is `Timeline::ApplyJsonDiff`,
including JSON parsing, native keyframe replacement, and cache invalidation.
There is no concurrent player or frame rendering. This isolates application cost;
it is not a measurement of end-to-end GUI responsiveness or lock contention.

The benchmark reports the median operations/second of three trials. One operation
is a JSON update here; for the existing rendering trials it is a frame.
Milliseconds per update = `1000 / operations_per_second`.

## Local comparison

Baseline: release-20260919 at cfa5dfa3, before native JSON/keyframe optimizations.
Optimized build: local changes described below. Both use the same benchmark,
build configuration (`-O3 -DNDEBUG`), system JsonCpp, and machine. These timings
are local measurements, not cross-machine performance guarantees.

| Existing keys per axis | Baseline ms/update | Optimized ms/update |
| --- | ---: | ---: |
| 0 (grows to 100) | 0.82 | 0.33 |
| 1,000 (grows to 1,100) | 17.99 | 6.73 |

The changes transfer already-owned JSON values through the existing by-value
interfaces, avoid copying derived animation data just to load base clip fields,
and retain keyframe storage with a fast append path for sorted input. Unsorted
and duplicate frame coordinates still use the existing `AddPoint` behavior.
Existing public signatures, virtual method layout, and the JSON format are unchanged.
The complete incoming animation still has to be parsed on every update.
2 changes: 1 addition & 1 deletion src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ add_feature_info("Wayland screen capture" OPENSHOT_WAYLAND_CAPTURE "Use the opti
# Find JUCE-based openshot Audio libraries
if(NOT TARGET OpenShot::Audio)
# Only load if necessary (not for integrated builds)
find_package(OpenShotAudio 1.0.0 REQUIRED)
find_package(OpenShotAudio 1.0.1 REQUIRED)
endif()
target_link_libraries(openshot PUBLIC OpenShot::Audio)

Expand Down
76 changes: 44 additions & 32 deletions src/Clip.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//
// SPDX-License-Identifier: LGPL-3.0-or-later

#include <utility>
#include "Clip.h"

#include "AudioResampler.h"
Expand Down Expand Up @@ -77,6 +78,7 @@ void Clip::init_settings()
ClipBase::End(0.0);
gravity = GRAVITY_CENTER;
scale = SCALE_FIT;
location_coordinate_system = "auto";
anchor = ANCHOR_CANVAS;
display = FRAME_DISPLAY_NONE;
mixing = VOLUME_MIX_NONE;
Expand Down Expand Up @@ -968,6 +970,7 @@ Json::Value Clip::JsonValue() const {
root["parentObjectId"] = parentObjectId;
root["gravity"] = gravity;
root["scale"] = scale;
root["location_coordinate_system"] = location_coordinate_system;
root["anchor"] = anchor;
root["display"] = display;
root["mixing"] = mixing;
Expand Down Expand Up @@ -1035,9 +1038,9 @@ void Clip::SetJson(const std::string value) {
// Parse JSON string into JSON objects
try
{
const Json::Value root = openshot::stringToJson(value);
Json::Value root = openshot::stringToJson(value);
// Set all values that match
SetJsonValue(root);
SetJsonValue(std::move(root));
}
catch (const std::exception& e)
{
Expand All @@ -1047,15 +1050,15 @@ void Clip::SetJson(const std::string value) {
}

// Load Json::Value into this object
void Clip::SetJsonValue(const Json::Value root) {
void Clip::SetJsonValue(Json::Value root) {
auto ensure_default_keyframe = [](Keyframe& kf, double default_value) {
if (kf.GetCount() == 0) {
kf = Keyframe(default_value);
}
};

// Set parent data
ClipBase::SetJsonValue(root);
SetBaseJsonValue(root);

// Older project files predate reader-applied orientation metadata and stored
// phone/camera rotation as ordinary clip rotation/scale keyframes.
Expand Down Expand Up @@ -1084,6 +1087,12 @@ void Clip::SetJsonValue(const Json::Value root) {
gravity = (GravityType) root["gravity"].asInt();
if (!root["scale"].isNull())
scale = (ScaleType) root["scale"].asInt();
if (!root["location_coordinate_system"].isNull()) {
const auto& coordinates = root["location_coordinate_system"];
location_coordinate_system = coordinates.isString() ? coordinates.asString() : "auto";
if (location_coordinate_system != "canvas" && location_coordinate_system != "geometry")
location_coordinate_system = "auto";
}
if (!root["anchor"].isNull())
anchor = (AnchorType) root["anchor"].asInt();
if (!root["display"].isNull())
Expand All @@ -1097,59 +1106,59 @@ void Clip::SetJsonValue(const Json::Value root) {
if (!root["waveform_mode"].isNull())
waveform_mode = root["waveform_mode"].asInt();
if (!root["scale_x"].isNull())
scale_x.SetJsonValue(root["scale_x"]);
scale_x.SetJsonValue(std::move(root["scale_x"]));
if (!root["scale_y"].isNull())
scale_y.SetJsonValue(root["scale_y"]);
scale_y.SetJsonValue(std::move(root["scale_y"]));
if (!root["location_x"].isNull())
location_x.SetJsonValue(root["location_x"]);
location_x.SetJsonValue(std::move(root["location_x"]));
if (!root["location_y"].isNull())
location_y.SetJsonValue(root["location_y"]);
location_y.SetJsonValue(std::move(root["location_y"]));
if (!root["alpha"].isNull())
alpha.SetJsonValue(root["alpha"]);
alpha.SetJsonValue(std::move(root["alpha"]));
if (!root["corner_radius"].isNull())
corner_radius.SetJsonValue(root["corner_radius"]);
corner_radius.SetJsonValue(std::move(root["corner_radius"]));
if (!root["margin"].isNull())
margin.SetJsonValue(root["margin"]);
margin.SetJsonValue(std::move(root["margin"]));
if (!root["rotation"].isNull())
rotation.SetJsonValue(root["rotation"]);
rotation.SetJsonValue(std::move(root["rotation"]));
if (!root["time"].isNull())
time.SetJsonValue(root["time"]);
time.SetJsonValue(std::move(root["time"]));
if (!root["volume"].isNull())
volume.SetJsonValue(root["volume"]);
volume.SetJsonValue(std::move(root["volume"]));
if (!root["wave_color"].isNull())
wave_color.SetJsonValue(root["wave_color"]);
wave_color.SetJsonValue(std::move(root["wave_color"]));
if (!root["shear_x"].isNull())
shear_x.SetJsonValue(root["shear_x"]);
shear_x.SetJsonValue(std::move(root["shear_x"]));
if (!root["shear_y"].isNull())
shear_y.SetJsonValue(root["shear_y"]);
shear_y.SetJsonValue(std::move(root["shear_y"]));
if (!root["origin_x"].isNull())
origin_x.SetJsonValue(root["origin_x"]);
origin_x.SetJsonValue(std::move(root["origin_x"]));
if (!root["origin_y"].isNull())
origin_y.SetJsonValue(root["origin_y"]);
origin_y.SetJsonValue(std::move(root["origin_y"]));
if (!root["channel_filter"].isNull())
channel_filter.SetJsonValue(root["channel_filter"]);
channel_filter.SetJsonValue(std::move(root["channel_filter"]));
if (!root["channel_mapping"].isNull())
channel_mapping.SetJsonValue(root["channel_mapping"]);
channel_mapping.SetJsonValue(std::move(root["channel_mapping"]));
if (!root["has_audio"].isNull())
has_audio.SetJsonValue(root["has_audio"]);
has_audio.SetJsonValue(std::move(root["has_audio"]));
if (!root["has_video"].isNull())
has_video.SetJsonValue(root["has_video"]);
has_video.SetJsonValue(std::move(root["has_video"]));
if (!root["perspective_c1_x"].isNull())
perspective_c1_x.SetJsonValue(root["perspective_c1_x"]);
perspective_c1_x.SetJsonValue(std::move(root["perspective_c1_x"]));
if (!root["perspective_c1_y"].isNull())
perspective_c1_y.SetJsonValue(root["perspective_c1_y"]);
perspective_c1_y.SetJsonValue(std::move(root["perspective_c1_y"]));
if (!root["perspective_c2_x"].isNull())
perspective_c2_x.SetJsonValue(root["perspective_c2_x"]);
perspective_c2_x.SetJsonValue(std::move(root["perspective_c2_x"]));
if (!root["perspective_c2_y"].isNull())
perspective_c2_y.SetJsonValue(root["perspective_c2_y"]);
perspective_c2_y.SetJsonValue(std::move(root["perspective_c2_y"]));
if (!root["perspective_c3_x"].isNull())
perspective_c3_x.SetJsonValue(root["perspective_c3_x"]);
perspective_c3_x.SetJsonValue(std::move(root["perspective_c3_x"]));
if (!root["perspective_c3_y"].isNull())
perspective_c3_y.SetJsonValue(root["perspective_c3_y"]);
perspective_c3_y.SetJsonValue(std::move(root["perspective_c3_y"]));
if (!root["perspective_c4_x"].isNull())
perspective_c4_x.SetJsonValue(root["perspective_c4_x"]);
perspective_c4_x.SetJsonValue(std::move(root["perspective_c4_x"]));
if (!root["perspective_c4_y"].isNull())
perspective_c4_y.SetJsonValue(root["perspective_c4_y"]);
perspective_c4_y.SetJsonValue(std::move(root["perspective_c4_y"]));

// Core clip transforms should never remain empty after load. Empty JSON
// point arrays can be produced by editing flows that remove every keyframe.
Expand Down Expand Up @@ -1692,7 +1701,10 @@ QTransform Clip::get_transform(std::shared_ptr<Frame> frame, int width, int heig
}
return location * (canvas_size - anchored_position);
};
if (scale == SCALE_CROP) {
// Preserve imported location curves in their original units. Converting only
// their keyframes cannot preserve animated scale/margin or reader resizing.
if (location_coordinate_system == "geometry" ||
(location_coordinate_system != "canvas" && scale == SCALE_CROP)) {
x += location_offset(location_x_value, x - layout_x, layout_width, scaled_source_width);
y += location_offset(location_y_value, y - layout_y, layout_height, scaled_source_height);
} else {
Expand Down
3 changes: 3 additions & 0 deletions src/Clip.h
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ namespace openshot {
public:
openshot::GravityType gravity; ///< The gravity of a clip determines where it snaps to its parent
openshot::ScaleType scale; ///< The scale determines how a clip should be resized to fit its parent
/// Location units retained by imported projects: "auto", "canvas", or "geometry".
/// Auto uses geometry-relative units for Crop and canvas-relative units otherwise.
std::string location_coordinate_system;
openshot::AnchorType anchor; ///< The anchor determines what parent a clip should snap to
openshot::FrameDisplayType display; ///< The format to display the frame number (if any)
openshot::VolumeMixType mixing; ///< What strategy should be followed when mixing audio with other clips
Expand Down
4 changes: 4 additions & 0 deletions src/ClipBase.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Json::Value ClipBase::JsonValue() const {

// Load Json::Value into this object
void ClipBase::SetJsonValue(const Json::Value root) {
SetBaseJsonValue(root);
}

void ClipBase::SetBaseJsonValue(const Json::Value& root) {

// Set data from Json (if key is found)
if (!root["id"].isNull())
Expand Down
3 changes: 3 additions & 0 deletions src/ClipBase.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ namespace openshot {
std::string previous_properties; ///< This string contains the previous JSON properties
openshot::TimelineBase* timeline; ///< Pointer to the parent timeline instance (if any)

/// Load common clip fields without copying derived animation data.
void SetBaseJsonValue(const Json::Value& root);

/// Generate JSON for a property
Json::Value add_property_json(std::string name, float value, std::string type, std::string memo, const Keyframe* keyframe, float min_value, float max_value, bool readonly, int64_t requested_frame) const;

Expand Down
15 changes: 8 additions & 7 deletions src/Color.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//
// SPDX-License-Identifier: LGPL-3.0-or-later

#include <utility>
#include <cmath>

#include "Color.h"
Expand Down Expand Up @@ -102,9 +103,9 @@ void Color::SetJson(const std::string value) {
// Parse JSON string into JSON objects
try
{
const Json::Value root = openshot::stringToJson(value);
Json::Value root = openshot::stringToJson(value);
// Set all values that match
SetJsonValue(root);
SetJsonValue(std::move(root));
}
catch (const std::exception& e)
{
Expand All @@ -114,15 +115,15 @@ void Color::SetJson(const std::string value) {
}

// Load Json::Value into this object
void Color::SetJsonValue(const Json::Value root) {
void Color::SetJsonValue(Json::Value root) {

// Set data from Json (if key is found)
if (!root["red"].isNull())
red.SetJsonValue(root["red"]);
red.SetJsonValue(std::move(root["red"]));
if (!root["green"].isNull())
green.SetJsonValue(root["green"]);
green.SetJsonValue(std::move(root["green"]));
if (!root["blue"].isNull())
blue.SetJsonValue(root["blue"]);
blue.SetJsonValue(std::move(root["blue"]));
if (!root["alpha"].isNull())
alpha.SetJsonValue(root["alpha"]);
alpha.SetJsonValue(std::move(root["alpha"]));
}
11 changes: 6 additions & 5 deletions src/EffectBase.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//
// SPDX-License-Identifier: LGPL-3.0-or-later

#include <utility>
#include <iostream>
#include <iomanip>
#include <algorithm>
Expand Down Expand Up @@ -136,7 +137,7 @@ void EffectBase::SetJson(const std::string value) {
{
Json::Value root = openshot::stringToJson(value);
// Set all values that match
SetJsonValue(root);
SetJsonValue(std::move(root));
}
catch (const std::exception& e)
{
Expand All @@ -146,7 +147,7 @@ void EffectBase::SetJson(const std::string value) {
}

// Load Json::Value into this object
void EffectBase::SetJsonValue(const Json::Value root) {
void EffectBase::SetJsonValue(Json::Value root) {
const std::string original_id = this->Id();
const std::string original_parent_effect_id = this->info.parent_effect_id;

Expand All @@ -159,15 +160,15 @@ void EffectBase::SetJsonValue(const Json::Value root) {
root["id"].asString() == original_parent_effect_id &&
root["id"].asString() != original_id;
if (applying_parent_payload) {
my_root = root;
my_root = std::move(root);
my_root["id"] = original_id;
my_root["parent_effect_id"] = original_parent_effect_id;
} else if (parentEffect){
my_root = parentEffect->JsonValue();
my_root["id"] = this->Id();
my_root["parent_effect_id"] = this->info.parent_effect_id;
} else {
my_root = root;
my_root = std::move(root);
}

// Legacy compatibility: older shared-mask JSON stored source trim
Expand All @@ -178,7 +179,7 @@ void EffectBase::SetJsonValue(const Json::Value root) {
my_root["end"] = my_root["mask_end"];

// Set parent data
ClipBase::SetJsonValue(my_root);
SetBaseJsonValue(my_root);

// Set data from Json (if key is found)
if (!my_root["order"].isNull())
Expand Down
31 changes: 31 additions & 0 deletions src/FFmpegColorRange.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2026 OpenShot Studios, LLC
// SPDX-License-Identifier: LGPL-3.0-or-later
#pragma once

#include "FFmpegUtilities.h"

// Normalize deprecated JPEG-range YUVJ formats before creating swscale contexts.
// swscale expects non-YUVJ formats plus explicit color-range metadata.
inline AVPixelFormat NormalizeDeprecatedPixFmt(AVPixelFormat pix_fmt, bool& is_full_range) {
switch (pix_fmt) {
case AV_PIX_FMT_YUVJ420P:
is_full_range = true;
return AV_PIX_FMT_YUV420P;
case AV_PIX_FMT_YUVJ422P:
is_full_range = true;
return AV_PIX_FMT_YUV422P;
case AV_PIX_FMT_YUVJ444P:
is_full_range = true;
return AV_PIX_FMT_YUV444P;
case AV_PIX_FMT_YUVJ440P:
is_full_range = true;
return AV_PIX_FMT_YUV440P;
#ifdef AV_PIX_FMT_YUVJ411P
case AV_PIX_FMT_YUVJ411P:
is_full_range = true;
return AV_PIX_FMT_YUV411P;
#endif
default:
return pix_fmt;
}
}
Loading
Loading