Merge pull request #1004 from OpenShot/360-spherical

360 spherical video metadata + projection effect
This commit is contained in:
Jonathan Thomas
2025-05-23 22:22:07 -05:00
committed by GitHub
17 changed files with 864 additions and 29 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
cmake-build-debug/*
tags
*~
**/.claude/*

BIN
examples/eq_sphere.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

BIN
examples/fisheye.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -120,6 +120,7 @@ set(EFFECTS_SOURCES
effects/Pixelate.cpp
effects/Saturation.cpp
effects/Shift.cpp
effects/SphericalProjection.cpp
effects/Wave.cpp
audio_effects/STFT.cpp
audio_effects/Noise.cpp

View File

@@ -12,6 +12,7 @@
#include "EffectInfo.h"
#include "Effects.h"
#include "effects/SphericalProjection.h"
using namespace openshot;
@@ -67,6 +68,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) {
else if (effect_type == "Shift")
return new Shift();
else if (effect_type == "SphericalProjection")
return new SphericalProjection();
else if (effect_type == "Wave")
return new Wave();
@@ -135,6 +139,7 @@ Json::Value EffectInfo::JsonValue() {
root.append(Pixelate().JsonInfo());
root.append(Saturation().JsonInfo());
root.append(Shift().JsonInfo());
root.append(SphericalProjection().JsonInfo());
root.append(Wave().JsonInfo());
/* Audio */
root.append(Noise().JsonInfo());

View File

