diff --git a/.gitignore b/.gitignore index 30818d52ae3..23b24565064 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ package-lock.json # Python *.pyc venv/ +temp_auto_push.bat +temp_interactive_push.bat diff --git a/src/config.cpp b/src/config.cpp index a79f7d88151..660ab0dfb05 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -524,6 +524,8 @@ namespace config { {} // wa }, // display_device + 0, // manual_rotation + 0, // max_bitrate 0 // minimum_fps_target (0 = framerate) }; @@ -1196,6 +1198,18 @@ namespace config { video.dd.wa.hdr_toggle_delay = std::chrono::milliseconds {value}; } + { + int rotation = 0; + int_f(vars, "manual_rotation", rotation); + // Normalize to valid rotation values + if (rotation == 90 || rotation == 180 || rotation == 270) { + video.manual_rotation = rotation; + } + else { + video.manual_rotation = 0; + } + } + int_f(vars, "max_bitrate", video.max_bitrate); double_between_f(vars, "minimum_fps_target", video.minimum_fps_target, {0.0, 1000.0}); diff --git a/src/config.h b/src/config.h index eb778a3ac68..da9dbf4956d 100644 --- a/src/config.h +++ b/src/config.h @@ -152,6 +152,8 @@ namespace config { workarounds_t wa; } dd; + int manual_rotation; ///< Manual display rotation in degrees (0, 90, 180, 270). Useful for portrait panels used in landscape orientation. + int max_bitrate; // Maximum bitrate, sets ceiling in kbps for bitrate requested from client double minimum_fps_target; ///< Lowest framerate that will be used when streaming. Range 0-1000, 0 = half of client's requested framerate. }; diff --git a/src/platform/linux/graphics.cpp b/src/platform/linux/graphics.cpp index 6cc2eb3bc3e..f61ab823cfd 100644 --- a/src/platform/linux/graphics.cpp +++ b/src/platform/linux/graphics.cpp @@ -7,6 +7,7 @@ // local includes #include "graphics.h" +#include "src/config.h" #include "src/file_handler.h" #include "src/logging.h" #include "src/video.h" @@ -786,10 +787,19 @@ namespace egl { sws.serial = std::numeric_limits::max(); + // When rotation is 90 or 270 degrees, swap input dimensions for aspect ratio calculation + // because the shader will rotate the texture, effectively swapping width and height + int effective_in_width = in_width; + int effective_in_height = in_height; + if (config::video.manual_rotation == 90 || config::video.manual_rotation == 270) { + effective_in_width = in_height; + effective_in_height = in_width; + } + // Ensure aspect ratio is maintained - auto scalar = std::fminf(out_width / (float) in_width, out_height / (float) in_height); - auto out_width_f = in_width * scalar; - auto out_height_f = in_height * scalar; + auto scalar = std::fminf(out_width / (float) effective_in_width, out_height / (float) effective_in_height); + auto out_width_f = effective_in_width * scalar; + auto out_height_f = effective_in_height * scalar; // result is always positive auto offsetX_f = (out_width - out_width_f) / 2; @@ -878,6 +888,15 @@ namespace egl { gl::ctx.UseProgram(sws.program[1].handle()); gl::ctx.Uniform1fv(loc_width_i, 1, &width_i); + // Set rotation uniform on UV shader (program[1]) + { + int rotation = config::video.manual_rotation; + auto loc_rotation = gl::ctx.GetUniformLocation(sws.program[1].handle(), "rotation"); + if (loc_rotation >= 0) { + gl::ctx.Uniform1i(loc_rotation, rotation); + } + } + auto color_p = video::color_vectors_from_colorspace({video::colorspace_e::rec601, false, 8}, true); std::pair members[] { std::make_pair("color_vec_y", util::view(color_p->color_vec_y)), @@ -902,6 +921,23 @@ namespace egl { sws.program[0].bind(sws.color_matrix); sws.program[1].bind(sws.color_matrix); + // Set rotation uniform on Y shader (program[0]) and Scene/Cursor shader (program[2]) + { + int rotation = config::video.manual_rotation; + + gl::ctx.UseProgram(sws.program[0].handle()); + auto loc_rot_y = gl::ctx.GetUniformLocation(sws.program[0].handle(), "rotation"); + if (loc_rot_y >= 0) { + gl::ctx.Uniform1i(loc_rot_y, rotation); + } + + gl::ctx.UseProgram(sws.program[2].handle()); + auto loc_rot_scene = gl::ctx.GetUniformLocation(sws.program[2].handle(), "rotation"); + if (loc_rot_scene >= 0) { + gl::ctx.Uniform1i(loc_rot_scene, rotation); + } + } + gl::ctx.BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); gl_drain_errors; diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index cc6a88110b3..975339f856b 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -85,6 +85,18 @@ const config = ref(props.config) :config="config" /> + +
+ + +
{{ $t('config.manual_rotation_desc') }}
+
+ > 1); @@ -18,5 +32,5 @@ void main() float v = idLow * 2.0; gl_Position = vec4(x, y, 0.0, 1.0); - tex = vec2(u, v); -} \ No newline at end of file + tex = rotate_uv(vec2(u, v), rotation); +} diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp new file mode 100644 index 00000000000..06ec1bc3c70 --- /dev/null +++ b/tests/unit/test_config.cpp @@ -0,0 +1,76 @@ +/** + * @file tests/unit/test_config.cpp + * @brief Test src/config.cpp + */ +#include "../tests_common.h" + +// standard includes +#include +#include + +// local includes +#include + +// Forward-declare the internal apply_config function for testing +namespace config { + // NOLINTNEXTLINE(modernize-use-transparent-functors) + void apply_config(std::unordered_map &&vars); +} + +class ManualRotationTest: public ::testing::TestWithParam> { +protected: + void SetUp() override { + // Reset to default before each test + config::video.manual_rotation = 0; + } +}; + +TEST_P(ManualRotationTest, ParsesRotationValues) { + const auto [input, expected] = GetParam(); + + // NOLINTNEXTLINE(modernize-use-transparent-functors) + std::unordered_map vars; + vars["manual_rotation"] = input; + config::apply_config(std::move(vars)); + + EXPECT_EQ(config::video.manual_rotation, expected); +} + +INSTANTIATE_TEST_SUITE_P( + ConfigTests, + ManualRotationTest, + testing::Values( + // Valid rotation values + std::make_pair("0", 0), + std::make_pair("90", 90), + std::make_pair("180", 180), + std::make_pair("270", 270), + // Invalid values should normalize to 0 + std::make_pair("45", 0), + std::make_pair("360", 0), + std::make_pair("-90", 0), + std::make_pair("1", 0), + std::make_pair("abc", 0) + ), + [](const testing::TestParamInfo &info) { + auto input = info.param.first; + // Replace non-alphanumeric chars for valid test name + std::replace_if( + input.begin(), input.end(), + [](char c) { return !std::isalnum(c); }, + '_' + ); + return "rotation_" + input; + } +); + +TEST(ManualRotationDefaultTest, DefaultIsZero) { + // Reset config and apply empty vars to verify default + config::video.manual_rotation = 999; + // NOLINTNEXTLINE(modernize-use-transparent-functors) + std::unordered_map vars; + vars["manual_rotation"] = "0"; + config::apply_config(std::move(vars)); + + EXPECT_EQ(config::video.manual_rotation, 0); +} diff --git a/vite.config.js b/vite.config.js index c430a437134..340c056c106 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,9 @@ import { ViteEjsPlugin } from "vite-plugin-ejs"; import { codecovVitePlugin } from "@codecov/vite-plugin"; import vue from '@vitejs/plugin-vue' import process from 'process' +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); /** * Before actually building the pages with Vite, we do an intermediate build step using ejs @@ -77,4 +80,4 @@ export default defineConfig({ }, }, }, -}) +});