You've already forked libopenshot
mirror of
https://github.com/OpenShot/libopenshot.git
synced 2026-03-02 08:53:52 -08:00
Merge pull request #1004 from OpenShot/360-spherical
360 spherical video metadata + projection effect
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@
|
||||
cmake-build-debug/*
|
||||
tags
|
||||
*~
|
||||
**/.claude/*
|
||||
|
||||
BIN
examples/eq_sphere.png
Normal file
BIN
examples/eq_sphere.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 635 B |
BIN
examples/fisheye.png
Normal file
BIN
examples/fisheye.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
262
src/effects/SphericalProjection.cpp
Normal file
262
src/effects/SphericalProjection.cpp
Normal 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();
|
||||
}
|
||||
74
src/effects/SphericalProjection.h
Normal file
74
src/effects/SphericalProjection.h
Normal 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
|
||||
@@ -40,10 +40,12 @@ set(OPENSHOT_TESTS
|
||||
QtImageReader
|
||||
ReaderBase
|
||||
Settings
|
||||
SphericalMetadata
|
||||
Timeline
|
||||
# Effects
|
||||
ChromaKey
|
||||
Crop
|
||||
SphericalEffect
|
||||
)
|
||||
|
||||
# ImageMagick related test files
|
||||
|
||||
@@ -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
146
tests/SphericalEffect.cpp
Normal 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
170
tests/SphericalMetadata.cpp
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user