@@ -554,28 +554,49 @@ void FFmpegReader::Open() {
info.metadata[str_key.toStdString()] = str_value.trimmed().toStdString();
}
// If "rotate" isn't already set, extract it from the video stream's side data.
// TODO: nb_side_data is depreciated, and I'm not sure the preferred way to do this
if (info.metadata.find("rotate") == info.metadata.end()) {
for (unsigned int i = 0; i < pFormatCtx->nb_streams; i++) {
if (pFormatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
for (int j = 0; j < pFormatCtx->streams[i]->nb_side_data; j++) {
// Get the j-th side data element.
AVPacketSideData *sd = &pFormatCtx->streams[i]->side_data[j];
#pragma GCC diagnostic pop
if (sd->type == AV_PKT_DATA_DISPLAYMATRIX && sd->size >= 9 * sizeof(int32_t)) {
double rotation = -av_display_rotation_get(reinterpret_cast<int32_t *>(sd->data));
if (isnan(rotation))
rotation = 0;
QString str_value = QString::number(rotation, 'g', 6);
info.metadata["rotate"] = str_value.trimmed().toStdString();
break;
}
// Process video stream side data (rotation, spherical metadata, etc)
for (unsigned int i = 0; i < pFormatCtx->nb_streams; i++) {
AVStream* st = pFormatCtx->streams[i];
if (st->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
// Only inspect the first video stream
for (int j = 0; j < st->nb_side_data; j++) {
AVPacketSideData *sd = &st->side_data[j];
// Handle rotation metadata (unchanged)
if (sd->type == AV_PKT_DATA_DISPLAYMATRIX &&
sd->size >= 9 * sizeof(int32_t) &&
!info.metadata.count("rotate"))
{
double rotation = -av_display_rotation_get(
reinterpret_cast<int32_t *>(sd->data));
if (std::isnan(rotation)) rotation = 0;
info.metadata["rotate"] = std::to_string(rotation);
}
// Handle spherical video metadata
else if (sd->type == AV_PKT_DATA_SPHERICAL) {
// Always mark as spherical
info.metadata["spherical"] = "1";
// Cast the raw bytes to an AVSphericalMapping
const AVSphericalMapping* map =
reinterpret_cast<const AVSphericalMapping*>(sd->data);
// Projection enum → string
const char* proj_name = av_spherical_projection_name(map->projection);
info.metadata["spherical_projection"] = proj_name
? proj_name
: "unknown";
// Convert 16.16 fixed-point to float degrees
auto to_deg = [](int32_t v){
return (double)v / 65536.0;
};
info.metadata["spherical_yaw"] = std::to_string(to_deg(map->yaw));
info.metadata["spherical_pitch"] = std::to_string(to_deg(map->pitch));
info.metadata["spherical_roll"] = std::to_string(to_deg(map->roll));
}
break; // Only process the first video stream.
}
break;
}
}

View File

@@ -68,6 +68,10 @@ extern "C" {
#include <libavutil/channel_layout.h>
#endif
#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(55, 0, 0)
#include <libavutil/spherical.h>
#endif
#if IS_FFMPEG_3_2
#include "libavutil/imgutils.h"
#endif

View File

@@ -2349,3 +2349,48 @@ void FFmpegWriter::RemoveScalers() {
// Clear vector
image_rescalers.clear();
}
// In FFmpegWriter.cpp
void FFmpegWriter::AddSphericalMetadata(const std::string& projection,
float yaw_deg,
float pitch_deg,
float roll_deg)
{
if (!oc) return;
if (!info.has_video || !video_st) return;
// Allow movenc.c to write out the sv3d atom
oc->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL;
#if LIBAVFORMAT_VERSION_INT >= AV_VERSION_INT(57, 0, 0)
// Map the projection name to the enum (defaults to equirectangular)
int proj = av_spherical_from_name(projection.c_str());
if (proj < 0)
proj = AV_SPHERICAL_EQUIRECTANGULAR;
// Allocate the sidedata structure
size_t sd_size = 0;
AVSphericalMapping* map = av_spherical_alloc(&sd_size);
if (!map) {
// Allocation failed; skip metadata
return;
}
// Populate it
map->projection = static_cast<AVSphericalProjection>(proj);
// yaw/pitch/roll are 16.16 fixed point
map->yaw = static_cast<int32_t>(yaw_deg * (1 << 16));
map->pitch = static_cast<int32_t>(pitch_deg * (1 << 16));
map->roll = static_cast<int32_t>(roll_deg * (1 << 16));
// Attach to the video stream so movenc will emit an sv3d atom
av_stream_add_side_data(
video_st,
AV_PKT_DATA_SPHERICAL,
reinterpret_cast<uint8_t*>(map),
sd_size
);
#else
// FFmpeg build too old: spherical side-data not supported
#endif
}

View File

@@ -318,6 +318,13 @@ namespace openshot {
/// by the Close() method if this method has not yet been called.
void WriteTrailer();
/// @brief Add spherical (360°) video metadata to the video stream
/// @param projection The projection type (e.g., "equirectangular", "cubemap")
/// @param yaw_deg The yaw angle in degrees (horizontal orientation, default 0)
/// @param pitch_deg The pitch angle in degrees (vertical orientation, default 0)
/// @param roll_deg The roll angle in degrees (tilt orientation, default 0)
void AddSphericalMetadata(const std::string& projection="equirectangular", float yaw_deg=0.0f, float pitch_deg=0.0f, float roll_deg=0.0f);
};
}

View File

@@ -30,6 +30,7 @@ Profile::Profile() {
info.display_ratio.num = 0;
info.display_ratio.den = 0;
info.interlaced_frame = false;
info.spherical = false; // Default to non-spherical (regular) video
}
// @brief Constructor for Profile.
@@ -38,8 +39,19 @@ Profile::Profile(std::string path) {
bool read_file = false;
// Call default constructor
Profile();
// Initialize all values to defaults (same as default constructor)
info.description = "";
info.height = 0;
info.width = 0;
info.pixel_format = 0;
info.fps.num = 0;
info.fps.den = 0;
info.pixel_ratio.num = 0;
info.pixel_ratio.den = 0;
info.display_ratio.num = 0;
info.display_ratio.den = 0;
info.interlaced_frame = false;
info.spherical = false; // Default to non-spherical (regular) video
try
{
@@ -104,6 +116,10 @@ Profile::Profile(std::string path) {
std::stringstream(value) >> value_int;
info.pixel_format = value_int;
}
else if (setting == "spherical") {
std::stringstream(value) >> value_int;
info.spherical = (bool)value_int;
}
}
read_file = true;
inputFile.close();
@@ -156,6 +172,12 @@ std::string Profile::Key() {
output << std::setfill('0') << std::setw(4) << fps_string << std::setfill('\0') << "_";
output << std::setfill('0') << std::setw(2) << info.display_ratio.num << std::setfill('\0') << "-";
output << std::setfill('0') << std::setw(2) << info.display_ratio.den << std::setfill('\0');
// Add spherical indicator
if (info.spherical) {
output << "_360";
}
return output.str();
}
@@ -168,6 +190,12 @@ std::string Profile::ShortName() {
}
std::string fps_string = formattedFPS(true);
output << info.width << "x" << info.height << progressive_str << fps_string;
// Add 360° indicator for spherical videos
if (info.spherical) {
output << " 360°";
}
return output.str();
}
@@ -181,6 +209,12 @@ std::string Profile::LongName() {
std::string fps_string = formattedFPS(true);
output << info.width << "x" << info.height << progressive_str << " @ " << fps_string
<< " fps (" << info.display_ratio.num << ":" << info.display_ratio.den << ")";
// Add 360° indicator for spherical videos
if (info.spherical) {
output << " 360°";
}
return output.str();
}
@@ -193,7 +227,14 @@ std::string Profile::LongNameWithDesc() {
}
std::string fps_string = formattedFPS(true);
output << info.width << "x" << info.height << progressive_str << " @ " << fps_string
<< " fps (" << info.display_ratio.num << ":" << info.display_ratio.den << ") " << info.description;
<< " fps (" << info.display_ratio.num << ":" << info.display_ratio.den << ")";
// Add 360° indicator for spherical videos
if (info.spherical) {
output << " 360°";
}
output << " " << info.description;
return output.str();
}
@@ -214,7 +255,8 @@ void Profile::Save(const std::string& file_path) const {
file << "sample_aspect_den=" << info.pixel_ratio.den << "\n";
file << "display_aspect_num=" << info.display_ratio.num << "\n";
file << "display_aspect_den=" << info.display_ratio.den << "\n";
file << "pixel_format=" << info.pixel_format;
file << "pixel_format=" << info.pixel_format << "\n";
file << "spherical=" << info.spherical;
file.close();
}
@@ -245,6 +287,7 @@ Json::Value Profile::JsonValue() const {
root["display_ratio"]["num"] = info.display_ratio.num;
root["display_ratio"]["den"] = info.display_ratio.den;
root["progressive"] = !info.interlaced_frame;
root["spherical"] = info.spherical;
// return JsonValue
return root;
@@ -294,5 +337,7 @@ void Profile::SetJsonValue(const Json::Value root) {
}
if (!root["progressive"].isNull())
info.interlaced_frame = !root["progressive"].asBool();
if (!root["spherical"].isNull())
info.spherical = root["spherical"].asBool();
}

View File

@@ -45,7 +45,8 @@ namespace openshot
Fraction fps; ///< Frames per second, as a fraction (i.e. 24/1 = 24 fps)
Fraction pixel_ratio; ///< The pixel ratio of the video stream as a fraction (i.e. some pixels are not square)
Fraction display_ratio; ///< The ratio of width to height of the video stream (i.e. 640x480 has a ratio of 4/3)
bool interlaced_frame; // Are the contents of this frame interlaced
bool interlaced_frame; ///< Are the contents of this frame interlaced
bool spherical; ///< Is this video a spherical/360° video
};
/**
@@ -127,8 +128,8 @@ namespace openshot
/// Equality operator (compare profile objects)
friend bool operator==(const Profile& l, const Profile& r)
{
return std::tie(l.info.width, l.info.height, l.info.fps.num, l.info.fps.den, l.info.display_ratio.num, l.info.display_ratio.den, l.info.interlaced_frame)
== std::tie(r.info.width, r.info.height, r.info.fps.num, r.info.fps.den, r.info.display_ratio.num, r.info.display_ratio.den, r.info.interlaced_frame);
return std::tie(l.info.width, l.info.height, l.info.fps.num, l.info.fps.den, l.info.display_ratio.num, l.info.display_ratio.den, l.info.interlaced_frame, l.info.spherical)
== std::tie(r.info.width, r.info.height, r.info.fps.num, r.info.fps.den, r.info.display_ratio.num, r.info.display_ratio.den, r.info.interlaced_frame, r.info.spherical);
}
public:
@@ -160,4 +161,4 @@ namespace openshot
}
#endif
#endif

View File

@@ -0,0 +1,262 @@
/**
* @file
* @brief Source file for SphericalProjection effect class
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "SphericalProjection.h"
#include "Exceptions.h"
#include <cmath>
#include <algorithm>
#include <omp.h>
using namespace openshot;
SphericalProjection::SphericalProjection()
: yaw(0.0)
, pitch(0.0)
, roll(0.0)
, fov(90.0)
, projection_mode(0)
, invert(0)
, interpolation(0)
{
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)
{
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;
}
std::shared_ptr<openshot::Frame> SphericalProjection::GetFrame(
std::shared_ptr<openshot::Frame> 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;
#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;
}
std::string SphericalProjection::Json() const
{
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;
}
void SphericalProjection::SetJson(const std::string value)
{
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();
}
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();
}

View File

@@ -0,0 +1,74 @@
/**
* @file
* @brief Header file for SphericalProjection effect class
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#ifndef OPENSHOT_SPHERICAL_PROJECTION_EFFECT_H
#define OPENSHOT_SPHERICAL_PROJECTION_EFFECT_H
#include "../EffectBase.h"
#include "../Frame.h"
#include "../Json.h"
#include "../KeyFrame.h"
#include <memory>
#include <string>
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.
*/
class SphericalProjection : public EffectBase
{
private:
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<Frame> GetFrame(int64_t frame_number) override
{ return GetFrame(std::make_shared<Frame>(), frame_number); }
/// EffectBase override: reproject the QImage
std::shared_ptr<Frame> GetFrame(std::shared_ptr<Frame> 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;
};
} // namespace openshot
#endif // OPENSHOT_SPHERICAL_PROJECTION_EFFECT_H

View File

@@ -40,10 +40,12 @@ set(OPENSHOT_TESTS
QtImageReader
ReaderBase
Settings
SphericalMetadata
Timeline
# Effects
ChromaKey
Crop
SphericalEffect
)
# ImageMagick related test files

View File

@@ -30,6 +30,7 @@ TEST_CASE( "empty constructor", "[libopenshot][profile]" )
CHECK(p1.info.pixel_ratio.num == 0);
CHECK(p1.info.pixel_ratio.den == 0);
CHECK(p1.info.interlaced_frame == false);
CHECK(p1.info.spherical == false);
}
@@ -51,6 +52,7 @@ TEST_CASE( "constructor with example profiles", "[libopenshot][profile]" )
CHECK(p1.info.pixel_ratio.num == 1);
CHECK(p1.info.pixel_ratio.den == 1);
CHECK(p1.info.interlaced_frame == false);
CHECK(p1.info.spherical == false);
// Export to JSON
openshot::Profile p1_json = openshot::Profile();
@@ -66,6 +68,7 @@ TEST_CASE( "constructor with example profiles", "[libopenshot][profile]" )
CHECK(p1_json.info.pixel_ratio.num == 1);
CHECK(p1_json.info.pixel_ratio.den == 1);
CHECK(p1_json.info.interlaced_frame == false);
CHECK(p1_json.info.spherical == false);
std::stringstream profile2;
profile2 << TEST_MEDIA_PATH << "example_profile2";
@@ -83,6 +86,7 @@ TEST_CASE( "constructor with example profiles", "[libopenshot][profile]" )
CHECK(p2.info.pixel_ratio.num == 1);
CHECK(p2.info.pixel_ratio.den == 1);
CHECK(p2.info.interlaced_frame == true);
CHECK(p2.info.spherical == false);
}
TEST_CASE( "24 fps names", "[libopenshot][profile]" )
@@ -163,7 +167,6 @@ TEST_CASE( "save profiles", "[libopenshot][profile]" )
// Save copy
std::stringstream profile1_copy;
profile1_copy << TEST_MEDIA_PATH << "example_profile1_copy";
std::cout << profile1_copy.str() << std::endl;
p1.Save(profile1_copy.str());
// Load saved copy
@@ -180,4 +183,52 @@ TEST_CASE( "save profiles", "[libopenshot][profile]" )
CHECK(p1_load_copy.info.pixel_ratio.num == 1);
CHECK(p1_load_copy.info.pixel_ratio.den == 1);
CHECK(p1_load_copy.info.interlaced_frame == false);
CHECK(p1_load_copy.info.spherical == false);
}
TEST_CASE( "spherical profiles", "[libopenshot][profile]" )
{
// Create a new profile with spherical=true
openshot::Profile p;
p.info.description = "360° Test Profile";
p.info.width = 3840;
p.info.height = 1920;
p.info.fps.num = 30;
p.info.fps.den = 1;
p.info.display_ratio.num = 2;
p.info.display_ratio.den = 1;
p.info.pixel_ratio.num = 1;
p.info.pixel_ratio.den = 1;
p.info.interlaced_frame = false;
p.info.spherical = true;
// Test the name methods for spherical content
CHECK(p.Key() == "03840x1920p0030_02-01_360");
CHECK(p.ShortName() == "3840x1920p30 360°");
CHECK(p.LongName() == "3840x1920p @ 30 fps (2:1) 360°");
CHECK(p.LongNameWithDesc() == "3840x1920p @ 30 fps (2:1) 360° 360° Test Profile");
// Test JSON serialization and deserialization
std::string json = p.Json();
openshot::Profile p_json;
p_json.SetJson(json);
CHECK(p_json.info.spherical == true);
CHECK(p_json.ShortName() == "3840x1920p30 360°");
// Save and reload to test file I/O
std::stringstream profile_path;
profile_path << TEST_MEDIA_PATH << "example_profile_360";
p.Save(profile_path.str());
// Load the saved profile
openshot::Profile p_loaded(profile_path.str());
CHECK(p_loaded.info.spherical == true);
CHECK(p_loaded.ShortName() == "3840x1920p30 360°");
// Test comparison operators
openshot::Profile p_non_spherical = p;
p_non_spherical.info.spherical = false;
CHECK_FALSE(p == p_non_spherical);
}

