diff --git a/.gitignore b/.gitignore index b89ccf59..eed73b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ cmake-build-debug/* tags *~ +**/.claude/* diff --git a/examples/eq_sphere.png b/examples/eq_sphere.png new file mode 100644 index 00000000..53273886 Binary files /dev/null and b/examples/eq_sphere.png differ diff --git a/examples/fisheye.png b/examples/fisheye.png new file mode 100644 index 00000000..8b149821 Binary files /dev/null and b/examples/fisheye.png differ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fdef3f65..1b525700 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -121,6 +121,7 @@ set(EFFECTS_SOURCES effects/Saturation.cpp effects/Sharpen.cpp effects/Shift.cpp + effects/SphericalProjection.cpp effects/Wave.cpp audio_effects/STFT.cpp audio_effects/Noise.cpp diff --git a/src/EffectInfo.cpp b/src/EffectInfo.cpp index 64cd3a45..5a271df8 100644 --- a/src/EffectInfo.cpp +++ b/src/EffectInfo.cpp @@ -13,6 +13,7 @@ #include "EffectInfo.h" #include "Effects.h" #include "effects/Sharpen.h" +#include "effects/SphericalProjection.h" using namespace openshot; @@ -71,6 +72,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(); @@ -140,6 +144,7 @@ Json::Value EffectInfo::JsonValue() { root.append(Saturation().JsonInfo()); root.append(Sharpen().JsonInfo()); root.append(Shift().JsonInfo()); + root.append(SphericalProjection().JsonInfo()); root.append(Wave().JsonInfo()); /* Audio */ root.append(Noise().JsonInfo()); diff --git a/src/FFmpegReader.cpp b/src/FFmpegReader.cpp index c23680ed..c42cc386 100644 --- a/src/FFmpegReader.cpp +++ b/src/FFmpegReader.cpp @@ -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(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(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(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; } } @@ -1307,7 +1328,10 @@ bool FFmpegReader::GetAVFrame() { frameFinished = 1; packet_status.video_decoded++; - av_image_alloc(pFrame->data, pFrame->linesize, info.width, info.height, (AVPixelFormat)(pStream->codecpar->format), 1); + // Allocate image (align 32 for simd) + if (AV_ALLOCATE_IMAGE(pFrame, (AVPixelFormat)(pStream->codecpar->format), info.width, info.height) <= 0) { + throw OutOfMemory("Failed to allocate image buffer", path); + } av_image_copy(pFrame->data, pFrame->linesize, (const uint8_t**)next_frame->data, next_frame->linesize, (AVPixelFormat)(pStream->codecpar->format), info.width, info.height); diff --git a/src/FFmpegUtilities.h b/src/FFmpegUtilities.h index 143b3427..f30f7fdc 100644 --- a/src/FFmpegUtilities.h +++ b/src/FFmpegUtilities.h @@ -68,6 +68,10 @@ extern "C" { #include #endif +#if LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(55, 0, 0) + #include +#endif + #if IS_FFMPEG_3_2 #include "libavutil/imgutils.h" #endif @@ -160,7 +164,7 @@ inline static bool ffmpeg_has_alpha(PixelFormat pix_fmt) { #define MY_INPUT_BUFFER_PADDING_SIZE AV_INPUT_BUFFER_PADDING_SIZE #define AV_ALLOCATE_FRAME() av_frame_alloc() #define AV_ALLOCATE_IMAGE(av_frame, pix_fmt, width, height) \ - av_image_alloc(av_frame->data, av_frame->linesize, width, height, pix_fmt, 1) + av_image_alloc(av_frame->data, av_frame->linesize, width, height, pix_fmt, 32) #define AV_RESET_FRAME(av_frame) av_frame_unref(av_frame) #define AV_FREE_FRAME(av_frame) av_frame_free(av_frame) #define AV_FREE_PACKET(av_packet) av_packet_unref(av_packet) @@ -198,7 +202,7 @@ inline static bool ffmpeg_has_alpha(PixelFormat pix_fmt) { #define MY_INPUT_BUFFER_PADDING_SIZE FF_INPUT_BUFFER_PADDING_SIZE #define AV_ALLOCATE_FRAME() av_frame_alloc() #define AV_ALLOCATE_IMAGE(av_frame, pix_fmt, width, height) \ - av_image_alloc(av_frame->data, av_frame->linesize, width, height, pix_fmt, 1) + av_image_alloc(av_frame->data, av_frame->linesize, width, height, pix_fmt, 32) #define AV_RESET_FRAME(av_frame) av_frame_unref(av_frame) #define AV_FREE_FRAME(av_frame) av_frame_free(av_frame) #define AV_FREE_PACKET(av_packet) av_packet_unref(av_packet) diff --git a/src/FFmpegWriter.cpp b/src/FFmpegWriter.cpp index a17cc442..340f3657 100644 --- a/src/FFmpegWriter.cpp +++ b/src/FFmpegWriter.cpp @@ -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 side‐data 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(proj); + // yaw/pitch/roll are 16.16 fixed point + map->yaw = static_cast(yaw_deg * (1 << 16)); + map->pitch = static_cast(pitch_deg * (1 << 16)); + map->roll = static_cast(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(map), + sd_size + ); +#else + // FFmpeg build too old: spherical side-data not supported +#endif +} diff --git a/src/FFmpegWriter.h b/src/FFmpegWriter.h index 4aa89550..1f54eed9 100644 --- a/src/FFmpegWriter.h +++ b/src/FFmpegWriter.h @@ -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); + }; } diff --git a/src/Profiles.cpp b/src/Profiles.cpp index c462ffc8..b80b467c 100644 --- a/src/Profiles.cpp +++ b/src/Profiles.cpp @@ -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(); } diff --git a/src/Profiles.h b/src/Profiles.h index 1181431d..5782441a 100644 --- a/src/Profiles.h +++ b/src/Profiles.h @@ -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 \ No newline at end of file diff --git a/src/effects/SphericalProjection.cpp b/src/effects/SphericalProjection.cpp new file mode 100644 index 00000000..0565a7a3 --- /dev/null +++ b/src/effects/SphericalProjection.cpp @@ -0,0 +1,262 @@ +/** + * @file + * @brief Source file for SphericalProjection effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2025 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "SphericalProjection.h" +#include "Exceptions.h" + +#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) +{ + 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 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; + +#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(); +} diff --git a/src/effects/SphericalProjection.h b/src/effects/SphericalProjection.h new file mode 100644 index 00000000..1738f976 --- /dev/null +++ b/src/effects/SphericalProjection.h @@ -0,0 +1,74 @@ +/** + * @file + * @brief Header file for SphericalProjection effect class + * @author Jonathan Thomas + * + * @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 +#include + +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 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; +}; + +} // namespace openshot + +#endif // OPENSHOT_SPHERICAL_PROJECTION_EFFECT_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 476ee8b6..bcae500b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,11 +40,13 @@ set(OPENSHOT_TESTS QtImageReader ReaderBase Settings + SphericalMetadata Timeline # Effects ChromaKey Crop Sharpen + SphericalEffect ) # ImageMagick related test files diff --git a/tests/Profiles.cpp b/tests/Profiles.cpp index b9f41a4a..d515655c 100644 --- a/tests/Profiles.cpp +++ b/tests/Profiles.cpp @@ -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); } \ No newline at end of file diff --git a/tests/SphericalEffect.cpp b/tests/SphericalEffect.cpp new file mode 100644 index 00000000..0794d189 --- /dev/null +++ b/tests/SphericalEffect.cpp @@ -0,0 +1,146 @@ +/** + * @file + * @brief Unit tests for openshot::SphericalProjection effect using PNG fixtures + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2025 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include +#include +#include +#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 loadFrame(const char* filename) +{ + QImage img(QString(TEST_MEDIA_PATH) + filename); + img = img.convertToFormat(QImage::Format_ARGB32); + auto f = std::make_shared(); + *f->GetImage() = img; + return f; +} + +// apply effect and sample center pixel +static QColor centerPixel(SphericalProjection& e, + std::shared_ptr 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)); +} diff --git a/tests/SphericalMetadata.cpp b/tests/SphericalMetadata.cpp new file mode 100644 index 00000000..a0bdd7a5 --- /dev/null +++ b/tests/SphericalMetadata.cpp @@ -0,0 +1,170 @@ +/** + * @file + * @brief Unit tests for FFmpegWriter spherical metadata + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2023 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "openshot_catch.h" + +#include +#include +#include +#include + +#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 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 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()); +} \ No newline at end of file