diff --git a/CMakeLists.txt b/CMakeLists.txt
index bb5a5e92d..c8a559ad3 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -24,8 +24,8 @@ For more information, please visit .
set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/Modules")
################ PROJECT VERSION ####################
-set(PROJECT_VERSION_FULL "0.4.0")
-set(PROJECT_SO_VERSION 27)
+set(PROJECT_VERSION_FULL "0.5.0")
+set(PROJECT_SO_VERSION 28)
# Remove the dash and anything following, to get the #.#.# version for project()
STRING(REGEX REPLACE "\-.*$" "" VERSION_NUM "${PROJECT_VERSION_FULL}")
diff --git a/examples/animation.gif b/examples/animation.gif
new file mode 100644
index 000000000..0b4ca00f5
Binary files /dev/null and b/examples/animation.gif differ
diff --git a/examples/eq_sphere_plane.png b/examples/eq_sphere_plane.png
new file mode 100644
index 000000000..ac574b514
Binary files /dev/null and b/examples/eq_sphere_plane.png differ
diff --git a/examples/fisheye_plane_equidistant.png b/examples/fisheye_plane_equidistant.png
new file mode 100644
index 000000000..6fb2a60b7
Binary files /dev/null and b/examples/fisheye_plane_equidistant.png differ
diff --git a/examples/fisheye_plane_equisolid.png b/examples/fisheye_plane_equisolid.png
new file mode 100644
index 000000000..811eada21
Binary files /dev/null and b/examples/fisheye_plane_equisolid.png differ
diff --git a/examples/fisheye_plane_orthographic.png b/examples/fisheye_plane_orthographic.png
new file mode 100644
index 000000000..becb2e8bb
Binary files /dev/null and b/examples/fisheye_plane_orthographic.png differ
diff --git a/examples/fisheye_plane_stereographic.png b/examples/fisheye_plane_stereographic.png
new file mode 100644
index 000000000..a72779393
Binary files /dev/null and b/examples/fisheye_plane_stereographic.png differ
diff --git a/external/godot-cpp b/external/godot-cpp
index 6388e26dd..d502d8e8a 160000
--- a/external/godot-cpp
+++ b/external/godot-cpp
@@ -1 +1 @@
-Subproject commit 6388e26dd8a42071f65f764a3ef3d9523dda3d6e
+Subproject commit d502d8e8aae35248bad69b9f40b98150ab694774
diff --git a/src/AudioWaveformer.cpp b/src/AudioWaveformer.cpp
index 18958319b..98c6610a0 100644
--- a/src/AudioWaveformer.cpp
+++ b/src/AudioWaveformer.cpp
@@ -12,6 +12,13 @@
#include "AudioWaveformer.h"
+#include
+
+#include
+#include
+
+#include "Clip.h"
+
using namespace std;
using namespace openshot;
@@ -31,104 +38,154 @@ AudioWaveformer::~AudioWaveformer()
// Extract audio samples from any ReaderBase class
AudioWaveformData AudioWaveformer::ExtractSamples(int channel, int num_per_second, bool normalize) {
- AudioWaveformData data;
-
- if (reader) {
- // Open reader (if needed)
- bool does_reader_have_video = reader->info.has_video;
- if (!reader->IsOpen()) {
- reader->Open();
- }
- // Disable video for faster processing
- reader->info.has_video = false;
-
- int sample_rate = reader->info.sample_rate;
- int sample_divisor = sample_rate / num_per_second;
- int total_samples = num_per_second * (reader->info.duration + 1.0);
- int extracted_index = 0;
-
- // Force output to zero elements for non-audio readers
- if (!reader->info.has_audio) {
- total_samples = 0;
- }
-
- // Resize and clear audio buffers
- data.resize(total_samples);
- data.zero(total_samples);
-
- // Bail out, if no samples needed
- if (total_samples == 0 || reader->info.channels == 0) {
- return data;
- }
-
- // Loop through all frames
- int sample_index = 0;
- float samples_max = 0.0;
- float chunk_max = 0.0;
- float chunk_squared_sum = 0.0;
-
- // How many channels are we using
- int channel_count = 1;
- if (channel == -1) {
- channel_count = reader->info.channels;
- }
-
- for (auto f = 1; f <= reader->info.video_length; f++) {
- // Get next frame
- shared_ptr frame = reader->GetFrame(f);
-
- // Cache channels for this frame, to reduce # of calls to frame->GetAudioSamples
- float* channels[channel_count];
- for (auto channel_index = 0; channel_index < reader->info.channels; channel_index++) {
- if (channel == channel_index || channel == -1) {
- channels[channel_index] = frame->GetAudioSamples(channel_index);
- }
- }
-
- // Get sample value from a specific channel (or all channels)
- for (auto s = 0; s < frame->GetAudioSamplesCount(); s++) {
- for (auto channel_index = 0; channel_index < reader->info.channels; channel_index++) {
- if (channel == channel_index || channel == -1) {
- float *samples = channels[channel_index];
- float rms_sample_value = std::sqrt(samples[s] * samples[s]);
-
- // Accumulate sample averages
- chunk_squared_sum += rms_sample_value;
- chunk_max = std::max(chunk_max, rms_sample_value);
- }
- }
-
- sample_index += 1;
-
- // Cut-off reached
- if (sample_index % sample_divisor == 0) {
- float avg_squared_sum = chunk_squared_sum / (sample_divisor * channel_count);
- data.max_samples[extracted_index] = chunk_max;
- data.rms_samples[extracted_index] = avg_squared_sum;
- extracted_index++;
-
- // Track max/min values
- samples_max = std::max(samples_max, chunk_max);
-
- // reset sample total and index
- sample_index = 0;
- chunk_max = 0.0;
- chunk_squared_sum = 0.0;
- }
- }
- }
-
- // Scale all values to the -1 to +1 range (regardless of how small or how large the
- // original audio sample values are)
- if (normalize && samples_max > 0.0) {
- float scale = 1.0f / samples_max;
- data.scale(total_samples, scale);
- }
-
- // Resume previous has_video value
- reader->info.has_video = does_reader_have_video;
- }
-
-
- return data;
+ AudioWaveformData data;
+
+ if (!reader || num_per_second <= 0) {
+ return data;
+ }
+
+ // Open reader (if needed)
+ bool does_reader_have_video = reader->info.has_video;
+ if (!reader->IsOpen()) {
+ reader->Open();
+ }
+ // Disable video for faster processing
+ reader->info.has_video = false;
+
+ int sample_rate = reader->info.sample_rate;
+ if (sample_rate <= 0) {
+ sample_rate = num_per_second;
+ }
+ int sample_divisor = sample_rate / num_per_second;
+ if (sample_divisor <= 0) {
+ sample_divisor = 1;
+ }
+
+ // Determine length of video frames (for waveform)
+ int64_t reader_video_length = reader->info.video_length;
+ if (const auto *clip = dynamic_cast(reader)) {
+ // If Clip-based reader, and time keyframes present
+ if (clip->time.GetCount() > 1) {
+ reader_video_length = clip->time.GetLength();
+ }
+ }
+ if (reader_video_length < 0) {
+ reader_video_length = 0;
+ }
+ float reader_duration = reader->info.duration;
+ double fps_value = reader->info.fps.ToDouble();
+ float frames_duration = 0.0f;
+ if (reader_video_length > 0 && fps_value > 0.0) {
+ frames_duration = static_cast(reader_video_length / fps_value);
+ }
+ const bool has_source_length = reader->info.video_length > 0;
+ const bool frames_extended = has_source_length && reader_video_length > reader->info.video_length;
+ if (reader_duration <= 0.0f) {
+ reader_duration = frames_duration;
+ } else if ((frames_extended || !has_source_length) && frames_duration > reader_duration + 1e-4f) {
+ reader_duration = frames_duration;
+ }
+ if (reader_duration < 0.0f) {
+ reader_duration = 0.0f;
+ }
+
+ if (!reader->info.has_audio) {
+ reader->info.has_video = does_reader_have_video;
+ return data;
+ }
+
+ int total_samples = static_cast(std::ceil(reader_duration * num_per_second));
+ if (total_samples <= 0 || reader->info.channels == 0) {
+ reader->info.has_video = does_reader_have_video;
+ return data;
+ }
+
+ if (channel != -1 && (channel < 0 || channel >= reader->info.channels)) {
+ reader->info.has_video = does_reader_have_video;
+ return data;
+ }
+
+ // Resize and clear audio buffers
+ data.resize(total_samples);
+ data.zero(total_samples);
+
+ int extracted_index = 0;
+ int sample_index = 0;
+ float samples_max = 0.0f;
+ float chunk_max = 0.0f;
+ float chunk_squared_sum = 0.0f;
+
+ int channel_count = (channel == -1) ? reader->info.channels : 1;
+ std::vector channels(reader->info.channels, nullptr);
+
+ for (int64_t f = 1; f <= reader_video_length && extracted_index < total_samples; f++) {
+ std::shared_ptr frame = reader->GetFrame(f);
+
+ for (int channel_index = 0; channel_index < reader->info.channels; channel_index++) {
+ if (channel == channel_index || channel == -1) {
+ channels[channel_index] = frame->GetAudioSamples(channel_index);
+ }
+ }
+
+ for (int s = 0; s < frame->GetAudioSamplesCount(); s++) {
+ for (int channel_index = 0; channel_index < reader->info.channels; channel_index++) {
+ if (channel == channel_index || channel == -1) {
+ float *samples = channels[channel_index];
+ if (!samples) {
+ continue;
+ }
+ float rms_sample_value = std::sqrt(samples[s] * samples[s]);
+
+ chunk_squared_sum += rms_sample_value;
+ chunk_max = std::max(chunk_max, rms_sample_value);
+ }
+ }
+
+ sample_index += 1;
+
+ if (sample_index % sample_divisor == 0) {
+ float avg_squared_sum = 0.0f;
+ if (channel_count > 0) {
+ avg_squared_sum = chunk_squared_sum / static_cast(sample_divisor * channel_count);
+ }
+
+ if (extracted_index < total_samples) {
+ data.max_samples[extracted_index] = chunk_max;
+ data.rms_samples[extracted_index] = avg_squared_sum;
+ samples_max = std::max(samples_max, chunk_max);
+ extracted_index++;
+ }
+
+ sample_index = 0;
+ chunk_max = 0.0f;
+ chunk_squared_sum = 0.0f;
+
+ if (extracted_index >= total_samples) {
+ break;
+ }
+ }
+ }
+ }
+
+ if (sample_index > 0 && extracted_index < total_samples) {
+ float avg_squared_sum = 0.0f;
+ if (channel_count > 0) {
+ avg_squared_sum = chunk_squared_sum / static_cast(sample_index * channel_count);
+ }
+
+ data.max_samples[extracted_index] = chunk_max;
+ data.rms_samples[extracted_index] = avg_squared_sum;
+ samples_max = std::max(samples_max, chunk_max);
+ extracted_index++;
+ }
+
+ if (normalize && samples_max > 0.0f) {
+ float scale = 1.0f / samples_max;
+ data.scale(total_samples, scale);
+ }
+
+ reader->info.has_video = does_reader_have_video;
+
+ return data;
}
+
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 153b2c1ec..249122ea3 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -117,6 +117,7 @@ set(EFFECTS_SOURCES
effects/Deinterlace.cpp
effects/Hue.cpp
effects/LensFlare.cpp
+ effects/AnalogTape.cpp
effects/Mask.cpp
effects/Negate.cpp
effects/Pixelate.cpp
diff --git a/src/CVTracker.cpp b/src/CVTracker.cpp
index 0690f8f14..f243fcfb5 100644
--- a/src/CVTracker.cpp
+++ b/src/CVTracker.cpp
@@ -14,6 +14,8 @@
#include
#include
#include
+#include
+#include
#include
@@ -25,12 +27,22 @@
using namespace openshot;
using google::protobuf::util::TimeUtil;
+// Clamp a rectangle to image bounds and ensure a minimal size
+static inline void clampRect(cv::Rect2d &r, int width, int height)
+{
+ r.x = std::clamp(r.x, 0.0, double(width - 1));
+ r.y = std::clamp(r.y, 0.0, double(height - 1));
+ r.width = std::clamp(r.width, 1.0, double(width - r.x));
+ r.height = std::clamp(r.height, 1.0, double(height - r.y));
+}
+
// Constructor
CVTracker::CVTracker(std::string processInfoJson, ProcessingController &processingController)
: processingController(&processingController), json_interval(false){
SetJson(processInfoJson);
start = 1;
end = 1;
+ lostCount = 0;
}
// Set desirable tracker method
@@ -54,152 +66,250 @@ cv::Ptr CVTracker::selectTracker(std::string trackerType){
return nullptr;
}
-// Track object in the hole clip or in a given interval
-void CVTracker::trackClip(openshot::Clip& video, size_t _start, size_t _end, bool process_interval){
-
+// Track object in the whole clip or in a given interval
+void CVTracker::trackClip(openshot::Clip& video,
+ size_t _start,
+ size_t _end,
+ bool process_interval)
+{
video.Open();
- if(!json_interval){
+ if (!json_interval) {
start = _start; end = _end;
-
- if(!process_interval || end <= 1 || end-start == 0){
- // Get total number of frames in video
- start = (int)(video.Start() * video.Reader()->info.fps.ToFloat()) + 1;
- end = (int)(video.End() * video.Reader()->info.fps.ToFloat()) + 1;
+ if (!process_interval || end <= 1 || end - start == 0) {
+ start = int(video.Start() * video.Reader()->info.fps.ToFloat()) + 1;
+ end = int(video.End() * video.Reader()->info.fps.ToFloat()) + 1;
}
+ } else {
+ start = int(start + video.Start() * video.Reader()->info.fps.ToFloat()) + 1;
+ end = int(video.End() * video.Reader()->info.fps.ToFloat()) + 1;
}
- else{
- start = (int)(start + video.Start() * video.Reader()->info.fps.ToFloat()) + 1;
- end = (int)(video.End() * video.Reader()->info.fps.ToFloat()) + 1;
- }
-
- if(error){
- return;
- }
-
+ if (error) return;
processingController->SetError(false, "");
- bool trackerInit = false;
-
- size_t frame;
- // Loop through video
- for (frame = start; frame <= end; frame++)
- {
- // Stop the feature tracker process
- if(processingController->ShouldStop()){
- return;
- }
+ bool trackerInit = false;
+ lostCount = 0; // reset lost counter once at the start
- size_t frame_number = frame;
- // Get current frame
- std::shared_ptr f = video.GetFrame(frame_number);
+ for (size_t frame = start; frame <= end; ++frame) {
+ if (processingController->ShouldStop()) return;
- // Grab OpenCV Mat image
- cv::Mat cvimage = f->GetImageCV();
+ auto f = video.GetFrame(frame);
+ cv::Mat img = f->GetImageCV();
- if(frame == start){
- // Take the normalized inital bounding box and multiply to the current video shape
- bbox = cv::Rect2d(int(bbox.x*cvimage.cols), int(bbox.y*cvimage.rows),
- int(bbox.width*cvimage.cols), int(bbox.height*cvimage.rows));
+ if (frame == start) {
+ bbox = cv::Rect2d(
+ int(bbox.x * img.cols),
+ int(bbox.y * img.rows),
+ int(bbox.width * img.cols),
+ int(bbox.height * img.rows)
+ );
}
- // Pass the first frame to initialize the tracker
- if(!trackerInit){
-
- // Initialize the tracker
- initTracker(cvimage, frame_number);
-
+ if (!trackerInit) {
+ initTracker(img, frame);
trackerInit = true;
+ lostCount = 0;
}
- else{
- // Update the object tracker according to frame
- trackerInit = trackFrame(cvimage, frame_number);
-
- // Draw box on image
- FrameData fd = GetTrackedData(frame_number);
+ else {
+ // trackFrame now manages lostCount and will re-init internally
+ trackFrame(img, frame);
+ // record whatever bbox we have now
+ FrameData fd = GetTrackedData(frame);
}
- // Update progress
- processingController->SetProgress(uint(100*(frame_number-start)/(end-start)));
+
+ processingController->SetProgress(
+ uint(100 * (frame - start) / (end - start))
+ );
}
}
// Initialize the tracker
-bool CVTracker::initTracker(cv::Mat &frame, size_t frameId){
-
+bool CVTracker::initTracker(cv::Mat &frame, size_t frameId)
+{
// Create new tracker object
tracker = selectTracker(trackerType);
- // Correct if bounding box contains negative proportions (width and/or height < 0)
- if(bbox.width < 0){
- bbox.x = bbox.x - abs(bbox.width);
- bbox.width = abs(bbox.width);
+ // Correct negative width/height
+ if (bbox.width < 0) {
+ bbox.x -= bbox.width;
+ bbox.width = -bbox.width;
}
- if(bbox.height < 0){
- bbox.y = bbox.y - abs(bbox.height);
- bbox.height = abs(bbox.height);
+ if (bbox.height < 0) {
+ bbox.y -= bbox.height;
+ bbox.height = -bbox.height;
}
+ // Clamp to frame bounds
+ clampRect(bbox, frame.cols, frame.rows);
+
// Initialize tracker
tracker->init(frame, bbox);
- float fw = frame.size().width;
- float fh = frame.size().height;
+ float fw = float(frame.cols), fh = float(frame.rows);
+
+ // record original pixel size
+ origWidth = bbox.width;
+ origHeight = bbox.height;
+
+ // initialize sub-pixel smoother at true center
+ smoothC_x = bbox.x + bbox.width * 0.5;
+ smoothC_y = bbox.y + bbox.height * 0.5;
// Add new frame data
- trackedDataById[frameId] = FrameData(frameId, 0, (bbox.x)/fw,
- (bbox.y)/fh,
- (bbox.x+bbox.width)/fw,
- (bbox.y+bbox.height)/fh);
+ trackedDataById[frameId] = FrameData(
+ frameId, 0,
+ bbox.x / fw,
+ bbox.y / fh,
+ (bbox.x + bbox.width) / fw,
+ (bbox.y + bbox.height) / fh
+ );
return true;
}
// Update the object tracker according to frame
-bool CVTracker::trackFrame(cv::Mat &frame, size_t frameId){
- // Update the tracking result
- bool ok = tracker->update(frame, bbox);
+// returns true if KLT succeeded, false otherwise
+bool CVTracker::trackFrame(cv::Mat &frame, size_t frameId)
+{
+ const int W = frame.cols, H = frame.rows;
+ const auto& prev = trackedDataById[frameId - 1];
+
+ // Reconstruct last-known box in pixel coords
+ cv::Rect2d lastBox(
+ prev.x1 * W, prev.y1 * H,
+ (prev.x2 - prev.x1) * W,
+ (prev.y2 - prev.y1) * H
+ );
+
+ // Convert to grayscale
+ cv::Mat gray;
+ cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
+
+ cv::Rect2d cand;
+ bool didKLT = false;
+
+ // Try KLT-based drift
+ if (!prevGray.empty() && !prevPts.empty()) {
+ std::vector currPts;
+ std::vector status;
+ std::vector err;
+ cv::calcOpticalFlowPyrLK(
+ prevGray, gray,
+ prevPts, currPts,
+ status, err,
+ cv::Size(21,21), 3,
+ cv::TermCriteria{cv::TermCriteria::COUNT|cv::TermCriteria::EPS,30,0.01},
+ cv::OPTFLOW_LK_GET_MIN_EIGENVALS, 1e-4
+ );
+
+ // collect per-point displacements
+ std::vector dx, dy;
+ for (size_t i = 0; i < status.size(); ++i) {
+ if (status[i] && err[i] < 12.0) {
+ dx.push_back(currPts[i].x - prevPts[i].x);
+ dy.push_back(currPts[i].y - prevPts[i].y);
+ }
+ }
- // Add frame number and box coords if tracker finds the object
- // Otherwise add only frame number
- if (ok)
- {
- float fw = frame.size().width;
- float fh = frame.size().height;
-
- cv::Rect2d filtered_box = filter_box_jitter(frameId);
- // Add new frame data
- trackedDataById[frameId] = FrameData(frameId, 0, (filtered_box.x)/fw,
- (filtered_box.y)/fh,
- (filtered_box.x+filtered_box.width)/fw,
- (filtered_box.y+filtered_box.height)/fh);
- }
- else
- {
- // Copy the last frame data if the tracker get lost
- trackedDataById[frameId] = trackedDataById[frameId-1];
+ if ((int)dx.size() >= minKltPts) {
+ auto median = [&](auto &v){
+ std::nth_element(v.begin(), v.begin()+v.size()/2, v.end());
+ return v[v.size()/2];
+ };
+ double mdx = median(dx), mdy = median(dy);
+
+ cand = lastBox;
+ cand.x += mdx;
+ cand.y += mdy;
+ cand.width = origWidth;
+ cand.height = origHeight;
+
+ lostCount = 0;
+ didKLT = true;
+ }
}
- return ok;
-}
+ // Fallback to whole-frame flow if KLT failed
+ if (!didKLT) {
+ ++lostCount;
+ cand = lastBox;
+ if (!fullPrevGray.empty()) {
+ cv::Mat flow;
+ cv::calcOpticalFlowFarneback(
+ fullPrevGray, gray, flow,
+ 0.5,3,15,3,5,1.2,0
+ );
+ cv::Scalar avg = cv::mean(flow);
+ cand.x += avg[0];
+ cand.y += avg[1];
+ }
+ cand.width = origWidth;
+ cand.height = origHeight;
-cv::Rect2d CVTracker::filter_box_jitter(size_t frameId){
- // get tracked data for the previous frame
- float last_box_width = trackedDataById[frameId-1].x2 - trackedDataById[frameId-1].x1;
- float last_box_height = trackedDataById[frameId-1].y2 - trackedDataById[frameId-1].y1;
+ if (lostCount >= 10) {
+ initTracker(frame, frameId);
+ cand = bbox;
+ lostCount = 0;
+ }
+ }
- float curr_box_width = bbox.width;
- float curr_box_height = bbox.height;
- // keep the last width and height if the difference is less than 1%
- float threshold = 0.01;
+ // Dead-zone sub-pixel smoothing
+ {
+ constexpr double JITTER_THRESH = 1.0;
+ double measCx = cand.x + cand.width * 0.5;
+ double measCy = cand.y + cand.height * 0.5;
+ double dx = measCx - smoothC_x;
+ double dy = measCy - smoothC_y;
+
+ if (std::abs(dx) > JITTER_THRESH || std::abs(dy) > JITTER_THRESH) {
+ smoothC_x = measCx;
+ smoothC_y = measCy;
+ }
- cv::Rect2d filtered_box = bbox;
- if(std::abs(1-(curr_box_width/last_box_width)) <= threshold){
- filtered_box.width = last_box_width;
+ cand.x = smoothC_x - cand.width * 0.5;
+ cand.y = smoothC_y - cand.height * 0.5;
}
- if(std::abs(1-(curr_box_height/last_box_height)) <= threshold){
- filtered_box.height = last_box_height;
+
+
+ // Candidate box may now lie outside frame; ROI for KLT is clamped below
+ // Re-seed KLT features
+ {
+ // Clamp ROI to frame bounds and avoid negative width/height
+ int roiX = int(std::clamp(cand.x, 0.0, double(W - 1)));
+ int roiY = int(std::clamp(cand.y, 0.0, double(H - 1)));
+ int roiW = int(std::min(cand.width, double(W - roiX)));
+ int roiH = int(std::min(cand.height, double(H - roiY)));
+ roiW = std::max(0, roiW);
+ roiH = std::max(0, roiH);
+
+ if (roiW > 0 && roiH > 0) {
+ cv::Rect roi(roiX, roiY, roiW, roiH);
+ cv::goodFeaturesToTrack(
+ gray(roi), prevPts,
+ kltMaxCorners, kltQualityLevel,
+ kltMinDist, cv::Mat(), kltBlockSize
+ );
+ for (auto &pt : prevPts)
+ pt += cv::Point2f(float(roi.x), float(roi.y));
+ } else {
+ prevPts.clear();
+ }
}
- return filtered_box;
+
+ // Commit state
+ fullPrevGray = gray.clone();
+ prevGray = gray.clone();
+ bbox = cand;
+ float fw = float(W), fh = float(H);
+ trackedDataById[frameId] = FrameData(
+ frameId, 0,
+ cand.x / fw,
+ cand.y / fh,
+ (cand.x + cand.width) / fw,
+ (cand.y + cand.height) / fh
+ );
+
+ return didKLT;
}
bool CVTracker::SaveTrackedData(){
diff --git a/src/CVTracker.h b/src/CVTracker.h
index eff8b50a8..023d9297b 100644
--- a/src/CVTracker.h
+++ b/src/CVTracker.h
@@ -94,11 +94,25 @@ namespace openshot
bool error = false;
- // Initialize the tracker
- bool initTracker(cv::Mat &frame, size_t frameId);
-
- // Update the object tracker according to frame
- bool trackFrame(cv::Mat &frame, size_t frameId);
+ // count of consecutive “missed” frames
+ int lostCount{0};
+
+ // KLT parameters and state
+ cv::Mat prevGray; // last frame in gray
+ std::vector prevPts; // tracked feature points
+ const int kltMaxCorners = 100; // max features to keep
+ const double kltQualityLevel = 0.01; // goodFeatures threshold
+ const double kltMinDist = 5.0; // min separation
+ const int kltBlockSize = 3; // window for feature detect
+ const int minKltPts = 10; // below this, we assume occluded
+ double smoothC_x = 0, smoothC_y = 0; ///< running, sub-pixel center
+ const double smoothAlpha = 0.8; ///< [0..1], higher → tighter but more jitter
+
+ // full-frame fall-back
+ cv::Mat fullPrevGray;
+
+ // last known good box size
+ double origWidth{0}, origHeight{0};
public:
@@ -113,8 +127,11 @@ namespace openshot
/// If start, end and process_interval are passed as argument, clip will be processed in [start,end)
void trackClip(openshot::Clip& video, size_t _start=0, size_t _end=0, bool process_interval=false);
- /// Filter current bounding box jitter
- cv::Rect2d filter_box_jitter(size_t frameId);
+ // Update the object tracker according to frame
+ bool trackFrame(cv::Mat &frame, size_t frameId);
+
+ // Initialize the tracker
+ bool initTracker(cv::Mat &frame, size_t frameId);
/// Get tracked data for a given frame
FrameData GetTrackedData(size_t frameId);
diff --git a/src/Clip.cpp b/src/Clip.cpp
index b63fdba7c..6e56dd81f 100644
--- a/src/Clip.cpp
+++ b/src/Clip.cpp
@@ -22,6 +22,9 @@
#include "Timeline.h"
#include "ZmqLogger.h"
+#include
+#include
+
#ifdef USE_IMAGEMAGICK
#include "MagickUtilities.h"
#include "ImageReader.h"
@@ -32,6 +35,34 @@
using namespace openshot;
+namespace {
+ struct CompositeChoice { const char* name; CompositeType value; };
+ const CompositeChoice composite_choices[] = {
+ {"Normal", COMPOSITE_SOURCE_OVER},
+
+ // Darken group
+ {"Darken", COMPOSITE_DARKEN},
+ {"Multiply", COMPOSITE_MULTIPLY},
+ {"Color Burn", COMPOSITE_COLOR_BURN},
+
+ // Lighten group
+ {"Lighten", COMPOSITE_LIGHTEN},
+ {"Screen", COMPOSITE_SCREEN},
+ {"Color Dodge", COMPOSITE_COLOR_DODGE},
+ {"Add", COMPOSITE_PLUS},
+
+ // Contrast group
+ {"Overlay", COMPOSITE_OVERLAY},
+ {"Soft Light", COMPOSITE_SOFT_LIGHT},
+ {"Hard Light", COMPOSITE_HARD_LIGHT},
+
+ // Compare
+ {"Difference", COMPOSITE_DIFFERENCE},
+ {"Exclusion", COMPOSITE_EXCLUSION},
+ };
+ const int composite_choices_count = sizeof(composite_choices)/sizeof(CompositeChoice);
+}
+
// Init default settings for a clip
void Clip::init_settings()
{
@@ -45,6 +76,7 @@ void Clip::init_settings()
anchor = ANCHOR_CANVAS;
display = FRAME_DISPLAY_NONE;
mixing = VOLUME_MIX_NONE;
+ composite = COMPOSITE_SOURCE_OVER;
waveform = false;
previous_properties = "";
parentObjectId = "";
@@ -515,8 +547,13 @@ std::shared_ptr Clip::GetParentTrackedObject() {
// Get file extension
std::string Clip::get_file_extension(std::string path)
{
- // return last part of path
- return path.substr(path.find_last_of(".") + 1);
+ // Return last part of path safely (handle filenames without a dot)
+ const auto dot_pos = path.find_last_of('.');
+ if (dot_pos == std::string::npos || dot_pos + 1 >= path.size()) {
+ return std::string();
+ }
+
+ return path.substr(dot_pos + 1);
}
// Adjust the audio and image of a time mapped frame
@@ -540,7 +577,8 @@ void Clip::apply_timemapping(std::shared_ptr frame)
// Get delta (difference from this frame to the next time mapped frame: Y value)
double delta = time.GetDelta(clip_frame_number + 1);
- bool is_increasing = time.IsIncreasing(clip_frame_number + 1);
+ const bool prev_is_increasing = time.IsIncreasing(clip_frame_number);
+ const bool is_increasing = time.IsIncreasing(clip_frame_number + 1);
// Determine length of source audio (in samples)
// A delta of 1.0 == normal expected samples
@@ -553,7 +591,7 @@ void Clip::apply_timemapping(std::shared_ptr frame)
// Determine starting audio location
AudioLocation location;
- if (previous_location.frame == 0 || abs(new_frame_number - previous_location.frame) > 2) {
+ if (previous_location.frame == 0 || abs(new_frame_number - previous_location.frame) > 2 || prev_is_increasing != is_increasing) {
// No previous location OR gap detected
location.frame = new_frame_number;
location.sample_start = 0;
@@ -562,6 +600,7 @@ void Clip::apply_timemapping(std::shared_ptr frame)
// We don't want to interpolate between unrelated audio data
if (resampler) {
delete resampler;
+ resampler = nullptr;
}
// Init resampler with # channels from Reader (should match the timeline)
resampler = new AudioResampler(Reader()->info.channels);
@@ -595,6 +634,12 @@ void Clip::apply_timemapping(std::shared_ptr frame)
std::shared_ptr source_frame = GetOrCreateFrame(location.frame, false);
int frame_sample_count = source_frame->GetAudioSamplesCount() - location.sample_start;
+ // Inform FrameMapper of the direction for THIS mapper frame
+ if (auto *fm = dynamic_cast(reader)) {
+ fm->SetDirectionHint(is_increasing);
+ }
+ source_frame->SetAudioDirection(is_increasing);
+
if (frame_sample_count == 0) {
// No samples found in source frame (fill with silence)
if (is_increasing) {
@@ -681,10 +726,17 @@ std::shared_ptr Clip::GetOrCreateFrame(int64_t number, bool enable_time)
try {
// Init to requested frame
int64_t clip_frame_number = adjust_frame_number_minimum(number);
+ bool is_increasing = true;
// Adjust for time-mapping (if any)
if (enable_time && time.GetLength() > 1) {
- clip_frame_number = adjust_frame_number_minimum(time.GetLong(clip_frame_number));
+ is_increasing = time.IsIncreasing(clip_frame_number + 1);
+ const int64_t time_frame_number = adjust_frame_number_minimum(time.GetLong(clip_frame_number));
+ if (auto *fm = dynamic_cast(reader)) {
+ // Inform FrameMapper which direction this mapper frame is being requested
+ fm->SetDirectionHint(is_increasing);
+ }
+ clip_frame_number = time_frame_number;
}
// Debug output
@@ -694,10 +746,12 @@ std::shared_ptr Clip::GetOrCreateFrame(int64_t number, bool enable_time)
// Attempt to get a frame (but this could fail if a reader has just been closed)
auto reader_frame = reader->GetFrame(clip_frame_number);
- reader_frame->number = number; // Override frame # (due to time-mapping might change it)
-
- // Return real frame
if (reader_frame) {
+ // Override frame # (due to time-mapping might change it)
+ reader_frame->number = number;
+ reader_frame->SetAudioDirection(is_increasing);
+
+ // Return real frame
// Create a new copy of reader frame
// This allows a clip to modify the pixels and audio of this frame without
// changing the underlying reader's frame data
@@ -760,6 +814,7 @@ std::string Clip::PropertiesJSON(int64_t requested_frame) const {
root["scale"] = add_property_json("Scale", scale, "int", "", NULL, 0, 3, false, requested_frame);
root["display"] = add_property_json("Frame Number", display, "int", "", NULL, 0, 3, false, requested_frame);
root["mixing"] = add_property_json("Volume Mixing", mixing, "int", "", NULL, 0, 2, false, requested_frame);
+ root["composite"] = add_property_json("Composite", composite, "int", "", NULL, 0, composite_choices_count - 1, false, requested_frame);
root["waveform"] = add_property_json("Waveform", waveform, "int", "", NULL, 0, 1, false, requested_frame);
root["parentObjectId"] = add_property_json("Parent", 0.0, "string", parentObjectId, NULL, -1, -1, false, requested_frame);
@@ -791,6 +846,10 @@ std::string Clip::PropertiesJSON(int64_t requested_frame) const {
root["mixing"]["choices"].append(add_property_choice_json("Average", VOLUME_MIX_AVERAGE, mixing));
root["mixing"]["choices"].append(add_property_choice_json("Reduce", VOLUME_MIX_REDUCE, mixing));
+ // Add composite choices (dropdown style)
+ for (int i = 0; i < composite_choices_count; ++i)
+ root["composite"]["choices"].append(add_property_choice_json(composite_choices[i].name, composite_choices[i].value, composite));
+
// Add waveform choices (dropdown style)
root["waveform"]["choices"].append(add_property_choice_json("Yes", true, waveform));
root["waveform"]["choices"].append(add_property_choice_json("No", false, waveform));
@@ -873,6 +932,7 @@ Json::Value Clip::JsonValue() const {
root["anchor"] = anchor;
root["display"] = display;
root["mixing"] = mixing;
+ root["composite"] = composite;
root["waveform"] = waveform;
root["scale_x"] = scale_x.JsonValue();
root["scale_y"] = scale_y.JsonValue();
@@ -961,6 +1021,8 @@ void Clip::SetJsonValue(const Json::Value root) {
display = (FrameDisplayType) root["display"].asInt();
if (!root["mixing"].isNull())
mixing = (VolumeMixType) root["mixing"].asInt();
+ if (!root["composite"].isNull())
+ composite = (CompositeType) root["composite"].asInt();
if (!root["waveform"].isNull())
waveform = root["waveform"].asBool();
if (!root["scale_x"].isNull())
@@ -1189,10 +1251,9 @@ void Clip::apply_background(std::shared_ptr frame, std::shared_
// Add background canvas
std::shared_ptr background_canvas = background_frame->GetImage();
QPainter painter(background_canvas.get());
- painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing, true);
// Composite a new layer onto the image
- painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
+ painter.setCompositionMode(static_cast(composite));
painter.drawImage(0, 0, *frame->GetImage());
painter.end();
@@ -1247,14 +1308,26 @@ void Clip::apply_keyframes(std::shared_ptr frame, QSize timeline_size) {
// Load timeline's new frame image into a QPainter
QPainter painter(background_canvas.get());
- painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing, true);
-
+ painter.setRenderHint(QPainter::TextAntialiasing, true);
+ if (!transform.isIdentity()) {
+ painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
+ }
// Apply transform (translate, rotate, scale)
painter.setTransform(transform);
// Composite a new layer onto the image
- painter.setCompositionMode(QPainter::CompositionMode_SourceOver);
- painter.drawImage(0, 0, *source_image);
+ painter.setCompositionMode(static_cast(composite));
+
+ // Apply opacity via painter instead of per-pixel alpha manipulation
+ const float alpha_value = alpha.GetValue(frame->number);
+ if (alpha_value != 1.0f) {
+ painter.setOpacity(alpha_value);
+ painter.drawImage(0, 0, *source_image);
+ // Reset so any subsequent drawing (e.g., overlays) isn’t faded
+ painter.setOpacity(1.0);
+ } else {
+ painter.drawImage(0, 0, *source_image);
+ }
if (timeline) {
Timeline *t = static_cast(timeline);
@@ -1347,31 +1420,6 @@ QTransform Clip::get_transform(std::shared_ptr frame, int width, int heig
// Get image from clip
std::shared_ptr source_image = frame->GetImage();
- /* ALPHA & OPACITY */
- if (alpha.GetValue(frame->number) != 1.0)
- {
- float alpha_value = alpha.GetValue(frame->number);
-
- // Get source image's pixels
- unsigned char *pixels = source_image->bits();
-
- // Loop through pixels
- for (int pixel = 0, byte_index=0; pixel < source_image->width() * source_image->height(); pixel++, byte_index+=4)
- {
- // Apply alpha to pixel values (since we use a premultiplied value, we must
- // multiply the alpha with all colors).
- pixels[byte_index + 0] *= alpha_value;
- pixels[byte_index + 1] *= alpha_value;
- pixels[byte_index + 2] *= alpha_value;
- pixels[byte_index + 3] *= alpha_value;
- }
-
- // Debug output
- ZmqLogger::Instance()->AppendDebugMethod("Clip::get_transform (Set Alpha & Opacity)",
- "alpha_value", alpha_value,
- "frame->number", frame->number);
- }
-
/* RESIZE SOURCE IMAGE - based on scale type */
QSize source_size = scale_size(source_image->size(), scale, width, height);
diff --git a/src/Clip.h b/src/Clip.h
index caeabd57b..cfb37768a 100644
--- a/src/Clip.h
+++ b/src/Clip.h
@@ -151,6 +151,15 @@ namespace openshot {
/// Get a frame object or create a blank one
std::shared_ptr GetOrCreateFrame(int64_t number, bool enable_time=true);
+ /// Determine the frames-per-second context used for timeline playback
+ double resolve_timeline_fps() const;
+
+ /// Determine the number of frames implied by time-mapping curves
+ int64_t curve_extent_frames() const;
+
+ /// Determine the number of frames implied by the clip's trim range
+ int64_t trim_extent_frames(double fps_value) const;
+
/// Adjust the audio and image of a time mapped frame
void apply_timemapping(std::shared_ptr frame);
@@ -169,6 +178,7 @@ namespace openshot {
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
+ openshot::CompositeType composite; ///< How this clip is composited onto lower layers
#ifdef USE_OPENCV
bool COMPILED_WITH_CV = true;
diff --git a/src/ClipBase.h b/src/ClipBase.h
index 4ba6b2f47..732160c16 100644
--- a/src/ClipBase.h
+++ b/src/ClipBase.h
@@ -16,8 +16,8 @@
#include
#include "CacheMemory.h"
#include "Frame.h"
-#include "Point.h"
#include "KeyFrame.h"
+#include "IdGenerator.h"
#include "Json.h"
#include "TimelineBase.h"
@@ -48,6 +48,7 @@ namespace openshot {
public:
/// Constructor for the base clip
ClipBase() :
+ id(IdGenerator::Generate()),
position(0.0),
layer(0),
start(0.0),
diff --git a/src/EffectInfo.cpp b/src/EffectInfo.cpp
index a9f67028d..dbedf534f 100644
--- a/src/EffectInfo.cpp
+++ b/src/EffectInfo.cpp
@@ -12,6 +12,7 @@
#include "EffectInfo.h"
#include "Effects.h"
+#include "effects/AnalogTape.h"
using namespace openshot;
@@ -25,6 +26,9 @@ std::string EffectInfo::Json() {
// Create a new effect instance
EffectBase* EffectInfo::CreateEffect(std::string effect_type) {
// Init the matching effect object
+ if (effect_type == "AnalogTape")
+ return new AnalogTape();
+
if (effect_type == "Bars")
return new Bars();
@@ -133,6 +137,7 @@ Json::Value EffectInfo::JsonValue() {
Json::Value root;
// Append info JSON from each supported effect
+ root.append(AnalogTape().JsonInfo());
root.append(Bars().JsonInfo());
root.append(Blur().JsonInfo());
root.append(Brightness().JsonInfo());
diff --git a/src/Effects.h b/src/Effects.h
index ad577f32a..c69776f9d 100644
--- a/src/Effects.h
+++ b/src/Effects.h
@@ -14,6 +14,7 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
/* Effects */
+#include "effects/AnalogTape.h"
#include "effects/Bars.h"
#include "effects/Blur.h"
#include "effects/Brightness.h"
diff --git a/src/Enums.h b/src/Enums.h
index 14b693166..e3029c1fe 100644
--- a/src/Enums.h
+++ b/src/Enums.h
@@ -64,6 +64,37 @@ enum VolumeMixType
VOLUME_MIX_REDUCE ///< Reduce volume by about %25, and then mix (louder, but could cause pops if the sum exceeds 100%)
};
+/// This enumeration determines how clips are composited onto lower layers.
+enum CompositeType {
+ COMPOSITE_SOURCE_OVER,
+ COMPOSITE_DESTINATION_OVER,
+ COMPOSITE_CLEAR,
+ COMPOSITE_SOURCE,
+ COMPOSITE_DESTINATION,
+ COMPOSITE_SOURCE_IN,
+ COMPOSITE_DESTINATION_IN,
+ COMPOSITE_SOURCE_OUT,
+ COMPOSITE_DESTINATION_OUT,
+ COMPOSITE_SOURCE_ATOP,
+ COMPOSITE_DESTINATION_ATOP,
+ COMPOSITE_XOR,
+
+ // svg 1.2 blend modes
+ COMPOSITE_PLUS,
+ COMPOSITE_MULTIPLY,
+ COMPOSITE_SCREEN,
+ COMPOSITE_OVERLAY,
+ COMPOSITE_DARKEN,
+ COMPOSITE_LIGHTEN,
+ COMPOSITE_COLOR_DODGE,
+ COMPOSITE_COLOR_BURN,
+ COMPOSITE_HARD_LIGHT,
+ COMPOSITE_SOFT_LIGHT,
+ COMPOSITE_DIFFERENCE,
+ COMPOSITE_EXCLUSION,
+
+ COMPOSITE_LAST = COMPOSITE_EXCLUSION
+};
/// This enumeration determines the distortion type of Distortion Effect.
enum DistortionType
diff --git a/src/FFmpegWriter.cpp b/src/FFmpegWriter.cpp
index 79cbc21c6..b1feafe3f 100644
--- a/src/FFmpegWriter.cpp
+++ b/src/FFmpegWriter.cpp
@@ -126,17 +126,18 @@ void FFmpegWriter::auto_detect_format() {
// Determine what format to use when encoding this output filename
oc->oformat = av_guess_format(NULL, path.c_str(), NULL);
if (oc->oformat == nullptr) {
- throw InvalidFormat(
- "Could not deduce output format from file extension.", path);
+ throw InvalidFormat("Could not deduce output format from file extension.", path);
}
- // Update video codec name
- if (oc->oformat->video_codec != AV_CODEC_ID_NONE && info.has_video)
- info.vcodec = avcodec_find_encoder(oc->oformat->video_codec)->name;
-
- // Update audio codec name
- if (oc->oformat->audio_codec != AV_CODEC_ID_NONE && info.has_audio)
- info.acodec = avcodec_find_encoder(oc->oformat->audio_codec)->name;
+ // Update video & audio codec name
+ if (oc->oformat->video_codec != AV_CODEC_ID_NONE && info.has_video) {
+ const AVCodec *vcodec = avcodec_find_encoder(oc->oformat->video_codec);
+ info.vcodec = vcodec ? vcodec->name : std::string();
+ }
+ if (oc->oformat->audio_codec != AV_CODEC_ID_NONE && info.has_audio) {
+ const AVCodec *acodec = avcodec_find_encoder(oc->oformat->audio_codec);
+ info.acodec = acodec ? acodec->name : std::string();
+ }
}
// initialize streams
diff --git a/src/Frame.cpp b/src/Frame.cpp
index f799bcea9..2e785fb2a 100644
--- a/src/Frame.cpp
+++ b/src/Frame.cpp
@@ -48,7 +48,7 @@ Frame::Frame(int64_t number, int width, int height, std::string color, int sampl
channels(channels), channel_layout(LAYOUT_STEREO),
sample_rate(44100),
has_audio_data(false), has_image_data(false),
- max_audio_sample(0)
+ max_audio_sample(0), audio_is_increasing(true)
{
// zero (fill with silence) the audio buffer
audio->clear();
@@ -96,6 +96,7 @@ void Frame::DeepCopy(const Frame& other)
pixel_ratio = Fraction(other.pixel_ratio.num, other.pixel_ratio.den);
color = other.color;
max_audio_sample = other.max_audio_sample;
+ audio_is_increasing = other.audio_is_increasing;
if (other.image)
image = std::make_shared(*(other.image));
@@ -801,14 +802,16 @@ void Frame::ResizeAudio(int channels, int length, int rate, ChannelLayout layout
max_audio_sample = length;
}
-// Reverse the audio buffer of this frame (will only reverse a single time, regardless of how many times
-// you invoke this method)
-void Frame::ReverseAudio() {
- if (audio && !audio_reversed) {
+/// Set the direction of the audio buffer of this frame
+void Frame::SetAudioDirection(bool is_increasing) {
+ if (audio && !audio_is_increasing && is_increasing) {
+ // Forward audio buffer
+ audio->reverse(0, audio->getNumSamples());
+ } else if (audio && audio_is_increasing && !is_increasing) {
// Reverse audio buffer
audio->reverse(0, audio->getNumSamples());
- audio_reversed = true;
}
+ audio_is_increasing = is_increasing;
}
// Add audio samples to a specific channel
@@ -838,8 +841,8 @@ void Frame::AddAudio(bool replaceSamples, int destChannel, int destStartSample,
if (new_length > max_audio_sample)
max_audio_sample = new_length;
- // Reset audio reverse flag
- audio_reversed = false;
+ // Reset audio direction
+ audio_is_increasing = true;
}
// Apply gain ramp (i.e. fading volume)
@@ -995,6 +998,6 @@ void Frame::AddAudioSilence(int numSamples)
// Calculate max audio sample added
max_audio_sample = numSamples;
- // Reset audio reverse flag
- audio_reversed = false;
+ // Reset audio direction
+ audio_is_increasing = true;
}
diff --git a/src/Frame.h b/src/Frame.h
index 528a69b85..7e8f26c55 100644
--- a/src/Frame.h
+++ b/src/Frame.h
@@ -102,7 +102,7 @@ namespace openshot
int sample_rate;
std::string color;
int64_t max_audio_sample; ///< The max audio sample count added to this frame
- bool audio_reversed; ///< Keep track of audio reversal (i.e. time keyframe)
+ bool audio_is_increasing; ///< Keep track of audio direction (i.e. related to time keyframe)
#ifdef USE_OPENCV
cv::Mat imagecv; ///< OpenCV image. It will always be in BGR format
@@ -244,9 +244,8 @@ namespace openshot
/// Set the original sample rate of this frame's audio data
void SampleRate(int orig_sample_rate) { sample_rate = orig_sample_rate; };
- /// Reverse the audio buffer of this frame (will only reverse a single time, regardless of how many times
- /// you invoke this method)
- void ReverseAudio();
+ /// Set the direction of the audio buffer of this frame
+ void SetAudioDirection(bool is_increasing);
/// Save the frame image to the specified path. The image format can be BMP, JPG, JPEG, PNG, PPM, XBM, XPM
void Save(std::string path, float scale, std::string format="PNG", int quality=100);
diff --git a/src/FrameMapper.cpp b/src/FrameMapper.cpp
index 8c05eb56e..fc0962b46 100644
--- a/src/FrameMapper.cpp
+++ b/src/FrameMapper.cpp
@@ -440,6 +440,34 @@ std::shared_ptr FrameMapper::GetFrame(int64_t requested_frame)
// Find parent properties (if any)
Clip *parent = static_cast(ParentClip());
bool is_increasing = true;
+ bool direction_flipped = false;
+
+ {
+ const std::lock_guard lock(directionMutex);
+
+ // One-shot: if a hint exists, consume it for THIS call, regardless of frame number.
+ if (have_hint) {
+ is_increasing = hint_increasing;
+ have_hint = false;
+ } else if (previous_frame > 0 && std::llabs(requested_frame - previous_frame) == 1) {
+ // Infer from request order when adjacent
+ is_increasing = (requested_frame > previous_frame);
+ } else if (last_dir_initialized) {
+ // Reuse last known direction if non-adjacent and no hint
+ is_increasing = last_is_increasing;
+ } else {
+ is_increasing = true; // default on first call
+ }
+
+ // Detect flips so we can reset SR context
+ if (!last_dir_initialized) {
+ last_is_increasing = is_increasing;
+ last_dir_initialized = true;
+ } else if (last_is_increasing != is_increasing) {
+ direction_flipped = true;
+ last_is_increasing = is_increasing;
+ }
+ }
if (parent) {
float position = parent->Position();
float start = parent->Start();
@@ -448,10 +476,6 @@ std::shared_ptr FrameMapper::GetFrame(int64_t requested_frame)
// since this heavily affects frame #s and audio mappings
is_dirty = true;
}
-
- // Determine direction of parent clip at this frame (forward or reverse direction)
- // This is important for reversing audio in our resampler, for smooth reversed audio.
- is_increasing = parent->time.IsIncreasing(requested_frame);
}
// Check if mappings are dirty (and need to be recalculated)
@@ -548,13 +572,13 @@ std::shared_ptr FrameMapper::GetFrame(int64_t requested_frame)
if (need_resampling)
{
- // Check for non-adjacent frame requests - so the resampler can be reset
- if (abs(frame->number - previous_frame) > 1) {
+ // Reset resampler when non-adjacent request OR playback direction flips
+ if (direction_flipped || (previous_frame > 0 && std::llabs(requested_frame - previous_frame) > 1)) {
if (avr) {
// Delete resampler (if exists)
SWR_CLOSE(avr);
SWR_FREE(&avr);
- avr = NULL;
+ avr = nullptr;
}
}
@@ -630,9 +654,8 @@ std::shared_ptr FrameMapper::GetFrame(int64_t requested_frame)
starting_frame++;
}
- // Reverse audio (if needed)
- if (!is_increasing)
- frame->ReverseAudio();
+ // Set audio direction
+ frame->SetAudioDirection(is_increasing);
// Resample audio on frame (if needed)
if (need_resampling)
@@ -1039,3 +1062,11 @@ int64_t FrameMapper::AdjustFrameNumber(int64_t clip_frame_number) {
return frame_number;
}
+
+// Set direction hint for the next call to GetFrame
+void FrameMapper::SetDirectionHint(const bool increasing)
+{
+ const std::lock_guard lock(directionMutex);
+ hint_increasing = increasing;
+ have_hint = true;
+}
diff --git a/src/FrameMapper.h b/src/FrameMapper.h
index 55ad74749..a932c2d4f 100644
--- a/src/FrameMapper.h
+++ b/src/FrameMapper.h
@@ -204,6 +204,13 @@ namespace openshot
int64_t previous_frame; // Used during resampling, to determine when a large gap is detected
SWRCONTEXT *avr; // Audio resampling context object
+ // Time curve / direction
+ std::recursive_mutex directionMutex;
+ bool have_hint = false;
+ bool hint_increasing = true;
+ bool last_is_increasing = true;
+ bool last_dir_initialized = false;
+
// Audio resampler (if resampling audio)
openshot::AudioResampler *resampler;
@@ -273,6 +280,9 @@ namespace openshot
/// Open the internal reader
void Open() override;
+ /// Set time-curve informed direction hint (from Clip class) for the next call to GetFrame
+ void SetDirectionHint(const bool increasing);
+
/// Print all of the original frames and which new frames they map to
void PrintMapping(std::ostream* out=&std::cout);
diff --git a/src/IdGenerator.h b/src/IdGenerator.h
new file mode 100644
index 000000000..85a10dd51
--- /dev/null
+++ b/src/IdGenerator.h
@@ -0,0 +1,36 @@
+/*
+ * @file
+ * @brief Header file for generating random identifier strings
+ */
+
+// Copyright (c) 2008-2025 OpenShot Studios, LLC
+//
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#ifndef OPENSHOT_ID_GENERATOR_H
+#define OPENSHOT_ID_GENERATOR_H
+
+#include
+#include
+
+namespace openshot {
+
+ class IdGenerator {
+ public:
+ static inline std::string Generate(int length = 8) {
+ static const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ std::random_device rd;
+ std::mt19937 gen(rd());
+ std::uniform_int_distribution<> dist(0, static_cast(sizeof(charset) - 2));
+
+ std::string result;
+ result.reserve(length);
+ for (int i = 0; i < length; ++i)
+ result += charset[dist(gen)];
+ return result;
+}
+};
+
+} // namespace openshot
+
+#endif // OPENSHOT_ID_GENERATOR_H
diff --git a/src/KeyFrame.cpp b/src/KeyFrame.cpp
index 1df3f3c06..8c7615816 100644
--- a/src/KeyFrame.cpp
+++ b/src/KeyFrame.cpp
@@ -399,7 +399,7 @@ void Keyframe::SetJsonValue(const Json::Value root) {
double Keyframe::GetDelta(int64_t index) const {
if (index < 1) return 0.0;
if (index == 1 && !Points.empty()) return Points[0].co.Y;
- if (index >= GetLength()) return 0.0;
+ if (index > GetLength()) return 1.0;
return GetValue(index) - GetValue(index - 1);
}
diff --git a/src/Qt/VideoCacheThread.cpp b/src/Qt/VideoCacheThread.cpp
index a7336b07d..643ed0a64 100644
--- a/src/Qt/VideoCacheThread.cpp
+++ b/src/Qt/VideoCacheThread.cpp
@@ -48,6 +48,14 @@ namespace openshot
// Is cache ready for playback (pre-roll)
bool VideoCacheThread::isReady()
{
+ if (!reader) {
+ return false;
+ }
+
+ if (min_frames_ahead < 0) {
+ return true;
+ }
+
return (cached_frame_count > min_frames_ahead);
}
@@ -96,11 +104,18 @@ namespace openshot
if (start_preroll) {
userSeeked = true;
- if (!reader->GetCache()->Contains(new_position))
+ CacheBase* cache = reader ? reader->GetCache() : nullptr;
+
+ if (cache && !cache->Contains(new_position))
{
// If user initiated seek, and current frame not found (
Timeline* timeline = static_cast(reader);
timeline->ClearAllCache();
+ cached_frame_count = 0;
+ }
+ else if (cache)
+ {
+ cached_frame_count = cache->Count();
}
}
requested_display_frame = new_position;
@@ -131,6 +146,7 @@ namespace openshot
// If paused and playhead not in cache, clear everything
Timeline* timeline = static_cast(reader);
timeline->ClearAllCache();
+ cached_frame_count = 0;
return true;
}
return false;
@@ -184,7 +200,7 @@ namespace openshot
try {
auto framePtr = reader->GetFrame(next_frame);
cache->Add(framePtr);
- ++cached_frame_count;
+ cached_frame_count = cache->Count();
}
catch (const OutOfBoundsFrame&) {
break;
@@ -211,8 +227,10 @@ namespace openshot
Settings* settings = Settings::Instance();
CacheBase* cache = reader ? reader->GetCache() : nullptr;
- // If caching disabled or no reader, sleep briefly
+ // If caching disabled or no reader, mark cache as ready and sleep briefly
if (!settings->ENABLE_PLAYBACK_CACHING || !cache) {
+ cached_frame_count = (cache ? cache->Count() : 0);
+ min_frames_ahead = -1;
std::this_thread::sleep_for(double_micro_sec(50000));
continue;
}
@@ -225,6 +243,8 @@ namespace openshot
int64_t playhead = requested_display_frame;
bool paused = (speed == 0);
+ cached_frame_count = cache->Count();
+
// Compute effective direction (±1)
int dir = computeDirection();
if (speed != 0) {
@@ -282,6 +302,16 @@ namespace openshot
}
int64_t ahead_count = static_cast(capacity *
settings->VIDEO_CACHE_PERCENT_AHEAD);
+ int64_t window_size = ahead_count + 1;
+ if (window_size < 1) {
+ window_size = 1;
+ }
+ int64_t ready_target = window_size - 1;
+ if (ready_target < 0) {
+ ready_target = 0;
+ }
+ int64_t configured_min = settings->VIDEO_CACHE_MIN_PREROLL_FRAMES;
+ min_frames_ahead = std::min(configured_min, ready_target);
// If paused and playhead is no longer in cache, clear everything
bool did_clear = clearCacheIfPaused(playhead, paused, cache);
diff --git a/src/Qt/VideoCacheThread.h b/src/Qt/VideoCacheThread.h
index 1fd6decd5..72f502e04 100644
--- a/src/Qt/VideoCacheThread.h
+++ b/src/Qt/VideoCacheThread.h
@@ -166,7 +166,7 @@ namespace openshot
int64_t requested_display_frame; ///< Frame index the user requested.
int64_t current_display_frame; ///< Currently displayed frame (unused here, reserved).
- int64_t cached_frame_count; ///< Count of frames currently added to cache.
+ int64_t cached_frame_count; ///< Estimated count of frames currently stored in cache.
int64_t min_frames_ahead; ///< Minimum number of frames considered “ready” (pre-roll).
int64_t timeline_max_frame; ///< Highest valid frame index in the timeline.
diff --git a/src/Timeline.cpp b/src/Timeline.cpp
index 9f8efb58b..272ae0fd8 100644
--- a/src/Timeline.cpp
+++ b/src/Timeline.cpp
@@ -21,6 +21,9 @@
#include
#include
+#include
+#include
+#include
using namespace openshot;
@@ -357,6 +360,9 @@ void Timeline::AddClip(Clip* clip)
// Add an effect to the timeline
void Timeline::AddEffect(EffectBase* effect)
{
+ // Get lock (prevent getting frames while this happens)
+ const std::lock_guard guard(getFrameMutex);
+
// Assign timeline to effect
effect->ParentTimeline(this);
@@ -370,14 +376,16 @@ void Timeline::AddEffect(EffectBase* effect)
// Remove an effect from the timeline
void Timeline::RemoveEffect(EffectBase* effect)
{
+ // Get lock (prevent getting frames while this happens)
+ const std::lock_guard guard(getFrameMutex);
+
effects.remove(effect);
// Delete effect object (if timeline allocated it)
- bool allocated = allocated_effects.count(effect);
- if (allocated) {
+ if (allocated_effects.count(effect)) {
+ allocated_effects.erase(effect); // erase before nulling the pointer
delete effect;
effect = NULL;
- allocated_effects.erase(effect);
}
// Sort effects
@@ -393,11 +401,10 @@ void Timeline::RemoveClip(Clip* clip)
clips.remove(clip);
// Delete clip object (if timeline allocated it)
- bool allocated = allocated_clips.count(clip);
- if (allocated) {
+ if (allocated_clips.count(clip)) {
+ allocated_clips.erase(clip); // erase before nulling the pointer
delete clip;
clip = NULL;
- allocated_clips.erase(clip);
}
// Sort clips
@@ -467,9 +474,18 @@ double Timeline::GetMaxTime() {
// Compute the highest frame# based on the latest time and FPS
int64_t Timeline::GetMaxFrame() {
- double fps = info.fps.ToDouble();
- auto max_time = GetMaxTime();
- return std::round(max_time * fps);
+ const double fps = info.fps.ToDouble();
+ const double t = GetMaxTime();
+ // Inclusive start, exclusive end -> ceil at the end boundary
+ return static_cast(std::ceil(t * fps));
+}
+
+// Compute the first frame# based on the first clip position
+int64_t Timeline::GetMinFrame() {
+ const double fps = info.fps.ToDouble();
+ const double t = GetMinTime();
+ // Inclusive start -> floor at the start boundary, then 1-index
+ return static_cast(std::floor(t * fps)) + 1;
}
// Compute the start time of the first timeline clip
@@ -478,13 +494,6 @@ double Timeline::GetMinTime() {
return min_time;
}
-// Compute the first frame# based on the first clip position
-int64_t Timeline::GetMinFrame() {
- double fps = info.fps.ToDouble();
- auto min_time = GetMinTime();
- return std::round(min_time * fps) + 1;
-}
-
// Apply a FrameMapper to a clip which matches the settings of this timeline
void Timeline::apply_mapper_to_clip(Clip* clip)
{
@@ -549,8 +558,9 @@ std::shared_ptr Timeline::apply_effects(std::shared_ptr frame, int
for (auto effect : effects)
{
// Does clip intersect the current requested time
- long effect_start_position = round(effect->Position() * info.fps.ToDouble()) + 1;
- long effect_end_position = round((effect->Position() + (effect->Duration())) * info.fps.ToDouble());
+ const double fpsD = info.fps.ToDouble();
+ int64_t effect_start_position = static_cast(std::llround(effect->Position() * fpsD)) + 1;
+ int64_t effect_end_position = static_cast(std::llround((effect->Position() + effect->Duration()) * fpsD));
bool does_effect_intersect = (effect_start_position <= timeline_frame_number && effect_end_position >= timeline_frame_number && effect->Layer() == layer);
@@ -558,8 +568,8 @@ std::shared_ptr Timeline::apply_effects(std::shared_ptr frame, int
if (does_effect_intersect)
{
// Determine the frame needed for this clip (based on the position on the timeline)
- long effect_start_frame = (effect->Start() * info.fps.ToDouble()) + 1;
- long effect_frame_number = timeline_frame_number - effect_start_position + effect_start_frame;
+ int64_t effect_start_frame = static_cast(std::llround(effect->Start() * fpsD)) + 1;
+ int64_t effect_frame_number = timeline_frame_number - effect_start_position + effect_start_frame;
if (!options->is_top_clip)
continue; // skip effect, if overlapped/covered by another clip on same layer
@@ -624,14 +634,13 @@ std::shared_ptr Timeline::GetOrCreateFrame(std::shared_ptr backgro
void Timeline::add_layer(std::shared_ptr new_frame, Clip* source_clip, int64_t clip_frame_number, bool is_top_clip, float max_volume)
{
// Create timeline options (with details about this current frame request)
- TimelineInfoStruct* options = new TimelineInfoStruct();
- options->is_top_clip = is_top_clip;
- options->is_before_clip_keyframes = true;
+ TimelineInfoStruct options{};
+ options.is_top_clip = is_top_clip;
+ options.is_before_clip_keyframes = true;
// Get the clip's frame, composited on top of the current timeline frame
std::shared_ptr source_frame;
- source_frame = GetOrCreateFrame(new_frame, source_clip, clip_frame_number, options);
- delete options;
+ source_frame = GetOrCreateFrame(new_frame, source_clip, clip_frame_number, &options);
// No frame found... so bail
if (!source_frame)
@@ -654,6 +663,12 @@ void Timeline::add_layer(std::shared_ptr new_frame, Clip* source_clip, in
"clip_frame_number", clip_frame_number);
if (source_frame->GetAudioChannelsCount() == info.channels && source_clip->has_audio.GetInt(clip_frame_number) != 0)
+ {
+ // Ensure timeline frame matches the source samples once per frame
+ if (new_frame->GetAudioSamplesCount() != source_frame->GetAudioSamplesCount()){
+ new_frame->ResizeAudio(info.channels, source_frame->GetAudioSamplesCount(), info.sample_rate, info.channel_layout);
+ }
+
for (int channel = 0; channel < source_frame->GetAudioChannelsCount(); channel++)
{
// Get volume from previous frame and this frame
@@ -690,18 +705,11 @@ void Timeline::add_layer(std::shared_ptr new_frame, Clip* source_clip, in
if (!isEqual(previous_volume, 1.0) || !isEqual(volume, 1.0))
source_frame->ApplyGainRamp(channel_mapping, 0, source_frame->GetAudioSamplesCount(), previous_volume, volume);
- // TODO: Improve FrameMapper (or Timeline) to always get the correct number of samples per frame.
- // Currently, the ResampleContext sometimes leaves behind a few samples for the next call, and the
- // number of samples returned is variable... and does not match the number expected.
- // This is a crude solution at best. =)
- if (new_frame->GetAudioSamplesCount() != source_frame->GetAudioSamplesCount()){
- // Force timeline frame to match the source frame
- new_frame->ResizeAudio(info.channels, source_frame->GetAudioSamplesCount(), info.sample_rate, info.channel_layout);
- }
// Copy audio samples (and set initial volume). Mix samples with existing audio samples. The gains are added together, to
// be sure to set the gain's correctly, so the sum does not exceed 1.0 (of audio distortion will happen).
new_frame->AddAudio(false, channel_mapping, 0, source_frame->GetAudioSamples(channel), source_frame->GetAudioSamplesCount(), 1.0);
}
+ }
else
// Debug output
ZmqLogger::Instance()->AppendDebugMethod(
@@ -1003,67 +1011,90 @@ std::shared_ptr Timeline::GetFrame(int64_t requested_frame)
"clips.size()", clips.size(),
"nearby_clips.size()", nearby_clips.size());
- // Find Clips near this time
+ // Precompute per-clip timing for this requested frame
+ struct ClipInfo {
+ Clip* clip;
+ int64_t start_pos;
+ int64_t end_pos;
+ int64_t start_frame;
+ int64_t frame_number;
+ bool intersects;
+ };
+ std::vector clip_infos;
+ clip_infos.reserve(nearby_clips.size());
+ const double fpsD = info.fps.ToDouble();
+
for (auto clip : nearby_clips) {
- long clip_start_position = round(clip->Position() * info.fps.ToDouble()) + 1;
- long clip_end_position = round((clip->Position() + clip->Duration()) * info.fps.ToDouble());
- bool does_clip_intersect = (clip_start_position <= requested_frame && clip_end_position >= requested_frame);
+ int64_t start_pos = static_cast(std::llround(clip->Position() * fpsD)) + 1;
+ int64_t end_pos = static_cast(std::llround((clip->Position() + clip->Duration()) * fpsD));
+ bool intersects = (start_pos <= requested_frame && end_pos >= requested_frame);
+ int64_t start_frame = static_cast(std::llround(clip->Start() * fpsD)) + 1;
+ int64_t frame_number = requested_frame - start_pos + start_frame;
+ clip_infos.push_back({clip, start_pos, end_pos, start_frame, frame_number, intersects});
+ }
+ // Determine top clip per layer (linear, no nested loop)
+ std::unordered_map top_start_for_layer;
+ std::unordered_map top_clip_for_layer;
+ for (const auto& ci : clip_infos) {
+ if (!ci.intersects) continue;
+ const int layer = ci.clip->Layer();
+ auto it = top_start_for_layer.find(layer);
+ if (it == top_start_for_layer.end() || ci.start_pos > it->second) {
+ top_start_for_layer[layer] = ci.start_pos; // strictly greater to match prior logic
+ top_clip_for_layer[layer] = ci.clip;
+ }
+ }
+
+ // Compute max_volume across all overlapping clips once
+ float max_volume_sum = 0.0f;
+ for (const auto& ci : clip_infos) {
+ if (!ci.intersects) continue;
+ if (ci.clip->Reader() && ci.clip->Reader()->info.has_audio &&
+ ci.clip->has_audio.GetInt(ci.frame_number) != 0) {
+ max_volume_sum += static_cast(ci.clip->volume.GetValue(ci.frame_number));
+ }
+ }
+
+ // Compose intersecting clips in a single pass
+ for (const auto& ci : clip_infos) {
// Debug output
ZmqLogger::Instance()->AppendDebugMethod(
"Timeline::GetFrame (Does clip intersect)",
"requested_frame", requested_frame,
- "clip->Position()", clip->Position(),
- "clip->Duration()", clip->Duration(),
- "does_clip_intersect", does_clip_intersect);
+ "clip->Position()", ci.clip->Position(),
+ "clip->Duration()", ci.clip->Duration(),
+ "does_clip_intersect", ci.intersects);
// Clip is visible
- if (does_clip_intersect) {
- // Determine if clip is "top" clip on this layer (only happens when multiple clips are overlapping)
- bool is_top_clip = true;
- float max_volume = 0.0;
- for (auto nearby_clip : nearby_clips) {
- long nearby_clip_start_position = round(nearby_clip->Position() * info.fps.ToDouble()) + 1;
- long nearby_clip_end_position = round((nearby_clip->Position() + nearby_clip->Duration()) * info.fps.ToDouble()) + 1;
- long nearby_clip_start_frame = (nearby_clip->Start() * info.fps.ToDouble()) + 1;
- long nearby_clip_frame_number = requested_frame - nearby_clip_start_position + nearby_clip_start_frame;
-
- // Determine if top clip
- if (clip->Id() != nearby_clip->Id() && clip->Layer() == nearby_clip->Layer() &&
- nearby_clip_start_position <= requested_frame && nearby_clip_end_position >= requested_frame &&
- nearby_clip_start_position > clip_start_position && is_top_clip == true) {
- is_top_clip = false;
- }
-
- // Determine max volume of overlapping clips
- if (nearby_clip->Reader() && nearby_clip->Reader()->info.has_audio &&
- nearby_clip->has_audio.GetInt(nearby_clip_frame_number) != 0 &&
- nearby_clip_start_position <= requested_frame && nearby_clip_end_position >= requested_frame) {
- max_volume += nearby_clip->volume.GetValue(nearby_clip_frame_number);
- }
- }
+ if (ci.intersects) {
+ // Is this the top clip on its layer?
+ bool is_top_clip = false;
+ const int layer = ci.clip->Layer();
+ auto top_it = top_clip_for_layer.find(layer);
+ if (top_it != top_clip_for_layer.end())
+ is_top_clip = (top_it->second == ci.clip);
// Determine the frame needed for this clip (based on the position on the timeline)
- long clip_start_frame = (clip->Start() * info.fps.ToDouble()) + 1;
- long clip_frame_number = requested_frame - clip_start_position + clip_start_frame;
+ int64_t clip_frame_number = ci.frame_number;
// Debug output
ZmqLogger::Instance()->AppendDebugMethod(
"Timeline::GetFrame (Calculate clip's frame #)",
- "clip->Position()", clip->Position(),
- "clip->Start()", clip->Start(),
+ "clip->Position()", ci.clip->Position(),
+ "clip->Start()", ci.clip->Start(),
"info.fps.ToFloat()", info.fps.ToFloat(),
"clip_frame_number", clip_frame_number);
// Add clip's frame as layer
- add_layer(new_frame, clip, clip_frame_number, is_top_clip, max_volume);
+ add_layer(new_frame, ci.clip, clip_frame_number, is_top_clip, max_volume_sum);
} else {
// Debug output
ZmqLogger::Instance()->AppendDebugMethod(
"Timeline::GetFrame (clip does not intersect)",
"requested_frame", requested_frame,
- "does_clip_intersect", does_clip_intersect);
+ "does_clip_intersect", ci.intersects);
}
} // end clip loop
@@ -1095,15 +1126,17 @@ std::vector Timeline::find_intersecting_clips(int64_t requested_frame, in
std::vector matching_clips;
// Calculate time of frame
- float min_requested_frame = requested_frame;
- float max_requested_frame = requested_frame + (number_of_frames - 1);
+ const int64_t min_requested_frame = requested_frame;
+ const int64_t max_requested_frame = requested_frame + (number_of_frames - 1);
// Find Clips at this time
+ matching_clips.reserve(clips.size());
+ const double fpsD = info.fps.ToDouble();
for (auto clip : clips)
{
// Does clip intersect the current requested time
- long clip_start_position = round(clip->Position() * info.fps.ToDouble()) + 1;
- long clip_end_position = round((clip->Position() + clip->Duration()) * info.fps.ToDouble()) + 1;
+ int64_t clip_start_position = static_cast(std::llround(clip->Position() * fpsD)) + 1;
+ int64_t clip_end_position = static_cast(std::llround((clip->Position() + clip->Duration()) * fpsD)) + 1;
bool does_clip_intersect =
(clip_start_position <= min_requested_frame || clip_start_position <= max_requested_frame) &&
@@ -1431,17 +1464,26 @@ void Timeline::apply_json_to_clips(Json::Value change) {
// Update existing clip
if (existing_clip) {
+ // Calculate start and end frames prior to the update
+ int64_t old_starting_frame = (existing_clip->Position() * info.fps.ToDouble()) + 1;
+ int64_t old_ending_frame = ((existing_clip->Position() + existing_clip->Duration()) * info.fps.ToDouble()) + 1;
+
// Update clip properties from JSON
existing_clip->SetJsonValue(change["value"]);
- // Calculate start and end frames that this impacts, and remove those frames from the cache
- int64_t old_starting_frame = (existing_clip->Position() * info.fps.ToDouble()) + 1;
- int64_t old_ending_frame = ((existing_clip->Position() + existing_clip->Duration()) * info.fps.ToDouble()) + 1;
+ // Calculate new start and end frames after the update
+ int64_t new_starting_frame = (existing_clip->Position() * info.fps.ToDouble()) + 1;
+ int64_t new_ending_frame = ((existing_clip->Position() + existing_clip->Duration()) * info.fps.ToDouble()) + 1;
+
+ // Remove both the old and new ranges from the timeline cache
final_cache->Remove(old_starting_frame - 8, old_ending_frame + 8);
+ final_cache->Remove(new_starting_frame - 8, new_ending_frame + 8);
// Remove cache on clip's Reader (if found)
- if (existing_clip->Reader() && existing_clip->Reader()->GetCache())
+ if (existing_clip->Reader() && existing_clip->Reader()->GetCache()) {
existing_clip->Reader()->GetCache()->Remove(old_starting_frame - 8, old_ending_frame + 8);
+ existing_clip->Reader()->GetCache()->Remove(new_starting_frame - 8, new_ending_frame + 8);
+ }
// Apply framemapper (or update existing framemapper)
if (auto_map_clips) {
@@ -1464,13 +1506,6 @@ void Timeline::apply_json_to_clips(Json::Value change) {
}
- // Calculate start and end frames that this impacts, and remove those frames from the cache
- if (!change["value"].isArray() && !change["value"]["position"].isNull()) {
- int64_t new_starting_frame = (change["value"]["position"].asDouble() * info.fps.ToDouble()) + 1;
- int64_t new_ending_frame = ((change["value"]["position"].asDouble() + change["value"]["end"].asDouble() - change["value"]["start"].asDouble()) * info.fps.ToDouble()) + 1;
- final_cache->Remove(new_starting_frame - 8, new_ending_frame + 8);
- }
-
// Re-Sort Clips (since they likely changed)
sort_clips();
}
@@ -1720,18 +1755,24 @@ void Timeline::ClearAllCache(bool deep) {
// Loop through all clips
try {
for (const auto clip : clips) {
- // Clear cache on clip
- clip->Reader()->GetCache()->Clear();
-
- // Clear nested Reader (if deep clear requested)
- if (deep && clip->Reader()->Name() == "FrameMapper") {
- FrameMapper *nested_reader = static_cast(clip->Reader());
- if (nested_reader->Reader() && nested_reader->Reader()->GetCache())
- nested_reader->Reader()->GetCache()->Clear();
+ // Clear cache on clip and reader if present
+ if (clip->Reader()) {
+ if (auto rc = clip->Reader()->GetCache())
+ rc->Clear();
+
+ // Clear nested Reader (if deep clear requested)
+ if (deep && clip->Reader()->Name() == "FrameMapper") {
+ FrameMapper *nested_reader = static_cast(clip->Reader());
+ if (nested_reader->Reader()) {
+ if (auto nc = nested_reader->Reader()->GetCache())
+ nc->Clear();
+ }
+ }
}
// Clear clip cache
- clip->GetCache()->Clear();
+ if (auto cc = clip->GetCache())
+ cc->Clear();
}
} catch (const ReaderClosed & e) {
// ...
diff --git a/src/Timeline.h b/src/Timeline.h
index e93d2a7f6..61a164f02 100644
--- a/src/Timeline.h
+++ b/src/Timeline.h
@@ -46,12 +46,18 @@ namespace openshot {
/// Comparison method for sorting clip pointers (by Layer and then Position). Clips are sorted
/// from lowest layer to top layer (since that is the sequence they need to be combined), and then
/// by position (left to right).
- struct CompareClips{
- bool operator()( openshot::Clip* lhs, openshot::Clip* rhs){
- if( lhs->Layer() < rhs->Layer() ) return true;
- if( lhs->Layer() == rhs->Layer() && lhs->Position() <= rhs->Position() ) return true;
- return false;
- }};
+ struct CompareClips {
+ bool operator()(openshot::Clip* lhs, openshot::Clip* rhs) const {
+ // Strict-weak ordering (no <=) to keep sort well-defined
+ if (lhs == rhs) return false; // irreflexive
+ if (lhs->Layer() != rhs->Layer())
+ return lhs->Layer() < rhs->Layer();
+ if (lhs->Position() != rhs->Position())
+ return lhs->Position() < rhs->Position();
+ // Stable tie-breaker on address to avoid equivalence when layer/position match
+ return std::less()(lhs, rhs);
+ }
+ };
/// Comparison method for sorting effect pointers (by Position, Layer, and Order). Effects are sorted
/// from lowest layer to top layer (since that is sequence clips are combined), and then by
diff --git a/src/effects/AnalogTape.cpp b/src/effects/AnalogTape.cpp
new file mode 100644
index 000000000..318707d89
--- /dev/null
+++ b/src/effects/AnalogTape.cpp
@@ -0,0 +1,447 @@
+/**
+ * @file
+ * @brief Source file for AnalogTape effect class
+ * @author Jonathan Thomas
+ */
+
+#include "AnalogTape.h"
+#include "Clip.h"
+#include "Exceptions.h"
+#include "ReaderBase.h"
+#include "Timeline.h"
+
+#include
+#include
+
+using namespace openshot;
+
+AnalogTape::AnalogTape()
+ : tracking(0.55), bleed(0.65), softness(0.40), noise(0.50), stripe(0.25f),
+ staticBands(0.20f), seed_offset(0) {
+ init_effect_details();
+}
+
+AnalogTape::AnalogTape(Keyframe t, Keyframe b, Keyframe s, Keyframe n,
+ Keyframe st, Keyframe sb, int seed)
+ : tracking(t), bleed(b), softness(s), noise(n), stripe(st),
+ staticBands(sb), seed_offset(seed) {
+ init_effect_details();
+}
+
+void AnalogTape::init_effect_details() {
+ InitEffectInfo();
+ info.class_name = "AnalogTape";
+ info.name = "Analog Tape";
+ info.description = "Vintage home video wobble, bleed, and grain.";
+ info.has_video = true;
+ info.has_audio = false;
+}
+
+static inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
+
+std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame,
+ int64_t frame_number) {
+ std::shared_ptr img = frame->GetImage();
+ int w = img->width();
+ int h = img->height();
+ int Uw = (w + 1) / 2;
+ int stride = img->bytesPerLine() / 4;
+ uint32_t *base = reinterpret_cast(img->bits());
+
+ if (w != last_w || h != last_h) {
+ last_w = w;
+ last_h = h;
+ Y.resize(w * h);
+ U.resize(Uw * h);
+ V.resize(Uw * h);
+ tmpY.resize(w * h);
+ tmpU.resize(Uw * h);
+ tmpV.resize(Uw * h);
+ dx.resize(h);
+ }
+
+
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y) {
+ uint32_t *row = base + y * stride;
+ float *yrow = &Y[y * w];
+ float *urow = &U[y * Uw];
+ float *vrow = &V[y * Uw];
+ for (int x2 = 0; x2 < Uw; ++x2) {
+ int x0 = x2 * 2;
+ uint32_t p0 = row[x0];
+ float r0 = ((p0 >> 16) & 0xFF) / 255.0f;
+ float g0 = ((p0 >> 8) & 0xFF) / 255.0f;
+ float b0 = (p0 & 0xFF) / 255.0f;
+ float y0 = 0.299f * r0 + 0.587f * g0 + 0.114f * b0;
+ float u0 = -0.14713f * r0 - 0.28886f * g0 + 0.436f * b0;
+ float v0 = 0.615f * r0 - 0.51499f * g0 - 0.10001f * b0;
+ yrow[x0] = y0;
+
+ float u, v;
+ if (x0 + 1 < w) {
+ uint32_t p1 = row[x0 + 1];
+ float r1 = ((p1 >> 16) & 0xFF) / 255.0f;
+ float g1 = ((p1 >> 8) & 0xFF) / 255.0f;
+ float b1 = (p1 & 0xFF) / 255.0f;
+ float y1 = 0.299f * r1 + 0.587f * g1 + 0.114f * b1;
+ float u1 = -0.14713f * r1 - 0.28886f * g1 + 0.436f * b1;
+ float v1 = 0.615f * r1 - 0.51499f * g1 - 0.10001f * b1;
+ yrow[x0 + 1] = y1;
+ u = (u0 + u1) * 0.5f;
+ v = (v0 + v1) * 0.5f;
+ } else {
+ u = u0;
+ v = v0;
+ }
+ urow[x2] = u;
+ vrow[x2] = v;
+ }
+ }
+
+ Fraction fps(1, 1);
+ Clip *clip = (Clip *)ParentClip();
+ Timeline *timeline = nullptr;
+ if (clip && clip->ParentTimeline())
+ timeline = (Timeline *)clip->ParentTimeline();
+ else if (ParentTimeline())
+ timeline = (Timeline *)ParentTimeline();
+ if (timeline)
+ fps = timeline->info.fps;
+ else if (clip && clip->Reader())
+ fps = clip->Reader()->info.fps;
+ double fps_d = fps.ToDouble();
+ double t = fps_d > 0 ? frame_number / fps_d : frame_number;
+
+ const float k_track = tracking.GetValue(frame_number);
+ const float k_bleed = bleed.GetValue(frame_number);
+ const float k_soft = softness.GetValue(frame_number);
+ const float k_noise = noise.GetValue(frame_number);
+ const float k_stripe = stripe.GetValue(frame_number);
+ const float k_bands = staticBands.GetValue(frame_number);
+
+ int r_y = std::round(lerp(0.0f, 2.0f, k_soft));
+ if (k_noise > 0.6f)
+ r_y = std::min(r_y, 1);
+ if (r_y > 0) {
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y)
+ box_blur_row(&Y[y * w], &tmpY[y * w], w, r_y);
+ Y.swap(tmpY);
+ }
+
+ float shift = lerp(0.0f, 2.5f, k_bleed);
+ int r_c = std::round(lerp(0.0f, 3.0f, k_bleed));
+ float sat = 1.0f - 0.30f * k_bleed;
+ float shift_h = shift * 0.5f;
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y) {
+ const float *srcU = &U[y * Uw];
+ const float *srcV = &V[y * Uw];
+ float *dstU = &tmpU[y * Uw];
+ float *dstV = &tmpV[y * Uw];
+ for (int x = 0; x < Uw; ++x) {
+ float xs = std::clamp(x - shift_h, 0.0f, float(Uw - 1));
+ int x0 = int(xs);
+ int x1 = std::min(x0 + 1, Uw - 1);
+ float t = xs - x0;
+ dstU[x] = srcU[x0] * (1 - t) + srcU[x1] * t;
+ dstV[x] = srcV[x0] * (1 - t) + srcV[x1] * t;
+ }
+ }
+ U.swap(tmpU);
+ V.swap(tmpV);
+
+ if (r_c > 0) {
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y)
+ box_blur_row(&U[y * Uw], &tmpU[y * Uw], Uw, r_c);
+ U.swap(tmpU);
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y)
+ box_blur_row(&V[y * Uw], &tmpV[y * Uw], Uw, r_c);
+ V.swap(tmpV);
+ }
+
+ uint32_t SEED = fnv1a_32(Id()) ^ (uint32_t)seed_offset;
+ uint32_t schedSalt = (uint32_t)(k_bands * 64.0f) ^
+ ((uint32_t)(k_stripe * 64.0f) << 8) ^
+ ((uint32_t)(k_noise * 64.0f) << 16);
+ uint32_t SCHED_SEED = SEED ^ fnv1a_32(schedSalt, 0x9e3779b9u);
+ const float PI = 3.14159265358979323846f;
+
+ float sigmaY = lerp(0.0f, 0.08f, k_noise);
+ const float decay = 0.88f + 0.08f * k_noise;
+ const float amp = 0.18f * k_noise;
+ const float baseP = 0.0025f + 0.02f * k_noise;
+
+ float Hfixed = lerp(0.0f, 0.12f * h, k_stripe);
+ float Gfixed = 0.10f * k_stripe;
+ float Nfixed = 1.0f + 1.5f * k_stripe;
+
+ float rate = 0.4f * k_bands;
+ int dur_frames = std::round(lerp(1.0f, 6.0f, k_bands));
+ float Hburst = lerp(0.06f * h, 0.25f * h, k_bands);
+ float Gburst = lerp(0.10f, 0.25f, k_bands);
+ float sat_band = lerp(0.8f, 0.5f, k_bands);
+ float Nburst = 1.0f + 2.0f * k_bands;
+
+ struct Band { float center; double t0; };
+ std::vector bands;
+ if (k_bands > 0.0f && rate > 0.0f) {
+ const double win_len = 0.25;
+ int win_idx = int(t / win_len);
+ double lambda = rate * win_len *
+ (0.25 + 1.5f * row_density(SCHED_SEED, frame_number, 0));
+ double prob_ge1 = 1.0 - std::exp(-lambda);
+ double prob_ge2 = 1.0 - std::exp(-lambda) - lambda * std::exp(-lambda);
+
+ auto spawn_band = [&](int kseed) {
+ float r1 = hash01(SCHED_SEED, uint32_t(win_idx), 11 + kseed, 0);
+ float start = r1 * win_len;
+ float center =
+ hash01(SCHED_SEED, uint32_t(win_idx), 12 + kseed, 0) * (h - Hburst) +
+ 0.5f * Hburst;
+ double t0 = win_idx * win_len + start;
+ double t1 = t0 + dur_frames / (fps_d > 0 ? fps_d : 1.0);
+ if (t >= t0 && t < t1)
+ bands.push_back({center, t0});
+ };
+
+ float r = hash01(SCHED_SEED, uint32_t(win_idx), 9, 0);
+ if (r < prob_ge1)
+ spawn_band(0);
+ if (r < prob_ge2)
+ spawn_band(1);
+ }
+
+ float ft = 2.0f;
+ int kf = int(std::floor(t * ft));
+ float a = float(t * ft - kf);
+
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y) {
+ float bandF = 0.0f;
+ if (Hfixed > 0.0f && y >= h - Hfixed)
+ bandF = (y - (h - Hfixed)) / std::max(1.0f, Hfixed);
+ float burstF = 0.0f;
+ for (const auto &b : bands) {
+ float halfH = Hburst * 0.5f;
+ float dist = std::abs(y - b.center);
+ float profile = std::max(0.0f, 1.0f - dist / halfH);
+ float life = float((t - b.t0) * fps_d);
+ float env = (life < 1.0f)
+ ? life
+ : (life < dur_frames - 1 ? 1.0f
+ : std::max(0.0f, dur_frames - life));
+ burstF = std::max(burstF, profile * env);
+ }
+
+ float sat_row = 1.0f - (1.0f - sat_band) * burstF;
+ if (burstF > 0.0f && sat_row != 1.0f) {
+ float *urow = &U[y * Uw];
+ float *vrow = &V[y * Uw];
+ for (int xh = 0; xh < Uw; ++xh) {
+ urow[xh] *= sat_row;
+ vrow[xh] *= sat_row;
+ }
+ }
+
+ float rowBias = row_density(SEED, frame_number, y);
+ float p = baseP * (0.25f + 1.5f * rowBias);
+ p *= (1.0f + 1.5f * bandF + 2.0f * burstF);
+
+ float hum = 0.008f * k_noise *
+ std::sin(2 * PI * (y * (6.0f / h) + 0.08f * t));
+ uint32_t s0 = SEED ^ 0x9e37u * kf ^ 0x85ebu * y;
+ uint32_t s1 = SEED ^ 0x9e37u * (kf + 1) ^ 0x85ebu * y ^ 0x1234567u;
+ auto step = [](uint32_t &s) {
+ s ^= s << 13;
+ s ^= s >> 17;
+ s ^= s << 5;
+ return s;
+ };
+ float lift = Gfixed * bandF + Gburst * burstF;
+ float rowSigma = sigmaY * (1 + (Nfixed - 1) * bandF +
+ (Nburst - 1) * burstF);
+ float k = 0.15f + 0.35f * hash01(SEED, uint32_t(frame_number), y, 777);
+ float sL = 0.0f, sR = 0.0f;
+ for (int x = 0; x < w; ++x) {
+ if (hash01(SEED, uint32_t(frame_number), y, x) < p)
+ sL = 1.0f;
+ if (hash01(SEED, uint32_t(frame_number), y, w - 1 - x) < p * 0.7f)
+ sR = 1.0f;
+ float n = ((step(s0) & 0xFFFFFF) / 16777215.0f) * (1 - a) +
+ ((step(s1) & 0xFFFFFF) / 16777215.0f) * a;
+ int idx = y * w + x;
+ float mt = std::clamp((Y[idx] - 0.2f) / (0.8f - 0.2f), 0.0f, 1.0f);
+ float val = Y[idx] + lift + rowSigma * (2 * n - 1) *
+ (0.6f + 0.4f * mt) + hum;
+ float streak = amp * (sL + sR);
+ float newY = val + streak * (k + (1.0f - val));
+ Y[idx] = std::clamp(newY, 0.0f, 1.0f);
+ sL *= decay;
+ sR *= decay;
+ }
+ }
+
+ float A = lerp(0.0f, 3.0f, k_track); // pixels
+ float f = lerp(0.25f, 1.2f, k_track); // Hz
+ float Hsk = lerp(0.0f, 0.10f * h, k_track); // pixels
+ float S = lerp(0.0f, 5.0f, k_track); // pixels
+ float phase = 2 * PI * (f * t) + 0.7f * (SEED * 0.001f);
+ for (int y = 0; y < h; ++y) {
+ float base = A * std::sin(2 * PI * 0.0035f * y + phase);
+ float skew = (y >= h - Hsk)
+ ? S * ((y - (h - Hsk)) / std::max(1.0f, Hsk))
+ : 0.0f;
+ dx[y] = base + skew;
+ }
+
+ auto remap_line = [&](const float *src, float *dst, int width, float scale) {
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y) {
+ float off = dx[y] * scale;
+ const float *s = src + y * width;
+ float *d = dst + y * width;
+ int start = std::max(0, int(std::ceil(-off)));
+ int end = std::min(width, int(std::floor(width - off)));
+ float xs = start + off;
+ int x0 = int(xs);
+ float t = xs - x0;
+ for (int x = start; x < end; ++x) {
+ int x1 = x0 + 1;
+ d[x] = s[x0] * (1 - t) + s[x1] * t;
+ xs += 1.0f;
+ x0 = int(xs);
+ t = xs - x0;
+ }
+ for (int x = 0; x < start; ++x)
+ d[x] = s[0];
+ for (int x = end; x < width; ++x)
+ d[x] = s[width - 1];
+ }
+ };
+
+ remap_line(Y.data(), tmpY.data(), w, 1.0f);
+ Y.swap(tmpY);
+ remap_line(U.data(), tmpU.data(), Uw, 0.5f);
+ U.swap(tmpU);
+ remap_line(V.data(), tmpV.data(), Uw, 0.5f);
+ V.swap(tmpV);
+
+#ifdef _OPENMP
+#pragma omp parallel for
+#endif
+ for (int y = 0; y < h; ++y) {
+ float *yrow = &Y[y * w];
+ float *urow = &U[y * Uw];
+ float *vrow = &V[y * Uw];
+ uint32_t *row = base + y * stride;
+ for (int x = 0; x < w; ++x) {
+ float xs = x * 0.5f;
+ int x0 = int(xs);
+ int x1 = std::min(x0 + 1, Uw - 1);
+ float t = xs - x0;
+ float u = (urow[x0] * (1 - t) + urow[x1] * t) * sat;
+ float v = (vrow[x0] * (1 - t) + vrow[x1] * t) * sat;
+ float yv = yrow[x];
+ float r = yv + 1.13983f * v;
+ float g = yv - 0.39465f * u - 0.58060f * v;
+ float b = yv + 2.03211f * u;
+ int R = int(std::clamp(r, 0.0f, 1.0f) * 255.0f);
+ int G = int(std::clamp(g, 0.0f, 1.0f) * 255.0f);
+ int B = int(std::clamp(b, 0.0f, 1.0f) * 255.0f);
+ uint32_t A = row[x] & 0xFF000000u;
+ row[x] = A | (R << 16) | (G << 8) | B;
+ }
+ }
+
+ return frame;
+}
+
+// JSON
+std::string AnalogTape::Json() const { return JsonValue().toStyledString(); }
+
+Json::Value AnalogTape::JsonValue() const {
+ Json::Value root = EffectBase::JsonValue();
+ root["type"] = info.class_name;
+ root["tracking"] = tracking.JsonValue();
+ root["bleed"] = bleed.JsonValue();
+ root["softness"] = softness.JsonValue();
+ root["noise"] = noise.JsonValue();
+ root["stripe"] = stripe.JsonValue();
+ root["static_bands"] = staticBands.JsonValue();
+ root["seed_offset"] = seed_offset;
+ return root;
+}
+
+void AnalogTape::SetJson(const std::string value) {
+ try {
+ Json::Value root = openshot::stringToJson(value);
+ SetJsonValue(root);
+ } catch (const std::exception &) {
+ throw InvalidJSON("JSON is invalid (missing keys or invalid data types)");
+ }
+}
+
+void AnalogTape::SetJsonValue(const Json::Value root) {
+ EffectBase::SetJsonValue(root);
+ if (!root["tracking"].isNull())
+ tracking.SetJsonValue(root["tracking"]);
+ if (!root["bleed"].isNull())
+ bleed.SetJsonValue(root["bleed"]);
+ if (!root["softness"].isNull())
+ softness.SetJsonValue(root["softness"]);
+ if (!root["noise"].isNull())
+ noise.SetJsonValue(root["noise"]);
+ if (!root["stripe"].isNull())
+ stripe.SetJsonValue(root["stripe"]);
+ if (!root["static_bands"].isNull())
+ staticBands.SetJsonValue(root["static_bands"]);
+ if (!root["seed_offset"].isNull())
+ seed_offset = root["seed_offset"].asInt();
+}
+
+std::string AnalogTape::PropertiesJSON(int64_t requested_frame) const {
+ Json::Value root = BasePropertiesJSON(requested_frame);
+ root["tracking"] =
+ add_property_json("Tracking", tracking.GetValue(requested_frame), "float",
+ "", &tracking, 0, 1, false, requested_frame);
+ root["bleed"] =
+ add_property_json("Bleed", bleed.GetValue(requested_frame), "float", "",
+ &bleed, 0, 1, false, requested_frame);
+ root["softness"] =
+ add_property_json("Softness", softness.GetValue(requested_frame), "float",
+ "", &softness, 0, 1, false, requested_frame);
+ root["noise"] =
+ add_property_json("Noise", noise.GetValue(requested_frame), "float", "",
+ &noise, 0, 1, false, requested_frame);
+ root["stripe"] =
+ add_property_json("Stripe", stripe.GetValue(requested_frame), "float",
+ "Bottom tracking stripe brightness and noise.",
+ &stripe, 0, 1, false, requested_frame);
+ root["static_bands"] =
+ add_property_json("Static Bands", staticBands.GetValue(requested_frame),
+ "float",
+ "Short bright static bands and extra dropouts.",
+ &staticBands, 0, 1, false, requested_frame);
+ root["seed_offset"] =
+ add_property_json("Seed Offset", seed_offset, "int", "", NULL, 0, 1000,
+ false, requested_frame);
+ return root.toStyledString();
+}
diff --git a/src/effects/AnalogTape.h b/src/effects/AnalogTape.h
new file mode 100644
index 000000000..d10ddd5a4
--- /dev/null
+++ b/src/effects/AnalogTape.h
@@ -0,0 +1,130 @@
+/**
+ * @file
+ * @brief Header file for AnalogTape effect class
+ *
+ * Vintage home video wobble, bleed, and grain.
+ *
+ * @author Jonathan Thomas
+ */
+
+#ifndef OPENSHOT_ANALOGTAPE_EFFECT_H
+#define OPENSHOT_ANALOGTAPE_EFFECT_H
+
+#include "../EffectBase.h"
+#include "../Frame.h"
+#include "../Json.h"
+#include "../KeyFrame.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if defined(__GNUC__) || defined(__clang__)
+#define OS_RESTRICT __restrict__
+#else
+#define OS_RESTRICT
+#endif
+
+namespace openshot {
+
+/// Analog video tape simulation effect.
+class AnalogTape : public EffectBase {
+private:
+ void init_effect_details();
+ static inline uint32_t fnv1a_32(const std::string &s) {
+ uint32_t h = 2166136261u;
+ for (unsigned char c : s) {
+ h ^= c;
+ h *= 16777619u;
+ }
+ return h;
+ }
+ static inline uint32_t fnv1a_32(uint32_t h, uint32_t d) {
+ unsigned char bytes[4];
+ bytes[0] = d & 0xFF;
+ bytes[1] = (d >> 8) & 0xFF;
+ bytes[2] = (d >> 16) & 0xFF;
+ bytes[3] = (d >> 24) & 0xFF;
+ for (int i = 0; i < 4; ++i) {
+ h ^= bytes[i];
+ h *= 16777619u;
+ }
+ return h;
+ }
+ static inline float hash01(uint32_t seed, uint32_t a, uint32_t b, uint32_t c) {
+ uint32_t h = fnv1a_32(seed, a);
+ h = fnv1a_32(h, b);
+ h = fnv1a_32(h, c);
+ return h / 4294967295.0f;
+ }
+ static inline float row_density(uint32_t seed, int frame, int y) {
+ int tc = (frame >> 3);
+ int y0 = (y >> 3);
+ float a = (y & 7) / 8.0f;
+ float h0 = hash01(seed, tc, y0, 31);
+ float h1 = hash01(seed, tc, y0 + 1, 31);
+ float m = (1 - a) * h0 + a * h1;
+ return m * m;
+ }
+ static inline void box_blur_row(const float *OS_RESTRICT src,
+ float *OS_RESTRICT dst, int w, int r) {
+ if (r == 0) {
+ std::memcpy(dst, src, w * sizeof(float));
+ return;
+ }
+ const int win = 2 * r + 1;
+ float sum = 0.0f;
+ for (int k = -r; k <= r; ++k)
+ sum += src[std::clamp(k, 0, w - 1)];
+ dst[0] = sum / win;
+ for (int x = 1; x < w; ++x) {
+ int add = std::min(w - 1, x + r);
+ int sub = std::max(0, x - r - 1);
+ sum += src[add] - src[sub];
+ dst[x] = sum / win;
+ }
+ }
+
+ int last_w = 0, last_h = 0;
+ std::vector Y, U, V, tmpY, tmpU, tmpV, dx;
+
+public:
+ Keyframe tracking; ///< tracking wobble amount
+ Keyframe bleed; ///< color bleed amount
+ Keyframe softness; ///< luma blur radius
+ Keyframe noise; ///< grain/dropouts amount
+ Keyframe stripe; ///< bottom tracking stripe strength
+ Keyframe staticBands; ///< burst static band strength
+ int seed_offset; ///< seed offset for deterministic randomness
+
+ AnalogTape();
+ AnalogTape(Keyframe tracking, Keyframe bleed, Keyframe softness,
+ Keyframe noise, Keyframe stripe, Keyframe staticBands,
+ int seed_offset = 0);
+
+ std::shared_ptr
+ GetFrame(std::shared_ptr frame,
+ int64_t frame_number) override;
+
+ std::shared_ptr GetFrame(int64_t frame_number) override {
+ return GetFrame(std::make_shared(), frame_number);
+ }
+
+ // JSON
+ std::string Json() const override;
+ void SetJson(const std::string value) override;
+ Json::Value JsonValue() const override;
+ void SetJsonValue(const Json::Value root) override;
+
+std::string PropertiesJSON(int64_t requested_frame) const override;
+};
+
+} // namespace openshot
+
+#undef OS_RESTRICT
+
+#endif
diff --git a/src/effects/ObjectDetection.cpp b/src/effects/ObjectDetection.cpp
index 6bc019552..6e1ae97c3 100644
--- a/src/effects/ObjectDetection.cpp
+++ b/src/effects/ObjectDetection.cpp
@@ -13,6 +13,7 @@
#include
#include
+#include
#include "effects/ObjectDetection.h"
#include "effects/Tracker.h"
@@ -29,29 +30,16 @@ using namespace std;
using namespace openshot;
-/// Blank constructor, useful when using Json to load the effect properties
-ObjectDetection::ObjectDetection(std::string clipObDetectDataPath) :
-display_box_text(1.0), display_boxes(1.0)
-{
- // Init effect properties
- init_effect_details();
-
- // Tries to load the tracker data from protobuf
- LoadObjDetectdData(clipObDetectDataPath);
-
- // Initialize the selected object index as the first object index
- selectedObjectIndex = trackedObjects.begin()->first;
-}
-
// Default constructor
-ObjectDetection::ObjectDetection() :
- display_box_text(1.0), display_boxes(1.0)
+ObjectDetection::ObjectDetection()
+ : display_box_text(1.0)
+ , display_boxes(1.0)
{
- // Init effect properties
+ // Init effect metadata
init_effect_details();
- // Initialize the selected object index as the first object index
- selectedObjectIndex = trackedObjects.begin()->first;
+ // We haven’t loaded any protobuf yet, so there's nothing to pick.
+ selectedObjectIndex = -1;
}
// Init effect settings
@@ -167,108 +155,99 @@ std::shared_ptr ObjectDetection::GetFrame(std::shared_ptr frame, i
}
// Load protobuf data file
-bool ObjectDetection::LoadObjDetectdData(std::string inputFilePath){
- // Create tracker message
- pb_objdetect::ObjDetect objMessage;
-
- // Read the existing tracker message.
- std::fstream input(inputFilePath, std::ios::in | std::ios::binary);
- if (!objMessage.ParseFromIstream(&input)) {
- std::cerr << "Failed to parse protobuf message." << std::endl;
- return false;
- }
-
- // Make sure classNames, detectionsData and trackedObjects are empty
- classNames.clear();
- detectionsData.clear();
- trackedObjects.clear();
-
- // Seed to generate same random numbers
- std::srand(1);
- // Get all classes names and assign a color to them
- for(int i = 0; i < objMessage.classnames_size(); i++)
- {
- classNames.push_back(objMessage.classnames(i));
- classesColor.push_back(cv::Scalar(std::rand()%205 + 50, std::rand()%205 + 50, std::rand()%205 + 50));
- }
-
- // Iterate over all frames of the saved message
- for (size_t i = 0; i < objMessage.frame_size(); i++)
- {
- // Create protobuf message reader
- const pb_objdetect::Frame& pbFrameData = objMessage.frame(i);
-
- // Get frame Id
- size_t id = pbFrameData.id();
-
- // Load bounding box data
- const google::protobuf::RepeatedPtrField &pBox = pbFrameData.bounding_box();
+bool ObjectDetection::LoadObjDetectdData(std::string inputFilePath)
+{
+ // Parse the file
+ pb_objdetect::ObjDetect objMessage;
+ std::fstream input(inputFilePath, std::ios::in | std::ios::binary);
+ if (!objMessage.ParseFromIstream(&input)) {
+ std::cerr << "Failed to parse protobuf message." << std::endl;
+ return false;
+ }
- // Construct data vectors related to detections in the current frame
- std::vector classIds;
- std::vector confidences;
- std::vector> boxes;
- std::vector objectIds;
+ // Clear out any old state
+ classNames.clear();
+ detectionsData.clear();
+ trackedObjects.clear();
+
+ // Seed colors for each class
+ std::srand(1);
+ for (int i = 0; i < objMessage.classnames_size(); ++i) {
+ classNames.push_back(objMessage.classnames(i));
+ classesColor.push_back(cv::Scalar(
+ std::rand() % 205 + 50,
+ std::rand() % 205 + 50,
+ std::rand() % 205 + 50
+ ));
+ }
- // Iterate through the detected objects
- for(int i = 0; i < pbFrameData.bounding_box_size(); i++)
- {
- // Get bounding box coordinates
- float x = pBox.Get(i).x();
- float y = pBox.Get(i).y();
- float w = pBox.Get(i).w();
- float h = pBox.Get(i).h();
- // Get class Id (which will be assign to a class name)
- int classId = pBox.Get(i).classid();
- // Get prediction confidence
- float confidence = pBox.Get(i).confidence();
-
- // Get the object Id
- int objectId = pBox.Get(i).objectid();
-
- // Search for the object id on trackedObjects map
- auto trackedObject = trackedObjects.find(objectId);
- // Check if object already exists on the map
- if (trackedObject != trackedObjects.end())
- {
- // Add a new BBox to it
- trackedObject->second->AddBox(id, x+(w/2), y+(h/2), w, h, 0.0);
- }
- else
- {
- // There is no tracked object with that id, so insert a new one
- TrackedObjectBBox trackedObj((int)classesColor[classId](0), (int)classesColor[classId](1), (int)classesColor[classId](2), (int)0);
- trackedObj.stroke_alpha = Keyframe(1.0);
- trackedObj.AddBox(id, x+(w/2), y+(h/2), w, h, 0.0);
-
- std::shared_ptr trackedObjPtr = std::make_shared(trackedObj);
- ClipBase* parentClip = this->ParentClip();
- trackedObjPtr->ParentClip(parentClip);
-
- // Create a temp ID. This ID is necessary to initialize the object_id Json list
- // this Id will be replaced by the one created in the UI
- trackedObjPtr->Id(std::to_string(objectId));
- trackedObjects.insert({objectId, trackedObjPtr});
+ // Walk every frame in the protobuf
+ for (size_t fi = 0; fi < objMessage.frame_size(); ++fi) {
+ const auto &pbFrame = objMessage.frame(fi);
+ size_t frameId = pbFrame.id();
+
+ // Buffers for DetectionData
+ std::vector classIds;
+ std::vector confidences;
+ std::vector> boxes;
+ std::vector objectIds;
+
+ // For each bounding box in this frame
+ for (int di = 0; di < pbFrame.bounding_box_size(); ++di) {
+ const auto &b = pbFrame.bounding_box(di);
+ float x = b.x(), y = b.y(), w = b.w(), h = b.h();
+ int classId = b.classid();
+ float confidence= b.confidence();
+ int objectId = b.objectid();
+
+ // Record for DetectionData
+ classIds.push_back(classId);
+ confidences.push_back(confidence);
+ boxes.emplace_back(x, y, w, h);
+ objectIds.push_back(objectId);
+
+ // Either append to an existing TrackedObjectBBox…
+ auto it = trackedObjects.find(objectId);
+ if (it != trackedObjects.end()) {
+ it->second->AddBox(frameId, x + w/2, y + h/2, w, h, 0.0);
+ }
+ else {
+ // …or create a brand-new one
+ TrackedObjectBBox tmpObj(
+ (int)classesColor[classId][0],
+ (int)classesColor[classId][1],
+ (int)classesColor[classId][2],
+ /*alpha=*/0
+ );
+ tmpObj.stroke_alpha = Keyframe(1.0);
+ tmpObj.AddBox(frameId, x + w/2, y + h/2, w, h, 0.0);
+
+ auto ptr = std::make_shared(tmpObj);
+ ptr->ParentClip(this->ParentClip());
+
+ // Prefix with effect UUID for a unique string ID
+ std::string prefix = this->Id();
+ if (!prefix.empty())
+ prefix += "-";
+ ptr->Id(prefix + std::to_string(objectId));
+ trackedObjects.emplace(objectId, ptr);
}
-
- // Create OpenCV rectangle with the bouding box info
- cv::Rect_ box(x, y, w, h);
-
- // Push back data into vectors
- boxes.push_back(box);
- classIds.push_back(classId);
- confidences.push_back(confidence);
- objectIds.push_back(objectId);
}
- // Assign data to object detector map
- detectionsData[id] = DetectionData(classIds, confidences, boxes, id, objectIds);
- }
+ // Save the DetectionData for this frame
+ detectionsData[frameId] = DetectionData(
+ classIds, confidences, boxes, frameId, objectIds
+ );
+ }
- // Delete all global objects allocated by libprotobuf.
- google::protobuf::ShutdownProtobufLibrary();
+ google::protobuf::ShutdownProtobufLibrary();
+
+ // Finally, pick a default selectedObjectIndex if we have any
+ if (!trackedObjects.empty()) {
+ selectedObjectIndex = trackedObjects.begin()->first;
+ }
- return true;
+ return true;
}
// Get the indexes and IDs of all visible objects in the given frame
@@ -377,68 +356,82 @@ void ObjectDetection::SetJson(const std::string value) {
}
// Load Json::Value into this object
-void ObjectDetection::SetJsonValue(const Json::Value root) {
- // Set parent data
+void ObjectDetection::SetJsonValue(const Json::Value root)
+{
+ // Parent properties
EffectBase::SetJsonValue(root);
- // Set data from Json (if key is found)
- if (!root["protobuf_data_path"].isNull() && protobuf_data_path.size() <= 1){
- protobuf_data_path = root["protobuf_data_path"].asString();
-
- if(!LoadObjDetectdData(protobuf_data_path)){
- throw InvalidFile("Invalid protobuf data path", "");
- protobuf_data_path = "";
+ // If a protobuf path is provided, load & prefix IDs
+ if (!root["protobuf_data_path"].isNull()) {
+ std::string new_path = root["protobuf_data_path"].asString();
+ if (protobuf_data_path != new_path || trackedObjects.empty()) {
+ protobuf_data_path = new_path;
+ if (!LoadObjDetectdData(protobuf_data_path)) {
+ throw InvalidFile("Invalid protobuf data path", "");
+ }
}
}
- // Set the selected object index
+ // Selected index, thresholds, UI flags, filters, etc.
if (!root["selected_object_index"].isNull())
- selectedObjectIndex = root["selected_object_index"].asInt();
-
+ selectedObjectIndex = root["selected_object_index"].asInt();
if (!root["confidence_threshold"].isNull())
- confidence_threshold = root["confidence_threshold"].asFloat();
-
+ confidence_threshold = root["confidence_threshold"].asFloat();
if (!root["display_box_text"].isNull())
- display_box_text.SetJsonValue(root["display_box_text"]);
-
- if (!root["display_boxes"].isNull())
- display_boxes.SetJsonValue(root["display_boxes"]);
-
- if (!root["class_filter"].isNull()) {
- class_filter = root["class_filter"].asString();
-
- // Convert the class_filter to a QString
- QString qClassFilter = QString::fromStdString(root["class_filter"].asString());
-
- // Split the QString by commas and automatically trim each resulting string
- QStringList classList = qClassFilter.split(',');
- classList.removeAll(""); // Skip empty parts
- display_classes.clear();
+ display_box_text.SetJsonValue(root["display_box_text"]);
+ if (!root["display_boxes"].isNull())
+ display_boxes.SetJsonValue(root["display_boxes"]);
+
+ if (!root["class_filter"].isNull()) {
+ class_filter = root["class_filter"].asString();
+ QStringList parts = QString::fromStdString(class_filter).split(',');
+ display_classes.clear();
+ for (auto &p : parts) {
+ auto s = p.trimmed().toLower();
+ if (!s.isEmpty()) {
+ display_classes.push_back(s.toStdString());
+ }
+ }
+ }
- // Iterate over the QStringList and add each trimmed, non-empty string
- for (const QString &classItem : classList) {
- QString trimmedItem = classItem.trimmed().toLower();
- if (!trimmedItem.isEmpty()) {
- display_classes.push_back(trimmedItem.toStdString());
- }
- }
- }
+ // Apply any per-object overrides
+ if (!root["objects"].isNull()) {
+ // Iterate over the supplied objects (indexed by id or position)
+ const auto memberNames = root["objects"].getMemberNames();
+ for (const auto& name : memberNames)
+ {
+ // Determine the numeric index of this object
+ int index = -1;
+ bool numeric_key = std::all_of(name.begin(), name.end(), ::isdigit);
+ if (numeric_key) {
+ index = std::stoi(name);
+ }
+ else
+ {
+ size_t pos = name.find_last_of('-');
+ if (pos != std::string::npos) {
+ try {
+ index = std::stoi(name.substr(pos + 1));
+ } catch (...) {
+ index = -1;
+ }
+ }
+ }
- if (!root["objects"].isNull()){
- for (auto const& trackedObject : trackedObjects){
- std::string obj_id = std::to_string(trackedObject.first);
- if(!root["objects"][obj_id].isNull()){
- trackedObject.second->SetJsonValue(root["objects"][obj_id]);
+ auto obj_it = trackedObjects.find(index);
+ if (obj_it != trackedObjects.end() && obj_it->second) {
+ // Update object id if provided as a non-numeric key
+ if (!numeric_key)
+ obj_it->second->Id(name);
+ obj_it->second->SetJsonValue(root["objects"][name]);
}
}
}
-
- // Set the tracked object's ids
- if (!root["objects_id"].isNull()){
- for (auto const& trackedObject : trackedObjects){
- Json::Value trackedObjectJSON;
- trackedObjectJSON["box_id"] = root["objects_id"][trackedObject.first].asString();
- trackedObject.second->SetJsonValue(trackedObjectJSON);
+ // Set the tracked object's ids (legacy format)
+ if (!root["objects_id"].isNull()) {
+ for (auto& kv : trackedObjects) {
+ if (!root["objects_id"][kv.first].isNull())
+ kv.second->Id(root["objects_id"][kv.first].asString());
}
}
}
@@ -468,9 +461,9 @@ std::string ObjectDetection::PropertiesJSON(int64_t requested_frame) const {
root["display_box_text"]["choices"].append(add_property_choice_json("Yes", true, display_box_text.GetValue(requested_frame)));
root["display_box_text"]["choices"].append(add_property_choice_json("No", false, display_box_text.GetValue(requested_frame)));
- root["display_boxes"] = add_property_json("Draw All Boxes", display_boxes.GetValue(requested_frame), "int", "", &display_boxes, 0, 1, false, requested_frame);
- root["display_boxes"]["choices"].append(add_property_choice_json("Yes", true, display_boxes.GetValue(requested_frame)));
- root["display_boxes"]["choices"].append(add_property_choice_json("No", false, display_boxes.GetValue(requested_frame)));
+ root["display_boxes"] = add_property_json("Draw All Boxes", display_boxes.GetValue(requested_frame), "int", "", &display_boxes, 0, 1, false, requested_frame);
+ root["display_boxes"]["choices"].append(add_property_choice_json("Yes", true, display_boxes.GetValue(requested_frame)));
+ root["display_boxes"]["choices"].append(add_property_choice_json("No", false, display_boxes.GetValue(requested_frame)));
// Return formatted string
return root.toStyledString();
diff --git a/src/effects/ObjectDetection.h b/src/effects/ObjectDetection.h
index d56eca721..1675bfca6 100644
--- a/src/effects/ObjectDetection.h
+++ b/src/effects/ObjectDetection.h
@@ -82,8 +82,6 @@ namespace openshot
/// Index of the Tracked Object that was selected to modify it's properties
int selectedObjectIndex;
- ObjectDetection(std::string clipTrackerDataPath);
-
/// Default constructor
ObjectDetection();
diff --git a/src/effects/SphericalProjection.cpp b/src/effects/SphericalProjection.cpp
index 0565a7a30..bf676b272 100644
--- a/src/effects/SphericalProjection.cpp
+++ b/src/effects/SphericalProjection.cpp
@@ -13,250 +13,519 @@
#include "SphericalProjection.h"
#include "Exceptions.h"
-#include
#include
+#include
#include
+#include
using namespace openshot;
SphericalProjection::SphericalProjection()
- : yaw(0.0)
- , pitch(0.0)
- , roll(0.0)
- , fov(90.0)
- , projection_mode(0)
- , invert(0)
- , interpolation(0)
+ : yaw(0.0), pitch(0.0), roll(0.0), fov(90.0), in_fov(180.0),
+ projection_mode(0), invert(0), input_model(INPUT_EQUIRECT), interpolation(3)
{
- init_effect_details();
+ init_effect_details();
}
-SphericalProjection::SphericalProjection(Keyframe new_yaw,
- Keyframe new_pitch,
- Keyframe new_roll,
- Keyframe new_fov)
- : yaw(new_yaw), pitch(new_pitch), roll(new_roll)
- , fov(new_fov), projection_mode(0), invert(0), interpolation(0)
+SphericalProjection::SphericalProjection(Keyframe new_yaw, Keyframe new_pitch,
+ Keyframe new_roll, Keyframe new_fov)
+ : yaw(new_yaw), pitch(new_pitch), roll(new_roll), fov(new_fov),
+ in_fov(180.0), projection_mode(0), invert(0),
+ input_model(INPUT_EQUIRECT), interpolation(3)
{
- init_effect_details();
+ init_effect_details();
}
void SphericalProjection::init_effect_details()
{
- InitEffectInfo();
- info.class_name = "SphericalProjection";
- info.name = "Spherical Projection";
- info.description = "Flatten and reproject 360° video with yaw, pitch, roll, and fov (sphere, hemisphere, fisheye modes)";
- info.has_audio = false;
- info.has_video = true;
+ InitEffectInfo();
+ info.class_name = "SphericalProjection";
+ info.name = "Spherical Projection";
+ info.description =
+ "Flatten and reproject 360° or fisheye inputs into a rectilinear view with yaw, pitch, roll, and FOV. Supports Equirect and multiple fisheye lens models.";
+ info.has_audio = false;
+ info.has_video = true;
}
-std::shared_ptr SphericalProjection::GetFrame(
- std::shared_ptr frame,
- int64_t frame_number)
-{
- auto img = frame->GetImage();
- if (img->format() != QImage::Format_ARGB32)
- *img = img->convertToFormat(QImage::Format_ARGB32);
-
- int W = img->width(), H = img->height();
- int bpl = img->bytesPerLine();
- uchar* src = img->bits();
-
- QImage output(W, H, QImage::Format_ARGB32);
- output.fill(Qt::black);
- uchar* dst = output.bits();
- int dst_bpl = output.bytesPerLine();
-
- // Evaluate keyframes (note roll is inverted + offset 180°)
- double yaw_r = yaw.GetValue(frame_number) * M_PI/180.0;
- double pitch_r = pitch.GetValue(frame_number) * M_PI/180.0;
- double roll_r = -roll.GetValue(frame_number) * M_PI/180.0 + M_PI;
- double fov_r = fov.GetValue(frame_number) * M_PI/180.0;
-
- // Build composite rotation matrix R = Ry * Rx * Rz
- double sy = sin(yaw_r), cy = cos(yaw_r);
- double sp = sin(pitch_r), cp = cos(pitch_r);
- double sr = sin(roll_r), cr = cos(roll_r);
-
- double r00 = cy*cr + sy*sp*sr, r01 = -cy*sr + sy*sp*cr, r02 = sy*cp;
- double r10 = cp*sr, r11 = cp*cr, r12 = -sp;
- double r20 = -sy*cr + cy*sp*sr, r21 = sy*sr + cy*sp*cr, r22 = cy*cp;
-
- // Precompute perspective scalars
- double hx = tan(fov_r*0.5);
- double vy = hx * double(H)/W;
+namespace {
+ inline double cubic_interp(double p0, double p1, double p2, double p3,
+ double t)
+ {
+ double a0 = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3;
+ double a1 = p0 - 2.5 * p1 + 2.0 * p2 - 0.5 * p3;
+ double a2 = -0.5 * p0 + 0.5 * p2;
+ double a3 = p1;
+ return ((a0 * t + a1) * t + a2) * t + a3;
+ }
+} // namespace
+
+std::shared_ptr
+SphericalProjection::GetFrame(std::shared_ptr frame,
+ int64_t frame_number) {
+ auto img = frame->GetImage();
+ if (img->format() != QImage::Format_ARGB32)
+ *img = img->convertToFormat(QImage::Format_ARGB32);
+
+ int W = img->width(), H = img->height();
+ int bpl = img->bytesPerLine();
+ uchar *src = img->bits();
+
+ QImage output(W, H, QImage::Format_ARGB32);
+ output.fill(Qt::black);
+ uchar *dst = output.bits();
+ int dst_bpl = output.bytesPerLine();
+
+ // Keyframes / angles
+ const double DEG = M_PI / 180.0;
+ double yaw_r = -yaw.GetValue(frame_number) * DEG; // drag right -> look right
+ double pitch_r = pitch.GetValue(frame_number) * DEG; // drag up -> look up
+ double roll_r = -roll.GetValue(frame_number) * DEG; // positive slider -> clockwise on screen
+ double in_fov_r = in_fov.GetValue(frame_number) * DEG;
+ double out_fov_r= fov.GetValue(frame_number) * DEG;
+
+ // Apply invert as a 180° yaw for equirect inputs (camera-centric; no mirroring)
+ if (input_model == INPUT_EQUIRECT && invert == INVERT_BACK) {
+ yaw_r += M_PI;
+ }
+
+ // Rotation R = Ry(yaw) * Rx(pitch). (Roll applied in screen space.)
+ double sy = sin(yaw_r), cy = cos(yaw_r);
+ double sp = sin(pitch_r),cp = cos(pitch_r);
+
+ double r00 = cy;
+ double r01 = sy * sp;
+ double r02 = sy * cp;
+
+ double r10 = 0.0;
+ double r11 = cp;
+ double r12 = -sp;
+
+ double r20 = -sy;
+ double r21 = cy * sp;
+ double r22 = cy * cp;
+
+ // Keep roll clockwise on screen regardless of facing direction
+ double roll_sign = (r22 >= 0.0) ? 1.0 : -1.0;
+
+ // Perspective scalars (rectilinear)
+ double hx = tan(out_fov_r * 0.5);
+ double vy = hx * double(H) / W;
+
+ auto q = [](double a) { return std::llround(a * 1e6); };
+ bool recompute = uv_map.empty() || W != cached_width || H != cached_height ||
+ q(yaw_r) != q(cached_yaw) ||
+ q(pitch_r) != q(cached_pitch) ||
+ q(roll_r) != q(cached_roll) ||
+ q(in_fov_r) != q(cached_in_fov) ||
+ q(out_fov_r) != q(cached_out_fov) ||
+ input_model != cached_input_model ||
+ projection_mode != cached_projection_mode ||
+ invert != cached_invert;
+
+ if (recompute) {
+ uv_map.resize(W * H * 2);
+
+#pragma omp parallel for schedule(static)
+ for (int yy = 0; yy < H; yy++) {
+ double ndc_y = (2.0 * (yy + 0.5) / H - 1.0) * vy;
+
+ for (int xx = 0; xx < W; xx++) {
+ double uf = -1.0, vf = -1.0;
+
+ const bool out_is_rect =
+ (projection_mode == MODE_RECT_SPHERE || projection_mode == MODE_RECT_HEMISPHERE);
+
+ if (!out_is_rect) {
+ // ---------------- FISHEYE OUTPUT ----------------
+ double cx = (xx + 0.5) - W * 0.5;
+ double cy_dn = (yy + 0.5) - H * 0.5;
+ double R = 0.5 * std::min(W, H);
+
+ // screen plane, Y-up; apply roll by -roll (clockwise), adjusted by roll_sign
+ double rx = cx / R;
+ double ry_up = -cy_dn / R;
+ double cR = cos(roll_r), sR = sin(roll_r) * roll_sign;
+ double rxr = cR * rx + sR * ry_up;
+ double ryr = -sR * rx + cR * ry_up;
+
+ double r_norm = std::sqrt(rxr * rxr + ryr * ryr);
+ if (r_norm <= 1.0) {
+ double theta_max = out_fov_r * 0.5;
+ double theta = 0.0;
+ switch (projection_mode) {
+ case MODE_FISHEYE_EQUIDISTANT:
+ // r ∝ θ
+ theta = r_norm * theta_max;
+ break;
+ case MODE_FISHEYE_EQUISOLID:
+ // r ∝ 2 sin(θ/2)
+ theta = 2.0 * std::asin(std::clamp(r_norm * std::sin(theta_max * 0.5), -1.0, 1.0));
+ break;
+ case MODE_FISHEYE_STEREOGRAPHIC:
+ // r ∝ 2 tan(θ/2)
+ theta = 2.0 * std::atan(r_norm * std::tan(theta_max * 0.5));
+ break;
+ case MODE_FISHEYE_ORTHOGRAPHIC:
+ // r ∝ sin(θ)
+ theta = std::asin(std::clamp(r_norm * std::sin(theta_max), -1.0, 1.0));
+ break;
+ default:
+ theta = r_norm * theta_max;
+ break;
+ }
+
+ // NOTE: Y was upside-down; fix by using +ryr (not -ryr)
+ double phi = std::atan2(ryr, rxr);
+
+ // Camera ray from fisheye output
+ double vx = std::sin(theta) * std::cos(phi);
+ double vy2= std::sin(theta) * std::sin(phi);
+ double vz = -std::cos(theta);
+
+ // Rotate into world
+ double dx = r00 * vx + r01 * vy2 + r02 * vz;
+ double dy = r10 * vx + r11 * vy2 + r12 * vz;
+ double dz = r20 * vx + r21 * vy2 + r22 * vz;
+
+ project_input(dx, dy, dz, in_fov_r, W, H, uf, vf);
+ } else {
+ uf = vf = -1.0; // outside disk
+ }
+
+ } else {
+ // ---------------- RECTILINEAR OUTPUT ----------------
+ double ndc_x = (2.0 * (xx + 0.5) / W - 1.0) * hx;
+
+ // screen plane Y-up; roll by -roll (clockwise), adjusted by roll_sign
+ double sx = ndc_x;
+ double sy_up = -ndc_y;
+ double cR = cos(roll_r), sR = sin(roll_r) * roll_sign;
+ double rx = cR * sx + sR * sy_up;
+ double ry = -sR * sx + cR * sy_up;
+
+ // Camera ray (camera looks down -Z)
+ double vx = rx, vy2 = ry, vz = -1.0;
+ double inv_len = 1.0 / std::sqrt(vx*vx + vy2*vy2 + vz*vz);
+ vx *= inv_len; vy2 *= inv_len; vz *= inv_len;
+
+ // Rotate into world
+ double dx = r00 * vx + r01 * vy2 + r02 * vz;
+ double dy = r10 * vx + r11 * vy2 + r12 * vz;
+ double dz = r20 * vx + r21 * vy2 + r22 * vz;
+
+ project_input(dx, dy, dz, in_fov_r, W, H, uf, vf);
+ }
+
+ int idx = 2 * (yy * W + xx);
+ uv_map[idx] = (float)uf;
+ uv_map[idx + 1] = (float)vf;
+ }
+ }
+
+ cached_width = W;
+ cached_height = H;
+ cached_yaw = yaw_r;
+ cached_pitch = pitch_r;
+ cached_roll = roll_r;
+ cached_in_fov = in_fov_r;
+ cached_out_fov= out_fov_r;
+ cached_input_model = input_model;
+ cached_projection_mode = projection_mode;
+ cached_invert = invert;
+ }
+
+ // Auto sampler selection (uses enums)
+ int sampler = interpolation;
+ if (interpolation == INTERP_AUTO) {
+ double coverage_r =
+ (projection_mode == MODE_RECT_SPHERE) ? 2.0 * M_PI :
+ (projection_mode == MODE_RECT_HEMISPHERE) ? M_PI :
+ in_fov_r; // rough heuristic otherwise
+ double ppd_src = W / coverage_r;
+ double ppd_out = W / out_fov_r;
+ double ratio = ppd_out / ppd_src;
+ if (ratio < 0.8) sampler = INTERP_AUTO; // mipmaps path below
+ else if (ratio <= 1.2) sampler = INTERP_BILINEAR;
+ else sampler = INTERP_BICUBIC;
+ }
+
+ // Build mipmaps only if needed (box)
+ std::vector mipmaps;
+ if (sampler == INTERP_AUTO) {
+ mipmaps.push_back(*img);
+ for (int level = 1; level < 4; ++level) {
+ const QImage &prev = mipmaps[level - 1];
+ if (prev.width() <= 1 || prev.height() <= 1) break;
+ int w = prev.width() / 2, h = prev.height() / 2;
+ QImage next(w, h, QImage::Format_ARGB32);
+ uchar *nb = next.bits(); int nbpl = next.bytesPerLine();
+ const uchar *pb = prev.bits(); int pbpl = prev.bytesPerLine();
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ for (int c = 0; c < 4; c++) {
+ int p00 = pb[(2*y) * pbpl + (2*x) * 4 + c];
+ int p10 = pb[(2*y) * pbpl + (2*x+1) * 4 + c];
+ int p01 = pb[(2*y+1) * pbpl + (2*x) * 4 + c];
+ int p11 = pb[(2*y+1) * pbpl + (2*x+1) * 4 + c];
+ nb[y * nbpl + x * 4 + c] = (p00 + p10 + p01 + p11) / 4;
+ }
+ }
+ }
+ mipmaps.push_back(next);
+ }
+ }
#pragma omp parallel for schedule(static)
- for (int yy = 0; yy < H; yy++) {
- uchar* dst_row = dst + yy * dst_bpl;
- double ndc_y = (2.0*(yy + 0.5)/H - 1.0) * vy;
-
- for (int xx = 0; xx < W; xx++) {
- // Generate ray in camera space
- double ndc_x = (2.0*(xx + 0.5)/W - 1.0) * hx;
- double vx = ndc_x, vy2 = -ndc_y, vz = -1.0;
- double inv = 1.0/sqrt(vx*vx + vy2*vy2 + vz*vz);
- vx *= inv; vy2 *= inv; vz *= inv;
-
- // Rotate ray into world coordinates
- double dx = r00*vx + r01*vy2 + r02*vz;
- double dy = r10*vx + r11*vy2 + r12*vz;
- double dz = r20*vx + r21*vy2 + r22*vz;
-
- // For sphere/hemisphere, optionally invert view by 180°
- if (projection_mode < 2 && invert) {
- dx = -dx;
- dz = -dz;
- }
-
- double uf, vf;
-
- if (projection_mode == 2) {
- // Fisheye mode: invert circular fisheye
- double ax = 0.0, ay = 0.0, az = invert ? -1.0 : 1.0;
- double cos_t = dx*ax + dy*ay + dz*az;
- double theta = acos(cos_t);
- double rpx = (theta / fov_r) * (W/2.0);
- double phi = atan2(dy, dx);
- uf = W*0.5 + rpx*cos(phi);
- vf = H*0.5 + rpx*sin(phi);
- }
- else {
- // Sphere or hemisphere: equirectangular sampling
- double lon = atan2(dx, dz);
- double lat = asin(dy);
- if (projection_mode == 1) // hemisphere
- lon = std::clamp(lon, -M_PI/2.0, M_PI/2.0);
- uf = ((lon + (projection_mode? M_PI/2.0 : M_PI))
- / (projection_mode? M_PI : 2.0*M_PI)) * W;
- vf = (lat + M_PI/2.0)/M_PI * H;
- }
-
- uchar* d = dst_row + xx*4;
-
- if (interpolation == 0) {
- // Nearest-neighbor sampling
- int x0 = std::clamp(int(std::floor(uf)), 0, W-1);
- int y0 = std::clamp(int(std::floor(vf)), 0, H-1);
- uchar* s = src + y0*bpl + x0*4;
- d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; d[3] = s[3];
- }
- else {
- // Bilinear sampling
- int x0 = std::clamp(int(std::floor(uf)), 0, W-1);
- int y0 = std::clamp(int(std::floor(vf)), 0, H-1);
- int x1 = std::clamp(x0 + 1, 0, W-1);
- int y1 = std::clamp(y0 + 1, 0, H-1);
- double dxr = uf - x0, dyr = vf - y0;
- uchar* p00 = src + y0*bpl + x0*4;
- uchar* p10 = src + y0*bpl + x1*4;
- uchar* p01 = src + y1*bpl + x0*4;
- uchar* p11 = src + y1*bpl + x1*4;
- for (int c = 0; c < 4; c++) {
- double v0 = p00[c]*(1-dxr) + p10[c]*dxr;
- double v1 = p01[c]*(1-dxr) + p11[c]*dxr;
- d[c] = uchar(v0*(1-dyr) + v1*dyr + 0.5);
- }
- }
- }
- }
-
- *img = output;
- return frame;
+ for (int yy = 0; yy < H; yy++) {
+ uchar *dst_row = dst + yy * dst_bpl;
+ for (int xx = 0; xx < W; xx++) {
+ int idx = 2 * (yy * W + xx);
+ double uf = uv_map[idx];
+ double vf = uv_map[idx + 1];
+ uchar *d = dst_row + xx * 4;
+
+ if (input_model == INPUT_EQUIRECT && projection_mode == MODE_RECT_SPHERE) {
+ uf = std::fmod(std::fmod(uf, W) + W, W);
+ vf = std::clamp(vf, 0.0, (double)H - 1);
+ } else if (input_model == INPUT_EQUIRECT && projection_mode == MODE_RECT_HEMISPHERE) {
+ uf = std::clamp(uf, 0.0, (double)W - 1);
+ vf = std::clamp(vf, 0.0, (double)H - 1);
+ } else if (uf < 0 || uf >= W || vf < 0 || vf >= H) {
+ d[0] = d[1] = d[2] = 0; d[3] = 0;
+ continue;
+ }
+
+ if (sampler == INTERP_NEAREST) {
+ int x0 = std::clamp(int(std::floor(uf)), 0, W - 1);
+ int y0 = std::clamp(int(std::floor(vf)), 0, H - 1);
+ uchar *s = src + y0 * bpl + x0 * 4;
+ d[0]=s[0]; d[1]=s[1]; d[2]=s[2]; d[3]=s[3];
+ } else if (sampler == INTERP_BILINEAR) {
+ int x0 = std::clamp(int(std::floor(uf)), 0, W - 1);
+ int y0 = std::clamp(int(std::floor(vf)), 0, H - 1);
+ int x1 = std::clamp(x0 + 1, 0, W - 1);
+ int y1 = std::clamp(y0 + 1, 0, H - 1);
+ double dxr = uf - x0, dyr = vf - y0;
+ uchar *p00 = src + y0 * bpl + x0 * 4;
+ uchar *p10 = src + y0 * bpl + x1 * 4;
+ uchar *p01 = src + y1 * bpl + x0 * 4;
+ uchar *p11 = src + y1 * bpl + x1 * 4;
+ for (int c = 0; c < 4; c++) {
+ double v0 = p00[c] * (1 - dxr) + p10[c] * dxr;
+ double v1 = p01[c] * (1 - dxr) + p11[c] * dxr;
+ d[c] = uchar(v0 * (1 - dyr) + v1 * dyr + 0.5);
+ }
+ } else if (sampler == INTERP_BICUBIC) {
+ int x1 = std::clamp(int(std::floor(uf)), 0, W - 1);
+ int y1 = std::clamp(int(std::floor(vf)), 0, H - 1);
+ double tx = uf - x1, ty = vf - y1;
+ for (int c = 0; c < 4; c++) {
+ double col[4];
+ for (int j = -1; j <= 2; j++) {
+ int y = std::clamp(y1 + j, 0, H - 1);
+ double row[4];
+ for (int i = -1; i <= 2; i++) {
+ int x = std::clamp(x1 + i, 0, W - 1);
+ row[i + 1] = src[y * bpl + x * 4 + c];
+ }
+ col[j + 1] = cubic_interp(row[0], row[1], row[2], row[3], tx);
+ }
+ double val = cubic_interp(col[0], col[1], col[2], col[3], ty);
+ d[c] = uchar(std::clamp(val, 0.0, 255.0) + 0.5);
+ }
+ } else { // INTERP_AUTO -> mipmaps + bilinear
+ double uf_dx = 0.0, vf_dx = 0.0, uf_dy = 0.0, vf_dy = 0.0;
+ if (xx + 1 < W) { uf_dx = uv_map[idx + 2] - uf; vf_dx = uv_map[idx + 3] - vf; }
+ if (yy + 1 < H) { uf_dy = uv_map[idx + 2 * W] - uf; vf_dy = uv_map[idx + 2 * W + 1] - vf; }
+ double scale_x = std::sqrt(uf_dx*uf_dx + vf_dx*vf_dx);
+ double scale_y = std::sqrt(uf_dy*uf_dy + vf_dy*vf_dy);
+ double scale = std::max(scale_x, scale_y);
+ int level = 0;
+ if (scale > 1.0)
+ level = std::min(std::floor(std::log2(scale)), (int)mipmaps.size() - 1);
+ const QImage &lvl = mipmaps[level];
+ int Wl = lvl.width(), Hl = lvl.height();
+ int bpl_l = lvl.bytesPerLine();
+ const uchar *srcl = lvl.bits();
+ double uf_l = uf / (1 << level);
+ double vf_l = vf / (1 << level);
+ int x0 = std::clamp(int(std::floor(uf_l)), 0, Wl - 1);
+ int y0 = std::clamp(int(std::floor(vf_l)), 0, Hl - 1);
+ int x1 = std::clamp(x0 + 1, 0, Wl - 1);
+ int y1 = std::clamp(y0 + 1, 0, Hl - 1);
+ double dxr = uf_l - x0, dyr = vf_l - y0;
+ const uchar *p00 = srcl + y0 * bpl_l + x0 * 4;
+ const uchar *p10 = srcl + y0 * bpl_l + x1 * 4;
+ const uchar *p01 = srcl + y1 * bpl_l + x0 * 4;
+ const uchar *p11 = srcl + y1 * bpl_l + x1 * 4;
+ for (int c = 0; c < 4; c++) {
+ double v0 = p00[c] * (1 - dxr) + p10[c] * dxr;
+ double v1 = p01[c] * (1 - dxr) + p11[c] * dxr;
+ d[c] = uchar(v0 * (1 - dyr) + v1 * dyr + 0.5);
+ }
+ }
+ }
+ }
+
+ *img = output;
+ return frame;
+}
+
+void SphericalProjection::project_input(double dx, double dy, double dz,
+ double in_fov_r, int W, int H,
+ double &uf, double &vf) const {
+ if (input_model == INPUT_EQUIRECT) {
+ // Center (-Z) -> lon=0; +X (screen right) -> +lon
+ double lon = std::atan2(dx, -dz);
+ double lat = std::asin(std::clamp(dy, -1.0, 1.0));
+
+ if (projection_mode == MODE_RECT_HEMISPHERE)
+ lon = std::clamp(lon, -M_PI / 2.0, M_PI / 2.0);
+
+ double horiz_span = (projection_mode == MODE_RECT_HEMISPHERE) ? M_PI : 2.0 * M_PI;
+ double lon_offset = (projection_mode == MODE_RECT_HEMISPHERE) ? M_PI / 2.0 : M_PI;
+ uf = ((lon + lon_offset) / horiz_span) * W;
+
+ // Image Y grows downward: north (lat = +π/2) at top
+ vf = (M_PI / 2.0 - lat) / M_PI * H;
+ return;
+ }
+
+ // -------- Fisheye inputs --------
+ // Optical axis default is -Z; "Invert" flips hemisphere.
+ const double ax = 0.0, ay = 0.0;
+ double az = -1.0;
+ if (invert == INVERT_BACK) az = 1.0;
+
+ double cos_t = std::clamp(dx * ax + dy * ay + dz * az, -1.0, 1.0);
+ double theta = std::acos(cos_t);
+ double tmax = std::max(1e-6, in_fov_r * 0.5);
+
+ double r_norm = 0.0;
+ switch (input_model) {
+ case INPUT_FEQ_EQUIDISTANT: r_norm = theta / tmax; break;
+ case INPUT_FEQ_EQUISOLID: r_norm = std::sin(theta*0.5) / std::max(1e-12, std::sin(tmax*0.5)); break;
+ case INPUT_FEQ_STEREOGRAPHIC: r_norm = std::tan(theta*0.5) / std::max(1e-12, std::tan(tmax*0.5)); break;
+ case INPUT_FEQ_ORTHOGRAPHIC: r_norm = std::sin(theta) / std::max(1e-12, std::sin(tmax)); break;
+ default: r_norm = theta / tmax; break;
+ }
+
+ // Azimuth in camera XY; final Y is downward -> subtract sine in vf
+ double phi = std::atan2(dy, dx);
+
+ double R = 0.5 * std::min(W, H);
+ double rpx = r_norm * R;
+ uf = W * 0.5 + rpx * std::cos(phi);
+ vf = H * 0.5 - rpx * std::sin(phi);
}
std::string SphericalProjection::Json() const
{
- return JsonValue().toStyledString();
+ return JsonValue().toStyledString();
}
Json::Value SphericalProjection::JsonValue() const
{
- Json::Value root = EffectBase::JsonValue();
- root["type"] = info.class_name;
- root["yaw"] = yaw.JsonValue();
- root["pitch"] = pitch.JsonValue();
- root["roll"] = roll.JsonValue();
- root["fov"] = fov.JsonValue();
- root["projection_mode"] = projection_mode;
- root["invert"] = invert;
- root["interpolation"] = interpolation;
- return root;
+ Json::Value root = EffectBase::JsonValue();
+ root["type"] = info.class_name;
+ root["yaw"] = yaw.JsonValue();
+ root["pitch"] = pitch.JsonValue();
+ root["roll"] = roll.JsonValue();
+ root["fov"] = fov.JsonValue();
+ root["in_fov"] = in_fov.JsonValue();
+ root["projection_mode"] = projection_mode;
+ root["invert"] = invert;
+ root["input_model"] = input_model;
+ root["interpolation"] = interpolation;
+ return root;
}
void SphericalProjection::SetJson(const std::string value)
{
- try {
- Json::Value root = openshot::stringToJson(value);
- SetJsonValue(root);
- }
- catch (...) {
- throw InvalidJSON("Invalid JSON for SphericalProjection");
- }
+ try
+ {
+ Json::Value root = openshot::stringToJson(value);
+ SetJsonValue(root);
+ }
+ catch (...)
+ {
+ throw InvalidJSON("Invalid JSON for SphericalProjection");
+ }
}
void SphericalProjection::SetJsonValue(const Json::Value root)
{
- EffectBase::SetJsonValue(root);
- if (!root["yaw"].isNull()) yaw.SetJsonValue(root["yaw"]);
- if (!root["pitch"].isNull()) pitch.SetJsonValue(root["pitch"]);
- if (!root["roll"].isNull()) roll.SetJsonValue(root["roll"]);
- if (!root["fov"].isNull()) fov.SetJsonValue(root["fov"]);
- if (!root["projection_mode"].isNull()) projection_mode = root["projection_mode"].asInt();
- if (!root["invert"].isNull()) invert = root["invert"].asInt();
- if (!root["interpolation"].isNull()) interpolation = root["interpolation"].asInt();
-}
+ EffectBase::SetJsonValue(root);
+
+ if (!root["yaw"].isNull()) yaw.SetJsonValue(root["yaw"]);
+ if (!root["pitch"].isNull()) pitch.SetJsonValue(root["pitch"]);
+ if (!root["roll"].isNull()) roll.SetJsonValue(root["roll"]);
+ if (!root["fov"].isNull()) fov.SetJsonValue(root["fov"]);
+ if (!root["in_fov"].isNull()) in_fov.SetJsonValue(root["in_fov"]);
+ if (!root["projection_mode"].isNull())
+ projection_mode = root["projection_mode"].asInt();
+
+ if (!root["invert"].isNull())
+ invert = root["invert"].asInt();
+
+ if (!root["input_model"].isNull())
+ input_model = root["input_model"].asInt();
+
+ if (!root["interpolation"].isNull())
+ interpolation = root["interpolation"].asInt();
+
+ // Clamp to enum options
+ projection_mode = std::clamp(projection_mode,
+ (int)MODE_RECT_SPHERE,
+ (int)MODE_FISHEYE_ORTHOGRAPHIC);
+ invert = std::clamp(invert, (int)INVERT_NORMAL, (int)INVERT_BACK);
+ input_model = std::clamp(input_model, (int)INPUT_EQUIRECT, (int)INPUT_FEQ_ORTHOGRAPHIC);
+ interpolation = std::clamp(interpolation, (int)INTERP_NEAREST, (int)INTERP_AUTO);
+
+ // any property change should invalidate cached UV map
+ uv_map.clear();
+
+
+ // any property change should invalidate cached UV map
+ uv_map.clear();
+}
std::string SphericalProjection::PropertiesJSON(int64_t requested_frame) const
{
- Json::Value root = BasePropertiesJSON(requested_frame);
-
- root["yaw"] = add_property_json("Yaw",
- yaw.GetValue(requested_frame),
- "float", "degrees",
- &yaw, -180, 180,
- false, requested_frame);
- root["pitch"] = add_property_json("Pitch",
- pitch.GetValue(requested_frame),
- "float", "degrees",
- &pitch, -90, 90,
- false, requested_frame);
- root["roll"] = add_property_json("Roll",
- roll.GetValue(requested_frame),
- "float", "degrees",
- &roll, -180, 180,
- false, requested_frame);
- root["fov"] = add_property_json("FOV",
- fov.GetValue(requested_frame),
- "float", "degrees",
- &fov, 1, 179,
- false, requested_frame);
-
- root["projection_mode"] = add_property_json("Projection Mode",
- projection_mode,
- "int", "",
- nullptr, 0, 2,
- false, requested_frame);
- root["projection_mode"]["choices"].append(add_property_choice_json("Sphere", 0, projection_mode));
- root["projection_mode"]["choices"].append(add_property_choice_json("Hemisphere", 1, projection_mode));
- root["projection_mode"]["choices"].append(add_property_choice_json("Fisheye", 2, projection_mode));
-
- root["invert"] = add_property_json("Invert View",
- invert,
- "int", "",
- nullptr, 0, 1,
- false, requested_frame);
- root["invert"]["choices"].append(add_property_choice_json("Normal", 0, invert));
- root["invert"]["choices"].append(add_property_choice_json("Invert", 1, invert));
-
- root["interpolation"] = add_property_json("Interpolation",
- interpolation,
- "int", "",
- nullptr, 0, 1,
- false, requested_frame);
- root["interpolation"]["choices"].append(add_property_choice_json("Nearest", 0, interpolation));
- root["interpolation"]["choices"].append(add_property_choice_json("Bilinear", 1, interpolation));
-
- return root.toStyledString();
+ Json::Value root = BasePropertiesJSON(requested_frame);
+
+ root["yaw"] = add_property_json("Yaw", yaw.GetValue(requested_frame), "float", "degrees", &yaw, -180, 180, false, requested_frame);
+ root["pitch"] = add_property_json("Pitch", pitch.GetValue(requested_frame), "float", "degrees", &pitch,-180, 180, false, requested_frame);
+ root["roll"] = add_property_json("Roll", roll.GetValue(requested_frame), "float", "degrees", &roll, -180, 180, false, requested_frame);
+
+ root["fov"] = add_property_json("Out FOV", fov.GetValue(requested_frame), "float", "degrees", &fov, 0, 179, false, requested_frame);
+ root["in_fov"] = add_property_json("In FOV", in_fov.GetValue(requested_frame), "float", "degrees", &in_fov, 1, 360, false, requested_frame);
+
+ root["projection_mode"] = add_property_json("Projection Mode", projection_mode, "int", "", nullptr,
+ (int)MODE_RECT_SPHERE, (int)MODE_FISHEYE_ORTHOGRAPHIC, false, requested_frame);
+ root["projection_mode"]["choices"].append(add_property_choice_json("Sphere", (int)MODE_RECT_SPHERE, projection_mode));
+ root["projection_mode"]["choices"].append(add_property_choice_json("Hemisphere", (int)MODE_RECT_HEMISPHERE, projection_mode));
+ root["projection_mode"]["choices"].append(add_property_choice_json("Fisheye: Equidistant", (int)MODE_FISHEYE_EQUIDISTANT, projection_mode));
+ root["projection_mode"]["choices"].append(add_property_choice_json("Fisheye: Equisolid", (int)MODE_FISHEYE_EQUISOLID, projection_mode));
+ root["projection_mode"]["choices"].append(add_property_choice_json("Fisheye: Stereographic", (int)MODE_FISHEYE_STEREOGRAPHIC,projection_mode));
+ root["projection_mode"]["choices"].append(add_property_choice_json("Fisheye: Orthographic", (int)MODE_FISHEYE_ORTHOGRAPHIC, projection_mode));
+
+ root["invert"] = add_property_json("Invert View", invert, "int", "", nullptr, 0, 1, false, requested_frame);
+ root["invert"]["choices"].append(add_property_choice_json("Normal", 0, invert));
+ root["invert"]["choices"].append(add_property_choice_json("Invert", 1, invert));
+
+ root["input_model"] = add_property_json("Input Model", input_model, "int", "", nullptr, INPUT_EQUIRECT, INPUT_FEQ_ORTHOGRAPHIC, false, requested_frame);
+ root["input_model"]["choices"].append(add_property_choice_json("Equirectangular (Panorama)", INPUT_EQUIRECT, input_model));
+ root["input_model"]["choices"].append(add_property_choice_json("Fisheye: Equidistant", INPUT_FEQ_EQUIDISTANT, input_model));
+ root["input_model"]["choices"].append(add_property_choice_json("Fisheye: Equisolid", INPUT_FEQ_EQUISOLID, input_model));
+ root["input_model"]["choices"].append(add_property_choice_json("Fisheye: Stereographic", INPUT_FEQ_STEREOGRAPHIC, input_model));
+ root["input_model"]["choices"].append(add_property_choice_json("Fisheye: Orthographic", INPUT_FEQ_ORTHOGRAPHIC, input_model));
+
+ root["interpolation"] = add_property_json("Interpolation", interpolation, "int", "", nullptr, 0, 3, false, requested_frame);
+ root["interpolation"]["choices"].append(add_property_choice_json("Nearest", 0, interpolation));
+ root["interpolation"]["choices"].append(add_property_choice_json("Bilinear", 1, interpolation));
+ root["interpolation"]["choices"].append(add_property_choice_json("Bicubic", 2, interpolation));
+ root["interpolation"]["choices"].append(add_property_choice_json("Auto", 3, interpolation));
+
+ return root.toStyledString();
}
diff --git a/src/effects/SphericalProjection.h b/src/effects/SphericalProjection.h
index 1738f976e..c8f77e274 100644
--- a/src/effects/SphericalProjection.h
+++ b/src/effects/SphericalProjection.h
@@ -20,53 +20,96 @@
#include
#include
+#include
-namespace openshot
-{
+namespace openshot {
/**
* @brief Projects 360° or fisheye video through a virtual camera.
- * Supports yaw, pitch, roll, FOV, sphere/hemisphere/fisheye modes,
- * optional inversion, and nearest/bilinear sampling.
+ * Supports yaw, pitch, roll, input and output FOV, sphere/hemisphere/fisheye
+ * modes, optional inversion, and automatic quality selection.
*/
-class SphericalProjection : public EffectBase
-{
+class SphericalProjection : public EffectBase {
private:
- void init_effect_details();
+ void init_effect_details();
public:
- Keyframe yaw; ///< Yaw around up-axis (degrees)
- Keyframe pitch; ///< Pitch around right-axis (degrees)
- Keyframe roll; ///< Roll around forward-axis (degrees)
- Keyframe fov; ///< Field-of-view (horizontal, degrees)
-
- int projection_mode; ///< 0=Sphere, 1=Hemisphere, 2=Fisheye
- int invert; ///< 0=Normal, 1=Invert (back lens / +180°)
- int interpolation; ///< 0=Nearest, 1=Bilinear
-
- /// Blank ctor (for JSON deserialization)
- SphericalProjection();
-
- /// Ctor with custom curves
- SphericalProjection(Keyframe new_yaw,
- Keyframe new_pitch,
- Keyframe new_roll,
- Keyframe new_fov);
-
- /// ClipBase override: create a fresh Frame then call the main GetFrame
- std::shared_ptr GetFrame(int64_t frame_number) override
- { return GetFrame(std::make_shared(), frame_number); }
-
- /// EffectBase override: reproject the QImage
- std::shared_ptr GetFrame(std::shared_ptr frame,
- int64_t frame_number) override;
-
- // JSON serialization
- std::string Json() const override;
- void SetJson(std::string value) override;
- Json::Value JsonValue() const override;
- void SetJsonValue(Json::Value root) override;
- std::string PropertiesJSON(int64_t requested_frame) const override;
+ // Enums
+ enum InputModel {
+ INPUT_EQUIRECT = 0,
+ INPUT_FEQ_EQUIDISTANT = 1, // r = f * theta
+ INPUT_FEQ_EQUISOLID = 2, // r = 2f * sin(theta/2)
+ INPUT_FEQ_STEREOGRAPHIC = 3, // r = 2f * tan(theta/2)
+ INPUT_FEQ_ORTHOGRAPHIC = 4 // r = f * sin(theta)
+ };
+
+ enum ProjectionMode {
+ MODE_RECT_SPHERE = 0, // Rectilinear view over full sphere
+ MODE_RECT_HEMISPHERE = 1, // Rectilinear view over hemisphere
+ MODE_FISHEYE_EQUIDISTANT = 2, // Output fisheye (equidistant)
+ MODE_FISHEYE_EQUISOLID = 3, // Output fisheye (equisolid)
+ MODE_FISHEYE_STEREOGRAPHIC = 4, // Output fisheye (stereographic)
+ MODE_FISHEYE_ORTHOGRAPHIC = 5 // Output fisheye (orthographic)
+ };
+
+ enum InterpMode {
+ INTERP_NEAREST = 0,
+ INTERP_BILINEAR = 1,
+ INTERP_BICUBIC = 2,
+ INTERP_AUTO = 3
+ };
+
+ enum InvertFlag {
+ INVERT_NORMAL = 0,
+ INVERT_BACK = 1
+ };
+
+ Keyframe yaw; ///< Yaw around up-axis (degrees)
+ Keyframe pitch; ///< Pitch around right-axis (degrees)
+ Keyframe roll; ///< Roll around forward-axis (degrees)
+ Keyframe fov; ///< Output field-of-view (degrees)
+ Keyframe in_fov; ///< Source lens coverage / FOV (degrees)
+
+ int projection_mode; ///< 0=Sphere, 1=Hemisphere, 2=Fisheye
+ int invert; ///< 0=Normal, 1=Invert (back lens / +180°)
+ int input_model; ///< 0=Equirect, 1=Fisheye-Equidistant
+ int interpolation; ///< 0=Nearest, 1=Bilinear, 2=Bicubic, 3=Auto
+
+ /// Blank ctor (for JSON deserialization)
+ SphericalProjection();
+
+ /// Ctor with custom curves
+ SphericalProjection(Keyframe new_yaw, Keyframe new_pitch, Keyframe new_roll,
+ Keyframe new_fov);
+
+ /// ClipBase override: create a fresh Frame then call the main GetFrame
+ std::shared_ptr GetFrame(int64_t frame_number) override {
+ return GetFrame(std::make_shared(), frame_number);
+ }
+
+ /// EffectBase override: reproject the QImage
+ std::shared_ptr GetFrame(std::shared_ptr frame,
+ int64_t frame_number) override;
+
+ // JSON serialization
+ std::string Json() const override;
+ void SetJson(std::string value) override;
+ Json::Value JsonValue() const override;
+ void SetJsonValue(Json::Value root) override;
+ std::string PropertiesJSON(int64_t requested_frame) const override;
+
+private:
+ void project_input(double dx, double dy, double dz, double in_fov_r, int W,
+ int H, double &uf, double &vf) const;
+
+ mutable std::vector uv_map; ///< Cached UV lookup
+ mutable int cached_width = 0;
+ mutable int cached_height = 0;
+ mutable double cached_yaw = 0.0, cached_pitch = 0.0, cached_roll = 0.0;
+ mutable double cached_in_fov = 0.0, cached_out_fov = 0.0;
+ mutable int cached_input_model = -1;
+ mutable int cached_projection_mode = -1;
+ mutable int cached_invert = -1;
};
} // namespace openshot
diff --git a/src/effects/Tracker.cpp b/src/effects/Tracker.cpp
index c4e023e82..2776ab7ad 100644
--- a/src/effects/Tracker.cpp
+++ b/src/effects/Tracker.cpp
@@ -14,6 +14,7 @@
#include
#include
#include
+#include
#include "effects/Tracker.h"
#include "Exceptions.h"
@@ -32,38 +33,25 @@ using namespace std;
using namespace openshot;
using google::protobuf::util::TimeUtil;
-/// Blank constructor, useful when using Json to load the effect properties
-Tracker::Tracker(std::string clipTrackerDataPath)
-{
- // Init effect properties
- init_effect_details();
- // Instantiate a TrackedObjectBBox object and point to it
- TrackedObjectBBox trackedDataObject;
- trackedData = std::make_shared(trackedDataObject);
- // Tries to load the tracked object's data from protobuf file
- trackedData->LoadBoxData(clipTrackerDataPath);
- ClipBase* parentClip = this->ParentClip();
- trackedData->ParentClip(parentClip);
- trackedData->Id(std::to_string(0));
- // Insert TrackedObject with index 0 to the trackedObjects map
- trackedObjects.insert({0, trackedData});
-}
// Default constructor
Tracker::Tracker()
{
- // Init effect properties
+ // Initialize effect metadata
init_effect_details();
- // Instantiate a TrackedObjectBBox object and point to it
- TrackedObjectBBox trackedDataObject;
- trackedData = std::make_shared(trackedDataObject);
- ClipBase* parentClip = this->ParentClip();
- trackedData->ParentClip(parentClip);
- trackedData->Id(std::to_string(0));
- // Insert TrackedObject with index 0 to the trackedObjects map
- trackedObjects.insert({0, trackedData});
-}
+ // Create a placeholder object so we always have index 0 available
+ trackedData = std::make_shared();
+ trackedData->ParentClip(this->ParentClip());
+
+ // Seed our map with a single entry at index 0
+ trackedObjects.clear();
+ trackedObjects.emplace(0, trackedData);
+
+ // Assign ID to the placeholder object
+ if (trackedData)
+ trackedData->Id(Id() + "-0");
+}
// Init effect settings
void Tracker::init_effect_details()
@@ -84,73 +72,80 @@ void Tracker::init_effect_details()
// This method is required for all derived classes of EffectBase, and returns a
// modified openshot::Frame object
-std::shared_ptr Tracker::GetFrame(std::shared_ptr frame, int64_t frame_number) {
- // Get the frame's QImage
- std::shared_ptr frame_image = frame->GetImage();
-
- // Check if frame isn't NULL
- if(frame_image && !frame_image->isNull() &&
- trackedData->Contains(frame_number) &&
- trackedData->visible.GetValue(frame_number) == 1) {
- QPainter painter(frame_image.get());
-
- // Get the bounding-box of the given frame
- BBox fd = trackedData->GetBox(frame_number);
-
- // Create a QRectF for the bounding box
- QRectF boxRect((fd.cx - fd.width / 2) * frame_image->width(),
- (fd.cy - fd.height / 2) * frame_image->height(),
- fd.width * frame_image->width(),
- fd.height * frame_image->height());
-
- // Check if track data exists for the requested frame
- if (trackedData->draw_box.GetValue(frame_number) == 1) {
- painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
-
- // Get trackedObjectBox keyframes
- std::vector stroke_rgba = trackedData->stroke.GetColorRGBA(frame_number);
- int stroke_width = trackedData->stroke_width.GetValue(frame_number);
- float stroke_alpha = trackedData->stroke_alpha.GetValue(frame_number);
- std::vector bg_rgba = trackedData->background.GetColorRGBA(frame_number);
- float bg_alpha = trackedData->background_alpha.GetValue(frame_number);
- float bg_corner = trackedData->background_corner.GetValue(frame_number);
-
- // Set the pen for the border
- QPen pen(QColor(stroke_rgba[0], stroke_rgba[1], stroke_rgba[2], 255 * stroke_alpha));
- pen.setWidth(stroke_width);
- painter.setPen(pen);
-
- // Set the brush for the background
- QBrush brush(QColor(bg_rgba[0], bg_rgba[1], bg_rgba[2], 255 * bg_alpha));
- painter.setBrush(brush);
-
- // Draw the rounded rectangle
- painter.drawRoundedRect(boxRect, bg_corner, bg_corner);
- }
-
- painter.end();
- }
-
- // No need to set the image back to the frame, as we directly modified the frame's QImage
- return frame;
+std::shared_ptr Tracker::GetFrame(std::shared_ptr frame, int64_t frame_number)
+{
+ // Sanity‐check
+ if (!frame) return frame;
+ auto frame_image = frame->GetImage();
+ if (!frame_image || frame_image->isNull()) return frame;
+ if (!trackedData) return frame;
+
+ // 2) Only proceed if we actually have a box and it's visible
+ if (!trackedData->Contains(frame_number) ||
+ trackedData->visible.GetValue(frame_number) != 1)
+ return frame;
+
+ QPainter painter(frame_image.get());
+ painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
+
+ // Draw the box
+ BBox fd = trackedData->GetBox(frame_number);
+ QRectF boxRect(
+ (fd.cx - fd.width/2) * frame_image->width(),
+ (fd.cy - fd.height/2) * frame_image->height(),
+ fd.width * frame_image->width(),
+ fd.height * frame_image->height()
+ );
+
+ if (trackedData->draw_box.GetValue(frame_number) == 1)
+ {
+ auto stroke_rgba = trackedData->stroke.GetColorRGBA(frame_number);
+ int stroke_width = trackedData->stroke_width.GetValue(frame_number);
+ float stroke_alpha = trackedData->stroke_alpha.GetValue(frame_number);
+ auto bg_rgba = trackedData->background.GetColorRGBA(frame_number);
+ float bg_alpha = trackedData->background_alpha.GetValue(frame_number);
+ float bg_corner = trackedData->background_corner.GetValue(frame_number);
+
+ QPen pen(QColor(
+ stroke_rgba[0], stroke_rgba[1], stroke_rgba[2],
+ int(255 * stroke_alpha)
+ ));
+ pen.setWidth(stroke_width);
+ painter.setPen(pen);
+
+ QBrush brush(QColor(
+ bg_rgba[0], bg_rgba[1], bg_rgba[2],
+ int(255 * bg_alpha)
+ ));
+ painter.setBrush(brush);
+
+ painter.drawRoundedRect(boxRect, bg_corner, bg_corner);
+ }
+
+ painter.end();
+ return frame;
}
// Get the indexes and IDs of all visible objects in the given frame
-std::string Tracker::GetVisibleObjects(int64_t frame_number) const{
-
- // Initialize the JSON objects
+std::string Tracker::GetVisibleObjects(int64_t frame_number) const
+{
Json::Value root;
root["visible_objects_index"] = Json::Value(Json::arrayValue);
- root["visible_objects_id"] = Json::Value(Json::arrayValue);
-
- // Iterate through the tracked objects
- for (const auto& trackedObject : trackedObjects){
- // Get the tracked object JSON properties for this frame
- Json::Value trackedObjectJSON = trackedObject.second->PropertiesJSON(frame_number);
- if (trackedObjectJSON["visible"]["value"].asBool()){
- // Save the object's index and ID if it's visible in this frame
- root["visible_objects_index"].append(trackedObject.first);
- root["visible_objects_id"].append(trackedObject.second->Id());
+ root["visible_objects_id"] = Json::Value(Json::arrayValue);
+
+ if (trackedObjects.empty())
+ return root.toStyledString();
+
+ for (auto const& kv : trackedObjects) {
+ auto ptr = kv.second;
+ if (!ptr) continue;
+
+ // Directly get the Json::Value for this object's properties
+ Json::Value propsJson = ptr->PropertiesJSON(frame_number);
+
+ if (propsJson["visible"]["value"].asBool()) {
+ root["visible_objects_index"].append(kv.first);
+ root["visible_objects_id"].append(ptr->Id());
}
}
@@ -214,51 +209,82 @@ void Tracker::SetJsonValue(const Json::Value root) {
// Set parent data
EffectBase::SetJsonValue(root);
- if (!root["BaseFPS"].isNull() && root["BaseFPS"].isObject())
- {
+ if (!root["BaseFPS"].isNull()) {
if (!root["BaseFPS"]["num"].isNull())
- {
- BaseFPS.num = (int) root["BaseFPS"]["num"].asInt();
- }
+ BaseFPS.num = root["BaseFPS"]["num"].asInt();
if (!root["BaseFPS"]["den"].isNull())
- {
- BaseFPS.den = (int) root["BaseFPS"]["den"].asInt();
- }
+ BaseFPS.den = root["BaseFPS"]["den"].asInt();
}
- if (!root["TimeScale"].isNull())
- TimeScale = (double) root["TimeScale"].asDouble();
+ if (!root["TimeScale"].isNull()) {
+ TimeScale = root["TimeScale"].asDouble();
+ }
- // Set data from Json (if key is found)
- if (!root["protobuf_data_path"].isNull() && protobuf_data_path.size() <= 1)
- {
- protobuf_data_path = root["protobuf_data_path"].asString();
- if(!trackedData->LoadBoxData(protobuf_data_path))
- {
- std::clog << "Invalid protobuf data path " << protobuf_data_path << '\n';
- protobuf_data_path = "";
+ if (!root["protobuf_data_path"].isNull()) {
+ std::string new_path = root["protobuf_data_path"].asString();
+ if (protobuf_data_path != new_path || trackedData->GetLength() == 0) {
+ protobuf_data_path = new_path;
+ if (!trackedData->LoadBoxData(protobuf_data_path)) {
+ std::clog << "Invalid protobuf data path " << protobuf_data_path << '\n';
+ protobuf_data_path.clear();
+ }
+ else {
+ // prefix "-" for each entry
+ for (auto& kv : trackedObjects) {
+ auto idx = kv.first;
+ auto ptr = kv.second;
+ if (ptr) {
+ std::string prefix = this->Id();
+ if (!prefix.empty())
+ prefix += "-";
+ ptr->Id(prefix + std::to_string(idx));
+ }
+ }
+ }
}
}
- if (!root["objects"].isNull()){
- for (auto const& trackedObject : trackedObjects){
- std::string obj_id = std::to_string(trackedObject.first);
- if(!root["objects"][obj_id].isNull()){
- trackedObject.second->SetJsonValue(root["objects"][obj_id]);
+ // then any per-object JSON overrides...
+ if (!root["objects"].isNull()) {
+ // Iterate over the supplied objects (indexed by id or position)
+ const auto memberNames = root["objects"].getMemberNames();
+ for (const auto& name : memberNames)
+ {
+ // Determine the numeric index of this object
+ int index = -1;
+ bool numeric_key = std::all_of(name.begin(), name.end(), ::isdigit);
+ if (numeric_key) {
+ index = std::stoi(name);
+ }
+ else
+ {
+ size_t pos = name.find_last_of('-');
+ if (pos != std::string::npos) {
+ try {
+ index = std::stoi(name.substr(pos + 1));
+ } catch (...) {
+ index = -1;
+ }
+ }
+ }
+
+ auto obj_it = trackedObjects.find(index);
+ if (obj_it != trackedObjects.end() && obj_it->second) {
+ // Update object id if provided as a non-numeric key
+ if (!numeric_key)
+ obj_it->second->Id(name);
+ obj_it->second->SetJsonValue(root["objects"][name]);
}
}
}
- // Set the tracked object's ids
- if (!root["objects_id"].isNull()){
- for (auto const& trackedObject : trackedObjects){
- Json::Value trackedObjectJSON;
- trackedObjectJSON["box_id"] = root["objects_id"][trackedObject.first].asString();
- trackedObject.second->SetJsonValue(trackedObjectJSON);
+ // Set the tracked object's ids (legacy format)
+ if (!root["objects_id"].isNull()) {
+ for (auto& kv : trackedObjects) {
+ if (!root["objects_id"][kv.first].isNull())
+ kv.second->Id(root["objects_id"][kv.first].asString());
}
}
-
- return;
}
// Get all properties for a specific frame
diff --git a/src/effects/Tracker.h b/src/effects/Tracker.h
index d05b72a1f..b34c376f6 100644
--- a/src/effects/Tracker.h
+++ b/src/effects/Tracker.h
@@ -54,8 +54,6 @@ namespace openshot
/// Default constructor
Tracker();
- Tracker(std::string clipTrackerDataPath);
-
/// @brief Apply this effect to an openshot::Frame
///
/// @returns The modified openshot::Frame object
diff --git a/src/effects/Wave.cpp b/src/effects/Wave.cpp
index 286cb6322..9e2854eaa 100644
--- a/src/effects/Wave.cpp
+++ b/src/effects/Wave.cpp
@@ -51,9 +51,10 @@ std::shared_ptr Wave::GetFrame(std::shared_ptr
// Get the frame's image
std::shared_ptr frame_image = frame->GetImage();
- // Get original pixels for frame image, and also make a copy for editing
- const unsigned char *original_pixels = (unsigned char *) frame_image->constBits();
- unsigned char *pixels = (unsigned char *) frame_image->bits();
+ // Copy original pixels for reference, and get a writable pointer for editing
+ QImage original = frame_image->copy();
+ const unsigned char *original_pixels = original.constBits();
+ unsigned char *pixels = frame_image->bits();
int pixel_count = frame_image->width() * frame_image->height();
// Get current keyframe values
@@ -77,7 +78,7 @@ std::shared_ptr Wave::GetFrame(std::shared_ptr
float waveformVal = sin((Y * wavelength_value) + (time * speed_y_value)); // Waveform algorithm on y-axis
float waveVal = (waveformVal + shift_x_value) * noiseAmp; // Shifts pixels on the x-axis
- long unsigned int source_px = round(pixel + waveVal);
+ int source_px = lround(pixel + waveVal);
if (source_px < 0)
source_px = 0;
if (source_px >= pixel_count)
diff --git a/src/sort_filter/KalmanTracker.cpp b/src/sort_filter/KalmanTracker.cpp
index 083f3b1ed..1a50e4c3b 100644
--- a/src/sort_filter/KalmanTracker.cpp
+++ b/src/sort_filter/KalmanTracker.cpp
@@ -15,23 +15,26 @@ using namespace cv;
void KalmanTracker::init_kf(
StateType stateMat)
{
- int stateNum = 7;
+ int stateNum = 8;
int measureNum = 4;
kf = KalmanFilter(stateNum, measureNum, 0);
measurement = Mat::zeros(measureNum, 1, CV_32F);
- kf.transitionMatrix = (Mat_(7, 7) << 1, 0, 0, 0, 1, 0, 0,
+ kf.transitionMatrix = (Mat_(8, 8) << 1, 0, 0, 0, 1, 0, 0, 0,
- 0, 1, 0, 0, 0, 1, 0,
- 0, 0, 1, 0, 0, 0, 1,
- 0, 0, 0, 1, 0, 0, 0,
- 0, 0, 0, 0, 1, 0, 0,
- 0, 0, 0, 0, 0, 1, 0,
- 0, 0, 0, 0, 0, 0, 1);
+ 0, 1, 0, 0, 0, 1, 0, 0,
+ 0, 0, 1, 0, 0, 0, 1, 0,
+ 0, 0, 0, 1, 0, 0, 0, 1,
+ 0, 0, 0, 0, 1, 0, 0, 0,
+ 0, 0, 0, 0, 0, 1, 0, 0,
+ 0, 0, 0, 0, 0, 0, 1, 0,
+ 0, 0, 0, 0, 0, 0, 0, 1);
setIdentity(kf.measurementMatrix);
setIdentity(kf.processNoiseCov, Scalar::all(1e-1));
+ kf.processNoiseCov.at(2, 2) = 1e0; // higher noise for area (s) to adapt to size changes
+ kf.processNoiseCov.at(3, 3) = 1e0; // higher noise for aspect ratio (r)
setIdentity(kf.measurementNoiseCov, Scalar::all(1e-4));
setIdentity(kf.errorCovPost, Scalar::all(1e-2));
@@ -40,6 +43,10 @@ void KalmanTracker::init_kf(
kf.statePost.at(1, 0) = stateMat.y + stateMat.height / 2;
kf.statePost.at(2, 0) = stateMat.area();
kf.statePost.at(3, 0) = stateMat.width / stateMat.height;
+ kf.statePost.at(4, 0) = 0.0f;
+ kf.statePost.at(5, 0) = 0.0f;
+ kf.statePost.at(6, 0) = 0.0f;
+ kf.statePost.at(7, 0) = 0.0f;
}
// Predict the estimated bounding box.
diff --git a/src/sort_filter/sort.cpp b/src/sort_filter/sort.cpp
index 611eeea42..78ae24320 100644
--- a/src/sort_filter/sort.cpp
+++ b/src/sort_filter/sort.cpp
@@ -7,10 +7,15 @@
using namespace std;
// Constructor
-SortTracker::SortTracker(int max_age, int min_hits)
+SortTracker::SortTracker(int max_age, int min_hits, int max_missed, double min_iou, double nms_iou_thresh, double min_conf)
{
_min_hits = min_hits;
_max_age = max_age;
+ _max_missed = max_missed;
+ _min_iou = min_iou;
+ _nms_iou_thresh = nms_iou_thresh;
+ _min_conf = min_conf;
+ _next_id = 0;
alive_tracker = true;
}
@@ -42,6 +47,40 @@ double SortTracker::GetCentroidsDistance(
return distance;
}
+// Function to apply NMS on detections
+void apply_nms(vector& detections, double nms_iou_thresh) {
+ if (detections.empty()) return;
+
+ // Sort detections by confidence descending
+ std::sort(detections.begin(), detections.end(), [](const TrackingBox& a, const TrackingBox& b) {
+ return a.confidence > b.confidence;
+ });
+
+ vector suppressed(detections.size(), false);
+
+ for (size_t i = 0; i < detections.size(); ++i) {
+ if (suppressed[i]) continue;
+
+ for (size_t j = i + 1; j < detections.size(); ++j) {
+ if (suppressed[j]) continue;
+
+ if (detections[i].classId == detections[j].classId &&
+ SortTracker::GetIOU(detections[i].box, detections[j].box) > nms_iou_thresh) {
+ suppressed[j] = true;
+ }
+ }
+ }
+
+ // Remove suppressed detections
+ vector filtered;
+ for (size_t i = 0; i < detections.size(); ++i) {
+ if (!suppressed[i]) {
+ filtered.push_back(detections[i]);
+ }
+ }
+ detections = filtered;
+}
+
void SortTracker::update(vector detections_cv, int frame_count, double image_diagonal, std::vector confidences, std::vector classIds)
{
vector detections;
@@ -51,6 +90,8 @@ void SortTracker::update(vector detections_cv, int frame_count, double
// initialize kalman trackers using first detections.
for (unsigned int i = 0; i < detections_cv.size(); i++)
{
+ if (confidences[i] < _min_conf) continue; // filter low conf
+
TrackingBox tb;
tb.box = cv::Rect_(detections_cv[i]);
@@ -58,7 +99,7 @@ void SortTracker::update(vector detections_cv, int frame_count, double
tb.confidence = confidences[i];
detections.push_back(tb);
- KalmanTracker trk = KalmanTracker(detections[i].box, detections[i].confidence, detections[i].classId, i);
+ KalmanTracker trk = KalmanTracker(detections.back().box, detections.back().confidence, detections.back().classId, _next_id++);
trackers.push_back(trk);
}
return;
@@ -67,12 +108,18 @@ void SortTracker::update(vector detections_cv, int frame_count, double
{
for (unsigned int i = 0; i < detections_cv.size(); i++)
{
+ if (confidences[i] < _min_conf) continue; // filter low conf
+
TrackingBox tb;
tb.box = cv::Rect_(detections_cv[i]);
tb.classId = classIds[i];
tb.confidence = confidences[i];
detections.push_back(tb);
}
+
+ // Apply NMS to remove duplicates
+ apply_nms(detections, _nms_iou_thresh);
+
for (auto it = frameTrackingResult.begin(); it != frameTrackingResult.end(); it++)
{
int frame_age = frame_count - it->frame;
@@ -101,22 +148,29 @@ void SortTracker::update(vector detections_cv, int frame_count, double
trkNum = predictedBoxes.size();
detNum = detections.size();
- centroid_dist_matrix.clear();
- centroid_dist_matrix.resize(trkNum, vector(detNum, 0));
+ cost_matrix.clear();
+ cost_matrix.resize(trkNum, vector(detNum, 0));
- for (unsigned int i = 0; i < trkNum; i++) // compute iou matrix as a distance matrix
+ for (unsigned int i = 0; i < trkNum; i++) // compute cost matrix using 1 - IOU with gating
{
for (unsigned int j = 0; j < detNum; j++)
{
- // use 1-iou because the hungarian algorithm computes a minimum-cost assignment.
- double distance = SortTracker::GetCentroidsDistance(predictedBoxes[i], detections[j].box) / image_diagonal;
- centroid_dist_matrix[i][j] = distance;
+ double iou = GetIOU(predictedBoxes[i], detections[j].box);
+ double dist = GetCentroidsDistance(predictedBoxes[i], detections[j].box) / image_diagonal;
+ if (trackers[i].classId != detections[j].classId || dist > max_centroid_dist_norm)
+ {
+ cost_matrix[i][j] = 1e9; // large cost for gating
+ }
+ else
+ {
+ cost_matrix[i][j] = 1 - iou + (1 - detections[j].confidence) * 0.1; // slight penalty for low conf
+ }
}
}
HungarianAlgorithm HungAlgo;
assignment.clear();
- HungAlgo.Solve(centroid_dist_matrix, assignment);
+ HungAlgo.Solve(cost_matrix, assignment);
// find matches, unmatched_detections and unmatched_predictions
unmatchedTrajectories.clear();
unmatchedDetections.clear();
@@ -150,7 +204,7 @@ void SortTracker::update(vector detections_cv, int frame_count, double
{
if (assignment[i] == -1) // pass over invalid values
continue;
- if (centroid_dist_matrix[i][assignment[i]] > max_centroid_dist_norm)
+ if (cost_matrix[i][assignment[i]] > 1 - _min_iou)
{
unmatchedTrajectories.insert(i);
unmatchedDetections.insert(assignment[i]);
@@ -171,7 +225,7 @@ void SortTracker::update(vector detections_cv, int frame_count, double
// create and initialise new trackers for unmatched detections
for (auto umd : unmatchedDetections)
{
- KalmanTracker tracker = KalmanTracker(detections[umd].box, detections[umd].confidence, detections[umd].classId, umd);
+ KalmanTracker tracker = KalmanTracker(detections[umd].box, detections[umd].confidence, detections[umd].classId, _next_id++);
trackers.push_back(tracker);
}
@@ -192,7 +246,8 @@ void SortTracker::update(vector detections_cv, int frame_count, double
frameTrackingResult.clear();
for (unsigned int i = 0; i < trackers.size();)
{
- if ((trackers[i].m_time_since_update < 1 && trackers[i].m_hit_streak >= _min_hits) || frame_count <= _min_hits)
+ if ((trackers[i].m_hits >= _min_hits && trackers[i].m_time_since_update <= _max_missed) ||
+ frame_count <= _min_hits)
{
alive_tracker = true;
TrackingBox res;
diff --git a/src/sort_filter/sort.hpp b/src/sort_filter/sort.hpp
index 6d7f22e23..74e905adc 100644
--- a/src/sort_filter/sort.hpp
+++ b/src/sort_filter/sort.hpp
@@ -9,6 +9,7 @@
#include
#include // to format image names using setw() and setfill()
#include
+#include // for std::sort
#include "opencv2/video/tracking.hpp"
#include "opencv2/highgui/highgui.hpp"
@@ -25,7 +26,7 @@ typedef struct TrackingBox
int classId = 0;
int id = 0;
cv::Rect_ box = cv::Rect_(0.0, 0.0, 0.0, 0.0);
- TrackingBox() {}
+ TrackingBox() {}
TrackingBox(int _frame, float _confidence, int _classId, int _id) : frame(_frame), confidence(_confidence), classId(_classId), id(_id) {}
} TrackingBox;
@@ -33,19 +34,19 @@ class SortTracker
{
public:
// Constructor
- SortTracker(int max_age = 7, int min_hits = 2);
+ SortTracker(int max_age = 50, int min_hits = 5, int max_missed = 7, double min_iou = 0.1, double nms_iou_thresh = 0.5, double min_conf = 0.3);
// Initialize tracker
// Update position based on the new frame
void update(std::vector detection, int frame_count, double image_diagonal, std::vector confidences, std::vector classIds);
- double GetIOU(cv::Rect_ bb_test, cv::Rect_ bb_gt);
+ static double GetIOU(cv::Rect_ bb_test, cv::Rect_ bb_gt);
double GetCentroidsDistance(cv::Rect_ bb_test, cv::Rect_ bb_gt);
std::vector trackers;
- double max_centroid_dist_norm = 0.05;
+ double max_centroid_dist_norm = 0.3;
std::vector> predictedBoxes;
- std::vector> centroid_dist_matrix;
+ std::vector> cost_matrix;
std::vector assignment;
std::set unmatchedDetections;
std::set unmatchedTrajectories;
@@ -60,5 +61,10 @@ class SortTracker
unsigned int detNum = 0;
int _min_hits;
int _max_age;
+ int _max_missed;
+ double _min_iou;
+ double _nms_iou_thresh;
+ double _min_conf;
+ unsigned int _next_id;
bool alive_tracker;
};
diff --git a/tests/AnalogTape.cpp b/tests/AnalogTape.cpp
new file mode 100644
index 000000000..4dcc15fa6
--- /dev/null
+++ b/tests/AnalogTape.cpp
@@ -0,0 +1,84 @@
+/**
+ * @file
+ * @brief Unit tests for AnalogTape effect
+ * @author Jonathan Thomas
+ */
+
+#include
+#include
+#include
+
+#include "Frame.h"
+#include "effects/AnalogTape.h"
+#include "openshot_catch.h"
+
+using namespace openshot;
+
+// Fixed helper ensures Frame invariants are respected (size/format/flags)
+static std::shared_ptr makeGrayFrame(int w = 64, int h = 64) {
+ auto f = std::make_shared(1, w, h, "#000000", 0, 2);
+
+ // Use premultiplied format to match Frame::AddImage expectations
+ auto img = std::make_shared(w, h, QImage::Format_RGBA8888_Premultiplied);
+ img->fill(QColor(100, 100, 100, 255));
+
+ // Route through AddImage so width/height/has_image_data are set correctly
+ f->AddImage(img);
+ return f;
+}
+
+TEST_CASE("AnalogTape modifies frame", "[effect][analogtape]") {
+ AnalogTape eff;
+ eff.Id("analogtape-test-seed");
+ eff.seed_offset = 1234;
+ auto frame = makeGrayFrame();
+ QColor before = frame->GetImage()->pixelColor(2, 2);
+ auto out = eff.GetFrame(frame, 1);
+ QColor after = out->GetImage()->pixelColor(2, 2);
+ CHECK(after != before);
+}
+
+TEST_CASE("AnalogTape deterministic per id", "[effect][analogtape]") {
+ AnalogTape e1;
+ e1.Id("same");
+ AnalogTape e2;
+ e2.Id("same");
+ auto f1 = makeGrayFrame();
+ auto f2 = makeGrayFrame();
+ auto o1 = e1.GetFrame(f1, 1);
+ auto o2 = e2.GetFrame(f2, 1);
+ QColor c1 = o1->GetImage()->pixelColor(1, 1);
+ QColor c2 = o2->GetImage()->pixelColor(1, 1);
+ CHECK(c1 == c2);
+}
+
+TEST_CASE("AnalogTape seed offset alters output", "[effect][analogtape]") {
+ AnalogTape e1;
+ e1.Id("seed");
+ e1.seed_offset = 0;
+ AnalogTape e2;
+ e2.Id("seed");
+ e2.seed_offset = 5;
+ auto f1 = makeGrayFrame();
+ auto f2 = makeGrayFrame();
+ auto o1 = e1.GetFrame(f1, 1);
+ auto o2 = e2.GetFrame(f2, 1);
+ QColor c1 = o1->GetImage()->pixelColor(1, 1);
+ QColor c2 = o2->GetImage()->pixelColor(1, 1);
+ CHECK(c1 != c2);
+}
+
+TEST_CASE("AnalogTape stripe lifts bottom", "[effect][analogtape]") {
+ AnalogTape e;
+ e.tracking = Keyframe(0.0);
+ e.bleed = Keyframe(0.0);
+ e.softness = Keyframe(0.0);
+ e.noise = Keyframe(0.0);
+ e.stripe = Keyframe(1.0);
+ e.staticBands = Keyframe(0.0);
+ auto frame = makeGrayFrame(20, 20);
+ auto out = e.GetFrame(frame, 1);
+ QColor top = out->GetImage()->pixelColor(10, 0);
+ QColor bottom = out->GetImage()->pixelColor(10, 19);
+ CHECK(bottom.red() > top.red());
+}
diff --git a/tests/AudioWaveformer.cpp b/tests/AudioWaveformer.cpp
index c8d856836..fb51fda2e 100644
--- a/tests/AudioWaveformer.cpp
+++ b/tests/AudioWaveformer.cpp
@@ -12,193 +12,337 @@
#include "openshot_catch.h"
#include "AudioWaveformer.h"
+#include "Clip.h"
#include "FFmpegReader.h"
+#include "Timeline.h"
+
+#include
+#include
using namespace openshot;
TEST_CASE( "Extract waveform data piano.wav", "[libopenshot][audiowaveformer]" )
{
- // Create a reader
- std::stringstream path;
- path << TEST_MEDIA_PATH << "piano.wav";
- FFmpegReader r(path.str());
- r.Open();
-
- // Create AudioWaveformer and extract a smaller "average" sample set of audio data
- AudioWaveformer waveformer(&r);
- for (auto channel = 0; channel < r.info.channels; channel++) {
- AudioWaveformData waveform = waveformer.ExtractSamples(channel, 20, false);
-
- if (channel == 0) {
- CHECK(waveform.rms_samples.size() == 107);
- CHECK(waveform.rms_samples[0] == Approx(0.04879f).margin(0.00001));
- CHECK(waveform.rms_samples[86] == Approx(0.13578f).margin(0.00001));
- CHECK(waveform.rms_samples[87] == Approx(0.0f).margin(0.00001));
- } else if (channel == 1) {
- CHECK(waveform.rms_samples.size() == 107);
- CHECK(waveform.rms_samples[0] == Approx(0.04879f).margin(0.00001));
- CHECK(waveform.rms_samples[86] == Approx(0.13578f).margin(0.00001));
- CHECK(waveform.rms_samples[87] == Approx(0.0f).margin(0.00001));
- }
-
- waveform.clear();
- }
-
- // Clean up
- r.Close();
+ // Create a reader
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "piano.wav";
+ FFmpegReader r(path.str());
+ r.Open();
+
+ // Create AudioWaveformer and extract a smaller "average" sample set of audio data
+ const int samples_per_second = 20;
+ const int expected_total = static_cast(std::ceil(r.info.duration * samples_per_second));
+ REQUIRE(expected_total > 1);
+
+ AudioWaveformer waveformer(&r);
+ for (auto channel = 0; channel < r.info.channels; channel++) {
+ AudioWaveformData waveform = waveformer.ExtractSamples(channel, samples_per_second, false);
+
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(waveform.rms_samples[0] == Approx(0.04879f).margin(0.00001));
+ CHECK(waveform.rms_samples[expected_total - 2] == Approx(0.13578f).margin(0.00001));
+ CHECK(waveform.rms_samples.back() == Approx(0.11945f).margin(0.00001));
+
+ waveform.clear();
+ }
+
+ // Clean up
+ r.Close();
}
TEST_CASE( "Extract waveform data sintel", "[libopenshot][audiowaveformer]" )
{
- // Create a reader
- std::stringstream path;
- path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
- FFmpegReader r(path.str());
-
- // Create AudioWaveformer and extract a smaller "average" sample set of audio data
- AudioWaveformer waveformer(&r);
- for (auto channel = 0; channel < r.info.channels; channel++) {
- AudioWaveformData waveform = waveformer.ExtractSamples(channel, 20, false);
-
- if (channel == 0) {
- CHECK(waveform.rms_samples.size() == 1058);
- CHECK(waveform.rms_samples[0] == Approx(0.00001f).margin(0.00001));
- CHECK(waveform.rms_samples[1037] == Approx(0.00003f).margin(0.00001));
- CHECK(waveform.rms_samples[1038] == Approx(0.0f).margin(0.00001));
- } else if (channel == 1) {
- CHECK(waveform.rms_samples.size() == 1058);
- CHECK(waveform.rms_samples[0] == Approx(0.00001f ).margin(0.00001));
- CHECK(waveform.rms_samples[1037] == Approx(0.00003f).margin(0.00001));
- CHECK(waveform.rms_samples[1038] == Approx(0.0f).margin(0.00001));
- }
-
- waveform.clear();
- }
-
- // Clean up
- r.Close();
+ // Create a reader
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
+ FFmpegReader r(path.str());
+
+ // Create AudioWaveformer and extract a smaller "average" sample set of audio data
+ const int samples_per_second = 20;
+ const int expected_total = static_cast(std::ceil(r.info.duration * samples_per_second));
+ REQUIRE(expected_total > 1);
+
+ AudioWaveformer waveformer(&r);
+ for (auto channel = 0; channel < r.info.channels; channel++) {
+ AudioWaveformData waveform = waveformer.ExtractSamples(channel, samples_per_second, false);
+
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(waveform.rms_samples[0] == Approx(0.00001f).margin(0.00001));
+ CHECK(waveform.rms_samples[expected_total - 2] == Approx(0.00003f).margin(0.00001));
+ CHECK(waveform.rms_samples.back() == Approx(0.00002f).margin(0.00002));
+
+ waveform.clear();
+ }
+
+ // Clean up
+ r.Close();
}
TEST_CASE( "Extract waveform data sintel (all channels)", "[libopenshot][audiowaveformer]" )
{
- // Create a reader
- std::stringstream path;
- path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
- FFmpegReader r(path.str());
+ // Create a reader
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
+ FFmpegReader r(path.str());
+
+ // Create AudioWaveformer and extract a smaller "average" sample set of audio data
+ const int samples_per_second = 20;
+ const int expected_total = static_cast(std::ceil(r.info.duration * samples_per_second));
+ REQUIRE(expected_total > 1);
- // Create AudioWaveformer and extract a smaller "average" sample set of audio data
- AudioWaveformer waveformer(&r);
- AudioWaveformData waveform = waveformer.ExtractSamples(-1, 20, false);
+ AudioWaveformer waveformer(&r);
+ AudioWaveformData waveform = waveformer.ExtractSamples(-1, samples_per_second, false);
- CHECK(waveform.rms_samples.size() == 1058);
- CHECK(waveform.rms_samples[0] == Approx(0.00001f).margin(0.00001));
- CHECK(waveform.rms_samples[1037] == Approx(0.00003f).margin(0.00001));
- CHECK(waveform.rms_samples[1038] == Approx(0.0f).margin(0.00001));
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(waveform.rms_samples[0] == Approx(0.00001f).margin(0.00001));
+ CHECK(waveform.rms_samples[expected_total - 2] == Approx(0.00003f).margin(0.00001));
+ CHECK(waveform.rms_samples.back() == Approx(0.00002f).margin(0.00002));
- waveform.clear();
+ waveform.clear();
- // Clean up
- r.Close();
+ // Clean up
+ r.Close();
}
TEST_CASE( "Normalize & scale waveform data piano.wav", "[libopenshot][audiowaveformer]" )
{
- // Create a reader
- std::stringstream path;
- path << TEST_MEDIA_PATH << "piano.wav";
- FFmpegReader r(path.str());
-
- // Create AudioWaveformer and extract a smaller "average" sample set of audio data
- AudioWaveformer waveformer(&r);
- for (auto channel = 0; channel < r.info.channels; channel++) {
- // Normalize values and scale them between -1 and +1
- AudioWaveformData waveform = waveformer.ExtractSamples(channel, 20, true);
-
- if (channel == 0) {
- CHECK(waveform.rms_samples.size() == 107);
- CHECK(waveform.rms_samples[0] == Approx(0.07524f).margin(0.00001));
- CHECK(waveform.rms_samples[35] == Approx(0.20063f).margin(0.00001));
- CHECK(waveform.rms_samples[86] == Approx(0.2094f).margin(0.00001));
- CHECK(waveform.rms_samples[87] == Approx(0.0f).margin(0.00001));
- }
-
- waveform.clear();
- }
-
- // Clean up
- r.Close();
+ // Create a reader
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "piano.wav";
+ FFmpegReader r(path.str());
+
+ // Create AudioWaveformer and extract a smaller "average" sample set of audio data
+ const int samples_per_second = 20;
+ const int expected_total = static_cast(std::ceil(r.info.duration * samples_per_second));
+ REQUIRE(expected_total > 1);
+
+ AudioWaveformer waveformer(&r);
+ for (auto channel = 0; channel < r.info.channels; channel++) {
+ // Normalize values and scale them between -1 and +1
+ AudioWaveformData waveform = waveformer.ExtractSamples(channel, samples_per_second, true);
+
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(waveform.rms_samples[0] == Approx(0.07524f).margin(0.00001));
+ CHECK(waveform.rms_samples.back() == Approx(0.18422f).margin(0.00001));
+ CHECK(*std::max_element(waveform.max_samples.begin(), waveform.max_samples.end()) == Approx(1.0f).margin(0.00001));
+
+ waveform.clear();
+ }
+
+ // Clean up
+ r.Close();
+}
+
+TEST_CASE( "Extract waveform data clip slowed by time curve", "[libopenshot][audiowaveformer][clip][time]" )
+{
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "sintel_trailer-720p.mp4";
+
+ FFmpegReader reader(path.str());
+ Clip clip(&reader);
+ clip.Open();
+
+ const int64_t original_video_length = clip.Reader()->info.video_length;
+ const double fps_value = clip.Reader()->info.fps.ToDouble();
+ REQUIRE(original_video_length > 0);
+ REQUIRE(fps_value > 0.0);
+
+ clip.time = Keyframe();
+ clip.time.AddPoint(1.0, 1.0, LINEAR);
+ clip.time.AddPoint(static_cast(original_video_length) * 2.0,
+ static_cast(original_video_length), LINEAR);
+
+ AudioWaveformer waveformer(&clip);
+ const int samples_per_second = 20;
+ AudioWaveformData waveform = waveformer.ExtractSamples(-1, samples_per_second, false);
+
+ const double expected_duration = (static_cast(original_video_length) * 2.0) / fps_value;
+ const int expected_total = static_cast(std::ceil(expected_duration * samples_per_second));
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(clip.time.GetLength() == original_video_length * 2);
+ CHECK(clip.time.GetLength() == static_cast(std::llround(expected_duration * fps_value)));
+
+ clip.Close();
+ reader.Close();
+}
+
+TEST_CASE( "Extract waveform data clip reversed by time curve", "[libopenshot][audiowaveformer][clip][time]" )
+{
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "piano.wav";
+
+ FFmpegReader reader(path.str());
+ Clip clip(&reader);
+ clip.Open();
+
+ const int samples_per_second = 20;
+ const int base_total = static_cast(std::ceil(clip.Reader()->info.duration * samples_per_second));
+ const int64_t original_video_length = clip.Reader()->info.video_length;
+ const double fps_value = clip.Reader()->info.fps.ToDouble();
+ REQUIRE(original_video_length > 0);
+ REQUIRE(fps_value > 0.0);
+
+ clip.time = Keyframe();
+ clip.time.AddPoint(1.0, static_cast(original_video_length), LINEAR);
+ clip.time.AddPoint(static_cast(original_video_length), 1.0, LINEAR);
+
+ AudioWaveformer waveformer(&clip);
+ AudioWaveformData waveform = waveformer.ExtractSamples(-1, samples_per_second, false);
+
+ const double expected_duration = static_cast(original_video_length) / fps_value;
+ const int expected_total = static_cast(std::ceil(expected_duration * samples_per_second));
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(expected_total == base_total);
+ CHECK(clip.time.GetLength() == original_video_length);
+ CHECK(clip.time.GetLength() == static_cast(std::llround(expected_duration * fps_value)));
+
+ clip.Close();
+ reader.Close();
+}
+
+TEST_CASE( "Extract waveform data clip reversed and slowed", "[libopenshot][audiowaveformer][clip][time]" )
+{
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "piano.wav";
+
+ FFmpegReader reader(path.str());
+ Clip clip(&reader);
+ clip.Open();
+
+ const int samples_per_second = 20;
+ const int base_total = static_cast(std::ceil(clip.Reader()->info.duration * samples_per_second));
+ const int64_t original_video_length = clip.Reader()->info.video_length;
+ const double fps_value = clip.Reader()->info.fps.ToDouble();
+ REQUIRE(original_video_length > 0);
+ REQUIRE(fps_value > 0.0);
+
+ clip.time = Keyframe();
+ clip.time.AddPoint(1.0, static_cast(original_video_length), LINEAR);
+ clip.time.AddPoint(static_cast(original_video_length) * 2.0, 1.0, LINEAR);
+
+ AudioWaveformer waveformer(&clip);
+ AudioWaveformData waveform = waveformer.ExtractSamples(-1, samples_per_second, false);
+
+ const double expected_duration = (static_cast(original_video_length) * 2.0) / fps_value;
+ const int expected_total = static_cast(std::ceil(expected_duration * samples_per_second));
+ CHECK(waveform.rms_samples.size() == expected_total);
+ CHECK(expected_total > base_total);
+ CHECK(clip.time.GetLength() == original_video_length * 2);
+ CHECK(clip.time.GetLength() == static_cast(std::llround(expected_duration * fps_value)));
+
+ clip.Close();
+ reader.Close();
+}
+
+TEST_CASE( "Clip duration uses parent timeline FPS when time-mapped", "[libopenshot][audiowaveformer][clip][time][timeline]" )
+{
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "piano.wav";
+
+ FFmpegReader reader(path.str());
+ Clip clip(&reader);
+ clip.Open();
+
+ const int64_t original_video_length = clip.Reader()->info.video_length;
+ const double reader_fps = clip.Reader()->info.fps.ToDouble();
+ REQUIRE(original_video_length > 0);
+ REQUIRE(reader_fps > 0.0);
+
+ Timeline timeline(
+ 640,
+ 480,
+ Fraction(60, 1),
+ clip.Reader()->info.sample_rate,
+ clip.Reader()->info.channels,
+ clip.Reader()->info.channel_layout);
+
+ clip.ParentTimeline(&timeline);
+
+ clip.time = Keyframe();
+ clip.time.AddPoint(1.0, 1.0, LINEAR);
+ clip.time.AddPoint(static_cast(original_video_length) * 2.0,
+ static_cast(original_video_length), LINEAR);
+
+ const double timeline_fps = timeline.info.fps.ToDouble();
+ REQUIRE(timeline_fps > 0.0);
+
+ const double expected_duration = (static_cast(original_video_length) * 2.0) / timeline_fps;
+ CHECK(clip.time.GetLength() == static_cast(std::llround(expected_duration * timeline_fps)));
+
+ clip.Close();
+ reader.Close();
}
TEST_CASE( "Extract waveform from image (no audio)", "[libopenshot][audiowaveformer]" )
{
- // Create a reader
- std::stringstream path;
- path << TEST_MEDIA_PATH << "front.png";
- FFmpegReader r(path.str());
+ // Create a reader
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "front.png";
+ FFmpegReader r(path.str());
- // Create AudioWaveformer and extract a smaller "average" sample set of audio data
- AudioWaveformer waveformer(&r);
- AudioWaveformData waveform = waveformer.ExtractSamples(-1, 20, false);
+ // Create AudioWaveformer and extract a smaller "average" sample set of audio data
+ AudioWaveformer waveformer(&r);
+ AudioWaveformData waveform = waveformer.ExtractSamples(-1, 20, false);
- CHECK(waveform.rms_samples.size() == 0);
- CHECK(waveform.max_samples.size() == 0);
+ CHECK(waveform.rms_samples.size() == 0);
+ CHECK(waveform.max_samples.size() == 0);
- // Clean up
- r.Close();
+ // Clean up
+ r.Close();
}
TEST_CASE( "AudioWaveformData struct methods", "[libopenshot][audiowaveformer]" )
{
- // Create a reader
- AudioWaveformData waveform;
-
- // Resize data to 10 elements
- waveform.resize(10);
- CHECK(waveform.rms_samples.size() == 10);
- CHECK(waveform.max_samples.size() == 10);
-
- // Set all values = 1.0
- for (auto s = 0; s < waveform.rms_samples.size(); s++) {
- waveform.rms_samples[s] = 1.0;
- waveform.max_samples[s] = 1.0;
- }
- CHECK(waveform.rms_samples[0] == Approx(1.0f).margin(0.00001));
- CHECK(waveform.rms_samples[9] == Approx(1.0f).margin(0.00001));
- CHECK(waveform.max_samples[0] == Approx(1.0f).margin(0.00001));
- CHECK(waveform.max_samples[9] == Approx(1.0f).margin(0.00001));
-
- // Scale all values by 2
- waveform.scale(10, 2.0);
- CHECK(waveform.rms_samples.size() == 10);
- CHECK(waveform.max_samples.size() == 10);
- CHECK(waveform.rms_samples[0] == Approx(2.0f).margin(0.00001));
- CHECK(waveform.rms_samples[9] == Approx(2.0f).margin(0.00001));
- CHECK(waveform.max_samples[0] == Approx(2.0f).margin(0.00001));
- CHECK(waveform.max_samples[9] == Approx(2.0f).margin(0.00001));
-
- // Zero out all values
- waveform.zero(10);
- CHECK(waveform.rms_samples.size() == 10);
- CHECK(waveform.max_samples.size() == 10);
- CHECK(waveform.rms_samples[0] == Approx(0.0f).margin(0.00001));
- CHECK(waveform.rms_samples[9] == Approx(0.0f).margin(0.00001));
- CHECK(waveform.max_samples[0] == Approx(0.0f).margin(0.00001));
- CHECK(waveform.max_samples[9] == Approx(0.0f).margin(0.00001));
-
- // Access vectors and verify size
- std::vector> vectors = waveform.vectors();
- CHECK(vectors.size() == 2);
- CHECK(vectors[0].size() == 10);
- CHECK(vectors[0].size() == 10);
-
- // Clear and verify internal data is empty
- waveform.clear();
- CHECK(waveform.rms_samples.size() == 0);
- CHECK(waveform.max_samples.size() == 0);
- vectors = waveform.vectors();
- CHECK(vectors.size() == 2);
- CHECK(vectors[0].size() == 0);
- CHECK(vectors[0].size() == 0);
+ // Create a reader
+ AudioWaveformData waveform;
+
+ // Resize data to 10 elements
+ waveform.resize(10);
+ CHECK(waveform.rms_samples.size() == 10);
+ CHECK(waveform.max_samples.size() == 10);
+
+ // Set all values = 1.0
+ for (auto s = 0; s < waveform.rms_samples.size(); s++) {
+ waveform.rms_samples[s] = 1.0;
+ waveform.max_samples[s] = 1.0;
+ }
+ CHECK(waveform.rms_samples[0] == Approx(1.0f).margin(0.00001));
+ CHECK(waveform.rms_samples[9] == Approx(1.0f).margin(0.00001));
+ CHECK(waveform.max_samples[0] == Approx(1.0f).margin(0.00001));
+ CHECK(waveform.max_samples[9] == Approx(1.0f).margin(0.00001));
+
+ // Scale all values by 2
+ waveform.scale(10, 2.0);
+ CHECK(waveform.rms_samples.size() == 10);
+ CHECK(waveform.max_samples.size() == 10);
+ CHECK(waveform.rms_samples[0] == Approx(2.0f).margin(0.00001));
+ CHECK(waveform.rms_samples[9] == Approx(2.0f).margin(0.00001));
+ CHECK(waveform.max_samples[0] == Approx(2.0f).margin(0.00001));
+ CHECK(waveform.max_samples[9] == Approx(2.0f).margin(0.00001));
+
+ // Zero out all values
+ waveform.zero(10);
+ CHECK(waveform.rms_samples.size() == 10);
+ CHECK(waveform.max_samples.size() == 10);
+ CHECK(waveform.rms_samples[0] == Approx(0.0f).margin(0.00001));
+ CHECK(waveform.rms_samples[9] == Approx(0.0f).margin(0.00001));
+ CHECK(waveform.max_samples[0] == Approx(0.0f).margin(0.00001));
+ CHECK(waveform.max_samples[9] == Approx(0.0f).margin(0.00001));
+
+ // Access vectors and verify size
+ std::vector> vectors = waveform.vectors();
+ CHECK(vectors.size() == 2);
+ CHECK(vectors[0].size() == 10);
+ CHECK(vectors[0].size() == 10);
+
+ // Clear and verify internal data is empty
+ waveform.clear();
+ CHECK(waveform.rms_samples.size() == 0);
+ CHECK(waveform.max_samples.size() == 0);
+ vectors = waveform.vectors();
+ CHECK(vectors.size() == 2);
+ CHECK(vectors[0].size() == 0);
+ CHECK(vectors[0].size() == 0);
}
diff --git a/tests/Benchmark.cpp b/tests/Benchmark.cpp
new file mode 100644
index 000000000..669542574
--- /dev/null
+++ b/tests/Benchmark.cpp
@@ -0,0 +1,226 @@
+/**
+ * @file
+ * @brief Benchmark executable for core libopenshot operations
+ * @author Jonathan Thomas
+ * @ref License
+ */
+// Copyright (c) 2025 OpenShot Studios, LLC
+//
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+#include
+#include
+#include
+#include
+#include
+
+#include "Clip.h"
+#include "FFmpegReader.h"
+#include "FFmpegWriter.h"
+#include "Fraction.h"
+#include "FrameMapper.h"
+#ifdef USE_IMAGEMAGICK
+#include "ImageReader.h"
+#else
+#include "QtImageReader.h"
+#endif
+#include "ReaderBase.h"
+#include "Timeline.h"
+#include "effects/Brightness.h"
+#include "effects/Crop.h"
+#include "effects/Mask.h"
+#include "effects/Saturation.h"
+
+using namespace openshot;
+using namespace std;
+
+using Clock = chrono::steady_clock;
+
+template double time_trial(const string &name, Func func) {
+ auto start = Clock::now();
+ func();
+ auto elapsed =
+ chrono::duration_cast(Clock::now() - start).count();
+ cout << name << "," << elapsed << "\n";
+ return static_cast(elapsed);
+}
+
+void read_forward_backward(ReaderBase &reader) {
+ int64_t len = reader.info.video_length;
+ for (int64_t i = 1; i <= len; ++i)
+ reader.GetFrame(i);
+ for (int64_t i = len; i >= 1; --i)
+ reader.GetFrame(i);
+}
+
+int main() {
+ cout << "Trial,Milliseconds\n";
+ double total = 0.0;
+ const string base = TEST_MEDIA_PATH;
+ const string video = base + "sintel_trailer-720p.mp4";
+ const string mask_img = base + "mask.png";
+ const string overlay = base + "front3.png";
+
+ total += time_trial("FFmpegReader", [&]() {
+ FFmpegReader r(video);
+ r.Open();
+ read_forward_backward(r);
+ r.Close();
+ });
+
+ total += time_trial("FFmpegWriter", [&]() {
+ FFmpegReader r(video);
+ r.Open();
+ FFmpegWriter w("benchmark_output.mp4");
+ w.SetAudioOptions("aac", r.info.sample_rate, 192000);
+ w.SetVideoOptions("libx264", r.info.width, r.info.height, r.info.fps,
+ 5000000);
+ w.Open();
+ for (int64_t i = 1; i <= r.info.video_length; ++i)
+ w.WriteFrame(r.GetFrame(i));
+ w.Close();
+ r.Close();
+ });
+
+ total += time_trial("FrameMapper", [&]() {
+ vector rates = {Fraction(24, 1), Fraction(30, 1), Fraction(60, 1),
+ Fraction(30000, 1001), Fraction(60000, 1001)};
+ for (auto &fps : rates) {
+ FFmpegReader r(video);
+ r.Open();
+ FrameMapper map(&r, fps, PULLDOWN_NONE, r.info.sample_rate,
+ r.info.channels, r.info.channel_layout);
+ map.Open();
+ for (int64_t i = 1; i <= map.info.video_length; ++i)
+ map.GetFrame(i);
+ map.Close();
+ r.Close();
+ }
+ });
+
+ total += time_trial("Clip", [&]() {
+ Clip c(video);
+ c.Open();
+ read_forward_backward(c);
+ c.Close();
+ });
+
+ total += time_trial("Timeline", [&]() {
+ Timeline t(1920, 1080, Fraction(24, 1), 44100, 2, LAYOUT_STEREO);
+ Clip video_clip(video);
+ video_clip.Layer(0);
+ video_clip.Start(0.0);
+ video_clip.End(video_clip.Reader()->info.duration);
+ video_clip.Open();
+ Clip overlay1(overlay);
+ overlay1.Layer(1);
+ overlay1.Start(0.0);
+ overlay1.End(video_clip.Reader()->info.duration);
+ overlay1.Open();
+ Clip overlay2(overlay);
+ overlay2.Layer(2);
+ overlay2.Start(0.0);
+ overlay2.End(video_clip.Reader()->info.duration);
+ overlay2.Open();
+ t.AddClip(&video_clip);
+ t.AddClip(&overlay1);
+ t.AddClip(&overlay2);
+ t.Open();
+ t.info.video_length = t.GetMaxFrame();
+ read_forward_backward(t);
+ t.Close();
+ });
+
+ total += time_trial("Timeline (with transforms)", [&]() {
+ Timeline t(1920, 1080, Fraction(24, 1), 44100, 2, LAYOUT_STEREO);
+ Clip video_clip(video);
+ int64_t last = video_clip.Reader()->info.video_length;
+ video_clip.Layer(0);
+ video_clip.Start(0.0);
+ video_clip.End(video_clip.Reader()->info.duration);
+ video_clip.alpha.AddPoint(1, 1.0);
+ video_clip.alpha.AddPoint(last, 0.0);
+ video_clip.Open();
+ Clip overlay1(overlay);
+ overlay1.Layer(1);
+ overlay1.Start(0.0);
+ overlay1.End(video_clip.Reader()->info.duration);
+ overlay1.Open();
+ overlay1.scale_x.AddPoint(1, 1.0);
+ overlay1.scale_x.AddPoint(last, 0.25);
+ overlay1.scale_y.AddPoint(1, 1.0);
+ overlay1.scale_y.AddPoint(last, 0.25);
+ Clip overlay2(overlay);
+ overlay2.Layer(2);
+ overlay2.Start(0.0);
+ overlay2.End(video_clip.Reader()->info.duration);
+ overlay2.Open();
+ overlay2.rotation.AddPoint(1, 90.0);
+ t.AddClip(&video_clip);
+ t.AddClip(&overlay1);
+ t.AddClip(&overlay2);
+ t.Open();
+ t.info.video_length = t.GetMaxFrame();
+ read_forward_backward(t);
+ t.Close();
+ });
+
+ total += time_trial("Effect_Mask", [&]() {
+ FFmpegReader r(video);
+ r.Open();
+#ifdef USE_IMAGEMAGICK
+ ImageReader mask_reader(mask_img);
+#else
+ QtImageReader mask_reader(mask_img);
+#endif
+ mask_reader.Open();
+ Clip clip(&r);
+ clip.Open();
+ Mask m(&mask_reader, Keyframe(0.0), Keyframe(0.5));
+ clip.AddEffect(&m);
+ read_forward_backward(clip);
+ mask_reader.Close();
+ clip.Close();
+ r.Close();
+ });
+
+ total += time_trial("Effect_Brightness", [&]() {
+ FFmpegReader r(video);
+ r.Open();
+ Clip clip(&r);
+ clip.Open();
+ Brightness b(Keyframe(0.5), Keyframe(1.0));
+ clip.AddEffect(&b);
+ read_forward_backward(clip);
+ clip.Close();
+ r.Close();
+ });
+
+ total += time_trial("Effect_Crop", [&]() {
+ FFmpegReader r(video);
+ r.Open();
+ Clip clip(&r);
+ clip.Open();
+ Crop c(Keyframe(0.25), Keyframe(0.25), Keyframe(0.25), Keyframe(0.25));
+ clip.AddEffect(&c);
+ read_forward_backward(clip);
+ clip.Close();
+ r.Close();
+ });
+
+ total += time_trial("Effect_Saturation", [&]() {
+ FFmpegReader r(video);
+ r.Open();
+ Clip clip(&r);
+ clip.Open();
+ Saturation s(Keyframe(0.25), Keyframe(0.25), Keyframe(0.25),
+ Keyframe(0.25));
+ clip.AddEffect(&s);
+ read_forward_backward(clip);
+ clip.Close();
+ r.Close();
+ });
+
+ cout << "Overall," << total << "\n";
+ return 0;
+}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c24b1f617..50d71b3f4 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -16,6 +16,11 @@ endif()
# Test media path, used by unit tests for input data
file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/examples/" TEST_MEDIA_PATH)
+# Benchmark executable
+add_executable(openshot-benchmark Benchmark.cpp)
+target_compile_definitions(openshot-benchmark PRIVATE -DTEST_MEDIA_PATH="${TEST_MEDIA_PATH}")
+target_link_libraries(openshot-benchmark openshot)
+
###
### TEST SOURCE FILES
###
@@ -48,8 +53,10 @@ set(OPENSHOT_TESTS
ChromaKey
Crop
LensFlare
+ AnalogTape
Sharpen
SphericalEffect
+ WaveEffect
)
# ImageMagick related test files
diff --git a/tests/CVTracker.cpp b/tests/CVTracker.cpp
index 95bcc6c8d..0ac1116c5 100644
--- a/tests/CVTracker.cpp
+++ b/tests/CVTracker.cpp
@@ -107,6 +107,43 @@ TEST_CASE( "Track_Video", "[libopenshot][opencv][tracker]" )
CHECK(height == Approx(166).margin(2));
}
+TEST_CASE( "Track_BoundingBoxClipping", "[libopenshot][opencv][tracker]" )
+{
+ // Create a video clip
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "test.avi";
+
+ // Open clip
+ openshot::Clip c1(path.str());
+ c1.Open();
+
+ std::string json_data = R"proto(
+ {
+ "tracker-type": "KCF",
+ "region": {
+ "normalized_x": -0.2,
+ "normalized_y": -0.2,
+ "normalized_width": 1.5,
+ "normalized_height": 1.5,
+ "first-frame": 1
+ }
+ } )proto";
+
+ ProcessingController tracker_pc;
+ CVTracker tracker(json_data, tracker_pc);
+ tracker_pc.SetError(false, "");
+
+ // Grab first frame and run tracker directly
+ std::shared_ptr f = c1.GetFrame(1);
+ cv::Mat image = f->GetImageCV();
+
+ tracker.initTracker(image, 1);
+ tracker.trackFrame(image, 2);
+
+ INFO(tracker_pc.GetErrorMessage());
+ CHECK(tracker_pc.GetError() == false);
+}
+
TEST_CASE( "SaveLoad_Protobuf", "[libopenshot][opencv][tracker]" )
{
diff --git a/tests/Clip.cpp b/tests/Clip.cpp
index a6e3e6299..62d0377e5 100644
--- a/tests/Clip.cpp
+++ b/tests/Clip.cpp
@@ -12,14 +12,21 @@
#include
#include
+#include
#include "openshot_catch.h"
#include
#include
#include
+#include
+#include
+#include
#include "Clip.h"
+
+#include
+
#include "DummyReader.h"
#include "Enums.h"
#include "Exceptions.h"
@@ -42,6 +49,7 @@ TEST_CASE( "default constructor", "[libopenshot][clip]" )
CHECK(c1.anchor == ANCHOR_CANVAS);
CHECK(c1.gravity == GRAVITY_CENTER);
CHECK(c1.scale == SCALE_FIT);
+ CHECK(c1.composite == COMPOSITE_SOURCE_OVER);
CHECK(c1.Layer() == 0);
CHECK(c1.Position() == Approx(0.0f).margin(0.00001));
CHECK(c1.Start() == Approx(0.0f).margin(0.00001));
@@ -60,6 +68,7 @@ TEST_CASE( "path string constructor", "[libopenshot][clip]" )
CHECK(c1.anchor == ANCHOR_CANVAS);
CHECK(c1.gravity == GRAVITY_CENTER);
CHECK(c1.scale == SCALE_FIT);
+ CHECK(c1.composite == COMPOSITE_SOURCE_OVER);
CHECK(c1.Layer() == 0);
CHECK(c1.Position() == Approx(0.0f).margin(0.00001));
CHECK(c1.Start() == Approx(0.0f).margin(0.00001));
@@ -76,6 +85,7 @@ TEST_CASE( "basic getters and setters", "[libopenshot][clip]" )
CHECK(c1.anchor == ANCHOR_CANVAS);
CHECK(c1.gravity == GRAVITY_CENTER);
CHECK(c1.scale == SCALE_FIT);
+ CHECK(c1.composite == COMPOSITE_SOURCE_OVER);
CHECK(c1.Layer() == 0);
CHECK(c1.Position() == Approx(0.0f).margin(0.00001));
CHECK(c1.Start() == Approx(0.0f).margin(0.00001));
@@ -221,6 +231,131 @@ TEST_CASE( "effects", "[libopenshot][clip]" )
CHECK((int)c10.Effects().size() == 2);
}
+TEST_CASE( "GIF_clip_properties", "[libopenshot][clip][gif]" )
+{
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "animation.gif";
+ Clip c(path.str());
+ c.Open();
+
+ FFmpegReader *r = dynamic_cast(c.Reader());
+ REQUIRE(r != nullptr);
+ CHECK(r->info.video_length == 20);
+ CHECK(r->info.fps.num == 5);
+ CHECK(r->info.fps.den == 1);
+ CHECK(r->info.duration == Approx(4.0f).margin(0.01));
+
+ c.Close();
+}
+
+TEST_CASE( "GIF_time_mapping", "[libopenshot][clip][gif]" )
+{
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "animation.gif";
+
+ auto frame_color = [](std::shared_ptr f) {
+ const unsigned char* row = f->GetPixels(25);
+ return row[25 * 4];
+ };
+ auto expected_color = [](int frame) {
+ return (frame - 1) * 10;
+ };
+
+ // Slow mapping: stretch 20 frames over 50 frames
+ Clip slow(path.str());
+ slow.time.AddPoint(1,1, LINEAR);
+ slow.time.AddPoint(50,20, LINEAR);
+ slow.Open();
+
+ std::set slow_colors;
+ for (int i = 1; i <= 50; ++i) {
+ int src = slow.time.GetLong(i);
+ int c = frame_color(slow.GetFrame(i));
+ CHECK(c == expected_color(src));
+ slow_colors.insert(c);
+ }
+ CHECK((int)slow_colors.size() == 20);
+ slow.Close();
+
+ // Fast mapping: shrink 20 frames to 10 frames
+ Clip fast(path.str());
+ fast.time.AddPoint(1,1, LINEAR);
+ fast.time.AddPoint(10,20, LINEAR);
+ fast.Open();
+
+ std::set fast_colors;
+ for (int i = 1; i <= 10; ++i) {
+ int src = fast.time.GetLong(i);
+ int c = frame_color(fast.GetFrame(i));
+ CHECK(c == expected_color(src));
+ fast_colors.insert(c);
+ }
+ CHECK((int)fast_colors.size() == 10);
+ fast.Close();
+}
+
+TEST_CASE( "GIF_timeline_mapping", "[libopenshot][clip][gif]" )
+{
+ // Create a timeline
+ Timeline t1(50, 50, Fraction(5, 1), 44100, 2, LAYOUT_STEREO);
+
+ std::stringstream path;
+ path << TEST_MEDIA_PATH << "animation.gif";
+
+ auto frame_color = [](std::shared_ptr f) {
+ const unsigned char* row = f->GetPixels(25);
+ return row[25 * 4];
+ };
+ auto expected_color = [](int frame) {
+ return (frame - 1) * 10;
+ };
+
+ // Slow mapping: stretch 20 frames over 50 frames
+ Clip slow(path.str());
+ slow.Position(0.0);
+ slow.Layer(1);
+ slow.time.AddPoint(1,1, LINEAR);
+ slow.time.AddPoint(50,20, LINEAR);
+ slow.End(10.0);
+ t1.AddClip(&slow);
+ t1.Open();
+
+ std::set slow_colors;
+ for (int i = 1; i <= 50; ++i) {
+ int src = slow.time.GetLong(i);
+ std::stringstream frame_save;
+ t1.GetFrame(i)->Save(frame_save.str(), 1.0, "PNG", 100);
+ int c = frame_color(t1.GetFrame(i));
+ CHECK(c == expected_color(src));
+ slow_colors.insert(c);
+ }
+ CHECK((int)slow_colors.size() == 20);
+ t1.Close();
+
+ // Create a timeline
+ Timeline t2(50, 50, Fraction(5, 1), 44100, 2, LAYOUT_STEREO);
+
+ // Fast mapping: shrink 20 frames to 10 frames
+ Clip fast(path.str());
+ fast.Position(0.0);
+ fast.Layer(1);
+ fast.time.AddPoint(1,1, LINEAR);
+ fast.time.AddPoint(10,20, LINEAR);
+ fast.End(2.0);
+ t2.AddClip(&fast);
+ t2.Open();
+
+ std::set fast_colors;
+ for (int i = 1; i <= 10; ++i) {
+ int src = fast.time.GetLong(i);
+ int c = frame_color(t2.GetFrame(i));
+ CHECK(c == expected_color(src));
+ fast_colors.insert(c);
+ }
+ CHECK((int)fast_colors.size() == 10);
+ t2.Close();
+}
+
TEST_CASE( "verify parent Timeline", "[libopenshot][clip]" )
{
Timeline t1(640, 480, Fraction(30,1), 44100, 2, LAYOUT_STEREO);
@@ -489,4 +624,498 @@ TEST_CASE( "resample_audio_8000_to_48000_reverse", "[libopenshot][clip]" )
map.Close();
reader.Close();
clip.Close();
+}
+
+// -----------------------------------------------------------------------------
+// Additional tests validating PR changes:
+// - safe extension parsing (no dot in path)
+// - painter-based opacity behavior (no per-pixel mutation)
+// - transform/scaling path sanity (conditional render hint use)
+// -----------------------------------------------------------------------------
+
+TEST_CASE( "safe_extension_parsing_no_dot", "[libopenshot][clip][pr]" )
+{
+ // Constructing a Clip with a path that has no dot used to risk UB in get_file_extension();
+ // This should now be safe and simply result in no reader being set.
+ openshot::Clip c1("this_is_not_a_real_path_and_has_no_extension");
+
+ // Reader() should throw since no reader could be inferred.
+ CHECK_THROWS_AS(c1.Reader(), openshot::ReaderClosed);
+
+ // Opening also throws (consistent with other tests for unopened readers).
+ CHECK_THROWS_AS(c1.Open(), openshot::ReaderClosed);
+}
+
+TEST_CASE( "painter_opacity_applied_no_per_pixel_mutation", "[libopenshot][clip][pr]" )
+{
+ // Build a red frame via DummyReader (no copies/assignments of DummyReader)
+ openshot::CacheMemory cache;
+ auto f = std::make_shared(1, 80, 60, "#000000", 0, 2);
+ f->AddColor(QColor(Qt::red)); // opaque red
+ cache.Add(f);
+
+ openshot::DummyReader dummy(openshot::Fraction(30,1), 80, 60, 44100, 2, 1.0, &cache);
+ dummy.Open();
+
+ // Clip that uses the dummy reader
+ openshot::Clip clip;
+ clip.Reader(&dummy);
+ clip.Open();
+
+ // Alpha 0.5 at frame 1 (exercise painter.setOpacity path)
+ clip.alpha.AddPoint(1, 0.5);
+ clip.display = openshot::FRAME_DISPLAY_NONE; // avoid font/overlay variability
+
+ // Render frame 1 (no timeline needed for this check)
+ std::shared_ptr