146
tests/SphericalEffect.cpp Normal file
View File

@@ -0,0 +1,146 @@
/**
* @file
* @brief Unit tests for openshot::SphericalProjection effect using PNG fixtures
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2025 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include <memory>
#include <QImage>
#include <QColor>
#include "Frame.h"
#include "effects/SphericalProjection.h"
#include "openshot_catch.h"
using namespace openshot;
// allow Catch2 to print QColor on failure
static std::ostream& operator<<(std::ostream& os, QColor const& c)
{
os << "QColor(" << c.red() << "," << c.green()
<< "," << c.blue() << "," << c.alpha() << ")";
return os;
}
// load a PNG into a Frame
static std::shared_ptr<Frame> loadFrame(const char* filename)
{
QImage img(QString(TEST_MEDIA_PATH) + filename);
img = img.convertToFormat(QImage::Format_ARGB32);
auto f = std::make_shared<Frame>();
*f->GetImage() = img;
return f;
}
// apply effect and sample center pixel
static QColor centerPixel(SphericalProjection& e,
std::shared_ptr<Frame> f)
{
auto img = e.GetFrame(f, 1)->GetImage();
int cx = img->width() / 2;
int cy = img->height() / 2;
return img->pixelColor(cx, cy);
}
TEST_CASE("sphere mode default and invert", "[effect][spherical]")
{
SphericalProjection e;
e.projection_mode = 0;
e.yaw = Keyframe(45.0);
{
auto f0 = loadFrame("eq_sphere.png");
e.invert = 0;
e.interpolation = 0;
// eq_sphere.png has green stripe at center
CHECK(centerPixel(e, f0) == QColor(255,0,0,255));
}
{
auto f1 = loadFrame("eq_sphere.png");
e.yaw = Keyframe(-45.0);
e.invert = 0;
e.interpolation = 1;
// invert flips view 180°, center maps to blue stripe
CHECK(centerPixel(e, f1) == QColor(0,0,255,255));
}
{
auto f1 = loadFrame("eq_sphere.png");
e.yaw = Keyframe(0.0);
e.invert = 1;
e.interpolation = 0;
// invert flips view 180°, center maps to blue stripe
CHECK(centerPixel(e, f1) == QColor(0,255,0,255));
}
}
TEST_CASE("hemisphere mode default and invert", "[effect][spherical]")
{
SphericalProjection e;
e.projection_mode = 1;
{
auto f0 = loadFrame("eq_sphere.png");
e.yaw = Keyframe(45.0);
e.invert = 0;
e.interpolation = 0;
// hemisphere on full pano still shows green at center
CHECK(centerPixel(e, f0) == QColor(255,0,0,255));
}
{
auto f1 = loadFrame("eq_sphere.png");
e.yaw = Keyframe(-45.0);
e.invert = 0;
e.interpolation = 1;
// invert=1 flips center to blue
CHECK(centerPixel(e, f1) == QColor(0,0,255,255));
}
{
auto f1 = loadFrame("eq_sphere.png");
e.yaw = Keyframe(-180.0);
e.invert = 0;
e.interpolation = 0;
// invert=1 flips center to blue
CHECK(centerPixel(e, f1) == QColor(0,255,0,255));
}
}
TEST_CASE("fisheye mode default and invert", "[effect][spherical]")
{
SphericalProjection e;
e.projection_mode = 2;
e.fov = Keyframe(180.0);
{
auto f0 = loadFrame("fisheye.png");
e.invert = 0;
e.interpolation = 0;
// circular mask center remains white
CHECK(centerPixel(e, f0) == QColor(255,255,255,255));
}
{
auto f1 = loadFrame("fisheye.png");
e.invert = 1;
e.interpolation = 1;
e.fov = Keyframe(90.0);
// invert has no effect on center
CHECK(centerPixel(e, f1) == QColor(255,255,255,255));
}
}
TEST_CASE("fisheye mode yaw has no effect at center", "[effect][spherical]")
{
SphericalProjection e;
e.projection_mode = 2;
e.interpolation = 0;
e.fov = Keyframe(180.0);
e.invert = 0;
auto f = loadFrame("fisheye.png");
e.yaw = Keyframe(45.0);
CHECK(centerPixel(e, f) == QColor(255,255,255,255));
}

170
tests/SphericalMetadata.cpp Normal file
View File

@@ -0,0 +1,170 @@
/**
* @file
* @brief Unit tests for FFmpegWriter spherical metadata
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2023 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "openshot_catch.h"
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include "FFmpegReader.h"
#include "FFmpegWriter.h"
#include "Fraction.h"
#include "Frame.h"
using namespace openshot;
TEST_CASE( "SphericalMetadata_Test", "[libopenshot][ffmpegwriter]" )
{
// Create a reader to grab some frames
FFmpegReader r(TEST_MEDIA_PATH "sintel_trailer-720p.mp4");
r.Open();
// Create a spherical metadata test video
std::string test_file = "spherical_test.mp4";
// Create a writer
FFmpegWriter w(test_file);
// Set options - Using MP4 with H.264 for best compatibility with spherical metadata
w.SetVideoOptions(true, "libx264", r.info.fps, r.info.width, r.info.height,
r.info.pixel_ratio, false, false, 3000000);
w.SetAudioOptions(true, "aac", r.info.sample_rate, r.info.channels,
r.info.channel_layout, 128000);
w.PrepareStreams();
// Add spherical metadata BEFORE opening the writer
float test_yaw = 30.0f;
w.AddSphericalMetadata("equirectangular", test_yaw, 0.0f, 0.0f);
// Open writer
w.Open();
// Write a few frames
for (int frame = 1; frame <= 30; frame++) {
// Get the frame
std::shared_ptr<Frame> f = r.GetFrame(frame);
// Write the frame
w.WriteFrame(f);
}
// Close the writer & reader
w.Close();
r.Close();
// Reopen the file with FFmpegReader to verify metadata was added
FFmpegReader test_reader(test_file);
test_reader.Open();
// Display format information for debugging
INFO("Container format: " << test_reader.info.vcodec);
INFO("Duration: " << test_reader.info.duration);
INFO("Width x Height: " << test_reader.info.width << "x" << test_reader.info.height);
// Check metadata map contents for debugging
INFO("Metadata entries in reader:");
for (const auto& entry : test_reader.info.metadata) {
INFO(" " << entry.first << " = " << entry.second);
}
// Verify presence of spherical metadata and orientation keys
CHECK(test_reader.info.metadata.count("spherical") > 0);
CHECK(test_reader.info.metadata["spherical"] == "1");
CHECK(test_reader.info.metadata.count("spherical_projection") > 0);
CHECK(test_reader.info.metadata.count("spherical_yaw") > 0);
CHECK(test_reader.info.metadata.count("spherical_pitch") > 0);
CHECK(test_reader.info.metadata.count("spherical_roll") > 0);
// Spot-check yaw value
float yaw_found = std::stof(test_reader.info.metadata["spherical_yaw"]);
CHECK(yaw_found == Approx(test_yaw).margin(0.5f));
// Clean up
test_reader.Close();
std::remove(test_file.c_str());
}
TEST_CASE( "SphericalMetadata_FullOrientation", "[libopenshot][ffmpegwriter]" )
{
// Create a reader to grab some frames
FFmpegReader r(TEST_MEDIA_PATH "sintel_trailer-720p.mp4");
r.Open();
// Create a spherical metadata test video
std::string test_file = "spherical_orientation_test.mp4";
// Create a writer
FFmpegWriter w(test_file);
// Set options - Using MP4 with H.264 for best compatibility with spherical metadata
w.SetVideoOptions(true, "libx264", r.info.fps, r.info.width, r.info.height,
r.info.pixel_ratio, false, false, 3000000);
w.SetAudioOptions(true, "aac", r.info.sample_rate, r.info.channels,
r.info.channel_layout, 128000);
w.PrepareStreams();
// Add spherical metadata BEFORE opening the writer
float test_yaw = 45.0f;
float test_pitch = 30.0f;
float test_roll = 15.0f;
w.AddSphericalMetadata("equirectangular", test_yaw, test_pitch, test_roll);
// Open writer
w.Open();
// Write a few frames
for (int frame = 1; frame <= 30; frame++) {
// Get the frame
std::shared_ptr<Frame> f = r.GetFrame(frame);
// Write the frame
w.WriteFrame(f);
}
// Close the writer & reader
w.Close();
r.Close();
// Reopen the file with FFmpegReader to verify metadata was added
FFmpegReader test_reader(test_file);
test_reader.Open();
// Check metadata map contents for debugging
INFO("Metadata entries in reader:");
for (const auto& entry : test_reader.info.metadata) {
INFO(" " << entry.first << " = " << entry.second);
}
// Verify presence of spherical metadata and orientation keys
CHECK(test_reader.info.metadata.count("spherical") > 0);
CHECK(test_reader.info.metadata["spherical"] == "1");
CHECK(test_reader.info.metadata.count("spherical_projection") > 0);
CHECK(test_reader.info.metadata.count("spherical_yaw") > 0);
CHECK(test_reader.info.metadata.count("spherical_pitch") > 0);
CHECK(test_reader.info.metadata.count("spherical_roll") > 0);
// Validate each orientation value
float yaw_found = std::stof(test_reader.info.metadata["spherical_yaw"]);
float pitch_found = std::stof(test_reader.info.metadata["spherical_pitch"]);
float roll_found = std::stof(test_reader.info.metadata["spherical_roll"]);
CHECK(yaw_found == Approx(test_yaw).margin(0.5f));
CHECK(pitch_found == Approx(test_pitch).margin(0.5f));
CHECK(roll_found == Approx(test_roll).margin(0.5f));
// Clean up
test_reader.Close();
std::remove(test_file.c_str());
}