From 767aaac83bfb8bbf60fef7605361aedbbdaaee02 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 4 Nov 2022 13:08:29 +1300 Subject: [PATCH] refactored video hw testing removed obsolete plugins --- Apprise/Apprise.csproj | 4 +- AudioNodes/AudioNodes.csproj | 4 +- BasicNodes/BasicNodes.csproj | 4 +- ChecksumNodes/ChecksumNodes.csproj | 4 +- CollectionNodes/CollectionNodes.csproj | 4 +- ComicNodes/ComicNodes.csproj | 4 +- DiscordNodes/DiscordNodes.csproj | 4 +- EmailNodes/EmailNodes.csproj | 4 +- Emby/Emby.csproj | 4 +- FileFlows.Plugin.dll | Bin 124416 -> 127488 bytes FileFlows.Plugin.pdb | Bin 28632 -> 30516 bytes FileFlowsPlugins.sln | 12 - Gotify/Gotify.csproj | 4 +- ImageNodes/ImageNodes.csproj | 4 +- MetaNodes/MetaNodes.csproj | 4 +- MusicNodes/ExtensionMethods.cs | 17 - MusicNodes/InputNodes/MusicFile.cs | 66 ---- MusicNodes/MusicInfo.cs | 21 -- MusicNodes/MusicInfoHelper.cs | 328 ------------------ MusicNodes/MusicNodes.csproj | 53 --- MusicNodes/MusicNodes.en.json | 83 ----- MusicNodes/Nodes/AudioFileNormalization.cs | 116 ------- MusicNodes/Nodes/ConvertNode.cs | 314 ----------------- MusicNodes/Nodes/MusicNode.cs | 110 ------ MusicNodes/Plugin.cs | 15 - .../Tests/AudioFileNormalizationTests.cs | 82 ----- MusicNodes/Tests/ConvertTests.cs | 153 -------- MusicNodes/Tests/MusicInfoTests.cs | 92 ----- MusicNodes/Tests/TestLogger.cs | 61 ---- Plex/Plex.csproj | 4 +- VideoLegacyNodes/ExtensionMethods.cs | 64 ---- VideoLegacyNodes/FFMpegEncoder.cs | 235 ------------- VideoLegacyNodes/GlobalUsings.cs | 9 - .../CanUseHardwareEncodingChecker.cs | 124 ------- .../LogicalNodes/DetectBlackBars.cs | 157 --------- VideoLegacyNodes/LogicalNodes/VideoCodec.cs | 48 --- VideoLegacyNodes/Plugin.cs | 15 - VideoLegacyNodes/ResolutionHelper.cs | 43 --- VideoLegacyNodes/VideoInfo.cs | 113 ------ VideoLegacyNodes/VideoInfoHelper.cs | 322 ----------------- VideoLegacyNodes/VideoLegacyNodes.csproj | 44 --- VideoLegacyNodes/VideoLegacyNodes.en.json | 254 -------------- VideoLegacyNodes/VideoNodes/AudioAddTrack.cs | 197 ----------- .../VideoNodes/AudioAdjustVolume.cs | 74 ---- .../VideoNodes/AudioNormalization.cs | 187 ---------- .../VideoNodes/AudioTrackRemover.cs | 85 ----- .../VideoNodes/AudioTrackReorder.cs | 141 -------- .../VideoNodes/AudioTrackSetLanguage.cs | 65 ---- VideoLegacyNodes/VideoNodes/AutoChapters.cs | 125 ------- .../VideoNodes/ComskipChapters.cs | 99 ------ VideoLegacyNodes/VideoNodes/EncodingNode.cs | 239 ------------- VideoLegacyNodes/VideoNodes/FFMPEG.cs | 71 ---- VideoLegacyNodes/VideoNodes/Remux.cs | 60 ---- .../VideoNodes/SubtitleLanguageRemover.cs | 85 ----- .../VideoNodes/SubtitleRemover.cs | 118 ------- VideoLegacyNodes/VideoNodes/VideoEncode.cs | 172 --------- VideoLegacyNodes/VideoNodes/VideoNode.cs | 164 --------- VideoLegacyNodes/VideoNodes/VideoScaler.cs | 142 -------- VideoLegacyNodes/VideoNodes/Video_H265_AC3.cs | 140 -------- .../FfmpegBuilderExecutor.cs | 9 +- VideoNodes/Helpers/VaapiHelper.cs | 11 + .../LogicalNodes/CanUseHardwareEncoding.cs | 61 +++- .../FfmpegBuilder_MetadataTests.cs | 33 ++ VideoNodes/Tests/_TestBase.cs | 19 +- VideoNodes/VideoInfoHelper.cs | 4 + VideoNodes/VideoNodes.csproj | Bin 4284 -> 2141 bytes VideoNodes/VideoNodes/EncodingNode.cs | 6 +- VideoNodes/test.settings.json | 5 - 68 files changed, 149 insertions(+), 5166 deletions(-) delete mode 100644 MusicNodes/ExtensionMethods.cs delete mode 100644 MusicNodes/InputNodes/MusicFile.cs delete mode 100644 MusicNodes/MusicInfo.cs delete mode 100644 MusicNodes/MusicInfoHelper.cs delete mode 100644 MusicNodes/MusicNodes.csproj delete mode 100644 MusicNodes/MusicNodes.en.json delete mode 100644 MusicNodes/Nodes/AudioFileNormalization.cs delete mode 100644 MusicNodes/Nodes/ConvertNode.cs delete mode 100644 MusicNodes/Nodes/MusicNode.cs delete mode 100644 MusicNodes/Plugin.cs delete mode 100644 MusicNodes/Tests/AudioFileNormalizationTests.cs delete mode 100644 MusicNodes/Tests/ConvertTests.cs delete mode 100644 MusicNodes/Tests/MusicInfoTests.cs delete mode 100644 MusicNodes/Tests/TestLogger.cs delete mode 100644 VideoLegacyNodes/ExtensionMethods.cs delete mode 100644 VideoLegacyNodes/FFMpegEncoder.cs delete mode 100644 VideoLegacyNodes/GlobalUsings.cs delete mode 100644 VideoLegacyNodes/LogicalNodes/CanUseHardwareEncodingChecker.cs delete mode 100644 VideoLegacyNodes/LogicalNodes/DetectBlackBars.cs delete mode 100644 VideoLegacyNodes/LogicalNodes/VideoCodec.cs delete mode 100644 VideoLegacyNodes/Plugin.cs delete mode 100644 VideoLegacyNodes/ResolutionHelper.cs delete mode 100644 VideoLegacyNodes/VideoInfo.cs delete mode 100644 VideoLegacyNodes/VideoInfoHelper.cs delete mode 100644 VideoLegacyNodes/VideoLegacyNodes.csproj delete mode 100644 VideoLegacyNodes/VideoLegacyNodes.en.json delete mode 100644 VideoLegacyNodes/VideoNodes/AudioAddTrack.cs delete mode 100644 VideoLegacyNodes/VideoNodes/AudioAdjustVolume.cs delete mode 100644 VideoLegacyNodes/VideoNodes/AudioNormalization.cs delete mode 100644 VideoLegacyNodes/VideoNodes/AudioTrackRemover.cs delete mode 100644 VideoLegacyNodes/VideoNodes/AudioTrackReorder.cs delete mode 100644 VideoLegacyNodes/VideoNodes/AudioTrackSetLanguage.cs delete mode 100644 VideoLegacyNodes/VideoNodes/AutoChapters.cs delete mode 100644 VideoLegacyNodes/VideoNodes/ComskipChapters.cs delete mode 100644 VideoLegacyNodes/VideoNodes/EncodingNode.cs delete mode 100644 VideoLegacyNodes/VideoNodes/FFMPEG.cs delete mode 100644 VideoLegacyNodes/VideoNodes/Remux.cs delete mode 100644 VideoLegacyNodes/VideoNodes/SubtitleLanguageRemover.cs delete mode 100644 VideoLegacyNodes/VideoNodes/SubtitleRemover.cs delete mode 100644 VideoLegacyNodes/VideoNodes/VideoEncode.cs delete mode 100644 VideoLegacyNodes/VideoNodes/VideoNode.cs delete mode 100644 VideoLegacyNodes/VideoNodes/VideoScaler.cs delete mode 100644 VideoLegacyNodes/VideoNodes/Video_H265_AC3.cs create mode 100644 VideoNodes/Helpers/VaapiHelper.cs delete mode 100644 VideoNodes/test.settings.json diff --git a/Apprise/Apprise.csproj b/Apprise/Apprise.csproj index aa5af76c..64692850 100644 --- a/Apprise/Apprise.csproj +++ b/Apprise/Apprise.csproj @@ -5,8 +5,8 @@ enable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/AudioNodes/AudioNodes.csproj b/AudioNodes/AudioNodes.csproj index dd4eb51b..1bd06fcd 100644 --- a/AudioNodes/AudioNodes.csproj +++ b/AudioNodes/AudioNodes.csproj @@ -6,8 +6,8 @@ enable true true - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/BasicNodes/BasicNodes.csproj b/BasicNodes/BasicNodes.csproj index 55f1cfef..cb2d0cc0 100644 --- a/BasicNodes/BasicNodes.csproj +++ b/BasicNodes/BasicNodes.csproj @@ -4,8 +4,8 @@ net6.0 enable enable - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true FileFlows John Andrews diff --git a/ChecksumNodes/ChecksumNodes.csproj b/ChecksumNodes/ChecksumNodes.csproj index 3c1a413f..349952fb 100644 --- a/ChecksumNodes/ChecksumNodes.csproj +++ b/ChecksumNodes/ChecksumNodes.csproj @@ -5,8 +5,8 @@ enable true true - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true FileFlows John Andrews diff --git a/CollectionNodes/CollectionNodes.csproj b/CollectionNodes/CollectionNodes.csproj index 376d2536..0a3caba1 100644 --- a/CollectionNodes/CollectionNodes.csproj +++ b/CollectionNodes/CollectionNodes.csproj @@ -4,8 +4,8 @@ net6.0 enable enable - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/ComicNodes/ComicNodes.csproj b/ComicNodes/ComicNodes.csproj index 36097bdb..03676312 100644 --- a/ComicNodes/ComicNodes.csproj +++ b/ComicNodes/ComicNodes.csproj @@ -5,8 +5,8 @@ enable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/DiscordNodes/DiscordNodes.csproj b/DiscordNodes/DiscordNodes.csproj index d548fadf..bbeadc67 100644 --- a/DiscordNodes/DiscordNodes.csproj +++ b/DiscordNodes/DiscordNodes.csproj @@ -5,8 +5,8 @@ enable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/EmailNodes/EmailNodes.csproj b/EmailNodes/EmailNodes.csproj index 6b43c5ec..6574778f 100644 --- a/EmailNodes/EmailNodes.csproj +++ b/EmailNodes/EmailNodes.csproj @@ -9,8 +9,8 @@ true true true - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true FileFlows John Andrews diff --git a/Emby/Emby.csproj b/Emby/Emby.csproj index 32c2e56b..0ba73565 100644 --- a/Emby/Emby.csproj +++ b/Emby/Emby.csproj @@ -5,8 +5,8 @@ enable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/FileFlows.Plugin.dll b/FileFlows.Plugin.dll index 780c4a66fc01071be5913ace2e2b0a9ee9796a1a..43071669648e5fdb60c4820117c77297d1e589cc 100644 GIT binary patch literal 127488 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 PeYV<7ImUl%zskT1y?8tw literal 124416 zcmc${4VV>n`8!0w`m2rE%UL_|bHL zh=_=Y1Q7`m;zgoFksuO+NEDSA!X*fVAR$DFA;fD4LB8`=pXxfxk2i_;`@VhHp7)%t z_dTbotGcVJdzQXKo=|?JRDga@o>b}$`ozyAdi%|rNwhnw{LL)&T4ZbG8@{WyR$lkr zG4&O9rIMplBgR#X9C7EJ$$Khp8C8)Qe`m#*J1Z{hH=tr%^43x9Gc%*DS^AouN?qmi ztCQ_Id}gBkPPME^_noWM9-mS{*ZYru60QJtu*uPwAo9kK`BayXQx{QGubZc$DE`?` zHsPY5Wu)6rBS+nVhB)=7!ciIId;hpkU3cofZ?Y?t3XA<=vab+!`+G*+e-G^4Trbp> z^ESmQl)AZnsy;Q69Byu0;onhN@pFk%m$Xlfx+6&ik*lJdwbrMnmAh2XG~9)Dr61(x zs5I5$Q>9)h@~NgwUF2Wdv~&^$N<~$>TNpS+GryyhFJ9<>u8~9o?JhPu{HNKdUOT1o z&UI7~MWf;|r*dMQ5HvHHa71Q}V%hiX3+( zvk0nqDjhh-#gyD#$y^tA0xop%MZo#MFT{&jqX&GfF$kD7Mgy}(BQR@FXY(|gvBnJe zSYshDYb*n1jSawBwVO~GM z8zy*T1&@xG$7{j7se<>a;2jXWYRB?Qnb#3M&a)>lw~O|3o`VFhjCsQauU_!9pLx?f zUOY7mn2&3Dghl2Gkwrpe1u%LtKj z^;%$#-W}LP&tPJ2K^!WG;{MvdAnrIr;)%j=lnzqpt@x(X*JiSrCs1 zVoAUf`vV(dHWLTK$r0}Z=7_Teafu}6FmZ(-t`)=&1#!PejHf;WHua6M$PqX>dYd#G zeJC*3w;tG}oy)`qIGH$I5a$WvDoM;^;u=BRC5R^ku{*MrZWhGtg7~E*n)BufLClWWh?T%x$2wqcsP4d~q0C9N7o42- zU|>#rEHH~q1UB_GXVGbbxJnRr3u0+h5X-rD#o^?L{eU@QBQQr?E{PRPTqTI>1#ycY z?(&H7)P7)7-%1ua1Sdy72F%ej({1!hU~aUwz(%Aci<|=|i(CZEBHGVY=;MjJ5HD}V z!~udhLJ*U{Oq}Tv&w!0coJEen$s(}~!lnwq zEOIxnIqp?VoB$^i7YX7`WW6Eik9u0obISV3E#na`axnoc2&) zPJ2ACNxLl*8wGKuATAWdbsjOE+5~K(w_}m_;N^)6TJfyHwxm% zf~fsW-0urEQs3A#5t09J`)!R;yOXx z4a~$79x@?-HAog;pFH=z}#r9fLWv)uu1y@CiW7<{(?AE5bGteGZPyGafTo+ z62vtgF`n80Y|`$+BAemlw08h=+MfY)+S$2;P1OEkSc2^eJ1}8_~2h7pG1m@_WJiH~A*8_9(t-z+f zz}l#Qh${p!QfMO% z12z#aXW}S0IpRcMjyOXQUzNmOOk697?+M~AK|Jaaw6Zw6_84=<(D}U?b9(MfSqUB3}Tr$Vp(+yNU^$DqPLPws11B z4w#AkfSH)|i1E~Yz$SV>7HNc&MP>oB$P!={Sr2TYU&F-Bg1AEv4+`Q5k4V?A<|JaG z_h*rGI5~PLFh_3-%+b36o9Ndvv6mqB7sR20*dU1mm^eic7YpJBLEI~e*D>*+Abur? zp%R;F8(@>_Kqj68C)cqDFxPRAAT~(i^(Iw8oGXaS1aXTb4r1aqK|CyonJsLpwZJ;n zc&ZbyIW#x0NLM&H?LNSq_6T4;G!ubYWE!v$8O$Ox;bf6Tz$~%~m_KVXhHR1n8W;*CrkFNjkGah@P9_lWV-DqvIJVJz}GoE&`% zFh}1G%=JA8%#HR1uo1b5MZScSMY@&|W|8T@EV2RE)O|P;x4_B7%yLT{4$Q=bz=n7; z6PLot#3O>(v%(Um0UP27CeDJBiSvOu)g^+s&Lh%&7O<)BEiCdLoGfw>m_^bm33J+& zz$W@gCbor>iT#0@c%L9HlEhn?xJ(e&3gT8lJRpgqn0Qza^IF=7-GMpPiNGe++n6{F zPEK`>ATANa)gCdPS_f3O>*hC-8#0i49L=ZO!;&w^AgNeHZ@e4up zwYCvk0h@^9m{<*`>0Q8_YJ(uolf*ljxJVFR7R2>}_@PIPr*;6FhDw^W;pFI_0dw?Y zz}!$pal$6;yO>xGCljlInb;Yai323@ZYB;D#4&={D2Q`CVm!4F*rc6ektJ|)^p(II zUHduuT2F-5%IcZ8Q4l{9#NsNO_CR1$-+P!i3{H+X0hlAs62zA!aXb@O3*rVr{7?`N zOX9su{8A9lX=5YS19Kf00-IFtW8zXcIn`ByxJ3{Tdc=6@3t-d2_nYW&vWPE1nCqJj z%+afXP4o#&>|a6QWS2e!N5!$53Cz1o@xX(^?i^|2R1o8WOX(PogG5w5HRZ;2j-UaR|}mfR_EKSi&i{rbt2H` zdc}abO^SgzpANvBPaUwS7d>+~ZQBig*69n(I@-@VLp_}r;uVir?nuEMC%E?kGk31! zPP5#Fg8Q=It`po3JuY1!fKC42w>rDw=ll-?bNv8F-0c>)hcOA^JSOP!iuo{?i*bK}$>;yJBJZ8E3 z1ow#G`f4pV518Ab1lZ&-)9O^h&pGr1<{YL1a}Jw;O%9J+?pDFwF1Wh|SNjcjmgRma zxc>GwZUHdItp(=%I{};gpZGu0nQe8tLZ92=K45N#mw`E-ZNR23Pg?Fy!Oc9!a(e?a zcQml!&avDi{2X_-;I0+i9g_Q$SlY(1*u8msgwAEP#KkK{-%sT6UP5%Mr_&IJVFvmRynBxxgxbf65VABqZtZAB$GNM(0(l(-wZ#=?cs`eSleK zIIwAl*DQCm;EosEDS|uC4%#a@${H!w-n000Wa}LXaO%88c?zegOvVz`0_xhE{VXeqvlgMGO%;D#j zdr)wX2<~yg&AZ6tA5WD4n|9b}bt>WKcIX7m?a&*T+hI5`AJfsmM&}n+XB_;jGai_A zw4ZgfpLH6g&L*ofMd-{II;(+M=R;uD*#T_wdB^JPhM)5}2F&^7T}+sDs)5b%-E6rX z;b(3yVCHH+bBB7|cxoiD$>CkAGe+o47CPF`I`f6jVyQ#lOECRpnb3Jv=xhXLo&CV3 zF2A(g&jfcwH(QtWz|7qN%Dv=y$^oo&J^4w zg1gD%##38?O%7YF&d2bxj=wu$&Y?3f=g2c$!6Tl{i9ahJGna!abm~*HDW}TtH z=9vDs<&G5GM!}scxUWm@PRm^{xH|;*u;8Zm6mfrRxq0w&yHx^n&gTGg&OIb|m*w^m z+`)o7T5zXI?kAQzQ*f6G?s~!9>2c$!eZZ#8cUzr<@N@o0fjR$^z?^@{<%CU}@3Gug z@H4j~Fmtuv)ZOF8Q+{T`RzyahHn z?6W%GY7OW2R_6%xxlK+2bDL!MBFuHE1?KVC3E1Sb-|BRQpLKcyvyS$&PCrkF#^a}! zJ4kRx32r?wbEkXUcxpDV$>D(2nFl}TumYHKSP#rPJAut{`Ge)|6WqgsdrWW(t`OXV zmRkxx$L$WxaYq7k+hv0tYapS3zz^3kptWH{Qn?o@$=THmGIa~y6 z>i#Fo?J2ke1b2ksPV~6()HGm|!)I1!Cj8tEOMy9uSAjW)ZNS`@b^;rn&#lg0_*v%* zVAeSa%sK^E5@wxJV54)`>a>ENbvgmFPA_2A83SzEY{~t?a_0-~ z62V;o%v|l~{5QdG^8bs~c~9u<5IXyX&Pia_3H1>=N32dJ{M-&Dz}yaPfjOUUz^u~? z*yQt9tJ4>L))@}WI^%#@XBx0+&!d(*OK=wo?h3)(BDsIF+--t;L~!%2vT=I>o48+E zZa?@r=Mli1bG_hBlidHY+*yLVLU1<=?orA8%5skjZlte`TLjE))e+d_{CCUk3P0!E zS8#_5?qrWkUqJ;n$LpBYnFT-VECA-ZF9+rv)&ZLw{$aVB1b4gO?i1Xj9+%#21~xey zw>rM7$q~EZ!Ef3PU&iNkr*1;0kI)$;bVdQQ z&NyJBfH|Mpz?{z#VAfdyY;+u}vkHFJ*$B)!+ksiI7{G7u8V9r7NS!bls87p=f44%bJz`Ra)?^)0l_^fxW(66ZVzB?!#=ns6gomYWb=VM?~mrTpuCAbF#_o(1z4-j06V~%4H{2aG8 zFvpz$%yDN+Znovl7u;onyIOERmR!;{ad!#s5y1^zXXBOwbGx+xHtiO(I<@d~{@s8% z{{g_9|5#wtZsapL)C=xp!JQ?z3ne$ta+eD3I>G%=aKG@l^sX1M$)7$=4kzH}{IdrV z=KQsv^RI;8R9ZQ*Bbo#6HXX6`7-B@GjIoZwCtTT#rld@Bo|q>2HP@oh3qN zwb0oD%sPjFP5#X+_lV%05ZuW1mKz7w+<2-M*yK=bbvnY&?JxqE+i(sr=dc~v|kH(=Ho3T*1q)^bM*?s&nS zCb-Kax1Hs#6x?lsdqi+!H;TB`mRk%z=iCOEbM6GpIS-KB8p|CjxJkin6x=x;H=bGm zY}&2X>MVwzb9fn;b9fz?bNCq8v|D@2{Z{ARbF9v{I(MCGb#{rm_=efK6a#Zzx&oWJ zbg?2F0eYIMGn(M4oiSJhxIat&X&7ba6cB@-GX~ka=Tb=Xt=F= zH8A(vzQ7!J7_e!#I?Ej;xJw0hx8PRaEVvh1Zb$gJ&3glL+~L3+ce3PmwcP1~J6CX* z3hp|OOW%3}He>cXR_8tVIftFVoWnt2&LM3CVO{rlDihe~Tx50f;AfoU0-6 zgM`lAz^pS9Sm#6Q@ZBwUuHY^aTYL81_xC1tIxzy@x6gsZTMo=|>wrzWU2eJG<~vFi z^mhWye4x9?VU);Wy2xR^mjk_14Q$%~3ahgWes06}fVnP*fw?Znfw@omM-etUy{%3J ze%2`fW}Ql4*69dr+T=>h?JBta1ve?UGbFc<<<1e@WrDj_aJP9}`V#@bCjYCf&R+OA z|3ko>zxH$fM?D=H3wVCE5c7UJb4iMZ4g1b<1>F+|CKCo19 zHw*3|!96Lt*H~_7w2fN~%=N7X=A3&2^KtJFY}&2A)fogo=RXFRbsB(KXCbhu`?Z$4 zRB&Gx+%1B8Kyn9I?qR__Cb((ewQ(zfP2B4&w=Mjfb0=WVxx3&FliY!pJ4$e83hwKI z`=Q6B?-l`@cDvr{?1rCn_zakHI0nq^Ry2k%w|_aX(HUfQ;_$Oh2VmB@2$*#S1GCNu zV54(`)fo*x>(m3Y&O~6=SpdvBOMs2eV5_qne%4tH%sLx@S!Xw})}e7U#BvV^?pK1F zcDv;k19J|Qz$S;GR;La8oWlrU&S4HP=dcXet?dSZn$C2OUKhkoG;Ad_dVCHrN zW^O;pz14CD3GQ6M-7C0XN$x1i{Z`)%yv^#I6gjlL)8;<_nDbuHy$zh!3_JW_e69jjS;O>yzJ1uvw;2sv-Jc^EL~JW+7hNbWtByFqZb3GRNuJ?U}dsZc%Xn|2#-bu!`S97=#W zhiYK1dtYFa|GkzwP;f^J?tOwg!{f$NbAU|__x&H~+;4T}L!axi6qxI>3YhD%6`1?d zc3_jw1go<{TCPn;agn+$Dm$QgGJ_?hcO|PwfRZIW$_G1MqVWM}SQ`j3>-F6abqX9<9SN zj=R_6##0A@O}jm0bq))iV?sy!Stsp2k^dCS&4i!xF9qgy(|+bwOYXy#+fi_P3hp3a z=H4y2Q!RIb;7%3Xd4l`0qWf)qy8&~~6M)UJ{=Vf- zhM(JNx!@iY+~blv-E#dCEVmMv+qoAo#~lr9;y!A*Nx_{UxKjjok>t*>++~8hT5vZC z?mo$V%yJJ2?lHm5{+`Xb8d&EXPjv(~{d1<(se_-}Z749;eF8AoeL1kH`{S0oN^tiI zZmhv_2LK!HEXy4VKgVqZ=C}(5ce~_1VY#~n_cOsgA-H)Dn7H(vAYfDX+5bm6PgN3$LfrMpLM1Jn>GPvoz=jmJ)g4Nb%MK7aE}UZW~1T8 zQw6{#hq+d#1b)t;4w!Qo1k5>%0X8{2ZMk;~?o`2DB)DrNcb?^L5ZoPtdqi+E9~5z) zvD^aqIpqno4U`pIs@Tnow2~IGZ~n576O~PKWn*51$UL;t`po{ z9+&3uz$S+UR_74>oWobZoI~0~!kj}ZV4XueRSj%(p0hd~;Afrgz^v0Bn04+3Hg#EO zxf29;y5Px=_tovFa4ZI@c^Ou=0!xXT51t;db0 zHUgU*erR>Jz|T2s2j(3119J{1flUs}EI0I!<#qyQ?igU^t^_vRmn`>n!QCjh9}4bg z9ygvk3T*OUZgq~q&-sU@5at{TfH{Ybz$S+uS#DSOncGirM+xpkk4xV{05&mE-P0h|2) z%j%TE&-vE^bN&|rv(5lu))@+HbXHoO5%9Cl6kyg_4$L}hfX%UZ#d0?Y?uUZAPjG!x z1$UL@M&ReTCBPiF7MSC9_qeon1Z?vEiPh-`KkJMIW}T_Ptg{N(tsG+a-jJGu+dpJH2~5A?Y7H+Fzc-QTo2BjGo72WB1ZXPqfRXNJ^SYjtJ|odrT?DKP7-2j*k3 z8QA3WGpqA${&R$ibyjC9^toO;fw^7>fH@!E_X(S0`j+KJ;Ad_LFmr2xncL6f##4iU zO?7O71T#cc$Pj7u-#Pds1>YS#Ic2n{!8C&Uq*> z=bQxQzIPw6IbQEroksY%?z4eeXDKl2Yy>uKzS(l$6WqOmdsJ|XW(e-PmRk-#$E^kC zxLtuc?qH7_PmKUJ`ERj0qv2vB1ooAh>fR_an<)Ah=5ecZJ|; zKezuz_)Y%Xtj-ppvrFh46gtO&P5!^KT>s-X{}?dm9|z{TcLg@wk1e-{;0_erae_NV za(`{PGX!_O;4T;3bsm@AX9G5M-)?o@gP+@fFEHnS6qxgm%p%Nf7y~vszp*++@Uu=E zVAkmjZ2ADOX@?z_J4|rz6WrN?yH;}l+j2Jw?pDFoevZ4#(jfsM{@txg;GxgE{{Hthh+`Sb%e?Xb&o2MO*-!A%P843A6CYk*A-pIDvw z@N*6;fjNgwz?{QAV6Mv{V576!>U;q|>zo8;9qnhG?AcbQ2-xWCu{x#jvrb!J*69e$ zIz50*o9wmRK7xC<;LaD^HIn-~%iSQj9}DgQ!Sz2W;_kEDboe>vB4Ey0`#I-UlKXqh ztrpzQg4+X_xx*!QzvX_L|8$_@Q>!yt%gYIf3@88g1c34wV%1WJT6_EfK8hpwL0JCziq1co7Fia@`=o|b!h|4 z`Sb)fb@|eA`@+x1Zjj)P65NR%m)6ICOdb_nb>;zcdoBj%9M%Gx9KN#Lje`4y z;I?_ja)$vM?%yqU6#N`F3CwXP2<|k?opc`lm-@VO{pxo5ZoNbQM}2)W`hVl2%1Jb% z|D--6T*Lndro*BR3p!j0$E|$aag)icZWDCqKlW15#yOf?;X{e;`%8rMa)nXIhf3(!tvTbRep6^B8^<}TBpiMuT~+-^GxOQ{>}0Q zXD#2-NwlCORl72$p(7fmrQ>hZfE?!=2LoHQ6ZS+4+ePz1yZ{z`cE;y(E;0yVEM*B`D$ezZkE-9&8>aN7c`XfQufL7%nh!DJg2`62Ne_jrr|59(gstf+;l?UQtHf`wl@(O^xWD7`vR zsN1`FB2FY~1zXoHs1=$;1FB}7acDq}3ynjAubl)%3+mofTjL~plTn-NI_s;DYa7~B z=ddT?dD7grA;GRjPy=#&$2c@7a}u>AYU)%%bqbQ8<}8L1H3J~fBBVxlb_%xDnDpE_er#7PXI=FgxrMCxVQ?Idm_Gc(u!bs-HpU$UBt zC2Fvt!x_z}R$&~d{w9hBcnkI;Z}`-zjdg%FJ` z>ibdZBO~FjMz0wKgnm^*b=$$^ouWqc2uF|L#!DVgy|6lrM)WsP0s4=Bl(ZIvzNll&Svt&+5!E z3!8+|ZAk}Sh4pzySC$$+qf-6x-Ybg8pI%La#b2L59|)(`QAa7F|9e42ADm0g60a?) zdhjU%Uk6*@l0xG;8lQwc2?rOJJ)tU9czgODx>mULX{NdkP$9QIj_N@AktT5i{`Be! zCqW_61L)KWN4D$mC#iX(`b0=P^qI5rY4kSb!_kyqa&Aa{L?`!XllW;}){~D8ojx(9 zhw3`{$l~g|eT>#Ls1d^YJQ=SIR;6~6Nq_#Hl<1W?y^C5pddjZEcpO%z0sW9VQ)Z-Y zyN*-T==MzNF+ttPdEAxhPo9xhERgI(Vvki|L~@Lx`JO*7EjKMe7r5wSR6(ym-6;h+ zpITtpDFx`-(Nv+)EkGmU)Oyfgymt4UwT?=4srTzfqdG1BJI&hbTJ?=f^G=ZHZ$|e!8+z z)_M+f_5)Al=<;=H?M^xJd7)USu0M?qMWa|8d)-)J(^$ihTln;2Ki>M!eC+3)QsA^> zzxI>@rycuUZUMUPHtlv_S^t?smoCsbgQyFf7P{vt1x^b+#w~Ehqcz*zcg8OA`Wf~$ z=^{tn0%z8MOc=3s$0JnothcXwQcHQX$j-MF)uA%eUFX=apu(^(4cd*HM z&#BI$D;xE}U|}MOo$X4_^{G5lPWcmbIgLj51nGPp-GkYkdp`Hk30VbQ-7r;!t`}C9 zCK+j6j?oF!pSTokCqZX0x_$6hRW~o92Z_oTFHDW13i|atPiZ{Pp*F#F1ZR_)mMo<2^SE`2P3ush9Eaxkd2TTG zASY;Ms5@IY7OuIdzLc)r=yy)zJd#xebYhK9lcl3XW6_!d@_!vH!)3lVDngTyD0LW~ z5P7pGchclf=$Q>w57%j$(x?pGFA_Ai=|W6{07sW58lBZgbks{Q=ZbzrXHTC-i3&2Q z`()}_(^8bksIH)?X#rp)qdMPVC7K^cc|T21DGugI)D^gYpB9g9Y;+2NBu%8Fin_hA zi9~mr#1E=g)Q>YM-}-W@Z8Yd_e1;k#=+H@kTF0HM>5dRC%1i5lg@RxMO6We3E_y<_ z*Av#z5p)a54iI$McRNYwAZH{WIy2c0l*VU~Ujw$f8x7=y^cZoErMZE}sB!xndQMx} z-}1d|OMjs5;%<)`(hWLI<}PJXSI^Zz_xaZ}wvhylpvDE1qpM&?PzJ3?RG?qctxPmQ zw=Z;Gd?U>p^CF^M^QaFdagxqUkEPc{$iTHakGkqjBwP~;7t-w|&Yu~vjG6-18;gX8 zmfw2&kg>`BR92Orm!O-JD9t&O1LzaOCo`5=J2%*XtYBuvvTD)_^U|v0u0a0R{=Dp1 zc1_GRTPpSLrLGpWOSOunp}&OGD4OqNp}$n>{t{?>4#&f3T!;^U;}7tW*7!VqnBIfI zPQ548rJAV029(gZmCRcVh7F;X!DxeMW4LMbdlWj^5SNR>!?g_dMdVvZY~F+WpmR*3A(8RNOcxo)-=0% zid{I-nscmMoeU(U>!p(d3bR8eg#-NWB^5qPQaZ@LmXz+TnL#n;7ExZo25ixbYOdyk zp~swCUk%-Xf{t~TgVSWNg}OBN1{<>DhSL`abi{v6<2O9m7JCYIu;pR>uVs_bMqT+P z47VJ&m37&?SS;oax7=7R4Y!y(-01Xq6NXzjMl)u2yyeC6Y6^M0<-`JK9B{ETZ^Y%t z@@tOxPn*#MqEgaxWZWB8An`*MqH@fwRFz9nR3wA$YA2f7!U2RCJ<0dsovSR z&iLKsgS0MBqb2;w8>o*Z2h%3KdR(qAF@#Wt@`sbSdSp5YI;{l5`l6&y>$Dh3{JgYS zS`FQLx;uwb$tqkev>9=)9;&*GO8WIVm##>97puBcGeyB}BxdcSd$MzVUl-ENZb@<& zIn*bVvYtaZsziNnF49)&Qokz_NXt*GqMT3$>yWonpP=Ocs+T@Cv?hybaC)%TpI;pe zCYs!IWZ(cr%P&mOiKUvRMwhElsyB_dRDaqeZw4=v8r;+oKBWZR=r=7v7jG?_7od}4 zm*tdJED%eZlNVI6VEKjAR=Aj4p+dQO`64P}#exssLNVKC5g19}YabnToThQ2%L|`I z$WYakv2p8LYn}61b#U6VIv#;Cj-?gdfP)wdJU*t|RVPoL45x;p-VlD`CM$(PLX@KR@EhhDbKsd9SM(E8DO1_K~se-f;s8^r!Xr7-> zdh{Dhd>8&)B~}3GJ|L|AoAwvjc(g2&Eff}}g^%L`P~WjmCg2vEh}4O;H)M)a7L~EYZfa)}#t;ooi`c zN8=2uO0?!rN5@_B*Nao|H(6W)zn1uP+NNWq&kLA;6`})S>PvJ!vH0~Vh!*(s^}!`h z3C%xmq4C}fT=&vW#}`w4bOf)aC#a@8MJvqc(vb*mer_JT%1gsPaVoLT^Y&iCol9hN z`5VPm!Qz=z5!{lEry?)Ji&72xvsr2)K8uocUl?^y0IR7uP5{s96F{IiaTj)BM2Ctp zy8IA(gQ+RRYM#6sduU}vn|f%}(7A%1Y?xbJ8V9jpQ7lw6SU)A9DMyeD_wh(xI;L~! zscBjYy`}JLZ=M#6Wfa{^yQ8s;`V^wjRXC8W2f~#yP?WK!+}sgU40$rRgiZ#9?$G1= z*gvS7F7xSi*6W=F-HE$bm#{yOyoUt!BMQ0#xXbz)&@wii*jhw}b}dpz-87G@x^qU$vzpT}*B3G|)&2M1$!eKNcgR_jID} znBhDCFP&o3XuL*)p^kgoQTem+?)$fR)ECbX58iK%XL&N-X}oLQ8tExU$e(OY(O!hFpWET1;qO$_3CL-naTA~k1P$;4wvzXw zzd8Ah*kxCA+_?hoGOEru&jTDq9aS}__OEZJ?>iC`h}*c7h;;9<3?KeR8jOKpLF0?~ zcmTU{RU$~o!!;5O#K83@O|2uX64FwEh_DV3T*S!lz54T0|`-C`ROC3KkI2Aq*4EdJ z)Ozf_nV6VRq&AWzUCP15$fQ6I@P0xECr778r{?H|1L<^6^KW{vN!2m)Z@O*6q&3$` zZH62sj)`ehI!C$hHeh|zQJvLK4!Lu4%)?)%mZA9pwOKTn+Cemb;`>BN?IM_%PAKsx zZRl8(oq2b_opv@Q?`=Y+AnWrW_jsEK@nid6%aMeb$<>v;=&yA(kKTZV`vuKm! zR1ZlTPq$dQG(Pjfx-{nJ`MF_R`U$smP1^X&ximiWqPjHJc5-v0w)AXWItyn>y|NR{ z3Iy~VF~>P`1x*{%iYCo(W0_6fmY@@)dkxcoE^6O14h_gbCq%b|2IQcVq3dA4SJ~zd z(isu&l}yr~%^#%Gp*Hnr^9Si{s7?Ke`CuP9B<1Kk?!#y5BmtU&T$}OpfaeKQO(T?VgVHI?gs?agC9L2zV3~z?{ zlXFO>Cdd8Md)-g~^Jid&@zgaVB^nN#`26Jr3!`IfTMMs6=D7u#Gtds%~R3 zp)g}lPr7FclkPB*QJ>_|Y=B5Lmb8dSZo;V@;78s29U4U@Dv9XD@b2g19HtZvIdu*l zOMLe!ld$=rlb5=TeE42R7s6#U5Ydl1`W4>Ch^fR|rX#k}SrphT_EBiDM(iVs?T&gy zM6Zq?qWFIOeo68XN{|{NnugZENu>&1a2%}%_dk?Ws$3z>Kg!foUnpMEw`Q-NkE zu?e(&0q5TuD#mFjno|Y!lI5vIho4%sFvn4;+2qyl#F(}5G^(tAY`=i^q^Fjjb^oih zCnGn#threMU5}yzDHU4y4Ah47)ucKt^&Z^UyYAG-qKsW7#p%~D?J}h->rdzb#WH%i zG&88rg`zQbka(m?WANC^q*I_BdyzAbz04+K&$Z0hGnPB{jOC8K%%nT^GR4@Vl};Xe z(wiO2=}jJRrBr|c*7a=)S|~vEsF7$8!0Vz z$4eyD`9-hSN6tK6B67S$5rbITr3amPi|;!TE3L=TyC9uIU)h}!W$OQUoe zJMCH&Z89EQ%ZvwOx#PiD?s$muc!-MeKrXdQ}+(5ny==X8*)2f#DUP3Wpa?|`ewT{B14&VUuq?}e~z4dUK zN71zvbE!aLDNe6?!lZAj>5ajnBKHA5?rQ^>b9rTdcxu@IU-GN$EU~@Q%%3=+Ie6c3 zl9~Q#K#s}Ap#eD_G7b&MF~vAEc*{u?AS~WG{1CEEAhAtN{A2oT zMg7ew_HwFUMh7+VN6=_S{}LI=<+O=b-yTfpxq@lY}!CFA9hX7ry& z(SrxN?L3Um?amo!{z+4>D4)w5eWPr4g%Z?vsujJWJ&1}VbD@)@gvcAjVt%yt@jbc0 z@m=X?(_B6jF^`wr``1?L-uYO1QtAb#*fgFalJ_oeffo{ggLb>0oTZ?d3I}LuIL#D( z=PZSbdEV?6jyKElM)T7d@!~8m(4WsjD>(}--aIR4lGN*4m!J{o=PWuczDXxu zk`*#ySYPVtN}Wxs^ncZgx5x?`LDGWemEWvc_OEHiOS2+Y7`oSdvu=6h-w=+MWkroT zwCUQbf6mgT<14f1B{+Sq=$l!k2&vATneU}Q-cO{hKhItDv z%A>{!%AN+$a2d(qC4h_gb%O0+S0k6Aw>+M)mp%cIU@h9<}BZt11Nl(m*a89Hf zWPGhix`z3ie3Oah8d16XVt+E580KE^)ZOL&WbEv_>5*W&_Vp!HVA4}=-sWM9U%xkD zrx(awQDnHsR3Y8bQmo<7;o6213UDo_UmalF8RCSa6wAvH!cImmedqs2yqqo?v<03n^ zenp9&pgyPOV_#saqaYrcX6kro7#gnEwTcCc)p)+>?r?pHY6?adi&JWBBaD#|kRJ2y z&^-PI2t9sZK)ckQ&a?)0s#b+*z0^F?qV*o4n%6`1Jt=*KgsP0WG=8q3V@wx$HG@9W z=o4!-@MANdHqX*;XZCx8gls%d#`oIWaQ~n?cK1caGxv{$wEIl`;|Jt-Ungup5u7WV zb`9;N$-MjI^cGXO@Wn_@+YSKSWLGz)vU_RGVy zhIT9hmBtFL(v@t@6^r$b#xXy zC2Oj6N@oe4xwDwQGK`Kl`pPr(d1_z5-`&IC&pWNJT(k0 zD59U6It_sOcG(res&X*-Q@4*m88_Tgrl5Mz0o5CpEVWo*p9#<%X<^ZqVtvhMO+E57mR7 ziBUN>c~qHeiTFvn_!zC~&SHmtD+fz=SWTkG2DYN2P;xCQ$_E+DP?#~rb!v9KogxJGzvWWuv72Vukq6YR~_A+Ax+&a@1_>e zgSMG>;;JpbTtD9Ot|Ltgo^J}K`p_X_d2};SG*TEEEPCj>uc^7Xp#C-#rR9Qv>u?%SQlIe} zZ-%#!btp3kV#&X*lV2Z99crVSwZ^~D*v)}kpDr6g%%g&hlxx5#o|#8i$#|jD0F!QR z`BEmA3hpsmO_n+Eqx`=8xlJH zW)gN9Ab{2HXmSf}@-lKW62Bx+d%05>kLs5;oLEL9ojC$dUZ&%|zmbufnaHN-YWLd? zG_g02UbeyK*98Ss7pK6GN$-8qYoxO|lF}bK=+{0sxqPz=NjB0&l=8*)LdwTG^SM>? zHLiA5@X<);QYwmV-IG+bXl5ZdBC7O4B8L)8etEtKsltp!l z={kK*Ct;@nd-JlK2k`lIL9?cH%1V5MI?<^Wbwbzi_p(BF}6yakEqZp_^TBh1}30=wM)52-@_Issh`b$Ol} z<>ln&2x=l1E#bKk$hh<^YU9o1QPrg^BA9May<1fmp&|7cxSv9MuM&|r-|hJMy8o&@ z#lgaJ^4+2}8B7@l$|qD30&WENl_aMDm+JIARWydnzjnK*yn5?C8jB=;1%W-4XD<`N z8X&z{NB{Ys{s{&2JEp1yeO^JHGM?W~3F&vA)g1c7#hx0+NBv$6q~Ghww}O6)37a1> z)lS;{j()2MLnkQinDBhFJ9V;ldX7R@I`?%fdg`PqJ|Yg*j-$z6(-ri21wLSD` zL{WOt=q-HEaHFeSAlFCBa(TMn9-&Z919ql658&hL0*c_%LlI2UqOL}V!D*l?U7+D< z+9duP9(K%wqa&R}yfgm{edf2N#hG92xjxg0G}84D(+yq36Z?#E<;^{YxZI@>_g*@%X_ITs4mFY+X8Ga%^g*ir{1M}f~qv{zY1wv zP|`f8_T*tZNpBbGZ9_gh%W|>(A_nf21&5mjRfm??w#&t~tXOYbVH?qVAC8Tpk`Gdp zsOsRn;t#4n=U{KFS?7u%EhqyQ>+O6Wa5N3uOIktyyUl@Hl;ja>YBuou&4H)1c&Tit zs%@^+ER{!@XQ?|X7gc1bzr`1k=3BYosyxK4$^o8OitXYOYzJ!p{W^yUI^viZlqcz& zKP@`kELT;w1ow@SSL1jK8hJiR+gx?L9R7EG&{;{_sQQf$H42nvR0h>6nb4`%wfu|I7YD(7g#q>QyeN5}Bp!R}qJe7a3#VGV& z6~%Oi`WW>tTIk;$tEi~+tUKr^z(SADQM;(e)e#OT9~EAZ)4jMS{e7P_Z5_~77F%y= zD?!#I;@w}?y|_2M#h#`XQ>4pUbf=SStqN-1L%L)w^?CH}1GKLwORDl0(TtylaaQ?KVN+yA=EGQY*A|Qs+5At{4 zh33qmui_#4V`RYEsN+4R9Bqx|l74NS(EDbpptk;|_dQN+3*LJ%s64Ks+S{hU7gQ^~Z?4|=f@-5J^tImVMLI(x-sd{Po2u4VrM_RT z=nsuNQ%GN443_&tq3b8b;!)Z@r?-_Yfq&%3c5Z8IkEdfhL*u{a0KZ#|?Zf0T3U9`< z$df3-wx$)fZT#52mx1m3+S5#LJ66H-VivZ4rYA+$Pg<|JKP?8Hm5J?J1=xPK8MfM2_@9nPjca*_%x#o7%Q6^DzliL^ZT-rK-2vw?kgHhTxL_@Tlqqi3avG;Hqw&@z5A8JLc z>-F|0;VQ!2BJF6qIoh7K!RUFk9Uj5h0C(R}x;5el2a^ z3qk*vVc;J}vHh<0-wpE!ZIWX-!+IH33>vOL4 zTHxw$yIT&Qlkt!_G6ILRF&{ zQXBp-27h7248q~&7JuCm?Yv#Lw)8)lfimviu30G4UY9{z1=W|WL$qBG!**ZEGTO$d z-Jp---p2O|ZBO{d({{Ln8a?cwF3&os#VZclWP|e>;a@v%(DsP)Hf>udlq~mQ`-R>P za<&k@S1UZJw=e0E*ZH=RKi&C_$l+5R{Sf)j_d)qO-=9O*RM5#tJ<_a<{vLu)4fd>K zv_IupUz7EaXJOw{+Im{O)odg2+@E*S{!thR>kB$L-bwolSpjd~NwRR?gP(WOz9frg zSo-Ikw5OWAMDy!6DqzlT*19Yj4lDg)zM4&w?kuuip|x&BXWpNcZlu4o^gFiZXKbXo zYR>cSzNKWPt4p-y)+j^ucCGL=&HB>pygyq*n&pr+gsuAv-VbD|TiAN2pdg&7#=4en zmn?OUw%&={meV|(r5<4GtyoWAmYT@c=NWxz7Woic>sxlGeUGyBOKm-=tw+M!vruvo z@B1hNDlf40A?2B+mb3LmSvkdfg{>jQ*!MbHEdt>EOk0nHyOv;I6{Z~O9p7~Y3F6hT zHMXFAI9pxA)}7713goCk+HzYdN8RjNVU^vNu7}@cD>M82aE`j0t-oaUrMb`qwtQJR zWIe=I3Dqc9J*utA;cD8KtLE^&=A@aYKIVP<3qBxg7h8WX=o-#bpK{4gg&(MV^*Qgm zwy-oTaSdVYH>-pP@TippcXyJ>cUpnvOeJ=bqQNn zmt8|vZ?d)5rRzt}e!q$e$@^G=bg{_Y&N02p^EgY5Rs-CSAWh2OXfUVZe z#)eDO!)#sH?4EE7HIuDoEglS)s<~{nZ!s-grhdTIl;&f@oopS9KTFo{*;-Q7(-&8NV(Zna zA%VC$%GRW!zBIEv&ejt}Ib;QvpyeM4k7?1<*G6Tqb#IFyfi|jut>3ro=}V|mw*Jy` zNFbrA*vh1MZB=`=iYZ=O)tRlQ{XKo{R5!Ms_YVoQQ&+HctXWTAwYr8afANq&wHnOU zZIow?8o^dQ}WD>#FP=vL0aT{nmX|d-V}pp9NkFw^#qo*0I2Hvi7lc zZfs@v9Ce7T-m%xo`YT(pmT!m8RsUeCwB;tU{4ct#G{9dT?x51OH97oU{|K`3d0!pv zJ5ROXeV5a|^Hjp!ry9xXsBUmAI`_O6K3`q6RBMveGu%bJ=vpdS`0H?;YVkw8Pu*0M z2wbRcWa~zT|aGX6+1LtOEb# zmK;^OH++e@hpklUAHv<$Yi#9aUG3|kKBXm6Xg;6ydH6Cl@HN-ElU53PsXN$eQ9dHv zOP#yg-Irc|G<=1+jID!N{|NU{uhHrzRL(6)1g=tv*InyH%B-)tj;*l;zR1<;MYcj| zeN{j83$}h(9**==Z@l61uA#d3Q-5dcj?ApcHR}8|?!Fr{^CH)(*WPrk_xyhdU#EWQ zTKb%Pz4{efd#KH>SG(BSMLlGY+RxU0>LG*F=WN}U)0gi2{>D}^Cx@&P+ImNQRM=My zR#|IxJbfh@3`jrkWJ1$gxFT3z=sb!p4K zYKS_Qt!rA+J*=u@>-pBjk)f(PTdP`^k#!|oyIXhn-Kef*>rm@7b)y=})@@aN)i5=Z zt%j-`vhHv#U89@Sc;1K1Zc=#r1Lp~3c9VL9Eo3%aJ;P} zLS`e>D{LXN5o!%v`&#w%-J&+I^?9oyfm_rs*+SMM)vwq>)+5y?Y?a1aM{ZU5Sl**N z&n0V=x{9q7S+}W&*?Neq(Q1vhrl`x~bY)Z@ur-&gF>1eCQn$qI>T}Pkj@+&Sv^0r+ zjqz}Yx{U}OC(@`MclU)?(NTF&J0`DZ1GZLy27_kFmpUn@ls@_s(ByQ zZ1s=$M%rGdt*6z&%vF&m)g-oNWW5=gt5#^sZQW{>i(v>hb`3oP1VR&4$Yd^s;O+X zhz$v>Rgb$C#ru2YXX+K+S6+ECvR=KxR&`}0x?cUlE14gCTRlb(b2 zFVycnD-qqS^662VTk_oKd#aCT)kQy2-}kH@(ch?Fd)C#_-3l*~ySyRM18S0IjgJ0V zed1Z;qkmVI{0b!}tD7t79?18ewjK%JTTvVd_&#Q9YDF1YpSTv)_Tgxn?}%rmsgUn$ zF1aOVMl|dTeypR1cjP=lRyJFK7TtXjUlChbEz)SktHQNN^XX{RccEvc(Hd7TVP*OT z2rJ7sTv*w@JA{?vo8Vfyhs1o7*+LJA`5yI3{xy>8`+;XIjOP1Z<&rqkg}$|H;Yb(y ze&Ll&Q$@aygw@RViLi=&e-u`8-EqxDbYl_ODRnC?^?D6Zf#t_eX$JZ@0N44gC@Axjv zTosLb*14HKqras8;!dmC*7vfurl_AQn!)?tX6qu~#%NpLr%g)!MO*uPKO(`lzVH7Y zD*JqOIc>;#R9kNS+WDU1eJI(^*X&bU5>Kt%ADA9UbhcCv^J@+ z+@ie8!Zs(zQLo0}nWX*LI^246b4&gwGvEm~&&lztu37N(EXTGe4(|R!_88;Yt+$cv$#~}rjkrDsjXr;4hF4585E!xVVH|T86UzWi0sD8pKaod*g zf7u!(k-wvA%FzO7hXA!hPEMNoCvnZ*=Sb79+cTgZE3kXro_V(1PWQVRx~=Nsecn-a zRIeiBu$A-!YPmi}Zkf~lZclLcqNHEV)V)Z0+~a<+?mwqz;(7+OLEVA<>NOpAyl#{K zzx-}iJ#_WQZ2S8#`u{J(`0WS*{rc@R*uj?4bOqo`6bzgFj8p_+R(Z^W}fB zOh~=?ZV6| z!nD6RYVO{nryM)?7@cW*rhbC+nCuCNb#|_+aQe2DKI6IDcq{ao3+c>B&x^eCjHCWa zW7e;**QYk=ZEwolr+Vjfroa5yg?=LnbBh>l)99Tb;7Z!k+YhwOQFVITU2l8qZGXKT zthXcdb_{K)?Pyy-_awl}Xo^xhCWz4ev4x4uG;(4M<>$wR*5Rp+TgzMWN_ zXnSqO6}0`R71qzo%7&;TzM-vdrh6wS%vN)9=&84Z^^1J%S)+DV&80hmVYL2s$oE*` zLg*B}ptmon4Jy#=Cp23{Eh3tAj94_EbnegGNM(*zT|t^yH#y{swR)G{>L|;3m&&gy z`hcDyUr@f2o`ASHG%r3v{i9oEkuP7h3v~2F97K#b&u4d~$Hx6jy3=+?5w`Ivnk_pc zY5rI2++9ph51hwooigIPp{h4+Z`Z8HiFL^Le$mymT}7-q=Z%(EsP1~(+xbn)k-q*; zX7L!>`*f+B+g-j6j+^HJYNZ;T)dux$^GTFXx2n0mHEMh24+!sSh73P#*3mb{LFOp) zw(mQX`3JtcopvR^_H9tZX(c~KZSqgr7SfZ6m@i2y^fBKMTGP*0;a0iMWh$qv%DGBq zwZ72FSDzHj^Fh!FP0g*ICE2KnGvLuUoKzs=n zUlvHoa-@(=phZH&%iVzZNPGg0?3sA-*mx(iab6QL_Dm+T$tFI#iL)DThK!xvB%7Bu z_9odRvt*o{n3+u+Ct1&UW6$jWckfsIeck;*mNRG1nR9k2p}OkUty{Nl-Fxd+ef4$! z;MTIzzgs`w_KD=awj*IN{KA&sOrWzaz~OZ12a-SQ`$uixR+s0GwSBOj|4iGLl4EcG zKh^u}$WgU=_u5kX?&Q=uG^c+H`H}T#?6Br&cXHSIx3%9WT^wRuciwqd`wq$E?)G1` zl)1aR{VU;&r1zE3wsk!Dh;Wkdy5{Im`$v+mz3qt7FTU+W`|G7sL+?}i{5H}*947sr zmH$ZcgW9KmB>A=BQ|-4(?MScm*L@YikLkSbBgv=NjwP>`c%JfliPNv!$*0$UsQr=B z^yVLH|5E9DH4ERRaV5GJa$)l?wkK`t?);zHUr&0s{(3vJ|J&`~C9VF=xZ! zD;?iwbeLHz@o)8X>GcxlZ?Bg)6??t(HJvZ~fQ^08_~~y$OFxqQMEhmY{B}!!NAHt; zB)P71(dO<(_~ARgLw)Uh`wuI9=IuY$@$vAT!$05glcvct;pz5YQ~nduE^7b0<5O1q z8LRz_!GG9vZzfdU_QLO>DKT)O79N;MCmt$k1D+{{D{&A!jCE4 zYi*yjw)?H^)7I*+wK{ICPFSn=TB{eW)#>o#YC9EvQt7$y38gdP=aeplUr@Rjt}DG5 zep%_agQ;R{MX8va7*4~M^2`f(fAzck){ z(s=tBl#^jvaTwV$)v-?!R75_~|u zd`a+~rT?IGcL@rQS$@p&S1kRArPnO|grzquecjTpSbDPktE;x^nctVYw<-Ob&Ru#A zdwJC^J?T5%y-VN5_kz+b;iA%ebpK$NZdH9!>7(H{l@7F7`1&m zx4pb-pPunvUUk4IPgpu>@JWMDhV$VU)=q_*a9%U`YX*N!@IPMrL4#incZI*|zG_^3 z+#sK^v@LBp@0V^;fdgM7x)U$yk}R{MFYy={=T zb{lm&{kFH$%NB!dG00Yf?6-8v((CPTd(+a;L4L&2EgjI=(qa4?dP0Qc3d{}FgEq&6`)0URg)aYCON}3vd%U?@VAU7<3V=ehxmcM2B zq{pP!1ANr-qn01D{Fvp-mM>fWisi3Z{+i{lS^kFQZ&?18fWisi3Z{+i{lS^kFQZ&?18xo-IzX$s_)fWisi3Z{;K7#rK$C0 z`5S2p#U$y)-%U`$r4a?uM zGz?l_gR~m4{HW!hwESsHmo2?w@T-=;X8G%uzhU{CmJe@%+K8o3S~~U?D4(|cvZYro zy=L(1mcL>7o0h+2`OzU-m51IIKEC$q&}-qV-B*XcQ97EOT%5jM=X8P($khMTYA;f>z0meVHIAs^tw`c zur05JN7r88!n`bRC4Tt$)&U}-ZPYGr`xw1jN^E&Ke0TV%@X7EC;eXW~@H>-<-k}>N_lD<1?c5!|z9xhlL%*%`FV}rm z=`Z#FzS6&13;fe}{gKk&wDdwBkk78A_IIq+U7K$y|1BGUkF*1SVk7zc4YQ+{eA&`F z4YKvEe=f*#mY%Zon>YQXAkW_UH%fnT%fDCp`B7*(<;U;%n)3H=TGbuGwYQOfc$oCP zmj2sqeS&}T?ezP7>*?$ACh~uK7wNFUKi)>Za|7uG!#Qr8{PtGxzt(<-C>-e@Ryumu zHl;Id^uBDVkGHJ@oWEQLzULkO5gYaWCZ!MFMeWaSeTOjrVk;8+_O}CBsrIk7z>nkn zFGk@P*WIhOYeybXdigGR_}r#Pl>f>IX&8M>>EGT({@vT5?DO@nY}Q`h3bj23&)1IF zJidQ)OszgV0&ln1J)?AB6C66e<2+%K*UthzXup}M(=SoY`vo>ko zfYPq;FZ7&eUHEaOCGEBJE{rr#U(lhqRP>7y`qhTjO7GCFNBcwRYDqe&uffxPOkaW^ z-5gXpEPXBM-NN-sHw&vIJ>95uR5c~t``av<+m-5UR4_f)Q(6k|kQOJ}%WYBqo5D7w zJB6iZKT1o|>K)48r<7kdy+`@`m6l{5JC%PxX-RL=+^hUMmGa%xyOn=fX-Q|Bdz62d z(vs}xLFM-<)xK0%I*nDzx5e{T(346_va?5(e@ba7>=Ra^-xPUF`2)gA!a=2aE+VW% z?_!QAKQ639-%+(s`DcWc=zGca7OQOZkn%^BmUOplT>0ZlOS0K#lz&!fNp^cg`4dV@ zvfpFMKc}=L8-9=S?^RmTZLA69->0;sx9d(SeNpv^ZcRO}{I{rH_q~;t!fDm(8K+V` zw^Mzhz3WTLPpLi$6{RJe-a%llFlZ+Rrw!LS_=P6ZxkirN0pYsRei5j60RvNg&z~YN%)x3lJ1baru@$- zEy)LeNco>vTGGkzKT`gGP+F2_{;=|&Qd-g*O5dsczgAib*EIs&epgxwzoZc);nPY> z;g>anlHOPPCxZNz(o*9JJ|Xe;bT zM_OTDF2FmWr=HF9{x|j73~O(1hE1-j>Pd4m-jC?nYcuS>(G$x?*vzU~gWW4%cc1=t zYgN8qe|z-zfc_qomwTtY;zRm-ST+2+6^7gR{E9vw(z)HF=I~#KO6hlXD*6{;U)%o` zrrSD_|E_Jd{<@QY-}Zd+yX~ivFSVby^2;6HtNom#hC!?#LPxh?(spLI6 zL*uP){o8xsVr6n^u`+S4GBdMpes;DzH}&|`sZ+b}3ws~0+2^a}nWc)AA5nSF?o+Bd zR9W0VU9C(m&Q~u@Okb`%ex_da_~fZm`=@IQGv!PBX3DkN?gy+zEBJm0HG>O*yB{b* z-M!~wz|WVb7Z1)?4_usH9IIWLn}ijC4^Gcb9WKwET`He7?gTnA|7zvn^h_*HR2H9~ zub$T{&N<+|`GrflqKRO4*jugCmSz?oe^8?gdn*^yOi|aWe|!nFgG+Ohr+0@F<=T14 zbz!EmI6Xf%F~3xutO$H`X=bK;W`^wefw`sGN;NHkFW}fD=*sMypP7-IySASzKK6J6fvx%h*m&myUV6am($ek+9r~a{)6(vDhP~zLSxCLpD+H)itMgSr4>{-hW8sL!gOVdX{%Ic<|of9KXI;H)raZc$+=qj<;t=_KH-Fl;xXNs5?2gQmw13$52{^ zS`^{Aa&?-8z1CVaRy|t)J#cZcGFQ`TUr;Aqo}I3(P{|-A@;Agewm=+FM7dm*eaovC z)ull~%h)sk#VvD-1#PrgOw(0)<_(a{qzf9mM=z)tuRX7zZvH|+)w3R-weUD02Q|0Y zD*U>2kJpaQ7t}|#iKJRvpO~Jl%r6yG9GYL8KR7dgp|#0)?eO&6(#6(_)I-#x=~fIW zb_}&!E00&_rM8QgT5HnDUJ=29#{+Xy3LMH)C+5kqM5arMEt@f4utx||7e%1qxFY-u ziaSI_5&vW*NT3RBk5!*Pr(kqq0T5(a2Ws__%zX0vi7J)H&b*@V_SwqI6+B@j)GR^{ z@>i_ItYc~Ze#<+H0J{&18PM)jO>SYZn zpFc47>U4E}4hyP-i8JekS5x1iPUHR{jI*uDY$_meNmCL00u$a)t7)|Luaw(EV< z3xp9to=*y~X!-FuMeqRQb}YP1DJ8H^K1{P1o}RBxUzXb|&pfNhJxoa2gz&~W`erO! zMiQA&XlnT=Rx6+%bW}&L7pTdN#t>eXW;Y*w~t{cj=eC4#JPwSE9v^H5@@NuD$HX)X=$jWkB&h4`z zCAZ8W*WQ31Jup+jJ`4_HS%he$az$$EF``vwCH5OxX%R+J9})JI7Z&9!w5`RgoKqP! z@nlNG^V2DeZi>Q6LoTea*{g&jIzE`5l&JStW-4bT$bhOS99Q3!GfRXtPgS+FVc8~F z?N)c6I%Q%tu4;Qm z^_{DVjy#V%Rmn>A+)12iy_9kf+ZinH#xiI;^2i`9BhRWZv#c~$t5s&t%v?G#z1RqD zQp>D`$9tw`88Dt@WRS?g#A0PZ+ur%5v*)tP6XmKTe2~!Sf)0wZ3LJ7)dP;G1r977b zBGIR(CH)+{9@}N8abOJhIcc5DytQ(C?q%CCTR_!<^=#$r(o9+BU(uts5|G1udHU>9 z)!ax{J)sRsrJ9uw&U2cSmDS9{xC8IkhM|_PK#hzVYsW>)!!iq~YkyQXjbahgs8{&c8je+~SOXQmf(wa1AZ>`>Lb!pWK+u145Xmt>k)t|_OXcutF- z#O!(M>oKa~WTgvnXIej-^K+`8^VlV+(5vz`Iss8U!a<02m0!u&u^Pn|pmmy^h}IEv z%b>x)TWN=Dlvk`g)O2ht?@`m?m3GSFiB{utQE+5Zx%>hhScA&kS!ESH z@S%`Y`yyquY*ZIL3)T_o{kbqT9c=O5SDu+Uqo6xX#*8tgQ&1y`DzmywaV{ESxS$PT zdB*AwH6I@sr>#(k+Bt%cgcAi@m>jmRtO$Q5PGkNM({}+nf>kwC6~YHJer#qY;2Q}B zYPa4FhPRah&063cQum&UfkTgU?3Qh2E{r>q-ofPjoMq~spTW_D0FX)3E;6e_ zct;dF%`VNRsvSi&ek>m{%d(qXK0B7#r8xx{1d6d$v=ck8onM%!R9~G|#J4Mj=a!W) zRneL8oVWHz4|O7ym(%ADxSMRwWUl^jWq;-6X$7NnTPas3&-uPHuyanciWRk$s@UN} zB#W=!#nDhI*MdW%RLn&+Zsg{FtJ4tCkW5b)lVyCat$0MCL#1k2By474C2db~r8FGQ zs_W6ZAy3RyDhswBIWdpn(q=Rd)ofy9DTN=K!!wDV`ObE4o2 zyyPk`TMgQ7%igNSvK_~nz&)1=${!`fvpB1le43zmQiZQZC(R<+G&_@3#uGZDQ!hw= z$_kE6Nf+jci1iSI(fQt0g3CP{IPj{r1F-@oo6h!(bTce!DxN`nVt;8i?zL-aIn{x* zbYlJ_XM#4c?1<31$Q9v&Vmu_Q*Vh4ubSxJS*w{E_h1_F1JVW{fPr7q>{(?HjK4i7N zK30QoC8TCc7o|)zNY@|5LdK-#W^Srnow5y~@$qbB%G9p026iW+7S^9XN+X@7oib*-@COE2`PF!q8xLd_?P+o? z_(@2ub)l+KExabC?Mw_5#iFmX>Ut|3wtJ3$A%@l4$YqBF{xjlnYM zke@2orYG|N%jYMBEe0)H%*vo;s`ftqz^PM<=ca4n$n<1&zBd2zqShbn26do&sU~nb zqxHya7m6&g+diRH4z{k>z-G`w?hY%cAFWatGcfjC}cQ z>L9SW?Cjjo@DWYmbWqt6=N+WysMadx8x1o;-EB=QXgd%=9Aw432B6s1kKmb7oip%= zD24;91_e6gzNi)^*<(&DR3_yOK-F$R`$bcPx<7GI=8EWlt~jwY$%#)CYtA5drJm2} z`N5EjGtDogpLLb?&9O5e&G9Dz)+8$-|AI#Wj@9~zz&h7aZf()!kY?TO)tArlmvv&F zR?&e!MzFSijO9F~j|vQ4e{f(uXQk=*K*6vS-k6Rmh~n@`sYj(@-j1l{xe0{A^V3u6 zHrO;PWJGbRvI}N9|IA%hP*ns}O_w+8!JkqX6KU2@*3tsAm1a4+O|$9Dq$TN1nv?dX zxvD+<;IVvMIZ!Pv8v-C9e+XjTrrF%tvtdX_w60oq-%{1`E=w)_(-l2Uw{_R!jD0gY zDD$$18aV*H)iedx9A)+Og88BJXqHGo z(SyYl)+BvSWi4W!S>0d0q(xSDy3)|oj!NntU8dvPwYf_cA{(i?#abynM96^1s0Ao% z!n5hHGe*lPS6P{MxkqAQI(s~(-EJCVhxvt5wi@YVhWz+kg|&%Nd_ZNJN24waQ<$eJ zNY@LDI`!6d7zoXsD+vgw^Gk+q_?pIsKj2UDz4jGM>;oxmA@X z^`5To%XXH%MF*c@a7?#u&#R`aHwC7Z&V|rd&cQ1B85eM9t$Rqq(Mq9w5FJ_u(K(~v zKo~RVwA$YX__?%H+*?-K<7FW!<8j7T+Tp zk8`!8&$FVbyT-ejC&zYaI`;c@>zMj)YG`FG4m_>av!b}DK9H>EBOevDXq@3WLGBhW zbK7X>>WzGAT zsLp9T%;Xo9o9AA&bikoS(x--Y&?JZMT28GzSJa9c`jif8RObzIO1)T1jVgmwpsLYi zkW7bx(SAl+Ov|i&gp>tiinJ%}phX_jt*upS!h;!2{J>m`?G#i1cYj z<3bi;16ZCPk&Z2?c0%b?Do3x05^`K@bXGctufjIaqpa-OkAyQ=)*cx1JR8D;2gJ)+ z^^OeBnYA2KX-XX2EvjX`dGUa5Ip3$z?$&?z+1nS%9_E!+*l0~y7fsf0*k-+t7tM+N zMXv`0U%}_JX&t^ISCZCJD!0fS`Oe83htV;m_&MZTIMbu$97d5}Xrxgzzw|~g@QVLG z!T&tPyqE!gmQ6n!1Ti#dJL%Xz;j>t>RD7p8cBIq2==Intk|R5*L~G&mhb=zA1ym*{8KeBiSx$c6^qo z!^#V-`W{yMX3P~N`$QvEytw!K=2v|Bg1aT-6B*+dzoJ-OS*GO=3B}JpLbgR&}Q%hg+1=76B>a%K5#dLVZ_*Sp-zL(`y7-_Nv zopPLt%DPByw!;YXa)1t0FNd)CDWgNQTBu*c9>Y*y5EG1Lty}F1Ydx99K&)%681m?y z(APS@4NAnJ_=WTOE97Pi*p78uShov!O13mpYVFs1mF)V!W!=XXv^w=cA8(VEH+X#p z$)pG-dlf#N&Wtn?Owk<#N_0Tvmcm2}zX!x;rIFT5N@dn0hGjW7xw6ewR&E}bj|zP` zgVyxJJJwm>KlM9TK%zHm#A)P6R?$N}ktlnK&_nxr$>`TiQqeYuMrD&7e1_HDR^wRh z?UdUXVMURQV}((0hz3{M@r2D_g0X_1dnlveoV{8923>hyab7X|3f*UvoASJLg*<-8 zR%mH1SYZe_t6tScpZfbDVx!xF&!=$gGTHFF(rNwro8xBD19Jxz=1!9ChNjQ7^N{8x zzX&gyed^Ffye!e8?Z%|j4Q1;|bE}?|O4`w1RKrR(=NMlQ*ToV~?#)wqGpjZ{$=;+x z#2~L&G=m2V($OY}__a7ioU)^IpI*r6<}CJr&k2P2Im59%@%>lPi3&!e}Snt|>)zKu34XwIpHt*t8JoP*hC?=LYG?9dsU#g+HaYTzNh8vT~&9?PUS_HoS5O+o3`nN2K!xJDYpJ$nY}R z7g*z?^z8fcY{f4c%Stj+G^Np1Bq3~%^G`DUPe``Zczu2*&^=~CY5zX+tFszyRsU(s`d%$OZ4RQ* zHbRnwCN(#fGxc%j%n{xdop?&PK`kXYRw0kCC+rnqLr1@!sZaq%rCT zJ3nH3G9d2c@zN3F712{!%Am)dJU?*Q7elf!(&S_+Vn**s-}uFSHmZRx8Q5gHPvX$@Dh$@bYu#R7R7-&UU|U-8ifQUx&9neN%IGWl&-K0mVtSf~ z6jPH|+(^%?k#NPSI^OYB@uw5>%^q#OYLfM{)Z3CXE1&IFHb0}Bw?u0l`-0U%4bKT} zY|A|qCC*sbQAdj)ssIMA#xfkWcgapiTyGn7K&LaJs5F%XyDdL8ijv4dQb}ZxVo7MF zH%z?8%KJ)q^i{0TCz>p&Z|=kW!YnfF*(Yf(NUu!7vKTKhi_g?+GHaz4ZN$g9U%>Kc z<>eTm(l%m5*#ik3=h0tDiW1hux+E>Mo`J-ky*A&ods=DnfqiOQ5d}Y!9We@1(iTqD z^@gQVW*=KSpj^}t^oh8iJ``^snG_YCPem%kyUjFWi`!~7qCYf~*kil(31vT-NVNV$ zo}zZ_Z0H>+w62#}CYDEp72D&X-e^n*4RcPC<9Tv6qU1gQDI;Qi=UFGS@n>;Vj}?uI z{=vGe??cM`;YwrE^U!DB{Me40Ua;@t*@64m{r z&rT4#IVYPj-a;Qy4$q6y*hPMnQ*mY}8;_rpPqL%y*)M#~ZJ52#!--wJG@P$%)gQ-o z3UzO66zY*v-+nzk%p$m?lQ#0V9ejFAVi7<22Uaf)?N3X1(~Nh=2|ErO9lS1h>`+(Q z`P0Jd3-%g=v?e0Tp6&$WQIhH3Z&LN=5@sp==srFdeho6(j8h~f+JtQ}mSApqF4y`T zYKNW`;d3Uf51yrfiRH1*@`RB(*<_7NA&%^)*#J}--w8s z!T5+!_+)nGH8*IEv|sa@D@il5Qc?f33*GzGc22U@Tz9iOPxBiycu`yM=zUsI4Y!tv z?BSVv2Eg!#!qBhm0b@0FXf_JUj6$h+h@8ShJ+E2DLc;d_;?!e2=(z7=j{W&@yf+kf zPBq}g7|YlB7Q24bsS^D`)j4~!R_#57jfchI1&t4{%Y6`@Wu@Lr=jCVvw<&;gw?O-+ z;iIZY9<=cqT4ZuDKPHK=FJ_EBtM!s*HRhY=g_w<}>T2@{SiWz=qK>EsPIj0H^c4-= zWp_W~J&-+iBScTH_RYz+!<)+&%Y?fT`KNi=C^F3`j1<$@L4Ug7DeM{HXXBkQPm@X8 z`PB}|#_Rls^~D@CO0JdCociL7luzhgk_4jsrp0?&l3vG}X8FMlb$FG_YJqn78EBAK zX@UqB2pX(sj;jyYodNcV`icsTSh-Hmg>45!=aPH_D~;=}Kl7h7$#7;SU(wG#C1N4v zWbiXNi0elpnqY;V17@+Wh+Fm4!}CRaq?LqY)_l()%_8x|d5d$8h+Ee>pTXUB3yAMo z)vWKq7qIvDd5(C|yK%n#1=E7qN^LeCRSTjpyifDF2Q$x^ruo&r12z-vQ|uhJkEck! zZ=sy@XZ6tzXm?TG?|{vM@81-o^tsK`8m$a!nMLoc&`2E!JJ`r|PrsWs}0Z?nE-Cr2n8iMpe6r z=pmC%S|S&o`h>pY!t;F~{e~WwEH_?M>d$JUUeTIeBA=m~-myQeG2KAY{Kg0`k`8#1 zX3s&jdL_<=_{PIhwz=3$ZM>_{dQX5+&>B9JYLtG@l-;SFh4xdA_GFOb%@Cs@8(3WzHF7x%Si;h9>8u`vx&y&29sXZf${E8XZ*yKTP3-z7)%j`dY3nt%^nN6O^ z`iL!jM&9gtORw_God?yYW>#aj&-Gqz(P7OF{>*p4LDF=~s_$CW&LNFEROSUZl2L5* zC|Hro8?B=cnBTyIyfbURQjt+wQSNTg82qddu;X6~|Eo)3%m{hFV+ z&U;VtYmY{nQ4)TN5LY5r_7&UeW)ElVkwOtyvdGb+ni=k|aW>|+V&N0}O+D8RC{Shw zIWfW0u@3mtBi3qeb4atb!a!@v`oyxbvjjANGxV_efN=4IEA+Oxu{Y?sjk1?g9~+KY zYsYHsLB3-1^Vai#tdH6mNsEqem}J04t_@?R(}8Pacevq3Xe8oeq&zetmZ+M z)(;IdhjT+bW9UDtbt*!b=VUOC6Xi`D#&bA}$0SQuR7H!D$&%tJo+c$5-Kze?EC$`j z<9qu=vtam|BUS^yVS*m|8K~drV&_LM^5l|lGhU<>M2kB1o80q5b9A<=ag;R@N-o1h z^8XVnI}gkgYX)Zz^pSC9v318;>3QfLH+{4-@0pD?F4yF8_i2Xl%u8dCO?x;?w!N>bE8+}2DC};QR7^B_jf2^7J=;K?t zeyGl~b1!dysEB9Zn=eUXBN}xp&joZ+&%ZLS^P+^+F=gue}8tj<#|p;Tj%G>63>(uaCNntigbPR(o!ZV4?NAMiBEwBVflL zb~{eoYmI3naZPoNAW}ykc=KWp`@ILfhe<*4C=na&VISvr61Rt&4XqM+0$;zdr2Gi% z#=l0!?%(4M&4SJu8_JvW^?y<5zge-d8>Jg26y4JYyZ^i%hAlkh@EYPbwCr|j7j-`} z>VAXhf7(19>v*${*VF@kU`b-1COOD>fE`jW%Ny%>{Jeo;~~_;~qI}7YFPm zIKxCI>JU8*jHe`E6j7u_=zdz*DtE%<1RxHIZR0e+)D497ZZi+SWM5qhB?Tux1= z8+Zkh?#2f57||QkR#7~=a^v77K^8K zuB0(!rJ^xV%NVq>4&fJ(73&(en#{ipVJj`rZAG|6^ljbT4mQ3Y{qZ#^zsrgHqp+

$GYriu1b>K>Q3W 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 M|DU!0*Cg=&0D<`{`Tzg` diff --git a/FileFlows.Plugin.pdb b/FileFlows.Plugin.pdb index 4fe983bf1873fd3b3a08a26c75a0b492fa0370fd..9e72db603290b18fadf10df463e73cc6913cb933 100644 GIT binary patch delta 15593 zcmaib2UrwI^LNj%EW70FfP^JuA_qaqIW7WXR??yVbQ&N@Be)J@ayX8uBxuC?&;|ndhR?T_Pik$IEl245i*;LkU9^c zJp|-qaTAU9W9^wylk|{zLV}bWg6D9(G`F-98N#u3^ z4N8uxydx<)pDAc}KrtF&yjO{9gbJ6jLS6v zV*pzK*b)7O?T(@WkiptR04b2C09Y8DL0+I50$e`O3ZN%|UdFWqfs_am1p|!%S_E_f z(BnWa0VPNRDU&4f0~!gm5NH+9vp}x{Rg@!;jvR@?ZS-0R3y=Ipj&~q1APzl*H1~B(?wTp>2|i32=UNNJ098u z=l~G*JY1GE78026>Uz!u;F@BsJ& z!T`~LQGj^BL_h{07cd)804M`21S|uraWog9O_13E*aJ8SI1V@mxC*!nXa_t8ya#jx z2q$yI1!w^D049KbfZ>2~00AH!kPDatC<0Uh76WPk8v*A5wX?_^J%o&g3-khD z=xUCJ0uljvgU!)yzzx6`H*+K&VvZI8jE0&cOF%ck%L5Dn$^eT1Tu+D$AP5lVWr1P< zqX7wkNdO^W8eoo>bRN*p*vcYgg$li_P&r@`U^$=`uofK#~RuCS|fkF zsxUuOG}q4*6#*&$G++f_Eno|vo{=*`d;N^iVZcei1;91HT|gUPn7U*#!4Nf?(Vlh1^0Y?In-)06hpe4mcOiM$ZB709^p@2sRo6mmyCFkEYe>KWQ2IU%-DT_Ijk<2tZA zkqWYd!7zn<11-_;8ukkAJ-d)9a)i=BP`V6&c_-lAAYUQn2LrDTvTLNg8}NF-F9Cix z$h!lNf2EBknw^A58}X$D!=dmp`pp}F9Oj2Xy(`pBlGaB9KNj+M{{De

fU`FW6UQ zcn#pMgMM%Q5a4gf@H>FNB?5&JQUmxteOrcy`w+vAkvfnltse{g2*~?N`EkHchdhqe zUCG@Hhgm|HZ$N z;rrud#~01DW@3lLtJ6=Zn4d~js=IN-T5JdR*5k9(H} zfDIH%kahq^KuLzjGRmOeQxDpB%v63AKqc_1G78v$nhcK(-~~pu0Sy@**J~2guX=nH zY5mK-Hlfo~kG;o}M5nhv%Ip41q0hf~N~S^VAYS2m9l{Ri%gAd1-&e*y?l4~5usm)b zVFQN$GGHX5fE_fJF^GBIzjzZF9!Jnr#vZnBCc|R~&3kyJeZ~Qce+gL1@YsNr437<1 z%ka3qpA3)d`^)gSet-;*>um@HhyZNu@vjJAPbmHhAy#mdQ5X!ovrPRpsO#mig{4wH z2|~I8^7ww?3i8R)N%#ntNqH>4LCU9yKwz^}!GlnN=QUj2^BCqS!{a9|ZyDYiy5I)} zd*yw4)EI37H8D2+H^mn>f$-dcE<;KpkqbjkLIDe1fs~ETK+1s|2mWn@TCUV3tVN(` zNJ&%+sT?{FDHm;pR1wwyAkyaXDoQd7w;UtSV&vr+d38o!gOMlES{#2lv;ylQG30yh zY$S{hb_MskryQYNrh$0i31&r8U;^FqbjwBpC|5u<$S5KqctJH$|>2~X(K?9`| zO!}Bf%?LT9ujI$1VuFpX+lk@IeqdJ%`EPbDkW%(-O!^pPbd}6VIi#)R$D~Q5NDgT# z$;rtfN{Po}qf_?rkbboHX0s9L7_Y=e5l$`2a7{S1K|0!*r@}^8AXPxOoV_8h=n~JQ zVo2p&Yau=3)B@>ryEaIN4Q>NT;b5f7M%xDSAU)&c4e3cYZ&d~K-Ys60EkdqC;(?hy zq!ucahqgi5Iuxm~k&_1xQg8**d=D|CpFC=rk`^XygVexFtiwikylWwS04WD)`m{h^ zf0@*uD4=M8C&pO|7JfBzOpMZADEAQJ+S9viI+;z2sy&Kpuz za6F^~f@>ii8{7h^PDs2yD1?Y1y&lp6>Db^lNVkW!^<|@eVJ*gN3MSu3KKG4W7zA~qh1>L67_&9N{Q zV2O({AL==H!h|$oQbJm`9m;k<$vF-v31uc^Webw&hlZ!*GIG=Dbw)06!mMOL7D^Fj zr6=U%KrUI3o#USh6d=gT5@w;qyc|I`N{&rUnv#vudWbMHCsmk{jWV-@NrG&sO`VvU zlPxs^jV&_Xqdw`dG~d2$pqKoi=7-bXTwYbzblto(Ml(>D1~cZr8-K$y(7fere&vf2 zxqQ@=$nA~gc_Y3kdVe~9>yK|o7CT%Agj<2?0kUZFIH61bP263w|3A^2Xn(8EQ(&Pjuc?uE<&G zSZH&`>}k;xhn&UtJw`t4P|NKTnVX&{$O=poiV}Kf!apZMegiMzh)cjj9sTzOpQpSi zimE&CcBzVZ^+*5Qgoc946`}#OPLGk0jQ_*JM_$>9eKp?&%WO>F4o+7Z=oGQ)>+^}R z(T;)&vz7fkpgbjfpzv-z)9chsHIv6@@@ zUNc6JCP?~kj|-0Ru9TeNyxy9$xP2XKUHru8tdEHeN%?IzW3%q9?z8Ah<~PllwA6o( z{ea$!rx5YhS%>djd!y#Uw=rF3N-OuS{^pvNQ~hT8J%zb??Zf`gc}0ISqXz!+x6q51 zec!%$Oj8{zv7_n2-lZ9<|0*N=C+|vFk#Z;V>%oghylF3Uoz&k71-!j+N0RlLAKstK zUu;_EVXd&ozmk9b`!!D8n>FgE-Sd~J#|mcrclQcU^4@PNA7kWTpZd2|b3uKS>Vb9N z9vQD08EqQzz^dLkGuMzlXs-7=oJ9oB)bH)8EZ)M%18ssi-kpC4Z^hI8-6#s5`lUK7m{9v*K-tDn5|n)}3ea}r{sRG)|M-&?Kd~->AaPN;HIrNDDn?%Bq-$D>t^)nig; z{?m2QFW-xcctf+9+a&&(8L`6Xok z)5dQTCwLn5??&F9qFng1--)-on$wSKo28{M&Q9Rk?isQFme6dD$^_ZoE7_*>+>eS{ zO4z)*`K?v&5|WJ+|GpREOolIf-2BS+RMxxuW0HsdH7#tfuUZiNM=2p~csBkwl+5s# z`0%xfxUQ%j(ApT;~0Gc73T;&U1P%S1yy@Wuf~k)Frb^3VFoM`lG|n z+26Xfx2($M3V%uAb+^1aMP1)P;oa-XsBKpiL&Ajve$XXYkM6a)*>AXKu-ouherH_-HJZsSrvwfuN= z_+!3jY3+WtRsD|Gh7WI5-T9Jgm2kLK@|jz`jAp3=@BV)IgQdr-Y)_!m-v*IggDmxY zRu9vhpg(1g+K%+Ob>;LbOZ{KnE1goB#dAM(=tB2o>$iQKgmn`~N3K#h|G`ap?A-G8 zyU9NecP{N#h=lv9Y$LmQNspfY7+ZHwk>%gva;z%!^#khm))#+|*xT72zajMD_#YZr zB`P5+Azgqk16kz`9-%F|`*7Qc6;4|+x;8BtR#nh(_{{VxPM;s$4!09_5lW;cKXEcF zh0%f(L6#sRNw)2Iro2^OqB!qYKYQ`mGBB3>xuIp$hK{>CR(1Df1>RWUvT_`U8CM2c_vj126Dvz@w zE-OtgSJNwl-R)!w^L)N7vb9Pr6V__y zz4NnrPv+{ADzl9e-c~KnT3%DP93Er{OTwH(m=eZ>PQE!E)X&h_(Z+6&qm6@ulZ&&B z!vNRh!N~$AJAp%DioiKB*}>V#B`Lwl&ED13-o?e$$vz>)DQU34j$Yo6M>;vu2m2)s zu(uoFoZw_YU`3V4v(b*vZL3;F9c+V(*sXn&|4(7}S3#K^e`s z){Vpn@eWvQOvv96Qn4g*pog;DQF`S7lki((JsRPq4(f6{-TZUkp8>Vy6`N8zOkyKa zld^=_!jv4FxYTUe>iH&QrzY7Xr@?DB!dYYc32gB4h0yuq+SS5}_ww4;-uw89<_t8U zj%?!W$CVD6Q&M*Lbc7#yYn*%GrsWyhXP~j@<(39zOtw*h$;&#ly0!BSU-Zg;x2#ZYF z*?rTL+!RuE?5V-K=|jJQ$Uth;_^}PRGAbzZ(wC}w{^4s&o+(NU-I)eP*=kVzYG!`rxWLFa6dFb}ma(xzsLg1U5MdQg=rD zQT+_JQQ-YLZ|)(-phfdU8-x#@exy6>%qRoZkrmiXuA1a)W!m}R!W(Za5T+)A)>zK(~qEb7cQk$M)uS@B*kFUdnO)d0xYE8BYKH5HT#j>@> z&FJO!{ivdcF*mSw!24~J&GrZT(<4fLUfbo~pT1{rLXAGMy#i|{F3}4P8CmLmV0O4o zV0_dcw3dT8b;DHgAoh5U&gvq!3B!ZMCuYhyudRJY^BwG|gF0zt*z8FY!x4&2mjjwA zt=+44x*F2U9E>SHn`~yW*getW!MBXZfi)Ul>SUVU0{V)B1vUE1GR7?D+_b9KPiUXT zbt_cn#`jNz%0434lt|$88Z@1nY^2NVM0X37ZD>l}5c zBJB-Za8M2vW*#lCI2N(*vE|aPaRWcoR~!vP*RstwVI%EsDz{Unj}0xj=qabYSG(7( zo_$%ijZb!ly|g1d)onL%M=ttn4eBmk6Fkpm>4%$V$J)>?P8MNJGyRyc9n){aWaV}I zfbI7?#xDOT@RIt}vsX)<$!yl8_7-KjoWUNrZQUg=aR27LFEq`g&!Zp5=uJ+R)UuLq z%w=g-{f)EDDLZ7xuo0&#jW##X#?HF*7bg{Jt@}tZ))sx#KK|Y@C3w}m47nEulokjW zP-7kW@3E^FxZ@s-Z0Cm66rM;pHR{YKMmyD6gSvaQwFGOweK~ex+V{KRTQ86P?zz8a z93!yBS=D=km*H+KQ2p`BfHk}_|NPswWoI;oAZdR>_|i^%uN?U|mQyIsvcLTEYrx^w zFF8xUpB_nna_$!<8vTu#A!?sj43nSX98nfy>hpYU%IDuk@XG)x*E128jhT5A+j}s4 z&Lz>CN4|&7H#P~&^j^`ET`Z_OtcCk<*6-yDxq9gXX#Ns4esR zAHkLN1EY^-&Nv^uwxVnJL0`dRdZeoX7Pjw@={oS;gzo&U7y~x#^ z8ufVd7hJjFa#i2fS0@7N`-RR_kWZIBRn-@sx-n=Q@dO6EIz%-J?2V3{h;+@ zj}{(c?_MP<889O=3toiD+^>}8J^cgUl6=>$Z(M15*6)ah?M%_ppjN3Cy>_rRRgnJl z9=3eeO6QUOq~_4Q8{Y@qag7S6FAX;AQ}^LKu1o8DaoC*~?z^c>Ks8q)b((O~7Ofkz zqy)9WyXW%5>!%#3^{$XRB^Z8TF+C0Ci81Eb|yq#=%_UZSC)!*;?O+zmoWy~_tW}(@_45`kth=sRt3ts%! z4srKw19lyGu{3Vssblm$H>1FPr*8a>M__?%WBR1LX5X6H={o{`?wTlLFd`u}O={}f z@lVHamv_#OEz^J2$EVoNP@(0)X*0U7JCE|6e1#dW9ST2U$L8GjZ#2$tx?S;O7#-to zP96I2dOg-$9G&=P{uOJVO(!Zhm`d0K=qh(z%6*nD<6*kX^5Bn83?r(Exqa7WZfT$o zyBknj`+s_fHD`{yk{Y^sOTg-Cx9g(~h6(8R?)svHN8wL!m{ZzvoBIb$@?FZV)4B0p zK1Rm<7(r5QR%%Y3vwrMoXrQ1w%eBk0AW$R*Q#j)u_ zL;6xLN1HU@2=9qpMqAz59JcqO@!jpNS1;3VhgeWwG&7s9T{TvB_a6!KLU&gu^FH>e z3Zn-O)u;a0*~;u&)@p|D+sobISMBuRyp#5x#q{{0W>o4WmvXErJj~f`Omg@Qj$55M zoVaUHsY}-nWd}xe4SR>%ZN0vi+|<{WUr+Zl%l6$izPF?J#Yk#W@8->HTjd38?!h&q zy1$B)!6HKIJ8JN?8%P}k1WeD=~KplW`S;)|2}l4*MnJ!+AhRTZvm zy}s0aP1{x9m9%fy{m}GPbfSkPHD%Vfm)Pu~zZ3hmPSNr&HhFUI{W6g;UF$(nUHNK_ zxU#Ki=1jrJo!%v0(;sB+^6{tt@Gzu&O9hwl$PO-yJfe{iA29E_ZcgAp>#tDRhc@uk zqHYnwPTapYL2tiWE4&LnR6Y5s<&Rj!N9>Wa3c=W_MFM-}xCtCtm}5M5l2&E{Xa)pT*8MQGI` zo@>q=C0}})ml0(WTFwmF`SCCBusttC?z|Ove{%5tCG>XC)K)votj&jpp6f=fVcz8y zHnZk-jT6!@LGx18$PR4w{*o~L>mFx=s!#7U@^q=&LYsIGq9Q-Am?x471GinBIyn*I zMX~I4%SD<{$)k(CRRbQ4>BsE2Ze4!1qkDQp$eQ_vkMeZ=hf8NdbZ$mYYI^Vfuxzzu zI}$6P9sRd{HaUJha8=sMCH{8f1@v8S9(Ddg6SKkMZscTV&x-V^vGx8G8F5OT*6=Z; zl$t*=&6Lu!-;D3C8M0;UtxRYZ3w`6YuIZ6H~f3W#g=%PXGwn?)+ zSy+Z%>!Tkv>*VwExTofiH159Vi^2+>#@*RzmayetzQS8wI9m*lY`ydM;c>lM)Iy@j z?-_5sy6=aV9-tST$+KEXEBP8w)35u&wia$P%{A}J-na2<;wm2OYig;Yy?l+Rsu}Lg z!$-V@dE-1ed7nbH+vZQMOJ>jwzJ}7L1L|cavkgf(aB{;F`ONULb*+D>FPPT`A~sZ& z=jbbVKDebMO{wAy3Og_-KG!wbM-J=DF2irD2EM{hL=D&6^w2#n-~fNcOjj5Ab6Ad! z@zbC#>#8%)Abq(}gX1UZMy!4E*=UmP_t#j!bYyz(8vOL*31%fdGx^7{pkr>4i{?$P z54x|Red(_Masl*E5{993;em1+yv!% zY{9zt?G^QehyK+b@tbcxOVI%VD~hF8a|j1w^1H#wx%weNYs-sri|J4A8S^RuYSdi& z{5mY~B8jhcF3Z}twpBr0(b0Sv7O>=J3Nm7{dpoJw@|t=2$x|*>v7N>WU%M_{Y3)bF zEXG!KfCjZa`UeyBK5;Wk&g$I@++Z(XyK=H)03&cOpikhyPVNpIi7A99&tumhzcS~a z7j4F7U6xLyf87=O&E3PS6{j+cYezrv@h#EsMEj6xH7yL(mrjB#(VVAv{OX^qK3J~& z(6?r-m7A`>Q_#gEXl1=*}zbwd2-xt3|t?1}C|8gYGRE#T&W0+{rP0p%0Cl)in88{)E}4D(wl;L)PSA3`PgOAjjLT= z?S4M1cHAxXHou}sKMd|eS^Ye~%wMa!FULI#aPmH2GtEN2?07A$5@JfVj&NhHo_#m> z+zB|698`Y)YoEYs#hr9eh-HM+#m-H*&2V?M`Nt=#`0ee!T{qB9eL}DQld=Q}$@u9h zHX(aT?~)=?SbqomGLzeJpwNrpFG;`r{fviGEZrPpO=W6lEyb1hjtmRDKVeJ2f^Qpc zic@Fh&|g9_`Qb-H-F|_K%i2FADXq?@GeY~&{7?l-d%OJ&98ax*a*sYV zKj$Cf=9A%0pT>iLDK*!%n7I=~iq6d_mD?Lq`KpvO_%b|)-V$m^MeMd?0_5xyot@Zx zGh948{#~@y-~RN2PMN>19h0}ZEZ!z=S0xaxS`}gFVdsYOjj!~XYXX@XR3jbaa>kZZv)GLHWrnTGf8@99X;D=Beo@6G$i8> zH|Nkr{vG*Cj8877t0PQ&b@Qy4hp@w28uz3;iSyn#cv6nm+=P%3JpqT8pS`#LbM8Z# z5fbXg68EpRhwnP0Du38Wbe!&rsOnSyCsF^LsP7~i)QJXtqM<+0FobA`rfVYmYX4Uq zeKFEOxtmndB^2Zcf{+-|ihO-TZ8!u7$7_guOZZ~Nl;kL&f$&ueBFH=zp+@`gx3jef z@jTLn{=n}?DA9&d+4}!woY-wo?2e+hML8&eN4pEb5dv;?Pow+@wZ;L%LJ2u7ax^_7 zdOFy67flma>B5+7W$=XR@6)FBm=5I!1K^j@8L>4v}7j}_FZd`FWK@@fsouyaDIms!J z;f*)r^ay(=qVNq-WR3ez=3gcAKaqthL|Hjeww)~dlPn)XmPL}~f07k?9PxYzPf=i_ z@kl?yZx)e1geVw96dfmux)|jFr1&%`IfBUiMP$KFvfw&d_!m)ro+y7wR+x|#Gs*Hw zveJaC9K{h=fuUj@dgG`nbtU#tVkj|#6t5t~cSy-J+JE#>eKo?Dm`IARDM-4wk}g8h zMdo{x`CxM=Z8*l8bDL>TTuKYZ1jtPymFOK~rfS#|;&-GHf?fVR8|rIOy%ICB~+u8Y!BAV797%|~5h;02=J!uBfY~aErgu!3pgn>Rcaf4(QgY735aC0%ghWX5;tfPF z$XqoDz9%UzAjR$YJ2egX5Q>NAH?*Kla+Ht)D~~_}kqsOYlZS(9Ny^ahR5(3Ga;9o z?H~(2kR>6u#$eHd{t$1fu40wuMe&2oq1WoLBEbMK zBc?)fSTHB@fPv}L4=F3EvydAL;!3bmIe6}y^GUvVrv>O)fSxAFk^eQ)JXRhX=}U){ zOX3L#U*?gdE*?gGQpCl)zLe*YB9S64An^PkSTJ~&BnMB9JUOI+rzSJ16v=@zt1Or> z1}r>nSzr)Kj9Gc`aU&ckBsq#mhs1HEI=H4UnWuy;nh~Q2aTRmv<`Lqvq<9}8 z=|hSnEHZy6nO{QY-y;jm@Q}%?IALPjN7>c#zd~b1=D|TnIr!KS;`9$j1y*uq)-bK` zNpW?E6<9Fs2@)7(;mbi8d(K4^3(tEI3)$hTK%bSzTm|+dM+3Uq!#HAwV&y4fqZAQK zh!+v!uY^R0aRf@>o9=g1=hdE4UYfy`lQB!nCnOfPDwg& zeWe4XLxP_&1F0tEokArAQLz*P3Bn;!-<)Mk08sQv8OHj3Xs>q~r@8lt_*| zCMrEcG)Pz3ryumj5CY6x`ijU2&Zpv)V*&>Z3*pTa$$`_Wcs9XcF^dkFX9F^7NKZKi zWSq!64qFNS>mH;_;A5;re|dtpX@HZc=z)0AAZR7u zj&7e~N{^qCt}%-!P9TeRA+lr>G4H&1s||gBN}X#cQDR3HixFAMCrew2axO#@8Azs@ z>B3=bLXr(DtS+8PW_YcH5X$L_3-V3u8jqyekV@WgeiID=NWAH;48D~TDX}8+&B+2s zvfvU~Bq5945LsqRR-8Aj)I_Fmv#3f&VfJ*oaHN(W=Mc1nKg!@36rD9KdWg{f1B(=V AhX4Qo delta 13923 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 khUGJnAenable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/ImageNodes/ImageNodes.csproj b/ImageNodes/ImageNodes.csproj index 0d77358d..915adfb1 100644 --- a/ImageNodes/ImageNodes.csproj +++ b/ImageNodes/ImageNodes.csproj @@ -5,8 +5,8 @@ enable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/MetaNodes/MetaNodes.csproj b/MetaNodes/MetaNodes.csproj index 191dbba1..d17498a0 100644 --- a/MetaNodes/MetaNodes.csproj +++ b/MetaNodes/MetaNodes.csproj @@ -6,8 +6,8 @@ enable true true - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/MusicNodes/ExtensionMethods.cs b/MusicNodes/ExtensionMethods.cs deleted file mode 100644 index 264e1947..00000000 --- a/MusicNodes/ExtensionMethods.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace FileFlows.MusicNodes -{ - internal static class ExtensionMethods - { - public static void AddOrUpdate(this Dictionary dict, string key, object value) - { - if (dict.ContainsKey(key)) - dict[key] = value; - else - dict.Add(key, value); - } - public static string? EmptyAsNull(this string str) - { - return str == string.Empty ? null : str; - } - } -} diff --git a/MusicNodes/InputNodes/MusicFile.cs b/MusicNodes/InputNodes/MusicFile.cs deleted file mode 100644 index a1633db2..00000000 --- a/MusicNodes/InputNodes/MusicFile.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace FileFlows.MusicNodes -{ - using System.ComponentModel; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - - public class MusicFile : MusicNode - { - public override bool Obsolete => true; - public override int Outputs => 1; - public override FlowElementType Type => FlowElementType.Input; - - private Dictionary _Variables; - public override Dictionary Variables => _Variables; - public MusicFile() - { - _Variables = new Dictionary() - { - { "mi.Album", "Album" }, - { "mi.Artist", "Artist" }, - { "mi.ArtistThe", "Artist, The" }, - { "mi.Bitrate", 845 }, - { "mi.Channels", 2 }, - { "mi.Codec", "flac" }, - { "mi.Date", new DateTime(2020, 05, 23) }, - { "mi.Year", 2020 }, - { "mi.Duration", 256 }, - { "mi.Encoder", "FLAC 1.2.1" }, - { "mi.Frequency", 44100 }, - { "mi.Genres", new [] { "Pop", "Rock" } }, - { "mi.Language", "English" }, - { "mi.Title", "Song Title" }, - { "mi.Track", 2 }, - { "mi.Disc", 2 }, - { "mi.TotalDiscs", 2 } - }; - } - - public override int Execute(NodeParameters args) - { - string ffmpegExe = GetFFMpegExe(args); - if (string.IsNullOrEmpty(ffmpegExe)) - return -1; - - try - { - if (ReadMusicFileInfo(args, ffmpegExe, args.WorkingFile)) - return 1; - - var musicInfo = GetMusicInfo(args); - - if (string.IsNullOrEmpty(musicInfo.Codec) == false) - args.RecordStatistic("CODEC", musicInfo.Codec); - - return 0; - } - catch (Exception ex) - { - args.Logger.ELog("Failed processing MusicFile: " + ex.Message); - return -1; - } - } - } - - -} \ No newline at end of file diff --git a/MusicNodes/MusicInfo.cs b/MusicNodes/MusicInfo.cs deleted file mode 100644 index f5128501..00000000 --- a/MusicNodes/MusicInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace FileFlows.MusicNodes -{ - public class MusicInfo - { - public string Language { get; set; } - public int Track { get; set; } - public int Disc { get; set; } - public int TotalDiscs { get; set; } - public string Artist { get; set; } - public string Title { get; set; } - public string Album { get; set; } - public DateTime Date { get; set; } - public string[] Genres { get; set; } - public string Encoder { get; set; } - public long Duration { get; set; } - public long Bitrate { get; set; } - public string Codec { get; set; } - public long Channels { get; set; } - public long Frequency { get; set; } - } -} \ No newline at end of file diff --git a/MusicNodes/MusicInfoHelper.cs b/MusicNodes/MusicInfoHelper.cs deleted file mode 100644 index 1f05e591..00000000 --- a/MusicNodes/MusicInfoHelper.cs +++ /dev/null @@ -1,328 +0,0 @@ -namespace FileFlows.MusicNodes -{ - using System.Diagnostics; - using System.IO; - using System.Text.RegularExpressions; - using FileFlows.Plugin; - - public class MusicInfoHelper - { - private string ffMpegExe; - private ILogger Logger; - - public MusicInfoHelper(string ffMpegExe, ILogger logger) - { - this.ffMpegExe = ffMpegExe; - this.Logger = logger; - } - - public static string GetFFMpegPath(NodeParameters args) => args.GetToolPath("FFMpeg"); - public MusicInfo Read(string filename) - { - var mi = new MusicInfo(); - if (File.Exists(filename) == false) - { - Logger.ELog("File not found: " + filename); - return mi; - } - if (string.IsNullOrEmpty(ffMpegExe) || File.Exists(ffMpegExe) == false) - { - Logger.ELog("FFMpeg not found: " + (ffMpegExe ?? "not passed in")); - return mi; - } - - mi = ReadMetaData(filename); - - try - { - using (var process = new Process()) - { - process.StartInfo = new ProcessStartInfo(); - process.StartInfo.FileName = ffMpegExe; - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.CreateNoWindow = true; - process.StartInfo.Arguments = $"-hide_banner -i \"{filename}\""; - process.Start(); - string output = process.StandardError.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - if (string.IsNullOrEmpty(error) == false && error != "At least one output file must be specified") - { - Logger.ELog("Failed reading ffmpeg info: " + error); - return mi; - } - - Logger.ILog("Music Information:" + Environment.NewLine + output); - - if(output.IndexOf("Input #0") < 0) - { - Logger.ELog("Failed to read audio information for file"); - return mi; - } - - if (output.ToLower().Contains("mp3")) - mi.Codec = "mp3"; - else if (output.ToLower().Contains("ogg")) - mi.Codec = "ogg"; - else if (output.ToLower().Contains("flac")) - mi.Codec = "flac"; - else if (output.ToLower().Contains("wav")) - mi.Codec = "wav"; - else if (filename.ToLower().EndsWith(".mp3")) - mi.Codec = "mp3"; - else if (filename.ToLower().EndsWith(".ogg")) - mi.Codec = "ogg"; - else if (filename.ToLower().EndsWith(".flac")) - mi.Codec = "flac"; - else if (filename.ToLower().EndsWith(".wav")) - mi.Codec = "wav"; - - foreach (string line in output.Split('\n')) - { - int colonIndex = line.IndexOf(":"); - if(colonIndex < 1) - continue; - - string lowLine = line.ToLower().Trim(); - - if (lowLine.StartsWith("language")) - mi.Language = line.Substring(colonIndex + 1).Trim(); - else if (lowLine.StartsWith("track") && lowLine.Contains("total") == false) - { - if (mi.Track < 1) - { - var trackMatch = Regex.Match(line.Substring(colonIndex + 1).Trim(), @"^[\d]+"); - if (trackMatch.Success && int.TryParse(trackMatch.Value, out int value)) - mi.Track = value; - } - } - else if (lowLine.StartsWith("artist") || lowLine.StartsWith("album_artist")) - { - if (string.IsNullOrWhiteSpace(mi.Artist)) - mi.Artist = line.Substring(colonIndex + 1).Trim(); - } - else if (lowLine.StartsWith("title") && lowLine.Contains(".jpg") == false) - { - if (string.IsNullOrWhiteSpace(mi.Title)) - mi.Title = line.Substring(colonIndex + 1).Trim(); - } - else if (lowLine.StartsWith("album")) - { - if (string.IsNullOrWhiteSpace(mi.Album)) - mi.Album = line.Substring(colonIndex + 1).Trim(); - } - else if (lowLine.StartsWith("disc")) - { - if (int.TryParse(line.Substring(colonIndex + 1).Trim(), out int value)) - mi.Disc = value; - } - else if (lowLine.StartsWith("disctotal") || lowLine.StartsWith("totaldiscs")) - { - if (int.TryParse(line.Substring(colonIndex + 1).Trim(), out int value)) - mi.TotalDiscs = value; - } - else if (lowLine.StartsWith("date") || lowLine.StartsWith("retail date") || lowLine.StartsWith("retaildate") || lowLine.StartsWith("originaldate") || lowLine.StartsWith("original date")) - { - if (int.TryParse(line.Substring(colonIndex + 1).Trim(), out int value)) - { - if(mi.Date < new DateTime(1900, 1, 1)) - mi.Date = new DateTime(value, 1, 1); - } - else if(DateTime.TryParse(line.Substring(colonIndex + 1).Trim(), out DateTime dtValue) && dtValue.Year > 1900) - mi.Date = dtValue; - } - else if (lowLine.StartsWith("genre")) - { - if(mi.Genres?.Any() != true) - mi.Genres = line.Substring(colonIndex + 1).Trim().Split(' '); - } - else if (lowLine.StartsWith("encoder")) - mi.Encoder = line.Substring(colonIndex + 1).Trim(); - else if (lowLine.StartsWith("duration")) - { - if (mi.Duration < 1) - { - string temp = line.Substring(colonIndex + 1).Trim(); - if (temp.IndexOf(",") > 0) - { - temp = temp.Substring(0, temp.IndexOf(",")); - if (TimeSpan.TryParse(temp, out TimeSpan value)) - mi.Duration = (long)value.TotalSeconds; - } - } - } - - - if (line.ToLower().IndexOf("bitrate:") > 0) - { - string br = line.Substring(line.ToLower().IndexOf("bitrate:") + "bitrate:".Length).Trim(); - if (br.IndexOf(" ") > 0) - { - br = br.Substring(0, br.IndexOf(" ")); - if (long.TryParse(br, out long value)) - mi.Bitrate = value; - } - } - - var match = Regex.Match(line, @"([\d]+) Hz"); - if (match.Success) - { - mi.Frequency = int.Parse(match.Groups[1].Value); - } - - if (line.IndexOf(" stereo,") > 0) - mi.Channels = 2; - } - - } - } - catch (Exception ex) - { - Logger.ELog(ex.Message, ex.StackTrace.ToString()); - } - - if (string.IsNullOrEmpty(mi.Artist) || string.IsNullOrEmpty(mi.Album) || mi.Track < 1 || string.IsNullOrEmpty(mi.Title)) - { - // try parse the file - ParseFileNameInfo(filename, mi); - } - - return mi; - } - - public MusicInfo ReadMetaData(string file) - { - using var tfile = TagLib.File.Create(file); - MusicInfo info = new MusicInfo(); - try - { - info.Title = tfile.Tag.Title; - info.Duration = (long)tfile.Properties.Duration.TotalSeconds; - info.TotalDiscs = Convert.ToInt32(tfile.Tag.DiscCount); - if (info.TotalDiscs < 1) - info.TotalDiscs = 1; - info.Disc = Convert.ToInt32(tfile.Tag.Disc); - if (info.Disc < 1) - info.Disc = 1; - info.Artist = String.Join(", ", tfile.Tag.AlbumArtists); - info.Album = tfile.Tag.Album; - info.Track = Convert.ToInt32(tfile.Tag.Track); - if(tfile.Tag.Year > 1900) - { - info.Date = new DateTime(Convert.ToInt32(tfile.Tag.Year), 1, 1); - } - info.Genres = tfile.Tag.Genres.SelectMany(x => x.Split(new[] { ";", "," }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim())).ToArray(); - } - catch (Exception) { } - tfile.Dispose(); - return info; - } - - public void ParseFileNameInfo(string filename, MusicInfo mi) - { - using var tfile = TagLib.File.Create(filename); - try - { - var fileInfo = new FileInfo(filename); - - bool dirty = false; - - if (mi.Disc < 1) - { - var cdMatch = Regex.Match(filename.Replace("\\", "/"), @"(?<=(/(cd|disc)))[\s]*([\d]+)(?!=(/))", RegexOptions.IgnoreCase); - if (cdMatch.Success && int.TryParse(cdMatch.Value.Trim(), out int disc)) - { - dirty = true; - mi.Disc = disc; - tfile.Tag.Disc = Convert.ToUInt32(disc); - } - } - - if (mi.Track < 1) - { - var trackMatch = Regex.Match(fileInfo.Name, @"[\-_\s\.]+([\d]{1,2})[\-_\s\.]+"); - if (trackMatch.Success) - { - string trackString = trackMatch.Value; - if (int.TryParse(Regex.Match(trackString, @"[\d]+").Value, out int track)) - { - mi.Track = track; - tfile.Tag.Track = Convert.ToUInt32(track); - dirty = true; - } - } - } - - string album = fileInfo.Directory.Name; - var yearMatch = Regex.Match(album, @"(?<=(\())[\d]{4}(?!=\))"); - if (yearMatch.Success) - { - album = album.Replace("(" + yearMatch.Value + ")", "").Trim(); - - if (mi.Date < new DateTime(1900, 1, 1)) - { - if (int.TryParse(yearMatch.Value, out int year)) - { - mi.Date = new DateTime(year, 1, 1); - tfile.Tag.Year = Convert.ToUInt32(year); - dirty = true; - } - } - } - - - if (string.IsNullOrEmpty(mi.Album)) - { - mi.Album = album; - if (string.IsNullOrEmpty(album) == false) - { - tfile.Tag.Album = mi.Album; - dirty = true; - } - } - - if (string.IsNullOrEmpty(mi.Artist)) - { - mi.Artist = fileInfo.Directory.Parent.Name; - if (string.IsNullOrEmpty(mi.Artist) == false) - { - tfile.Tag.AlbumArtists = new[] { mi.Artist }; - dirty = true; - } - } - - // the title - if (string.IsNullOrEmpty(mi.Title)) - { - int titleIndex = fileInfo.Name.LastIndexOf(" - "); - if (titleIndex > 0) - { - mi.Title = fileInfo.Name.Substring(titleIndex + 3); - if (string.IsNullOrEmpty(fileInfo.Extension) == false) - { - mi.Title = mi.Title.Replace(fileInfo.Extension, ""); - tfile.Tag.Title = mi.Title; - dirty = true; - } - } - } - - if(dirty) - tfile.Save(); - - } - catch (Exception ex) - { - Logger?.WLog("Failed parsing music info from filename: " + ex.Message + Environment.NewLine + ex.StackTrace); - } - finally - { - tfile.Dispose(); - } - } - - } -} \ No newline at end of file diff --git a/MusicNodes/MusicNodes.csproj b/MusicNodes/MusicNodes.csproj deleted file mode 100644 index 44558225..00000000 --- a/MusicNodes/MusicNodes.csproj +++ /dev/null @@ -1,53 +0,0 @@ - - - - net6.0 - enable - enable - true - true - 1.0.4.189 - 1.0.4.189 - true - true - FileFlows - John Andrews - Music Nodes - https://fileflows.com/ - OBSOLETE. This plugin has been replaced by the Audio Nodes plugin. - - - - 1701;1702;CS8618;CS8601;CS8602;CS8603;CS8604;CS8618;CS8625;CS8765;CS8767; - 0 - - - - 1701;1702;CS8618;CS8601;CS8602;CS8603;CS8604;CS8618;CS8625;CS8765;CS8767; - 0 - - - - - - - - - - - Always - - - - - - - - - - ..\FileFlows.Plugin.dll - False - - - - diff --git a/MusicNodes/MusicNodes.en.json b/MusicNodes/MusicNodes.en.json deleted file mode 100644 index 61f355ab..00000000 --- a/MusicNodes/MusicNodes.en.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "Flow":{ - "Parts": { - "MusicFile": { - "Description": "An input music file that has had its Music Information read and can be processed", - "Outputs": { - "1": "Music file from library" - } - }, - "AudioFileNormalization": { - "Description": "Normalizes an audio file using two passes of FFMPEGs loudnorm filter", - "Outputs": { - "1": "Audio file normalized and saved to temporary file" - } - }, - "ConvertAudio": { - "Description": "Convert a music file to the specified audio codec", - "Outputs": { - "1": "Audio converted and saved to temporary file", - "2": "Audio already in codec, no conversion done" - }, - "Fields": { - "Bitrate": "Bitrate", - "Bitrate-Help": "The bitrate for the new file, the higher the bitrate the better the quality but larger the file.", - "Codec": "Codec", - "Codec-Help": "The audio codec to convert the file into.", - "SkipIfCodecMatches": "Skip If Codec Matches", - "SkipIfCodecMatches-Help": "If the existing audio codec matches, this file will not be processed regardless of the bitrate. Otherwise if off, the bitrate must be less than or equal to for it to skip." - } - }, - "ConvertToAAC": { - "Description": "Convert a music file to AAC", - "Outputs": { - "1": "Audio converted and saved to temporary file" - }, - "Fields": { - "Bitrate": "Bitrate", - "Bitrate-Help": "The bitrate for the new AAC file, the higher the bitrate the better the quality but larger the file. 192 Kbps is the recommended rate." - } - }, - "ConvertToFLAC": { - "Description": "Convert a music file to FLAC", - "Outputs": { - "1": "Audio converted and saved to temporary file" - }, - "Fields": { - "Bitrate": "Bitrate", - "Bitrate-Help": "The bitrate for the new FLAC file, the higher the bitrate the better the quality but larger the file. 128 Kbps is the recommended rate." - } - }, - "ConvertToMP3": { - "Description": "Convert a music file to MP3", - "Outputs": { - "1": "Audio converted and saved to temporary file" - }, - "Fields": { - "Bitrate": "Bitrate", - "Bitrate-Help": "The bitrate for the new MP3 file, the higher the bitrate the better the quality but larger the file. 192 Kbps is the recommended rate." - } - }, - "ConvertToOGG": { - "Description": "Convert a music file to OGG", - "Outputs": { - "1": "Audio converted and saved to temporary file" - }, - "Fields": { - "Bitrate": "Bitrate", - "Bitrate-Help": "The bitrate for the new OGG file, the higher the bitrate the better the quality but larger the file. 128 Kbps is the recommended rate." - } - }, - "ConvertToWAV": { - "Description": "Convert a music file to WAV", - "Outputs": { - "1": "Audio converted and saved to temporary file" - }, - "Fields": { - "Bitrate": "Bitrate", - "Bitrate-Help": "The bitrate for the new WAV file, the higher the bitrate the better the quality but larger the file. 128 Kbps is the recommended rate." - } - } - } - } -} \ No newline at end of file diff --git a/MusicNodes/Nodes/AudioFileNormalization.cs b/MusicNodes/Nodes/AudioFileNormalization.cs deleted file mode 100644 index fd88bbc0..00000000 --- a/MusicNodes/Nodes/AudioFileNormalization.cs +++ /dev/null @@ -1,116 +0,0 @@ -using FileFlows.Plugin; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; - -namespace FileFlows.MusicNodes; - -public class AudioFileNormalization : MusicNode -{ - public override bool Obsolete => true; - public override int Inputs => 1; - public override int Outputs => 1; - public override FlowElementType Type => FlowElementType.Process; - - public override string Icon => "fas fa-volume-up"; - - - const string LOUDNORM_TARGET = "I=-24:LRA=7:TP=-2.0"; - - public override int Execute(NodeParameters args) - { - try - { - string ffmpegExe = GetFFMpegExe(args); - if (string.IsNullOrEmpty(ffmpegExe)) - return -1; - - MusicInfo musicInfo = GetMusicInfo(args); - if (musicInfo == null) - return -1; - - List ffArgs = new List(); - - - long sampleRate = musicInfo.Frequency > 0 ? musicInfo.Frequency : 48_000; - - string twoPass = DoTwoPass(args, ffmpegExe); - ffArgs.AddRange(new[] { "-i", args.WorkingFile, "-c:a", musicInfo.Codec, "-ar", sampleRate.ToString(), "-af", twoPass }); - - string extension = new FileInfo(args.WorkingFile).Extension; - if (extension.StartsWith(".")) - extension = extension.Substring(1); - - string outputFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + extension); - ffArgs.Add(outputFile); - - var result = args.Execute(new ExecuteArgs - { - Command = ffmpegExe, - ArgumentList = ffArgs.ToArray() - }); - - return result.ExitCode == 0 ? 1 : -1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing AudioFile: " + ex.Message); - return -1; - } - } - - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] - public string DoTwoPass(NodeParameters args, string ffmpegExe) - { - //-af loudnorm=I=-24:LRA=7:TP=-2.0" - var result = args.Execute(new ExecuteArgs - { - Command = ffmpegExe, - ArgumentList = new[] - { - "-hide_banner", - "-i", args.WorkingFile, - "-af", "loudnorm=" + LOUDNORM_TARGET + ":print_format=json", - "-f", "null", - "-" - } - }); - if(result.ExitCode != 0) - throw new Exception("Failed to prcoess audio track"); - - string output = result.StandardOutput; - - int index = output.LastIndexOf("{"); - if (index == -1) - throw new Exception("Failed to detected json in output"); - string json = output.Substring(index); - json = json.Substring(0, json.IndexOf("}") + 1); - if (string.IsNullOrEmpty(json)) - throw new Exception("Failed to parse TwoPass json"); - LoudNormStats stats = JsonSerializer.Deserialize(json); - string ar = $"loudnorm=print_format=summary:linear=true:{LOUDNORM_TARGET}:measured_I={stats.input_i}:measured_LRA={stats.input_lra}:measured_tp={stats.input_tp}:measured_thresh={stats.input_thresh}:offset={stats.target_offset}"; - return ar; - } - - private class LoudNormStats - { - /* -{ - "input_i" : "-7.47", - "input_tp" : "12.33", - "input_lra" : "6.70", - "input_thresh" : "-18.13", - "output_i" : "-24.25", - "output_tp" : "-3.60", - "output_lra" : "5.90", - "output_thresh" : "-34.74", - "normalization_type" : "dynamic", - "target_offset" : "0.25" -} - */ - public string input_i { get; set; } - public string input_tp { get; set; } - public string input_lra { get; set; } - public string input_thresh { get; set; } - public string target_offset { get; set; } - } -} diff --git a/MusicNodes/Nodes/ConvertNode.cs b/MusicNodes/Nodes/ConvertNode.cs deleted file mode 100644 index 7c943604..00000000 --- a/MusicNodes/Nodes/ConvertNode.cs +++ /dev/null @@ -1,314 +0,0 @@ -using FileFlows.Plugin; -using FileFlows.Plugin.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace FileFlows.MusicNodes -{ - public class ConvertToMP3 : ConvertNode - { - public override bool Obsolete => true; - protected override string Extension => "mp3"; - public static List BitrateOptions => ConvertNode.BitrateOptions; - protected override List GetArguments() - { - return new List - { - "-c:a", - "mp3", - "-ab", - Bitrate + "k" - }; - } - } - public class ConvertToWAV : ConvertNode - { - protected override string Extension => "wav"; - public static List BitrateOptions => ConvertNode.BitrateOptions; - protected override List GetArguments() - { - return new List - { - "-c:a", - "pcm_s16le", - "-ab", - Bitrate + "k" - }; - } - } - - public class ConvertToAAC : ConvertNode - { - protected override string Extension => "aac"; - public static List BitrateOptions => ConvertNode.BitrateOptions; - - protected override bool SetId3Tags => true; - - protected override List GetArguments() - { - return new List - { - "-c:a", - "aac", - "-ab", - Bitrate + "k" - }; - } - } - public class ConvertToOGG: ConvertNode - { - protected override string Extension => "ogg"; - public static List BitrateOptions => ConvertNode.BitrateOptions; - protected override List GetArguments() - { - return new List - { - "-c:a", - "libvorbis", - "-ab", - Bitrate + "k" - }; - } - } - - //public class ConvertToFLAC : ConvertNode - //{ - // protected override string Extension => "flac"; - // public static List BitrateOptions => ConvertNode.BitrateOptions; - // protected override List GetArguments() - // { - // return new List - // { - // "-c:a", - // "flac", - // "-ab", - // Bitrate + "k" - // }; - // } - //} - - public class ConvertAudio : ConvertNode - { - protected override string Extension => Codec; - - public static List BitrateOptions => ConvertNode.BitrateOptions; - - [Select(nameof(CodecOptions), 0)] - public string Codec { get; set; } - - [Boolean(3)] - public bool SkipIfCodecMatches { get; set; } - - public override int Outputs => 2; - - private static List _CodecOptions; - public static List CodecOptions - { - get - { - if (_CodecOptions == null) - { - _CodecOptions = new List - { - new ListOption { Label = "AAC", Value = "aac"}, - new ListOption { Label = "MP3", Value = "MP3"}, - new ListOption { Label = "OGG", Value = "ogg"}, - new ListOption { Label = "WAV", Value = "wav"}, - }; - } - return _CodecOptions; - } - } - - protected override List GetArguments() - { - string codec = Codec switch - { - "ogg" => "libvorbis", - "wav" => "pcm_s16le", - _ => Codec.ToLower() - }; - - return new List - { - "-c:a", - codec, - "-ab", - Bitrate + "k" - }; - } - - public override int Execute(NodeParameters args) - { - MusicInfo musicInfo = GetMusicInfo(args); - if (musicInfo == null) - return -1; - - if(musicInfo.Codec?.ToLower() == Codec?.ToLower()) - { - if (SkipIfCodecMatches) - { - args.Logger?.ILog($"Music file already '{Codec}' at bitrate '{musicInfo.Bitrate}', and set to skip if codec matches"); - return 2; - } - - if(musicInfo.Bitrate <= Bitrate) - { - args.Logger?.ILog($"Music file already '{Codec}' at bitrate '{musicInfo.Bitrate}'"); - return 2; - } - } - return base.Execute(args); - - } - } - - public abstract class ConvertNode:MusicNode - { - protected abstract string Extension { get; } - - protected virtual bool SetId3Tags => false; - - public override int Inputs => 1; - public override int Outputs => 1; - - protected virtual List GetArguments() - { - return new List - { - "-map_metadata", - "0:0", - "-ab", - Bitrate + "k" - }; - } - - public override FlowElementType Type => FlowElementType.Process; - - [Select(nameof(BitrateOptions), 1)] - public int Bitrate { get; set; } - - private static List _BitrateOptions; - public static List BitrateOptions - { - get - { - if (_BitrateOptions == null) - { - _BitrateOptions = new List - { - new ListOption { Label = "64 Kbps", Value = 64}, - new ListOption { Label = "96 Kbps", Value = 96}, - new ListOption { Label = "128 Kbps", Value = 128}, - new ListOption { Label = "160 Kbps", Value = 160}, - new ListOption { Label = "192 Kbps", Value = 192}, - new ListOption { Label = "224 Kbps", Value = 224}, - new ListOption { Label = "256 Kbps", Value = 256}, - new ListOption { Label = "288 Kbps", Value = 288}, - new ListOption { Label = "320 Kbps", Value = 320}, - }; - } - return _BitrateOptions; - } - } - - - public override int Execute(NodeParameters args) - { - string ffmpegExe = GetFFMpegExe(args); - if (string.IsNullOrEmpty(ffmpegExe)) - return -1; - - //MusicInfo musicInfo = GetMusicInfo(args); - //if (musicInfo == null) - // return -1; - - if (Bitrate < 64 || Bitrate > 320) - { - args.Logger?.ILog("Bitrate not set or invalid, setting to 192kbps"); - Bitrate = 192; - } - - string outputFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + Extension); - - var ffArgs = GetArguments(); - ffArgs.Insert(0, "-hide_banner"); - ffArgs.Insert(1, "-y"); // tells ffmpeg to replace the file if already exists, which it shouldnt but just incase - ffArgs.Insert(2, "-i"); - ffArgs.Insert(3, args.WorkingFile); - ffArgs.Insert(4, "-vn"); // disables video - ffArgs.Add(outputFile); - - args.Logger?.ILog("FFArgs: " + String.Join(" ", ffArgs.Select(x => x.IndexOf(" ") > 0 ? "\"" + x + "\"" : x).ToArray())); - - var result = args.Execute(new ExecuteArgs - { - Command = ffmpegExe, - ArgumentList = ffArgs.ToArray() - }); - - if(result.ExitCode != 0) - { - args.Logger?.ELog("Invalid exit code detected: " + result.ExitCode); - return -1; - } - - //CopyMetaData(outputFile, args.FileName); - - args.SetWorkingFile(outputFile); - - // update the music file info - if (ReadMusicFileInfo(args, ffmpegExe, args.WorkingFile)) - return 1; - - return -1; - } - - //private void CopyMetaData(string outputFile, string originalFile) - //{ - // Track original = new Track(originalFile); - // Track dest = new Track(outputFile); - - // dest.Album = original.Album; - // dest.AlbumArtist = original.AlbumArtist; - // dest.Artist = original.Artist; - // dest.Comment = original.Comment; - // dest.Composer= original.Composer; - // dest.Conductor = original.Conductor; - // dest.Copyright = original.Copyright; - // dest.Date = original.Date; - // dest.Description= original.Description; - // dest.DiscNumber= original.DiscNumber; - // dest.DiscTotal = original.DiscTotal; - // if (original.EmbeddedPictures?.Any() == true) - // { - // foreach (var pic in original.EmbeddedPictures) - // dest.EmbeddedPictures.Add(pic); - // } - // dest.Genre= original.Genre; - // dest.Lyrics= original.Lyrics; - // dest.OriginalAlbum= original.OriginalAlbum; - // dest.OriginalArtist = original.OriginalArtist; - // dest.Popularity= original.Popularity; - // dest.Publisher= original.Publisher; - // dest.PublishingDate= original.PublishingDate; - // dest.Title= original.Title; - // dest.TrackNumber= original.TrackNumber; - // dest.TrackTotal= original.TrackTotal; - // dest.Year= original.Year; - // foreach (var key in original.AdditionalFields.Keys) - // { - // if(dest.AdditionalFields.ContainsKey(key)) - // dest.AdditionalFields[key] = original.AdditionalFields[key]; - // else - // dest.AdditionalFields.Add(key, original.AdditionalFields[key]); - // } - - // dest.Save(); - //} - } -} diff --git a/MusicNodes/Nodes/MusicNode.cs b/MusicNodes/Nodes/MusicNode.cs deleted file mode 100644 index 62d5e261..00000000 --- a/MusicNodes/Nodes/MusicNode.cs +++ /dev/null @@ -1,110 +0,0 @@ -namespace FileFlows.MusicNodes -{ - using FileFlows.Plugin; - - public abstract class MusicNode : Node - { - public override bool Obsolete => true; - public override string Icon => "fas fa-music"; - - protected string GetFFMpegExe(NodeParameters args) - { - string ffmpeg = args.GetToolPath("FFMpeg"); - if (string.IsNullOrEmpty(ffmpeg)) - { - args.Logger.ELog("FFMpeg tool not found."); - return ""; - } - var fileInfo = new FileInfo(ffmpeg); - if (fileInfo.Exists == false) - { - args.Logger.ELog("FFMpeg tool configured by ffmpeg file does not exist."); - return ""; - } - return fileInfo.FullName; - } - protected string GetFFMpegPath(NodeParameters args) - { - string ffmpeg = args.GetToolPath("FFMpeg"); - if (string.IsNullOrEmpty(ffmpeg)) - { - args.Logger.ELog("FFMpeg tool not found."); - return ""; - } - var fileInfo = new FileInfo(ffmpeg); - if (fileInfo.Exists == false) - { - args.Logger.ELog("FFMpeg tool configured by ffmpeg file does not exist."); - return ""; - } - return fileInfo.DirectoryName; - } - - private const string MUSIC_INFO = "MusicInfo"; - protected void SetMusicInfo(NodeParameters args, MusicInfo musicInfo, Dictionary variables) - { - if (args.Parameters.ContainsKey(MUSIC_INFO)) - args.Parameters[MUSIC_INFO] = musicInfo; - else - args.Parameters.Add(MUSIC_INFO, musicInfo); - - if(musicInfo.Artist.EndsWith(", The")) - variables.AddOrUpdate("mi.Artist", "The " + musicInfo.Artist.Substring(0, musicInfo.Artist.Length - ", The".Length).Trim()); - else - variables.AddOrUpdate("mi.Artist", musicInfo.Artist); - - if(musicInfo.Artist?.StartsWith("The ") == true) - variables.AddOrUpdate("mi.ArtistThe", musicInfo.Artist.Substring(4).Trim() + ", The"); - else - variables.AddOrUpdate("mi.ArtistThe", musicInfo.Artist); - - variables.AddOrUpdate("mi.Album", musicInfo.Album); - variables.AddOrUpdate("mi.Bitrate", musicInfo.Bitrate); - variables.AddOrUpdate("mi.Channels", musicInfo.Channels); - variables.AddOrUpdate("mi.Codec", musicInfo.Codec); - variables.AddOrUpdate("mi.Date", musicInfo.Date); - variables.AddOrUpdate("mi.Year", musicInfo.Date.Year); - variables.AddOrUpdate("mi.Duration", musicInfo.Duration); - variables.AddOrUpdate("mi.Encoder", musicInfo.Encoder); - variables.AddOrUpdate("mi.Frequency", musicInfo.Frequency); - variables.AddOrUpdate("mi.Genres", musicInfo.Genres); - variables.AddOrUpdate("mi.Language", musicInfo.Language); - variables.AddOrUpdate("mi.Title", musicInfo.Title); - variables.AddOrUpdate("mi.Track", musicInfo.Track); - variables.AddOrUpdate("mi.Disc", musicInfo.Disc < 1 ? 1 : musicInfo.Disc); - variables.AddOrUpdate("mi.TotalDiscs", musicInfo.TotalDiscs < 1 ? 1 : musicInfo.TotalDiscs); - - args.UpdateVariables(variables); - } - - protected MusicInfo GetMusicInfo(NodeParameters args) - { - if (args.Parameters.ContainsKey(MUSIC_INFO) == false) - { - args.Logger.WLog("No codec information loaded, use a 'Music File' node first"); - return null; - } - var result = args.Parameters[MUSIC_INFO] as MusicInfo; - if (result == null) - { - args.Logger.WLog("MusicInfo not found for file"); - return null; - } - return result; - } - - protected bool ReadMusicFileInfo(NodeParameters args, string ffmpegExe, string filename) - { - - var musicInfo = new MusicInfoHelper(ffmpegExe, args.Logger).Read(filename); - if (musicInfo.Duration == 0) - { - args.Logger?.ILog("Failed to load music information."); - return false; - } - - SetMusicInfo(args, musicInfo, Variables); - return true; - } - } -} \ No newline at end of file diff --git a/MusicNodes/Plugin.cs b/MusicNodes/Plugin.cs deleted file mode 100644 index a6d1efab..00000000 --- a/MusicNodes/Plugin.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace FileFlows.MusicNodes; - -using System.ComponentModel.DataAnnotations; -using FileFlows.Plugin.Attributes; - -public class Plugin : FileFlows.Plugin.IPlugin -{ - public Guid Uid => new Guid("d84fbd06-f0e3-4827-8de0-6b0ef20dd883"); - public string Name => "Music Nodes (Obsolete)"; - public string MinimumVersion => "1.0.4.2019"; - - public void Init() - { - } -} diff --git a/MusicNodes/Tests/AudioFileNormalizationTests.cs b/MusicNodes/Tests/AudioFileNormalizationTests.cs deleted file mode 100644 index 6e2f7602..00000000 --- a/MusicNodes/Tests/AudioFileNormalizationTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -#if(DEBUG) - - -namespace FileFlows.MusicNodes.Tests; - -using FileFlows.MusicNodes; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -[TestClass] -public class AudioFileNormalizationTests -{ - [TestMethod] - public void AudioFileNormalization_Mp3() - { - - const string file = @"D:\music\unprocessed\01-billy_joel-movin_out.mp3"; - - AudioFileNormalization node = new (); - var logger = new TestLogger(); - var args = new FileFlows.Plugin.NodeParameters(file, logger, false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - string log = logger.ToString(); - - Assert.AreEqual(1, output); - } - [TestMethod] - public void AudioFileNormalization_Bulk() - { - - foreach (var file in Directory.GetFiles(@"d:\music\unprocessed")) - { - - AudioFileNormalization node = new(); - var logger = new TestLogger(); - var args = new FileFlows.Plugin.NodeParameters(file, logger, false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - string log = logger.ToString(); - - Assert.AreEqual(1, output); - } - } - - - [TestMethod] - public void AudioFileNormalization_ConvertFlacToMp3() - { - - const string file = @"D:\music\flacs\03-billy_joel-dont_ask_me_why.flac"; - var logger = new TestLogger(); - var args = new FileFlows.Plugin.NodeParameters(file, logger, false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - - new MusicFile().Execute(args); // need to read the music info and set it - - ConvertToMP3 convertNode = new(); - int output = convertNode.Execute(args); - - - AudioFileNormalization normalNode = new(); - output = normalNode.Execute(args); - - string log = logger.ToString(); - - Assert.AreEqual(1, output); - } -} - -#endif \ No newline at end of file diff --git a/MusicNodes/Tests/ConvertTests.cs b/MusicNodes/Tests/ConvertTests.cs deleted file mode 100644 index 6954a3e6..00000000 --- a/MusicNodes/Tests/ConvertTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -#if(DEBUG) - - -namespace FileFlows.MusicNodes.Tests -{ - using FileFlows.MusicNodes; - using Microsoft.VisualStudio.TestTools.UnitTesting; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - [TestClass] - public class ConvertTests - { - [TestMethod] - public void Convert_FlacToAac() - { - - const string file = @"D:\music\unprocessed\01-billy_joel-you_may_be_right.flac"; - - ConvertToAAC node = new (); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - - [TestMethod] - public void Convert_FlacToMp3() - { - - const string file = @"D:\music\unprocessed\01-billy_joel-you_may_be_right.flac"; - - ConvertToMP3 node = new(); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - [TestMethod] - public void Convert_Mp3ToWAV() - { - - const string file = @"D:\music\unprocessed\04-billy_joel-scenes_from_an_italian_restaurant-b2125758.mp3"; - - ConvertToWAV node = new(); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - - [TestMethod] - public void Convert_Mp3ToOgg() - { - - const string file = @"D:\music\unprocessed\04-billy_joel-scenes_from_an_italian_restaurant-b2125758.mp3"; - - ConvertToOGG node = new(); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - - - [TestMethod] - public void Convert_AacToMp3() - { - - const string file = @"D:\music\temp\37f315a0-4afc-4a72-a0b4-eb7eb681b9b3.aac"; - - ConvertToMP3 node = new(); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - - [TestMethod] - public void Convert_Mp3_AlreadyMp3() - { - - const string file = @"D:\videos\music\13-the_cranberries-why.mp3"; - - ConvertAudio node = new(); - node.SkipIfCodecMatches = true; - node.Codec = "mp3"; - - node.Bitrate = 192; - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - new MusicFile().Execute(args); // need to read the music info and set it - int output = node.Execute(args); - - Assert.AreEqual(2, output); - } - - [TestMethod] - public void Convert_VideoToMp3() - { - - const string file = @"D:\videos\testfiles\basic.mkv"; - - ConvertToMP3 node = new(); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - //new MusicFile().Execute(args); // need to read the music info and set it - node.PreExecute(args); - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - - [TestMethod] - public void Convert_VideoToAac() - { - - const string file = @"D:\videos\testfiles\basic.mkv"; - - ConvertToAAC node = new(); - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; - args.TempPath = @"D:\music\temp"; - //new MusicFile().Execute(args); // need to read the music info and set it - node.PreExecute(args); - int output = node.Execute(args); - - Assert.AreEqual(1, output); - } - } -} - -#endif \ No newline at end of file diff --git a/MusicNodes/Tests/MusicInfoTests.cs b/MusicNodes/Tests/MusicInfoTests.cs deleted file mode 100644 index fc7e86cc..00000000 --- a/MusicNodes/Tests/MusicInfoTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -#if(DEBUG) - - -namespace FileFlows.MusicNodes.Tests -{ - using FileFlows.MusicNodes; - using Microsoft.VisualStudio.TestTools.UnitTesting; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - [TestClass] - public class MusicInfoTests - { - [TestMethod] - public void MusicInfo_SplitTrack() - { - - const string file = @"\\oracle\music\The Cranberries\No Need To Argue\The Cranberries - No Need To Argue - 00 - I Don't Need (Demo).mp3"; - const string ffmpegExe = @"C:\utils\ffmpeg\ffmpeg.exe"; - - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => ffmpegExe; - args.TempPath = @"D:\music\temp"; - - var musicInfo = new MusicInfoHelper(ffmpegExe, args.Logger).Read(args.WorkingFile); - - Assert.AreEqual(9, musicInfo.Track); - } - - [TestMethod] - public void MusicInfo_NormalTrack() - { - - const string file = @"\\oracle\music\Taylor Swift\Speak Now\Taylor Swift - Speak Now - 08 - Never Grow Up.mp3"; - const string ffmpegExe = @"C:\utils\ffmpeg\ffmpeg.exe"; - - var args = new FileFlows.Plugin.NodeParameters(file, new TestLogger(), false, string.Empty); - args.GetToolPathActual = (string tool) => ffmpegExe; - args.TempPath = @"D:\music\temp"; - - var musicInfo = new MusicInfoHelper(ffmpegExe, args.Logger).Read(args.WorkingFile); - - Assert.AreEqual(8, musicInfo.Track); - } - - [TestMethod] - public void MusicInfo_GetMetaData() - { - const string ffmpegExe = @"C:\utils\ffmpeg\ffmpeg.exe"; - var logger = new TestLogger(); - foreach (string file in Directory.GetFiles(@"D:\videos\music")) - { - var args = new FileFlows.Plugin.NodeParameters(file, logger, false, string.Empty); - args.GetToolPathActual = (string tool) => ffmpegExe; - - // laod the variables - Assert.AreEqual(1, new MusicFile().Execute(args)); - - var mi = new MusicInfoHelper(ffmpegExe, args.Logger).Read(args.WorkingFile); - - string folder = args.ReplaceVariables("{mi.ArtistThe} ({mi.Year})"); - Assert.AreEqual($"{mi.Artist} ({mi.Date.Year})", folder); - - string fname = args.ReplaceVariables("{mi.Artist} - {mi.Album} - {mi.Track:##} - {mi.Title}"); - Assert.AreEqual($"{mi.Artist} - {mi.Track.ToString("00")} - {mi.Title}", fname); - } - } - - [TestMethod] - public void MusicInfo_FileNameMetadata() - { - const string ffmpegExe = @"C:\utils\ffmpeg\ffmpeg.exe"; - var logger = new TestLogger(); - string file = @"\\jor-el\music\Meat Loaf\Bat out of Hell II- Back Into Hell… (1993)\Meat Loaf - Bat out of Hell II- Back Into Hell… - 03 - I’d Do Anything for Love (but I Won’t Do That).flac"; - - var mi = new MusicInfo(); - - new MusicInfoHelper(ffmpegExe, logger).ParseFileNameInfo(file, mi); - - Assert.AreEqual("Meat Loaf", mi.Artist); - Assert.AreEqual("Bat out of Hell II- Back Into Hell…", mi.Album); - Assert.AreEqual(1993, mi.Date.Year); - Assert.AreEqual("I’d Do Anything for Love (but I Won’t Do That)", mi.Title); - Assert.AreEqual(3, mi.Track); - } - } -} - -#endif \ No newline at end of file diff --git a/MusicNodes/Tests/TestLogger.cs b/MusicNodes/Tests/TestLogger.cs deleted file mode 100644 index 4b5592e8..00000000 --- a/MusicNodes/Tests/TestLogger.cs +++ /dev/null @@ -1,61 +0,0 @@ -#if(DEBUG) - -namespace FileFlows.MusicNodes.Tests -{ - using FileFlows.Plugin; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - internal class TestLogger : ILogger - { - private List Messages = new List(); - - public void DLog(params object[] args) => Log("DBUG", args); - - public void ELog(params object[] args) => Log("ERRR", args); - - public void ILog(params object[] args) => Log("INFO", args); - - public void WLog(params object[] args) => Log("WARN", args); - - private void Log(string type, object[] args) - { - if (args == null || args.Length == 0) - return; - string message = type + " -> " + - string.Join(", ", args.Select(x => - x == null ? "null" : - x.GetType().IsPrimitive || x is string ? x.ToString() : - System.Text.Json.JsonSerializer.Serialize(x))); - Messages.Add(message); - } - - public bool Contains(string message) - { - if (string.IsNullOrWhiteSpace(message)) - return false; - - string log = string.Join(Environment.NewLine, Messages); - return log.Contains(message); - } - - public override string ToString() - { - return String.Join(Environment.NewLine, this.Messages.ToArray()); - } - - public string GetTail(int length = 50) - { - if (length <= 0) - length = 50; - if (Messages.Count <= length) - return string.Join(Environment.NewLine, Messages); - return string.Join(Environment.NewLine, Messages.TakeLast(length)); - } - } -} - -#endif \ No newline at end of file diff --git a/Plex/Plex.csproj b/Plex/Plex.csproj index f3f24749..37cc75f4 100644 --- a/Plex/Plex.csproj +++ b/Plex/Plex.csproj @@ -5,8 +5,8 @@ enable enable FileFlows.$(MSBuildProjectName.Replace(" ", "_")) - 1.0.4.189 - 1.0.4.189 + 1.0.6.495 + 1.0.6.495 true true FileFlows diff --git a/VideoLegacyNodes/ExtensionMethods.cs b/VideoLegacyNodes/ExtensionMethods.cs deleted file mode 100644 index 560e3432..00000000 --- a/VideoLegacyNodes/ExtensionMethods.cs +++ /dev/null @@ -1,64 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.Linq; - using System.Text.RegularExpressions; - - internal static class ExtensionMethods - { - public static void AddOrUpdate(this Dictionary dict, string key, object value) { - if (dict.ContainsKey(key)) - dict[key] = value; - else - dict.Add(key, value); - } - public static string? EmptyAsNull(this string str) - { - return str == string.Empty ? null : str; - } - - public static bool TryMatch(this Regex regex, string input, out Match match) - { - match = regex.Match(input); - return match.Success; - } - - public static IEnumerable SplitCommandLine(this string commandLine) - { - bool inQuotes = false; - - return commandLine.Split(c => - { - if (c == '\"') - inQuotes = !inQuotes; - - return !inQuotes && c == ' '; - }) - .Select(arg => arg.Trim().TrimMatchingQuotes('\"')) - .Where(arg => !string.IsNullOrEmpty(arg)); - } - public static IEnumerable Split(this string str, - Func controller) - { - int nextPiece = 0; - - for (int c = 0; c < str.Length; c++) - { - if (controller(str[c])) - { - yield return str.Substring(nextPiece, c - nextPiece); - nextPiece = c + 1; - } - } - - yield return str.Substring(nextPiece); - } - public static string TrimMatchingQuotes(this string input, char quote) - { - if ((input.Length >= 2) && - (input[0] == quote) && (input[input.Length - 1] == quote)) - return input.Substring(1, input.Length - 2); - - return input; - } - } -} diff --git a/VideoLegacyNodes/FFMpegEncoder.cs b/VideoLegacyNodes/FFMpegEncoder.cs deleted file mode 100644 index f07b911b..00000000 --- a/VideoLegacyNodes/FFMpegEncoder.cs +++ /dev/null @@ -1,235 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.Diagnostics; - using System.Text; - using System.Text.RegularExpressions; - using FileFlows.Plugin; - - public class FFMpegEncoder - { - private string ffMpegExe; - private ILogger Logger; - - StringBuilder outputBuilder, errorBuilder; - TaskCompletionSource outputCloseEvent, errorCloseEvent; - - private Regex rgxTime = new Regex(@"(?<=(time=))([\d]+:?)+\.[\d]+"); - - public delegate void TimeEvent(TimeSpan time); - public event TimeEvent AtTime; - - private Process process; - - public FFMpegEncoder(string ffMpegExe, ILogger logger) - { - this.ffMpegExe = ffMpegExe; - this.Logger = logger; - } - - public (bool successs, string output) Encode(string input, string output, List arguments, bool dontAddInputFile = false, bool dontAddOutputFile = false) - { - arguments ??= new List (); - - // -y means it will overwrite a file if output already exists - if (dontAddInputFile == false) { - arguments.Insert(0, "-i"); - arguments.Insert(1, input); - arguments.Insert(2, "-y"); - } - - if (dontAddOutputFile == false) - { - if (arguments.Last() != "-") - arguments.Add(output); - else - Logger.ILog("Last argument '-' skipping adding output file"); - } - - string argsString = String.Join(" ", arguments.Select(x => x.IndexOf(" ") > 0 ? "\"" + x + "\"" : x)); - Logger.ILog(new string('=', ("FFMpeg.Arguments: " + argsString).Length)); - Logger.ILog("FFMpeg.Arguments: " + argsString); - Logger.ILog(new string('=', ("FFMpeg.Arguments: " + argsString).Length)); - - var task = ExecuteShellCommand(ffMpegExe, arguments, 0); - task.Wait(); - Logger.ILog("Exit Code: " + task.Result.ExitCode); - return (task.Result.ExitCode == 0, task.Result.Output); // exitcode 0 means it was successful - } - - internal void Cancel() - { - try - { - if (this.process != null) - { - this.process.Kill(); - this.process = null; - } - - } - catch (Exception) { } - } - - public async Task ExecuteShellCommand(string command, List arguments, int timeout = 0) - { - var result = new ProcessResult(); - - using (var process = new Process()) - { - this.process = process; - - process.StartInfo.FileName = command; - if (arguments?.Any() == true) - { - foreach (string arg in arguments) - process.StartInfo.ArgumentList.Add(arg); - } - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardInput = true; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.CreateNoWindow = true; - - outputBuilder = new StringBuilder(); - outputCloseEvent = new TaskCompletionSource(); - - process.OutputDataReceived += OnOutputDataReceived; - - errorBuilder = new StringBuilder(); - errorCloseEvent = new TaskCompletionSource(); - - process.ErrorDataReceived += OnErrorDataReceived; - - bool isStarted; - - try - { - isStarted = process.Start(); - } - catch (Exception error) - { - // Usually it occurs when an executable file is not found or is not executable - - result.Completed = true; - result.ExitCode = -1; - result.Output = error.Message; - - isStarted = false; - } - - if (isStarted) - { - // Reads the output stream first and then waits because deadlocks are possible - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - // Creates task to wait for process exit using timeout - var waitForExit = WaitForExitAsync(process, timeout); - - // Create task to wait for process exit and closing all output streams - var processTask = Task.WhenAll(waitForExit, outputCloseEvent.Task, errorCloseEvent.Task); - - // Waits process completion and then checks it was not completed by timeout - if ( - ( - (timeout > 0 && await Task.WhenAny(Task.Delay(timeout), processTask) == processTask) || - (timeout == 0 && await Task.WhenAny(processTask) == processTask) - ) - && waitForExit.Result) - { - result.Completed = true; - result.ExitCode = process.ExitCode; - result.Output = $"{outputBuilder}{errorBuilder}"; - } - else - { - try - { - // Kill hung process - process.Kill(); - } - catch - { - } - } - } - } - process = null; - - return result; - } - public void OnOutputDataReceived(object sender, DataReceivedEventArgs e) - { - // The output stream has been closed i.e. the process has terminated - if (e.Data == null) - { - outputCloseEvent.SetResult(true); - } - else - { - if (e.Data.Contains("Skipping NAL unit")) - return; // just slighlty ignore these - if (rgxTime.IsMatch(e.Data)) - { - var timeString = rgxTime.Match(e.Data).Value; - var ts = TimeSpan.Parse(timeString); - Logger.DLog("TimeSpan Detected: " + ts); - if (AtTime != null) - AtTime.Invoke(ts); - } - Logger.ILog(e.Data); - outputBuilder.AppendLine(e.Data); - } - } - - public void OnErrorDataReceived(object sender, DataReceivedEventArgs e) - { - // The error stream has been closed i.e. the process has terminated - if (e.Data == null) - { - errorCloseEvent.SetResult(true); - } - else if (e.Data.ToLower().Contains("failed") || e.Data.Contains("No capable devices found") || e.Data.ToLower().Contains("error")) - { - Logger.ELog(e.Data); - errorBuilder.AppendLine(e.Data); - } - else if (e.Data.Contains("Skipping NAL unit")) - { - return; // just slighlty ignore these - } - else - { - if (rgxTime.IsMatch(e.Data)) - { - var timeString = rgxTime.Match(e.Data).Value; - var ts = TimeSpan.Parse(timeString); - if (AtTime != null) - AtTime.Invoke(ts); - } - Logger.ILog(e.Data); - outputBuilder.AppendLine(e.Data); - } - } - - - private static Task WaitForExitAsync(Process process, int timeout) - { - if (timeout > 0) - return Task.Run(() => process.WaitForExit(timeout)); - return Task.Run(() => - { - process.WaitForExit(); - return Task.FromResult(true); - }); - } - - - public struct ProcessResult - { - public bool Completed; - public int? ExitCode; - public string Output; - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/GlobalUsings.cs b/VideoLegacyNodes/GlobalUsings.cs deleted file mode 100644 index 8c2a7e49..00000000 --- a/VideoLegacyNodes/GlobalUsings.cs +++ /dev/null @@ -1,9 +0,0 @@ -global using System; -global using System.Linq; -global using System.Collections.Generic; -global using System.Text.RegularExpressions; -global using System.ComponentModel.DataAnnotations; -global using FileFlows.Plugin; -global using FileFlows.Plugin.Attributes; -global using System.ComponentModel; - diff --git a/VideoLegacyNodes/LogicalNodes/CanUseHardwareEncodingChecker.cs b/VideoLegacyNodes/LogicalNodes/CanUseHardwareEncodingChecker.cs deleted file mode 100644 index e83d9aad..00000000 --- a/VideoLegacyNodes/LogicalNodes/CanUseHardwareEncodingChecker.cs +++ /dev/null @@ -1,124 +0,0 @@ -namespace FileFlows.VideoNodes; - -class CanUseHardwareEncodingChecker -{ - public enum HardwareEncoder - { - Nvidia_H264 = 1, - Amd_H264 = 2, - Qsv_H264 = 3, - Vaapi_H264 = 4, - - Nvidia_Hevc = 11, - Amd_Hevc = 12, - Qsv_Hevc = 13, - Vaapi_Hevc = 14, - } - - - ///

- /// Checks if this flow runner can use NVIDIA HEVC encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Nvidia_Hevc(NodeParameters args) => CanProcess(args, "hevc_nvenc"); - - /// - /// Checks if this flow runner can use NVIDIA H.264 encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Nvidia_H264(NodeParameters args) => CanProcess(args, "h264_nvenc"); - - /// - /// Checks if this flow runner can use AND HEVC encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Amd_Hevc(NodeParameters args) => CanProcess(args, "hevc_amf"); - - /// - /// Checks if this flow runner can use AND H.264 encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Amd_H264(NodeParameters args) => CanProcess(args, "h264_amf"); - - - /// - /// Checks if this flow runner can use Intels QSV HEVC encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Qsv_Hevc(NodeParameters args) => CanProcess(args, "hevc_qsv -global_quality 28 -load_plugin hevc_hw"); - - /// - /// Checks if this flow runner can use Intels QSV H.264 encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Qsv_H264(NodeParameters args) => CanProcess(args, "h264_qsv"); - - /// - /// Checks if this flow runner can use VAAPI HEVC encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Vaapi_Hevc(NodeParameters args) => CanProcess(args, "hevc_vaapi"); - - /// - /// Checks if this flow runner can use VAAPI H.264 encoder - /// - /// the node parameters - /// true if can use it, otherwise false - internal static bool CanProcess_Vaapi_H264(NodeParameters args) => CanProcess(args, "h264_vaapi"); - - private static bool CanProcess(NodeParameters args, string encodingParams) - { - string ffmpeg = args.GetToolPath("FFMpeg"); - if (string.IsNullOrEmpty(ffmpeg)) - { - args.Logger.ELog("FFMpeg tool not found."); - return false; - } - - return CanProcess(args, ffmpeg, encodingParams); - } - - /// - /// Tests if the encoding parameters can be executed - /// - /// the node paramterse - /// the location of ffmpeg - /// the encoding parameter to test - /// true if can be processed - internal static bool CanProcess(NodeParameters args, string ffmpeg, string encodingParams) - { - bool can = CanExecute(); - if (can == false && encodingParams?.Contains("amf") == true) - { - // AMD/AMF has a issue where it reports false at first but then passes - // https://github.com/revenz/FileFlows/issues/106 - Thread.Sleep(2000); - can = CanExecute(); - } - return can; - - bool CanExecute() - { - string cmdArgs = $"-loglevel error -f lavfi -i color=black:s=1080x1080 -vframes 1 -an -c:v {encodingParams} -f null -\""; - var cmd = args.Process.ExecuteShellCommand(new ExecuteArgs - { - Command = ffmpeg, - Arguments = cmdArgs, - Silent = true - }).Result; - if (cmd.ExitCode != 0 || string.IsNullOrWhiteSpace(cmd.Output) == false) - { - args.Logger?.WLog($"Cant process '{encodingParams}': {cmd.Output ?? ""}"); - return false; - } - return true; - } - } -} diff --git a/VideoLegacyNodes/LogicalNodes/DetectBlackBars.cs b/VideoLegacyNodes/LogicalNodes/DetectBlackBars.cs deleted file mode 100644 index 3abf347c..00000000 --- a/VideoLegacyNodes/LogicalNodes/DetectBlackBars.cs +++ /dev/null @@ -1,157 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.ComponentModel; - using System.Diagnostics; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - - public class DetectBlackBars : VideoNode - { - public override int Outputs => 2; - public override int Inputs => 1; - public override FlowElementType Type => FlowElementType.Logic; - - public override string HelpUrl => "https://docs.fileflows.com/plugins/video-nodes/logical-nodes/detect-black-bars"; - - public override string Icon => "fas fa-film"; - - internal const string CROP_KEY = "VideoCrop"; - - private Dictionary _Variables; - public override Dictionary Variables => _Variables; - - [NumberInt(1)] - public int CroppingThreshold { get; set; } - - public DetectBlackBars() - { - _Variables = new Dictionary() - { - { CROP_KEY, "1920:1000:0:40" } - }; - } - - public override int Execute(NodeParameters args) - { - var videoInfo = GetVideoInfo(args); - if (videoInfo == null || videoInfo.VideoStreams?.Any() != true) - return -1; - - - string crop = Detect(FFMPEG, videoInfo, args, this.CroppingThreshold); - if (crop == string.Empty) - return 2; - - args.Logger?.ILog("Black bars detected, crop: " + crop); - args.UpdateVariables(new Dictionary - { - { CROP_KEY, crop } - }); - - return 1; - } - - public static string Detect(string ffmpeg, VideoInfo videoInfo, NodeParameters args, int threshold) - { - int vidWidth = videoInfo.VideoStreams[0].Width; - int vidHeight = videoInfo.VideoStreams[0].Height; - if (vidWidth < 1) - { - args.Logger?.ELog("Failed to find video width"); - return string.Empty; - } - if (vidHeight < 1) - { - args.Logger?.ELog("Failed to find video height"); - return string.Empty; - } - return Execute(ffmpeg, args.WorkingFile, args, vidWidth, vidHeight, threshold); - } - - public static string Execute(string ffplay, string file, NodeParameters args, int vidWidth, int vidHeight, int threshold) - { - try - { - int x = int.MaxValue; - int y = int.MaxValue; - int width = 0; - int height = 0; - foreach (int ss in new int[] { 60, 120, 240, 360 }) // check at multiple times - { - using (var process = new Process()) - { - process.StartInfo = new ProcessStartInfo(); - process.StartInfo.FileName = ffplay; - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.CreateNoWindow = true; - process.StartInfo.Arguments = $" -ss {ss} -i \"{file}\" -hide_banner -vframes 25 -vf cropdetect -f null -"; - args.Logger?.DLog("Executing ffmpeg " + process.StartInfo.Arguments); - process.Start(); - string output = process.StandardError.ReadToEnd(); - Console.WriteLine(output); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - var dimMatch = Regex.Match(output, @"Stream #[\d]+:[\d]+(.*?)Video:(.*?)([\d]+)x([\d]+)", RegexOptions.Multiline); - if (dimMatch.Success == false) - { - args.Logger?.WLog("Can't find dimensions for video"); - continue; - } - - var matches = Regex.Matches(output, @"(?<=(crop=))([\d]+:){3}[\d]+"); - foreach (Match match in matches) - { - int[] parts = match.Value.Split(':').Select(x => int.Parse(x)).ToArray(); - x = Math.Min(x, parts[2]); - y = Math.Min(y, parts[3]); - width = Math.Max(width, parts[0]); - height = Math.Max(height, parts[1]); - } - } - } - - if (width == 0 || height == 0) - { - args.Logger?.WLog("Width/Height not detected: " + width + "x" + height); - return String.Empty; - } - if(x == 0 && y == 0) - { - // nothing to do - return String.Empty; - } - - if (x == int.MaxValue) - x = 0; - if (y == int.MaxValue) - y = 0; - - if (threshold < 0) - threshold = 0; - - args.Logger?.DLog($"Video dimensions: {vidWidth}x{vidHeight}"); - - var willCrop = TestAboveThreshold(vidWidth, vidHeight, width, height, threshold); - args.Logger?.ILog($"Crop detection, x:{x}, y:{y}, width: {width}, height: {height}, total:{willCrop.diff}, threshold:{threshold}, above threshold: {willCrop}"); - - return willCrop.crop ? $"{width}:{height}:{x}:{y}" : string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } - - public static (bool crop, int diff) TestAboveThreshold(int vidWidth, int vidHeight, int detectedWidth, int detectedHeight, int threshold) - { - int diff = (vidWidth - detectedWidth) + (vidHeight - detectedHeight); - return (diff > threshold, diff); - - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/LogicalNodes/VideoCodec.cs b/VideoLegacyNodes/LogicalNodes/VideoCodec.cs deleted file mode 100644 index 4b838bd2..00000000 --- a/VideoLegacyNodes/LogicalNodes/VideoCodec.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.Linq; - using System.ComponentModel; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System.ComponentModel.DataAnnotations; - - public class VideoCodec : VideoNode - { - - public override string ObsoleteMessage => "This node has been combined into 'Video Has Track'"; - - public override int Inputs => 1; - public override int Outputs => 2; - public override FlowElementType Type => FlowElementType.Logic; - - public override string HelpUrl => "https://docs.fileflows.com/plugins/video-nodes/logical-nodes/video-codec"; - - [StringArray(1)] - [Required] - public string[] Codecs { get; set; } - - public override int Execute(NodeParameters args) - { - var videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - var codec = videoInfo.VideoStreams.FirstOrDefault(x => Codecs.Contains(x.Codec.ToLower())); - if (codec != null) - { - args.Logger.ILog($"Matching video codec found[{codec.Index}]: {codec.Codec}"); - return 1; - } - - var acodec = videoInfo.AudioStreams.FirstOrDefault(x => Codecs.Contains(x.Codec.ToLower())); - if (acodec != null) - { - args.Logger.ILog($"Matching audio codec found[{acodec.Index}]: {acodec.Codec}, language: {acodec.Language}"); - return 1; - } - - // not found, execute 2nd outputacodec - return 2; - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/Plugin.cs b/VideoLegacyNodes/Plugin.cs deleted file mode 100644 index f1c409cf..00000000 --- a/VideoLegacyNodes/Plugin.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace FileFlows.VideoNodes; - -using System.ComponentModel.DataAnnotations; -using FileFlows.Plugin.Attributes; - -public class Plugin : FileFlows.Plugin.IPlugin -{ - public Guid Uid => new Guid("881b486b-4b38-4e66-b39e-fbc0fc9deee0"); - public string Name => "Video Nodes"; - public string MinimumVersion => "1.0.4.2019"; - - public void Init() - { - } -} diff --git a/VideoLegacyNodes/ResolutionHelper.cs b/VideoLegacyNodes/ResolutionHelper.cs deleted file mode 100644 index 14507293..00000000 --- a/VideoLegacyNodes/ResolutionHelper.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace FileFlows.VideoNodes -{ - internal class ResolutionHelper - { - public enum Resolution - { - Unknown, - r480p, - r720p, - r1080p, - r4k, - } - - public static Resolution GetResolution(VideoInfo videoInfo) - { - var video = videoInfo?.VideoStreams?.FirstOrDefault(); - if (video == null) - return Resolution.Unknown; - return GetResolution(video.Width, video.Height); - } - - public static Resolution GetResolution(int width, int height) - { - // so if the video is in portait mode, we test the height as if it were the width - int w = Math.Max(width, height); - int h = Math.Min(width, height); - - if (Between(w, 1860, 1980)) - return Resolution.r1080p; - else if (Between(w, 3780, 3900)) - return Resolution.r4k; - else if (Between(w, 1220, 1340)) - return Resolution.r720p; - else if (Between(w, 600, 700)) - return Resolution.r480p; - - return Resolution.Unknown; - } - - - private static bool Between(int value, int lower, int max) => value >= lower && value <= max; - } -} diff --git a/VideoLegacyNodes/VideoInfo.cs b/VideoLegacyNodes/VideoInfo.cs deleted file mode 100644 index e1927f29..00000000 --- a/VideoLegacyNodes/VideoInfo.cs +++ /dev/null @@ -1,113 +0,0 @@ -namespace FileFlows.VideoNodes -{ - public class VideoInfo - { - public string FileName { get; set; } - /// - /// Gets or sets the bitrate in bytes per second - /// - public float Bitrate { get; set; } - public List VideoStreams { get; set; } = new List(); - public List AudioStreams { get; set; } = new List(); - public List SubtitleStreams { get; set; } = new List(); - - public List Chapters { get; set; } = new List(); - } - - public class VideoFileStream - { - /// - /// The original index of the stream in the overall video - /// - public int Index { get; set; } - /// - /// The index of the specific type - /// - public int TypeIndex { get; set; } - /// - /// The stream title (name) - /// - public string Title { get; set; } = ""; - - /// - /// The bitrate(BPS) of the video stream in bytes per second - /// - public float Bitrate { get; set; } - - /// - /// The codec of the stream - /// - public string Codec { get; set; } = ""; - - public string IndexString { get; set; } - - /// - /// Gets or sets if the stream is HDR - /// - public bool HDR { get; set; } - } - - public class VideoStream : VideoFileStream - { - /// - /// The width of the video stream - /// - public int Width { get; set; } - /// - /// The height of the video stream - /// - public int Height { get; set; } - /// - /// The number of frames per second - /// - public float FramesPerSecond { get; set; } - - /// - /// The duration of the stream - /// - public TimeSpan Duration { get; set; } - } - - public class AudioStream : VideoFileStream - { - /// - /// The language of the stream - /// - public string Language { get; set; } - - /// - /// The channels of the stream - /// - public float Channels { get; set; } - - /// - /// The duration of the stream - /// - public TimeSpan Duration { get; set; } - - /// - /// The sample rate of the audio stream - /// - public int SampleRate { get; set; } - } - - public class SubtitleStream : VideoFileStream - { - /// - /// The language of the stream - /// - public string Language { get; set; } - - /// - /// If this is a forced subtitle - /// - public bool Forced { get; set; } - } - - public class Chapter - { - public string Title { get; set; } - public TimeSpan Start { get; set; } - public TimeSpan End { get; set; } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoInfoHelper.cs b/VideoLegacyNodes/VideoInfoHelper.cs deleted file mode 100644 index e499f08c..00000000 --- a/VideoLegacyNodes/VideoInfoHelper.cs +++ /dev/null @@ -1,322 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.Collections.Generic; - using System.Diagnostics; - using System.IO; - using System.Text.RegularExpressions; - using FileFlows.Plugin; - - public class VideoInfoHelper - { - private string ffMpegExe; - private ILogger Logger; - - static Regex rgxTitle = new Regex(@"(?<=((^[\s]+title[\s]+:[\s])))(.*?)$", RegexOptions.Multiline); - static Regex rgxDuration = new Regex(@"(?<=((^[\s]+DURATION(\-[\w]+)?[\s]+:[\s])))([\d]+:?)+\.[\d]{1,7}", RegexOptions.Multiline); - static Regex rgxDuration2 = new Regex(@"(?<=((^[\s]+Duration:[\s])))([\d]+:?)+\.[\d]{1,7}", RegexOptions.Multiline); - static Regex rgxAudioSampleRate = new Regex(@"(?<=((,|\s)))[\d]+(?=([\s]?hz))", RegexOptions.IgnoreCase); - - static int _ProbeSize = 25; - internal static int ProbeSize - { - get => _ProbeSize; - set - { - if (value < 5) - _ProbeSize = 5; - else if (value > 1000) - _ProbeSize = 1000; - else - _ProbeSize = value; - } - } - - public VideoInfoHelper(string ffMpegExe, ILogger logger) - { - this.ffMpegExe = ffMpegExe; - this.Logger = logger; - } - - public static string GetFFMpegPath(NodeParameters args) => args.GetToolPath("FFMpeg"); - public VideoInfo Read(string filename) - { - var vi = new VideoInfo(); - vi.FileName = filename; - if (File.Exists(filename) == false) - { - Logger.ELog("File not found: " + filename); - return vi; - } - if (string.IsNullOrEmpty(ffMpegExe) || File.Exists(ffMpegExe) == false) - { - Logger.ELog("FFMpeg not found: " + (ffMpegExe ?? "not passed in")); - return vi; - } - - try - { - using (var process = new Process()) - { - process.StartInfo = new ProcessStartInfo(); - process.StartInfo.FileName = ffMpegExe; - process.StartInfo.UseShellExecute = false; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.CreateNoWindow = true; - foreach (var arg in new[] - { - "-hide_banner", - "-probesize", ProbeSize + "M", - "-i", - filename, - }) - { - process.StartInfo.ArgumentList.Add(arg); - } - process.Start(); - string output = process.StandardError.ReadToEnd(); - output = output.Replace("At least one output file must be specified", string.Empty).Trim(); - string error = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - if (string.IsNullOrEmpty(error) == false && error != "At least one output file must be specified") - { - Logger.ELog("Failed reading ffmpeg info: " + error); - return vi; - } - - Logger.ILog("Video Information:" + Environment.NewLine + output); - vi = ParseOutput(Logger, output); - } - } - catch (Exception ex) - { - Logger.ELog(ex.Message, ex.StackTrace.ToString()); - } - - return vi; - } - - public static VideoInfo ParseOutput(ILogger logger, string output) - { - var vi = new VideoInfo(); - var rgxStreams = new Regex(@"Stream\s#[\d]+:[\d]+(.*?)(?=(Stream\s#[\d]|$))", RegexOptions.Singleline); - var streamMatches = rgxStreams.Matches(output); - int streamIndex = 0; - - - // get a rough estimate, bitrate: 346 kb/s - var rgxBitrate = new Regex(@"(?<=(bitrate: ))[\d\.]+(?!=( kb/s))"); - var brMatch = rgxBitrate.Match(output); - if (brMatch.Success) - { - vi.Bitrate = float.Parse(brMatch.Value) * 1_000; // to convert to b/s - } - - vi.Chapters = ParseChapters(output); - - int subtitleIndex = 0; - int videoIndex = 0; - int audioIndex = 0; - foreach (Match sm in streamMatches) - { - if (sm.Value.Contains(" Video: ")) - { - var vs = ParseVideoStream(logger, sm.Value, output); - if (vs != null) - { - vs.Index = streamIndex; - vs.TypeIndex = videoIndex; - var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+"); - if (match.Success) - vs.IndexString = match.Value; - vi.VideoStreams.Add(vs); - } - ++videoIndex; - } - else if (sm.Value.Contains(" Audio: ")) - { - var audio = ParseAudioStream(sm.Value); - if (audio != null) - { - audio.TypeIndex = audioIndex; - audio.Index = streamIndex; - var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+"); - if (match.Success) - audio.IndexString = match.Value; - vi.AudioStreams.Add(audio); - } - ++audioIndex; - } - else if (sm.Value.Contains(" Subtitle: ")) - { - var sub = ParseSubtitleStream(sm.Value); - if (sub != null) - { - sub.Index = streamIndex; - sub.TypeIndex = subtitleIndex; - var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+"); - if (match.Success) - sub.IndexString = match.Value; - vi.SubtitleStreams.Add(sub); - } - ++subtitleIndex; - } - ++streamIndex; - } - return vi; - } - - private static List ParseChapters(string output) - { - try - { - var rgxChatpers = new Regex("(?<=(Chapters:))(.*?)(?=(Stream))", RegexOptions.Singleline); - string strChapters; - if (rgxChatpers.TryMatch(output, out Match matchChapters)) - strChapters = matchChapters.Value.Trim(); - else - return new List(); - - var rgxChapter = new Regex("Chapter #(.*?)(?=(Chapter #|$))", RegexOptions.Singleline); - var chapters = new List(); - - var rgxTitle = new Regex(@"title[\s]*:[\s]*(.*?)$"); - var rgxStart = new Regex(@"(?<=(start[\s]))[\d]+\.[\d]+"); - var rgxEnd = new Regex(@"(?<=(end[\s]))[\d]+\.[\d]+"); - foreach (Match match in rgxChapter.Matches(strChapters)) - { - try - { - Chapter chapter = new Chapter(); - if (rgxTitle.TryMatch(match.Value.Trim(), out Match title)) - chapter.Title = title.Groups[1].Value; - - if (rgxStart.TryMatch(match.Value, out Match start)) - { - double startSeconds = double.Parse(start.Value); - chapter.Start = TimeSpan.FromSeconds(startSeconds); - } - if (rgxEnd.TryMatch(match.Value, out Match end)) - { - double endSeconds = double.Parse(end.Value); - chapter.End = TimeSpan.FromSeconds(endSeconds); - } - - if (chapter.Start > TimeSpan.Zero || chapter.End > TimeSpan.Zero) - { - chapters.Add(chapter); - } - } - catch (Exception ) { } - } - - return chapters; - }catch (Exception) { return new List(); } - } - - public static VideoStream ParseVideoStream(ILogger logger, string info, string fullOutput) - { - // Stream #0:0(eng): Video: h264 (High), yuv420p(tv, bt709/unknown/unknown, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 23.98 fps, 23.98 tbr, 1k tbn (default) - string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First(); - VideoStream vs = new VideoStream(); - vs.Codec = line.Substring(line.IndexOf("Video: ") + "Video: ".Length).Replace(",", "").Trim().Split(' ').First().ToLower(); - var dimensions = Regex.Match(line, @"([\d]{3,})x([\d]{3,})"); - if (int.TryParse(dimensions.Groups[1].Value, out int width)) - vs.Width = width; - if (int.TryParse(dimensions.Groups[2].Value, out int height)) - vs.Height = height; - - if (Regex.IsMatch(line, @"([\d]+(\.[\d]+)?)\sfps") && float.TryParse(Regex.Match(line, @"([\d]+(\.[\d]+)?)\sfps").Groups[1].Value, out float fps)) - vs.FramesPerSecond = fps; - - var rgxBps = new Regex(@"(?<=((BPS(\-[\w]+)?[\s]*:[\s])))([\d]+)"); - if (rgxBps.IsMatch(info) && float.TryParse(rgxBps.Match(info).Value, out float bps)) - vs.Bitrate = bps; - - if (rgxDuration.IsMatch(info) && TimeSpan.TryParse(rgxDuration.Match(info).Value, out TimeSpan duration) && duration.TotalSeconds > 0) - { - vs.Duration = duration; - logger?.ILog("Video stream duration: " + vs.Duration); - } - else if (rgxDuration2.IsMatch(fullOutput) && TimeSpan.TryParse(rgxDuration2.Match(fullOutput).Value, out TimeSpan duration2) && duration2.TotalSeconds > 0) - { - vs.Duration = duration2; - logger?.ILog("Video stream duration: " + vs.Duration); - } - else - { - logger?.ILog("Failed to read duration for VideoStream: " + info); - } - - // As per https://video.stackexchange.com/a/33827 - // "HDR is only the new transfer function" (PQ or HLG) - vs.HDR = info.Contains("arib-std-b67") || info.Contains("smpte2084"); - - return vs; - } - - public static AudioStream ParseAudioStream(string info) - { - // Stream #0:1(eng): Audio: dts (DTS), 48000 Hz, stereo, fltp, 1536 kb/s (default) - string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First(); - var parts = line.Split(",").Select(x => x?.Trim() ?? "").ToArray(); - AudioStream audio = new AudioStream(); - audio.Title = ""; - // this isnt type index, this is overall index - audio.TypeIndex = int.Parse(Regex.Match(line, @"#([\d]+):([\d]+)").Groups[2].Value) - 1; - audio.Codec = parts[0].Substring(parts[0].IndexOf("Audio: ") + "Audio: ".Length).Trim().Split(' ').First().ToLower() ?? ""; - audio.Language = Regex.Match(line, @"(?<=(Stream\s#[\d]+:[\d]+)\()[^\)]+").Value?.ToLower() ?? ""; - if (info.IndexOf("0 channels") >= 0) - { - audio.Channels = 0; - } - else - { - try - { - //Logger.ILog("codec: " + vs.Codec); - if (parts[2] == "stereo") - audio.Channels = 2; - else if (parts[2] == "mono") - audio.Channels = 1; - else if (Regex.IsMatch(parts[2], @"^[\d]+(\.[\d]+)?")) - { - audio.Channels = float.Parse(Regex.Match(parts[2], @"^[\d]+(\.[\d]+)?").Value); - } - } - catch (Exception) { } - } - - var match = rgxAudioSampleRate.Match(info); - if (match.Success) - audio.SampleRate = int.Parse(match.Value); - - if (rgxTitle.IsMatch(info)) - audio.Title = rgxTitle.Match(info).Value.Trim(); - - - if (rgxDuration.IsMatch(info)) - audio.Duration = TimeSpan.Parse(rgxDuration.Match(info).Value); - - - return audio; - } - public static SubtitleStream ParseSubtitleStream(string info) - { - // Stream #0:1(eng): Audio: dts (DTS), 48000 Hz, stereo, fltp, 1536 kb/s (default) - string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First(); - var parts = line.Split(",").Select(x => x?.Trim() ?? "").ToArray(); - SubtitleStream sub = new SubtitleStream(); - sub.TypeIndex = int.Parse(Regex.Match(line, @"#([\d]+):([\d]+)").Groups[2].Value); - sub.Codec = line.Substring(line.IndexOf("Subtitle: ") + "Subtitle: ".Length).Trim().Split(' ').First().ToLower(); - sub.Language = Regex.Match(line, @"(?<=(Stream\s#[\d]+:[\d]+)\()[^\)]+").Value?.ToLower() ?? ""; - - if (rgxTitle.IsMatch(info)) - sub.Title = rgxTitle.Match(info).Value.Trim(); - - sub.Forced = info.ToLower().Contains("forced"); - return sub; - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoLegacyNodes.csproj b/VideoLegacyNodes/VideoLegacyNodes.csproj deleted file mode 100644 index 57efa418..00000000 --- a/VideoLegacyNodes/VideoLegacyNodes.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - net6.0 - enable - enable - true - true - 1.0.4.189 - 1.0.4.189 - true - FileFlows - John Andrews - Video Legacy Nodes - https://fileflows.com/ - Legacy Video Nodes that are now obsolete and have been replaced. - FileFlows.VideoNodes - - - - - - - - - Always - - - - - - - - - - - - - ..\FileFlows.Plugin.dll - False - - - - diff --git a/VideoLegacyNodes/VideoLegacyNodes.en.json b/VideoLegacyNodes/VideoLegacyNodes.en.json deleted file mode 100644 index 2410c4a9..00000000 --- a/VideoLegacyNodes/VideoLegacyNodes.en.json +++ /dev/null @@ -1,254 +0,0 @@ -{ - "H": { - "264": "H.264", - "265": "H.265" - }, - "5": { "1": "5.1" }, - "7": { "1": "7.1" }, - "Flow": { - "Parts": { - "AudioAddTrack": { - "Outputs": { - "1": "Audio track added and saved to temporary file" - }, - "Description": "Adds a new audio track to the video file, all other audio tracks will remain. This will use the first audio track of the file as the source audio track to convert.", - "Fields": { - "Index": "Index", - "Index-Help": "The index where to insert the new audio track. 0 based, so to insert the new audio track as the first track set this to 0.", - "Channels": "Channels", - "Channels-Help": "The number of channels to convert this audio track to.", - "Bitrate": "Bitrate", - "Bitrate-Help": "Bitrate of the new audio track" - } - - }, - "AudioAdjustVolume": { - "Outputs": { - "1": "Audio tracks volume was adjusted and saved to temporary file", - "2": "Audio tracks were not adjusted" - }, - "Description": "Adjusts audio tracks volume in a video file using FFMPEG", - "Fields": { - "VolumePercent": "Volume Percent", - "VolumePercent-Help": "The percent of the adjusted volume.\n100 means no adjustment\n50 means half volume\n0 means muted" - } - }, - "AudioNormalization": { - "Outputs": { - "1": "Audio tracks were normalized and saved to temporary file", - "2": "No audio tracks were found to be normalized" - }, - "Description": "Normalizes all audio tracks in a video file using FFMPEGs loudnorm filter", - "Fields": { - "AllAudio": "All Audio Tracks", - "AllAudio-Help": "If all audio tracks should be normalized or if just the first track should be", - "TwoPass": "Two Pass", - "TwoPass-Help": "If the audio tracks should use two pass normalization. This improves the normalization but increases the processing time.", - "Pattern": "Pattern", - "Pattern-Help": "An optional regular expression to filter out audio tracks to normalize. Will match against the title, codec and language", - "NotMatching": "Not Matching", - "NotMatching-Help": "If the pattern should be used to exclude audio tracks from normalization, otherwise if the audio track matches they will be normalized." - } - }, - "AudioTrackRemover": { - "Outputs": { - "1": "Audio tracks were removed", - "2": "Audio tracks were NOT removed" - }, - "Description": "Allows you to remove audio tracks based on either their title or their language codes.\n\nAny title (or language code if set to \"Use Language Code\") that is blank will NOT be removed regardless of the pattern.", - "Fields": { - "Pattern": "Pattern", - "Pattern-Help": "A regular expression to match against, eg \"commentary\" to remove commentary tracks", - "NotMatching": "Not Matching", - "NotMatching-Help": "If audio tracks NOT matching the pattern should be removed", - "UseLanguageCode": "Use Language Code", - "UseLanguageCode-Help": "If the language code of the audio track should be used instead of the title" - } - }, - "AudioTrackReorder": { - "Outputs": { - "1": "Audio tracks re-ordered in new temporary file", - "2": "Audio tracks NOT re-ordered" - }, - "Description": "Allows you to reorder audio tracks in the preferred order.\n\nEnter the languages/audio codecs/channels in the order you want. Any not listed will be ordered after the ones entered in their original order.\nIf there are multiple tracks with same language/codec/channels, they will be ordered first by the order you entered, then in their original order.\n\nOutput 1: Tracks were reordered\nOutput 2: Tracks did not need reordering", - "Fields": { - "OrderedTracks": "Ordered Audio Codecs", - "OrderedTracks-Help": "The order of audio codecs to the audio tracks by. This is done after the languages (if any)", - "Languages": "Languages", - "Languages-Help": "The order of languages to sort the audio tracks by. This sorting is done before the codec.", - "Channels": "Channels", - "Channels-Help": "The order of audio channels to sort the audio tracks by. This sorting is done before languages.\nFor example \"5.1\",\"7.1\",\"6.2\",\"2\"" - } - }, - "AudioTrackSetLanguage": { - "Label": "Audio Set Language", - "Outputs": { - "1": "Audio tracks updated to new temporary file", - "2": "Audio tracks NOT updated" - }, - "Description": "Allows you to set the language for any audio tracks that have no language set. If the audio track does have a language set, it will be skipped.\n\nOutput 1: Audio Tracks were updated\nOutput 2: No audio tracks were needing to be updated", - "Fields": { - "Language": "Language", - "Language-Help": "The [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code to use." - } - }, - "AutoChapters": { - "Description": "Automatically detect scene changes in the video to generate chapters.", - "Outputs": { - "1": "Chapters generated and saved to temporary file", - "2": "No chapters detected or video already had chapters" - }, - "Fields": { - "MinimumLength": "Minimum Length", - "MinimumLength-Suffix": "seconds", - "MinimumLength-Help": "The minimum length of a chapter in seconds", - "Percent": "Percent", - "Percent-Suffix": "%", - "Percent-Help": "The threshold percentage value to use for scene detection changes. A good value is 45%" - } - }, - "ComskipChapters": { - "Description": "Uses a comskip EDL file and will create chapters given that EDL comskip file.", - "Outputs": { - "1": "Commercials chapters created, saved to temporary file", - "2": "No commercials detected" - } - }, - "DetectBlackBars": { - "Description": "Processes a video file and scans for black bars in the video.\n\nIf found a parameter \"VideoCrop\" will be added.\n\nOutput 1: Black bars detected\nOutput 2: Not detected", - "Outputs": { - "1": "Black bars detected", - "2": "No black bars detected" - }, - "Fields": { - "CroppingThreshold": "Threshold", - "CroppingThreshold-Help": "The amount of pixels that must be greater than to crop. E.g. if there's only 5 pixels detected as black space, you may consider this too small to crop." - } - }, - "FFMPEG": { - "Description": "The node lets you run any FFMPEG command you like. Giving you full control over what it can do.\n\nFor more information refer to the FFMPEG documentation", - "Outputs": { - "1": "Video processed" - }, - "Fields": { - "Extension": "Extension", - "Extension-Help": "The file extension to use on the newly created file", - "CommandLine": "Command Line", - "CommandLine-Help": "The command line to run with FFMPEG.\n'{WorkingFile}': the working file of the flow\n'{Output}': The output file that will be passed as the last parameter to FFMPEG including the extension defined above." - } - }, - "RemuxToMKV": { - "Descritption": "Remuxes a video file into a MKV container. All streams will be copied to the new container", - "Outputs": { - "1": "File remuxed to temporary file", - "2": "File was already in a MKV container" - }, - "Fields": { - "Force": "Force", - "Force-Help": "If the file should be always remuxed into a MKV container even when it already is in a MKV container.\ni.e. a new temporary file will always be created." - } - }, - "RemuxToMP4": { - "Descritption": "Remuxes a video file into a MP4 container. All streams will be copied to the new container", - "Outputs": { - "1": "File remuxed to temporary file", - "2": "File was already in a MP4 container" - }, - "Fields": { - "Force": "Force", - "Force-Help": "If the file should be always remuxed into a MP4 container even when it already is in a MP4 container.\ni.e. a new temporary file will always be created." - } - }, - "SubtitleRemover": { - "Description": "Removes subtitles from a video file if found.\n\nOutput 1: Subtitles were removed\nOutput 2: No subtitles found that needed to be removed", - "Outputs": { - "1": "Subtitles removed in new temporary file", - "2": "No subtitles to remove" - }, - "Fields": { - "SubtitlesToRemove": "Subtitles To Remove", - "RemoveAll": "Remove All", - "RemoveAll-Help": "When checked, all subtitles will be removed from the file, otherwise only those selected below will be" - } - }, - "SubtitleLanguageRemover": { - "Outputs": { - "1": "Subtitles were removed", - "2": "Subtitles were NOT removed" - }, - "Description": "Allows you to remove subtitles based on either their title or their language codes.\n\nAny language (or title if set to \"Use Title\") that is blank will NOT be removed regardless of the pattern.", - "Fields": { - "Pattern": "Pattern", - "Pattern-Help": "A regular expression to match against, eg \"eng\" to remove English tracks", - "NotMatching": "Not Matching", - "NotMatching-Help": "If subtitles NOT matching the pattern should be removed", - "UseTitle": "Use Title", - "UseTitle-Help": "If the title of the subtitle should be used for matching instead of the language" - } - }, - "VideoCodec": { - "Description": "This node will check the codecs in the input file, and trigger when matched.\n\nOutput 1: Matches\nOutput 2: Does not match", - "Fields": { - "Codecs": "Codecs", - "Codecs-Help": "Enter a list of case insensitive video or audio codecs.\nEg hevc, h265, mpeg4, ac3" - } - }, - "VideoEncode": { - "Description": "A generic video encoding node, this lets you customize how to encode a video file using ffmpeg.\n\nOutput 1: Video was processed\nOutput 2: No processing required", - "Outputs": { - "1": "Video re-encoded to temporary file", - "2": "Video not re-encoded" - }, - "Fields": { - "Extension": "Extension", - "Extension-Help": "The file extension to use on the newly created file.", - "VideoCodec": "Video Codec", - "VideoCodec-Help": "The video codec the video should be in, for example hevc, h264.\nIf left empty all original video tracks will be copied.", - "VideoCodecParameters": "Video Codec Parameters", - "VideoCodecParameters-Help": "The parameters to use to encode the video, eg. \"hevc_nvenc -preset hq -crf 23\" to encode into hevc using the HQ preset a constant rate factor of 23 and using NVIDIA hardware acceleration.", - "AudioCodec": "Audio Codec", - "AudioCodec-Help": "The audio codec to encode the video with.\nIf left empty all original audio tracks will be copied.", - "Language": "Language", - "Language-Help": "Optional [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code to use. Will attempt to find an audio track with this language code if not the best audio track will be used." - } - }, - "VideoHasStream": { - "Description": "Tests if a video file contains a stream", - "Outputs": { - "1": "Contains the matching stream", - "2": "Does not contain the matching stream" - }, - "Fields": { - "Stream": "Type", - "Stream-Help": "The type of stream to look for", - "Title": "Title", - "Title-Help": "A regular expression used to test the stream title", - "Codec": "Codec", - "Codec-Help": "A regular expression used to test the stream codec", - "Language": "Language", - "Language-Help": "A regular expression used to test the stream language", - "Channels": "Channels", - "Channels-Help": "The number of channels to test for. Set to 0 to ignore this check" - } - }, - "VideoScaler": { - "Description": "This allows you to scale a video to the specified dimensions. It will retain the aspect ratio of the video so if the video was 1920x1000 it would scale to 1280x668 if you select 720P.", - "Outputs": { - "1": "Video rescaled to temporary file", - "2": "Video was already in/near the scaled resolution" - }, - "Fields": { - "VideoCodec": "Video Codec", - "Language-Help": "The video codec to encode the scaled video in", - "Extension": "Extension", - "Extension-Help": "The file extension to use on the newly created file", - "Force": "Force", - "Force-Help": "When checked the video will be force scaled even if the working file is already in this resolution (or near this resolution).", - "Resolution": "Resolution", - "VideoCodecParameters": "Video Codec Parameters", - "VideoCodecParameters-Help": "The parameters to use to encode the video, eg. \"hevc_nvenc -preset hq -crf 23\" to encode into hevc using the HQ preset a constant rate factor of 23 and using NVIDIA hardware acceleration." - } - } - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoNodes/AudioAddTrack.cs b/VideoLegacyNodes/VideoNodes/AudioAddTrack.cs deleted file mode 100644 index d20d16d1..00000000 --- a/VideoLegacyNodes/VideoNodes/AudioAddTrack.cs +++ /dev/null @@ -1,197 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.ComponentModel.DataAnnotations; - using System.Linq; - - public class AudioAddTrack: EncodingNode - { - public override int Outputs => 1; - - public override string Icon => "fas fa-volume-down"; - - [NumberInt(1)] - [Range(0, 100)] - [DefaultValue(2)] - public int Index { get; set; } - - - [DefaultValue("aac")] - [Select(nameof(CodecOptions), 1)] - public string Codec { get; set; } - - private static List _CodecOptions; - public static List CodecOptions - { - get - { - if (_CodecOptions == null) - { - _CodecOptions = new List - { - new ListOption { Label = "AAC", Value = "aac"}, - new ListOption { Label = "AC3", Value = "ac3"}, - new ListOption { Label = "EAC3", Value = "eac3" }, - new ListOption { Label = "MP3", Value = "mp3"}, - }; - } - return _CodecOptions; - } - } - - [DefaultValue(2f)] - [Select(nameof(ChannelsOptions), 2)] - public float Channels { get; set; } - - private static List _ChannelsOptions; - public static List ChannelsOptions - { - get - { - if (_ChannelsOptions == null) - { - _ChannelsOptions = new List - { - new ListOption { Label = "Same as source", Value = 0}, - new ListOption { Label = "Mono", Value = 1f}, - new ListOption { Label = "Stereo", Value = 2f} - }; - } - return _ChannelsOptions; - } - } - - [Select(nameof(BitrateOptions), 3)] - public int Bitrate { get; set; } - - private static List _BitrateOptions; - public static List BitrateOptions - { - get - { - if (_BitrateOptions == null) - { - _BitrateOptions = new List - { - new ListOption { Label = "Automatic", Value = 0}, - new ListOption { Label = "64 Kbps", Value = 64}, - new ListOption { Label = "96 Kbps", Value = 96}, - new ListOption { Label = "128 Kbps", Value = 128}, - new ListOption { Label = "160 Kbps", Value = 160}, - new ListOption { Label = "192 Kbps", Value = 192}, - new ListOption { Label = "224 Kbps", Value = 224}, - new ListOption { Label = "256 Kbps", Value = 256}, - new ListOption { Label = "288 Kbps", Value = 288}, - new ListOption { Label = "320 Kbps", Value = 320}, - }; - } - return _BitrateOptions; - } - } - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - List ffArgs = new List - { - "-c", "copy", - "-map", "0:v", - }; - - bool added = false; - int audioIndex = 0; - for(int i = 0; i < videoInfo.AudioStreams.Count; i++) - { - if(i == Index) - { - ffArgs.AddRange(GetNewAudioTrackParameters(videoInfo, audioIndex)); - added = true; - ++audioIndex; - } - ffArgs.AddRange(new[] - { - "-map", videoInfo.AudioStreams[i].IndexString, - "-c:a:" + audioIndex, "copy" - }); - ++audioIndex; - } - - if(added == false) // incase the index is greater than the number of tracks this file has - ffArgs.AddRange(GetNewAudioTrackParameters(videoInfo, audioIndex)); - - if (videoInfo.SubtitleStreams?.Any() == true) - ffArgs.AddRange(new[] { "-map", "0:s" }); - - if (Index < 2) - { - // this makes the first audio track now the default track - ffArgs.AddRange(new[] { "-disposition:a:0", "default" }); - } - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - - private string[] GetNewAudioTrackParameters(VideoInfo videoInfo, int index) - { - if (Channels == 0) - { - // same as source - if(Bitrate == 0) - { - return new[] - { - "-map", videoInfo.AudioStreams[0].IndexString, - "-c:a:" + index, Codec - }; - } - return new[] - { - "-map", videoInfo.AudioStreams[0].IndexString, - "-c:a:" + index, Codec, - "-b:a:" + index, Bitrate + "k" - }; - } - else - { - if (Bitrate == 0) - { - return new[] - { - "-map", videoInfo.AudioStreams[0].IndexString, - "-c:a:" + index, Codec, - "-ac", Channels.ToString() - }; - } - return new[] - { - "-map", videoInfo.AudioStreams[0].IndexString, - "-c:a:" + index, Codec, - "-ac", Channels.ToString(), - "-b:a:" + index, Bitrate + "k" - }; - } - } - } -} diff --git a/VideoLegacyNodes/VideoNodes/AudioAdjustVolume.cs b/VideoLegacyNodes/VideoNodes/AudioAdjustVolume.cs deleted file mode 100644 index 4567ec32..00000000 --- a/VideoLegacyNodes/VideoNodes/AudioAdjustVolume.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - using System.Linq; - using System.Text; - - public class AudioAdjustVolume: EncodingNode - { - public override int Outputs => 2; - - public override string Icon => "fas fa-volume-up"; - - [NumberInt(1)] - [Range(0, 1000)] - public int VolumePercent { get; set; } - - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - if (videoInfo.AudioStreams?.Any() != true) - { - args.Logger?.ILog("No audio streams detected"); - return 2; - } - - if(VolumePercent == 100) - { - args.Logger?.ILog("Volume percent set to 100, no adjustment necessary"); - return 2; - } - - List ffArgs = new List - { - "-c", "copy", - "-map", "0:v", - }; - - float volume = this.VolumePercent / 100f; - foreach (var audio in videoInfo.AudioStreams) - { - ffArgs.AddRange(new[] { "-map", $"0:a:{audio.TypeIndex}", "-filter:a", $"volume={volume.ToString(".0######")}" }); - } - - if (videoInfo.SubtitleStreams?.Any() == true) - ffArgs.AddRange(new[] { "-map", "0:s" }); - - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } -} - } -} diff --git a/VideoLegacyNodes/VideoNodes/AudioNormalization.cs b/VideoLegacyNodes/VideoNodes/AudioNormalization.cs deleted file mode 100644 index a99bae72..00000000 --- a/VideoLegacyNodes/VideoNodes/AudioNormalization.cs +++ /dev/null @@ -1,187 +0,0 @@ -namespace FileFlows.VideoNodes; - -using FileFlows.Plugin; -using FileFlows.Plugin.Attributes; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; - -public class AudioNormalization: EncodingNode -{ - public override int Outputs => 2; - - public override string Icon => "fas fa-volume-up"; - - [Boolean(1)] - public bool AllAudio { get; set; } - - [Boolean(2)] - public bool TwoPass { get; set; } - - [TextVariable(3)] - public string Pattern { get; set; } - - [Boolean(4)] - public bool NotMatching { get; set; } - - internal const string LOUDNORM_TARGET = "I=-24:LRA=7:TP=-2.0"; - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - if (videoInfo.AudioStreams?.Any() != true) - { - args.Logger?.ILog("No audio streams detected"); - return 2; - } - - List ffArgs = new List(); - - ffArgs.AddRange(new[] { "-strict", "-2" }); // allow experimental stuff - - ffArgs.AddRange(new[] { "-c", "copy" }); - - - if (videoInfo.VideoStreams?.Any() == true) - ffArgs.AddRange(new[] { "-map", "0:v" }); - - List tracksToNormalize = new (); - for (int j = 0; j < videoInfo.AudioStreams.Count;j++) - { - var audio = videoInfo.AudioStreams[j]; - - if(string.IsNullOrEmpty(Pattern) == false) - { - string audioString = audio.Title + ":" + audio.Language + ":" + audio.Codec; - args.Logger?.ILog($"Audio Track [{j}] test string: {audioString}"); - bool match = new Regex(Pattern, RegexOptions.IgnoreCase).IsMatch(audioString); - if (NotMatching) - match = !match; - if (match == false) - { - ffArgs.AddRange(new[] { "-map", $"0:a:{j}" }); - continue; - } - } - - if (AllAudio || j == 0) - { - if (TwoPass) - { - string twoPass = DoTwoPass(this, args, FFMPEG, j); - ffArgs.AddRange(new[] { "-map", $"0:a:{j}", "-c:a:" + j, audio.Codec, "-filter:a:" + j, twoPass }); - } - else - { - ffArgs.AddRange(new[] { "-map", $"0:a:{j}", "-c:a:" + j, audio.Codec, "-filter:a:" + j, $"loudnorm={LOUDNORM_TARGET}" }); - } - tracksToNormalize.Add(j); - } - else - { - ffArgs.AddRange(new[] { "-map", $"0:a:{j}" }); - } - } - - if (tracksToNormalize.Any() == false) - { - args.Logger?.ILog("No audio streams to normalize"); - return 2; - } - - foreach (int i in tracksToNormalize) - args.Logger?.ILog($"Normalizing track [{i}]: {videoInfo.AudioStreams[i].Title};{videoInfo.AudioStreams[i].Language};{videoInfo.AudioStreams[i].Codec};"); - - if (videoInfo.SubtitleStreams?.Any() == true) - ffArgs.AddRange(new[] { "-map", "0:s" }); - - string extension = new FileInfo(args.WorkingFile).Extension; - if (extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - - [RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Deserialize(string, System.Text.Json.JsonSerializerOptions?)")] - public static string DoTwoPass(EncodingNode node, NodeParameters args, string ffmpegExe, int audioIndex) - { - //-af loudnorm=I=-24:LRA=7:TP=-2.0" - string output; - var result = node.Encode(args, ffmpegExe, new List - { - "-hide_banner", - "-i", args.WorkingFile, - "-strict", "-2", // allow experimental stuff - "-map", "0:a:" + audioIndex, - "-af", "loudnorm=" + LOUDNORM_TARGET + ":print_format=json", - "-f", "null", - "-" - }, out output, updateWorkingFile: false, dontAddInputFile: true); - - if (result == false) - throw new Exception("Failed to prcoess audio track"); - - int index = output.LastIndexOf("{"); - if (index == -1) - throw new Exception("Failed to detected json in output"); - - string json = output.Substring(index); - json = json.Substring(0, json.IndexOf("}") + 1); - if (string.IsNullOrEmpty(json)) - throw new Exception("Failed to parse TwoPass json"); -#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. - LoudNormStats stats = JsonSerializer.Deserialize(json); -#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. - - if (stats.input_i == "-inf" || stats.input_lra == "-inf" || stats.input_tp == "-inf" || stats.input_thresh == "-inf" || stats.target_offset == "-inf") - { - args.Logger?.WLog("-inf detected in loud norm two pass, falling back to single pass loud norm"); - return $"loudnorm={LOUDNORM_TARGET}"; - } - - string ar = $"loudnorm=print_format=summary:linear=true:{LOUDNORM_TARGET}:measured_I={stats.input_i}:measured_LRA={stats.input_lra}:measured_tp={stats.input_tp}:measured_thresh={stats.input_thresh}:offset={stats.target_offset}"; - return ar; - } - - private class LoudNormStats - { - /* -{ - "input_i" : "-7.47", - "input_tp" : "12.33", - "input_lra" : "6.70", - "input_thresh" : "-18.13", - "output_i" : "-24.25", - "output_tp" : "-3.60", - "output_lra" : "5.90", - "output_thresh" : "-34.74", - "normalization_type" : "dynamic", - "target_offset" : "0.25" -} - */ - public string input_i { get; set; } - public string input_tp { get; set; } - public string input_lra { get; set; } - public string input_thresh { get; set; } - public string target_offset { get; set; } - } -} diff --git a/VideoLegacyNodes/VideoNodes/AudioTrackRemover.cs b/VideoLegacyNodes/VideoNodes/AudioTrackRemover.cs deleted file mode 100644 index 9a634e64..00000000 --- a/VideoLegacyNodes/VideoNodes/AudioTrackRemover.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text.RegularExpressions; - - public class AudioTrackRemover : EncodingNode - { - public override int Outputs => 2; - - public override string Icon => "fas fa-volume-off"; - - [TextVariable(1)] - public string Pattern { get; set; } - - [Boolean(2)] - public bool NotMatching { get; set; } - - [Boolean(3)] - public bool UseLanguageCode { get; set; } - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - List ffArgs = new List - { - "-c", "copy", - "-map", "0:v", - }; - - bool removing = false; - var regex = new Regex(this.Pattern, RegexOptions.IgnoreCase); - for(int i=0;i< videoInfo.AudioStreams.Count;i++) - { - var audio = videoInfo.AudioStreams[i]; - string str = UseLanguageCode ? audio.Language : audio.Title; - if(string.IsNullOrEmpty(str) == false) // if empty we always use this since we have no info to go on - { - bool matches = regex.IsMatch(str); - if (NotMatching) - matches = !matches; - if (matches) - { - removing = true; - continue; - } - } - - ffArgs.AddRange(new[] { "-map", "0:a:" + i }); - } - - if(removing == false) - { - args.Logger.ILog("Nothing found to remove"); - return 2; - } - - if (videoInfo.SubtitleStreams?.Any() == true) - ffArgs.AddRange(new[] { "-map", "0:s" }); - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - } -} diff --git a/VideoLegacyNodes/VideoNodes/AudioTrackReorder.cs b/VideoLegacyNodes/VideoNodes/AudioTrackReorder.cs deleted file mode 100644 index fc1498ee..00000000 --- a/VideoLegacyNodes/VideoNodes/AudioTrackReorder.cs +++ /dev/null @@ -1,141 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - - public class AudioTrackReorder: EncodingNode - { - public override int Outputs => 2; - - public override string Icon => "fas fa-volume-off"; - - [StringArray(1)] - public List Languages { get; set; } - - [StringArray(2)] - public List OrderedTracks { get; set; } - - [StringArray(3)] - public List Channels { get; set; } - - public List Reorder(List input) - { - Languages ??= new List(); - OrderedTracks ??= new List(); - Channels ??= new List(); - List actualChannels = Channels.Select(x => - { - if (float.TryParse(x, out float value)) - return value; - return -1f; - }).Where(x => x > 0f).ToList(); - - if (Languages.Any() == false && OrderedTracks.Any() == false && actualChannels.Any() == false) - return input; // nothing to do - - Languages.Reverse(); - OrderedTracks.Reverse(); - actualChannels.Reverse(); - - const int base_number = 1_000_000_000; - int count = base_number; - var debug = new StringBuilder(); - var data = input.OrderBy(x => - { - int langIndex = Languages.IndexOf(x.Language?.ToLower() ?? String.Empty); - int codecIndex = OrderedTracks.IndexOf(x.Codec?.ToLower() ?? String.Empty); - int channelIndex = actualChannels.IndexOf(x.Channels); - - int result = base_number; - if (langIndex >= 0) - { - result -= ((langIndex + 1) * 10_000_000); - } - if(codecIndex >= 0) - { - result -= ((codecIndex + 1) * 100_000); - } - if(channelIndex >= 0) - { - result -= ((channelIndex + 1) * 1_000); - } - if (result == base_number) - result = ++count; - return result; - }).ToList(); - - return data; - - } - - public bool AreSame(List original, List reordered) - { - for (int i = 0; i < reordered.Count; i++) - { - if (reordered[i] != original[i]) - { - return false; - } - } - return true; - } - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - List ffArgs = new List - { - "-c", "copy", - "-map", "0:v", - }; - - - OrderedTracks = OrderedTracks?.Select(x => x.ToLower())?.ToList() ?? new (); - - var reordered = Reorder(videoInfo.AudioStreams); - - bool same = AreSame(videoInfo.AudioStreams, reordered); - - if(same) - { - args.Logger?.ILog("No audio tracks need reordering"); - return 2; - } - - foreach (var audio in reordered) - { - ffArgs.AddRange(new[] { "-map", audio.IndexString }); - } - - if (videoInfo.SubtitleStreams?.Any() == true) - ffArgs.AddRange(new[] { "-map", "0:s" }); - - // this makes the first audio track now the default track - ffArgs.AddRange(new[] { "-disposition:a:0", "default" }); - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - } -} diff --git a/VideoLegacyNodes/VideoNodes/AudioTrackSetLanguage.cs b/VideoLegacyNodes/VideoNodes/AudioTrackSetLanguage.cs deleted file mode 100644 index 039df493..00000000 --- a/VideoLegacyNodes/VideoNodes/AudioTrackSetLanguage.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - - public class AudioTrackSetLanguage : EncodingNode - { - public override int Outputs => 2; - - public override string Icon => "fas fa-comment-dots"; - - [Required] - [Text(1)] - public string Language { get; set; } - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - List ffArgs = new List(); - - int index = 0; - foreach(var at in videoInfo.AudioStreams) - { - if (string.IsNullOrEmpty(at.Language)) - { - ffArgs.AddRange(new[] { $"-metadata:s:a:{index}", $"language={Language.ToLower()}" }); - } - ++index; - } - if (ffArgs.Count == 0) - return 2; // nothing to do - - - ffArgs.Insert(0, "-map"); - ffArgs.Insert(1, "0"); - ffArgs.Insert(2, "-c"); - ffArgs.Insert(3, "copy"); - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - args.Logger?.DLog("Working file: " + args.WorkingFile); - args.Logger?.DLog("Extension: " + extension); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } -} - } -} diff --git a/VideoLegacyNodes/VideoNodes/AutoChapters.cs b/VideoLegacyNodes/VideoNodes/AutoChapters.cs deleted file mode 100644 index 8bfcc5f3..00000000 --- a/VideoLegacyNodes/VideoNodes/AutoChapters.cs +++ /dev/null @@ -1,125 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.ComponentModel; - using System.Linq; - using System.Text; - using System.Text.RegularExpressions; - - - public class AutoChapters: EncodingNode - { - public override int Outputs => 2; - - - [NumberInt(1)] - [DefaultValue(60)] - public int MinimumLength { get; set; } = 60; - - [NumberInt(2)] - [DefaultValue(45)] - public int Percent { get; set; } = 45; - - public override int Execute(NodeParameters args) - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - if (videoInfo.Chapters?.Count > 3) - { - args.Logger.ILog(videoInfo.Chapters.Count + " chapters already detected in file"); - return 2; - } - - string tempMetaDataFile = GenerateMetaDataFile(this, args, videoInfo, FFMPEG, this.Percent, this.MinimumLength); - if (string.IsNullOrEmpty(tempMetaDataFile)) - return 2; - - string[] ffArgs = new[] { "-i", tempMetaDataFile, "-map_metadata", "1", "-codec", "copy", "-max_muxing_queue_size", "1024" }; - if (Encode(args, FFMPEG, ffArgs.ToList())) - { - args.Logger?.ILog($"Adding chapters to file"); - return 1; - } - args.Logger?.ELog("Processing failed"); - return -1; - } - - internal static string GenerateMetaDataFile(EncodingNode node, NodeParameters args, VideoInfo videoInfo, string ffmpegExe, int percent, int minimumLength) - { - string output; - var result = node.Encode(args, ffmpegExe, new List - { - "-hide_banner", - "-i", args.WorkingFile, - "-filter:v", $"select='gt(scene,{percent / 100f})',showinfo", - "-f", "null", - "-" - }, out output, updateWorkingFile: false, dontAddInputFile: true); - - if (result == false) - { - args.Logger?.WLog("Failed to detect scenes"); - return string.Empty; - } - - - if (minimumLength < 30) - { - args.Logger?.ILog("Mimium length set to invalid number, defaulting to 60 seconds"); - minimumLength = 60; - } - else - { - args.Logger?.ILog($"Minimum length of chapter {minimumLength} seconds"); - } - - StringBuilder metadata = new StringBuilder(); - metadata.AppendLine(";FFMETADATA1"); - metadata.AppendLine(""); - - int chapter = 0; - - TimeSpan previous = TimeSpan.Zero; - foreach (Match match in Regex.Matches(output, @"(?<=(pts_time:))[\d]+\.[\d]+")) - { - TimeSpan time = TimeSpan.FromSeconds(double.Parse(match.Value)); - if (Math.Abs((time - previous).TotalSeconds) < minimumLength) - continue; - - AddChapter(previous, time); - previous = time; - } - - var totalTime = TimeSpan.FromSeconds(videoInfo.VideoStreams[0].Duration.TotalSeconds); - if (Math.Abs((totalTime - previous).TotalSeconds) > minimumLength) - AddChapter(previous, totalTime); - - if (chapter == 0) - { - args.Logger?.ILog("Failed to detect any scene changes"); - return string.Empty; - } - - string tempMetaDataFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + ".txt"); - File.WriteAllText(tempMetaDataFile, metadata.ToString()); - - return tempMetaDataFile; - - void AddChapter(TimeSpan start, TimeSpan end) - { - - metadata.AppendLine("[CHAPTER]"); - metadata.AppendLine("TIMEBASE=1/1000"); - metadata.AppendLine("START=" + ((int)(start.TotalSeconds * 1000))); - metadata.AppendLine("END=" + ((int)(end.TotalSeconds * 1000))); - metadata.AppendLine("title=Chapter " + (++chapter)); - metadata.AppendLine(); - } - } - } -} diff --git a/VideoLegacyNodes/VideoNodes/ComskipChapters.cs b/VideoLegacyNodes/VideoNodes/ComskipChapters.cs deleted file mode 100644 index 98e64ad1..00000000 --- a/VideoLegacyNodes/VideoNodes/ComskipChapters.cs +++ /dev/null @@ -1,99 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - - - public class ComskipChapters : EncodingNode - { - public override int Outputs => 2; - - public override int Execute(NodeParameters args) - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - string tempMetaDataFile = GenerateMetaDataFile(args, videoInfo); - if (string.IsNullOrEmpty(tempMetaDataFile)) - return 2; - - string[] ffArgs = new[] { "-i", tempMetaDataFile, "-map_metadata", "1", "-codec", "copy", "-max_muxing_queue_size", "1024" }; - if (Encode(args, FFMPEG, ffArgs.ToList())) - { - args.Logger?.ILog($"Added chapters to file"); - return 1; - } - args.Logger?.ELog("Processing failed"); - return -1; - } - - internal static string GenerateMetaDataFile(NodeParameters args, VideoInfo videoInfo) - { - float totalTime = (float)videoInfo.VideoStreams[0].Duration.TotalSeconds; - - string edlFile = args.WorkingFile.Substring(0, args.WorkingFile.LastIndexOf(".") + 1) + "edl"; - if (File.Exists(edlFile) == false) - edlFile = args.WorkingFile.Substring(0, args.WorkingFile.LastIndexOf(".") + 1) + "edl"; - if (File.Exists(edlFile) == false) - { - args.Logger?.ILog("No EDL file found for file"); - return string.Empty; - } - - string text = File.ReadAllText(edlFile) ?? string.Empty; - float last = 0; - - StringBuilder metadata = new StringBuilder(); - metadata.AppendLine(";FFMETADATA1"); - metadata.AppendLine(""); - int chapter = 0; - - foreach (string line in text.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries)) - { - // 93526.47 93650.13 0 - string[] parts = line.Split(new[] { " ", "\t" }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 2) - continue; - float start = 0; - float end = 0; - if (float.TryParse(parts[0], out start) == false || float.TryParse(parts[1], out end) == false) - continue; - - if (start < last) - continue; - - AddChapter(last, start); - last = end; - } - - if (chapter == 0) - { - args.Logger?.ILog("No ads found in edl file"); - return string.Empty; - } - AddChapter(last, totalTime); - - string tempMetaDataFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + ".txt"); - File.WriteAllText(tempMetaDataFile, metadata.ToString()); - return tempMetaDataFile; - - void AddChapter(float start, float end) - { - - metadata.AppendLine("[CHAPTER]"); - metadata.AppendLine("TIMEBASE=1/1000"); - metadata.AppendLine("START=" + ((int)(start * 1000))); - metadata.AppendLine("END=" + ((int)(end * 1000))); - metadata.AppendLine("title=Chapter " + (++chapter)); - metadata.AppendLine(); - } - - } - } -} diff --git a/VideoLegacyNodes/VideoNodes/EncodingNode.cs b/VideoLegacyNodes/VideoNodes/EncodingNode.cs deleted file mode 100644 index 06d7152c..00000000 --- a/VideoLegacyNodes/VideoNodes/EncodingNode.cs +++ /dev/null @@ -1,239 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.ComponentModel; - using System.Text.RegularExpressions; - using System.Threading.Tasks; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - - public abstract class EncodingNode : VideoNode - { - public override int Outputs => 2; - public override int Inputs => 1; - public override FlowElementType Type => FlowElementType.Process; - - protected TimeSpan TotalTime; - - private FFMpegEncoder Encoder; - - public bool Encode(NodeParameters args, string ffmpegExe, List ffmpegParameters, string extension = "mkv", string outputFile = "", bool updateWorkingFile = true, bool dontAddInputFile = false, bool dontAddOutputFile = false) - { - string output; - return Encode(args, ffmpegExe, ffmpegParameters, out output, extension, outputFile, updateWorkingFile, dontAddInputFile: dontAddInputFile, dontAddOutputFile: dontAddOutputFile); - } - - public bool Encode(NodeParameters args, string ffmpegExe, List ffmpegParameters, out string output, string extension = "mkv", string outputFile = "", bool updateWorkingFile = true, bool dontAddInputFile = false, bool dontAddOutputFile = false) - { - if (string.IsNullOrEmpty(extension)) - extension = "mkv"; - - Encoder = new FFMpegEncoder(ffmpegExe, args.Logger); - Encoder.AtTime += AtTimeEvent; - - if (string.IsNullOrEmpty(outputFile)) - outputFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + extension); - - if (TotalTime.TotalMilliseconds == 0) - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo != null) - { - TotalTime = videoInfo.VideoStreams[0].Duration; - args.Logger.ILog("### Total Time: " + TotalTime); - } - } - - var success = Encoder.Encode(args.WorkingFile, outputFile, ffmpegParameters, dontAddInputFile: dontAddInputFile, dontAddOutputFile: dontAddOutputFile); - args.Logger.ILog("Encoding successful: " + success.successs); - if (success.successs && updateWorkingFile) - { - args.SetWorkingFile(outputFile); - - // get the new video info - var videoInfo = new VideoInfoHelper(ffmpegExe, args.Logger).Read(outputFile); - SetVideoInfo(args, videoInfo, this.Variables ?? new Dictionary()); - } - Encoder.AtTime -= AtTimeEvent; - Encoder = null; - output = success.output; - return success.successs; - } - - public override Task Cancel() - { - if (Encoder != null) - Encoder.Cancel(); - return base.Cancel(); - } - - void AtTimeEvent(TimeSpan time) - { - if (TotalTime.TotalMilliseconds == 0) - { - //Args?.Logger?.DLog("Can't report time progress as total time is 0"); - return; - } - float percent = (float)((time.TotalMilliseconds / TotalTime.TotalMilliseconds) * 100); - if(Args?.PartPercentageUpdate != null) - Args.PartPercentageUpdate(percent); - } - - public string CheckVideoCodec(string ffmpeg, string vidparams) - { - if (string.IsNullOrEmpty(vidparams)) - return string.Empty; - - var splitParams = vidparams.ToLower().Split(" "); - - if(splitParams.Contains("hevc") || splitParams.Contains("h265")) - { - // try find best hevc encoder - foreach(string vidparam in new [] { "hevc_nvenc -preset hq", "hevc_qsv -global_quality 28 -load_plugin hevc_hw", "hevc_amf", "hevc_vaapi" }) - { - bool canProcess = CanUseHardwareEncodingChecker.CanProcess(Args, ffmpeg, vidparam); - if (canProcess) - return vidparam; - } - return "libx265"; - } - if (splitParams.Contains("h264")) - { - // try find best hevc encoder - foreach (string vidparam in new[] { "h264_nvenc", "h264_qsv", "h264_amf", "h264_vaapi" }) - { - bool canProcess = CanUseHardwareEncodingChecker.CanProcess(Args, ffmpeg, vidparam); - if (canProcess) - return vidparam; - } - return "libx264"; - } - - // removed in FF-137 - //if (vidparams.ToLower().Contains("hevc_nvenc")) - //{ - // // nvidia h265 encoding, check can - // bool canProcess = CanUseHardwareEncodingChecker.CanProcess(Args, ffmpeg, vidparams); - // if (canProcess == false) - // { - // // change to cpu encoding - // Args.Logger?.ILog("Can't encode using hevc_nvenc, falling back to CPU encoding H265 (libx265)"); - // return "libx265"; - // } - // return vidparams; - //} - //else if (vidparams.ToLower().Contains("h264_nvenc")) - //{ - // // nvidia h264 encoding, check can - // bool canProcess = CanUseHardwareEncodingChecker.CanProcess(Args, ffmpeg, vidparams); - // if (canProcess == false) - // { - // // change to cpu encoding - // Args.Logger?.ILog("Can't encode using h264_nvenc, falling back to CPU encoding H264 (libx264)"); - // return "libx264"; - // } - // return vidparams; - //} - //else if (vidparams.ToLower().Contains("hevc_qsv")) - //{ - // // nvidia h265 encoding, check can - // bool canProcess = CanUseHardwareEncodingChecker.CanProcess(Args, ffmpeg, vidparams); - // if (canProcess == false) - // { - // // change to cpu encoding - // Args.Logger?.ILog("Can't encode using hevc_qsv, falling back to CPU encoding H265 (libx265)"); - // return "libx265"; - // } - // return vidparams; - //} - //else if (vidparams.ToLower().Contains("h264_qsv")) - //{ - // // nvidia h264 encoding, check can - // bool canProcess = CanUseHardwareEncodingChecker.CanProcess(Args, ffmpeg, vidparams); - // if (canProcess == false) - // { - // // change to cpu encoding - // Args.Logger?.ILog("Can't encode using h264_qsv, falling back to CPU encoding H264 (libx264)"); - // return "libx264"; - // } - // return vidparams; - //} - return vidparams; - } - - public bool HasNvidiaCard(string ffmpeg) - { - try - { - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - { - var cmd = Args.Process.ExecuteShellCommand(new ExecuteArgs - { - Command = "wmic", - Arguments = "path win32_VideoController get name" - }).Result; - if (cmd.ExitCode == 0) - { - // it worked - if (cmd.Output?.ToLower()?.Contains("nvidia") == false) - return false; - } - } - else - { - // linux, crude method, look for nvidia in the /dev dir - var dir = new DirectoryInfo("/dev"); - if (dir.Exists == false) - return false; - - bool dev = dir.GetDirectories().Any(x => x.Name.ToLower().Contains("nvidia")); - if (dev == false) - return false; - } - - // check cuda in ffmpeg itself - var result = Args.Process.ExecuteShellCommand(new ExecuteArgs - { - Command = ffmpeg, - Arguments = "-hide_banner -init_hw_device list" - }).Result; - return result.Output?.Contains("cuda") == true; - } - catch (Exception ex) - { - Args.Logger?.ELog("Failed to detect NVIDIA card: " + ex.Message + Environment.NewLine + ex.StackTrace); - return false; - } - } - - protected bool IsSameVideoCodec(string current, string wanted) - { - wanted = ReplaceCommon(wanted); - current = ReplaceCommon(current); - - return wanted == current; - - string ReplaceCommon(string input) - { - input = input.ToLower(); - input = Regex.Replace(input, "^(divx|xvid|m(-)?peg(-)4)$", "mpeg4", RegexOptions.IgnoreCase); - input = Regex.Replace(input, "^(hevc|h[\\.x\\-]?265)$", "h265", RegexOptions.IgnoreCase); - input = Regex.Replace(input, "^(h[\\.x\\-]?264)$", "h264", RegexOptions.IgnoreCase); - return input; - } - } - - protected bool SupportsSubtitles(NodeParameters args, VideoInfo videoInfo, string extension) - { - if (videoInfo?.SubtitleStreams?.Any() != true) - return false; - bool mov_text = videoInfo.SubtitleStreams.Any(x => x.Codec == "mov_text"); - // if mov_text and going to mkv, we can't convert these subtitles - if (mov_text && extension?.ToLower()?.EndsWith("mkv") == true) - return false; - return true; - //if (Regex.IsMatch(container ?? string.Empty, "(mp(e)?(g)?4)|avi|divx|xvid", RegexOptions.IgnoreCase)) - // return false; - //return true; - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoNodes/FFMPEG.cs b/VideoLegacyNodes/VideoNodes/FFMPEG.cs deleted file mode 100644 index a31f6101..00000000 --- a/VideoLegacyNodes/VideoNodes/FFMPEG.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.ComponentModel; - using System.ComponentModel.DataAnnotations; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - - public class FFMPEG : EncodingNode - { - public override int Outputs => 1; - - [DefaultValue("-i {WorkingFile} {Output}")] - [TextArea(1)] - [Required] - public string CommandLine { get; set; } - - [DefaultValue("mkv")] - [Text(2)] - [Required] - public string Extension { get; set; } - - public override string Icon => "far fa-file-video"; - - public List GetFFMPEGArgs(NodeParameters args, string outputFile) - { - string cmdLine = args.ReplaceVariables(CommandLine); - - List ffArgs = cmdLine.SplitCommandLine().Select(x => - { - if (x.ToLower() == "{workingfile}") return args.WorkingFile; - if (x.ToLower() == "{output}") return outputFile; - return x; - }).ToList(); - return ffArgs; - } - - public override int Execute(NodeParameters args) - { - if (string.IsNullOrEmpty(CommandLine)) - { - args.Logger.ELog("Command Line not set"); - return -1; - } - try - { - - if (string.IsNullOrEmpty(Extension)) - Extension = "mkv"; - - string outputFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + Extension); - var ffArgs = GetFFMPEGArgs(args, outputFile); - - if (Encode(args, FFMPEG, ffArgs, updateWorkingFile: false, dontAddInputFile: true, dontAddOutputFile: true) == false) - return -1; - - if (File.Exists(outputFile)) - { - args.Logger?.ILog("Output file exists, updating working file: " + outputFile); - args.SetWorkingFile(outputFile); - } - - return 1; - } - catch (Exception ex) - { - args.Logger.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoNodes/Remux.cs b/VideoLegacyNodes/VideoNodes/Remux.cs deleted file mode 100644 index 32d368cb..00000000 --- a/VideoLegacyNodes/VideoNodes/Remux.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - - public class RemuxToMKV: EncodingNode - { - public override string Icon => "far fa-file-video"; - - [Boolean(1)] - public bool Force { get; set; } - - public override int Execute(NodeParameters args) - { - if (Force == false && args.WorkingFile?.ToLower()?.EndsWith(".mkv") == true) - return 2; - - try - { - if (Encode(args, FFMPEG, new List { "-c", "copy", "-map", "0" }, "mkv") == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - } - - public class RemuxToMP4 : EncodingNode - { - public override string Icon => "far fa-file-video"; - - [Boolean(1)] - public bool Force { get; set; } - - public override int Execute(NodeParameters args) - { - if (Force == false && args.WorkingFile?.ToLower()?.EndsWith(".mp4") == true) - return 2; - - try - { - if (Encode(args, FFMPEG, new List { "-c", "copy", "-map", "0" }, "mp4") == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - } -} diff --git a/VideoLegacyNodes/VideoNodes/SubtitleLanguageRemover.cs b/VideoLegacyNodes/VideoNodes/SubtitleLanguageRemover.cs deleted file mode 100644 index 14385b71..00000000 --- a/VideoLegacyNodes/VideoNodes/SubtitleLanguageRemover.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text.RegularExpressions; - - public class SubtitleLanguageRemover: EncodingNode - { - public override int Outputs => 2; - - public override string Icon => "fas fa-comment"; - - - [TextVariable(1)] - public string Pattern { get; set; } - - [Boolean(2)] - public bool NotMatching { get; set; } - - [Boolean(3)] - public bool UseTitle { get; set; } - - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - List ffArgs = new List() - { - "-map", "0:v", - "-map", "0:a", - }; - - bool removing = false; - var regex = new Regex(this.Pattern, RegexOptions.IgnoreCase); - for (int i = 0; i < videoInfo.SubtitleStreams.Count; i++) - { - var sub = videoInfo.SubtitleStreams[i]; - string str = UseTitle ? sub.Title : sub.Language; - if (string.IsNullOrEmpty(str) == false) // if empty we always use this since we have no info to go on - { - bool matches = regex.IsMatch(str); - if (NotMatching) - matches = !matches; - if (matches) - { - removing = true; - continue; - } - } - ffArgs.AddRange(new[] { "-map", "0:s:" + i }); - } - - if(removing == false) - { - // nothing to remove - return 2; - } - ffArgs.AddRange(new[] { "-c", "copy" }); - - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } -} - } -} diff --git a/VideoLegacyNodes/VideoNodes/SubtitleRemover.cs b/VideoLegacyNodes/VideoNodes/SubtitleRemover.cs deleted file mode 100644 index e11a6c7b..00000000 --- a/VideoLegacyNodes/VideoNodes/SubtitleRemover.cs +++ /dev/null @@ -1,118 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - public class SubtitleRemover: EncodingNode - { - public override int Outputs => 2; - - public override string Icon => "fas fa-comment"; - - - [Boolean(1)] - public bool RemoveAll { get; set; } - - [Checklist(nameof(Options), 2)] - [ConditionEquals(nameof(RemoveAll), false)] - public List SubtitlesToRemove { get; set; } - - private static List _Options; - public static List Options - { - get - { - if (_Options == null) - { - _Options = new List - { - new ListOption { Value = "mov_text", Label = "3GPP Timed Text subtitle"}, - new ListOption { Value = "ssa", Label = "ASS (Advanced SubStation Alpha) subtitle (codec ass)"}, - new ListOption { Value = "ass", Label = "ASS (Advanced SubStation Alpha) subtitle"}, - new ListOption { Value = "xsub", Label = "DivX subtitles (XSUB)" }, - new ListOption { Value = "dvbsub", Label = "DVB subtitles (codec dvb_subtitle)"}, - new ListOption { Value = "dvdsub", Label = "DVD subtitles (codec dvd_subtitle)"}, - new ListOption { Value = "dvb_teletext", Label = "DVB/Teletext Format"}, - new ListOption { Value = "text", Label = "Raw text subtitle"}, - new ListOption { Value = "subrip", Label = "SubRip subtitle"}, - new ListOption { Value = "srt", Label = "SubRip subtitle (codec subrip)"}, - new ListOption { Value = "ttml", Label = "TTML subtitle"}, - new ListOption { Value = "mov_text", Label = "TX3G (mov_text)"}, - new ListOption { Value = "webvtt", Label = "WebVTT subtitle"}, - }; - } - return _Options; - } - } - - public override int Execute(NodeParameters args) - { - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - List ffArgs = new List() - { - "-map", "0:v", - "-map", "0:a", - }; - - bool foundBadSubtitle = false; - - if (RemoveAll == false) - { - - var removeCodecs = SubtitlesToRemove?.Where(x => string.IsNullOrWhiteSpace(x) == false)?.Select(x => x.ToLower())?.ToList() ?? new List(); - - if (removeCodecs.Count == 0) - return 2; // nothing to remove - - - foreach (var sub in videoInfo.SubtitleStreams) - { - args.Logger?.ILog("Subtitle found: " + sub.Codec + ", " + sub.Title); - if (removeCodecs.Contains(sub.Codec.ToLower())) - { - foundBadSubtitle = true; - continue; - } - ffArgs.AddRange(new[] { "-map", sub.IndexString }); - } - } - else - { - foundBadSubtitle = videoInfo.SubtitleStreams?.Any() == true; - } - - if(foundBadSubtitle == false) - { - // nothing to remove - return 2; - } - ffArgs.AddRange(new[] { "-c", "copy" }); - - - string extension = new FileInfo(args.WorkingFile).Extension; - if(extension.StartsWith(".")) - extension = extension.Substring(1); - - if (Encode(args, FFMPEG, ffArgs, extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } -} - } -} diff --git a/VideoLegacyNodes/VideoNodes/VideoEncode.cs b/VideoLegacyNodes/VideoNodes/VideoEncode.cs deleted file mode 100644 index 80fcdb9c..00000000 --- a/VideoLegacyNodes/VideoNodes/VideoEncode.cs +++ /dev/null @@ -1,172 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.ComponentModel; - using System.ComponentModel.DataAnnotations; - using System.Diagnostics; - using System.Text.RegularExpressions; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - - public class VideoEncode : EncodingNode - { - - [DefaultValue("hevc")] - [TextVariable(1)] - public string VideoCodec { get; set; } - - [DefaultValue("hevc")] - [TextVariable(2)] - public string VideoCodecParameters { get; set; } - - [DefaultValue("ac3")] - [TextVariable(3)] - public string AudioCodec { get; set; } - - [DefaultValue("eng")] - [TextVariable(4)] - public string Language { get; set; } - - [DefaultValue("mkv")] - [TextVariable(5)] - public string Extension { get; set; } - - public override string Icon => "far fa-file-video"; - - public override int Execute(NodeParameters args) - { - if (VideoCodec == "COPY") - VideoCodec = string.Empty; - if (AudioCodec == "COPY") - AudioCodec = string.Empty; - - if (string.IsNullOrEmpty(VideoCodec) && string.IsNullOrEmpty(AudioCodec)) - { - args.Logger?.ELog("Video codec or Audio codec must be set"); - return -1; - } - - if (string.IsNullOrWhiteSpace(VideoCodecParameters)) - VideoCodecParameters = VideoCodec; - - VideoCodec = args.ReplaceVariables(VideoCodec ?? string.Empty); - VideoCodecParameters = args.ReplaceVariables(VideoCodecParameters ?? string.Empty); - AudioCodec = args.ReplaceVariables(AudioCodec ?? string.Empty); - Language = args.ReplaceVariables(Language); - - VideoCodec = VideoCodec.ToLower(); - AudioCodec = AudioCodec.ToLower(); - - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - Language = Language?.ToLower() ?? ""; - - // ffmpeg is one based for stream index, so video should be 1, audio should be 2 - - string encodeVideoParameters = string.Empty, encodeAudioParameters = string.Empty; - const string copyVideoStream = "-map 0:v -c:v copy"; - const string copyAudioStream = "-map 0:a -c:a copy"; - - if (string.IsNullOrEmpty(VideoCodec) == false) - { - - var videoIsRightCodec = videoInfo.VideoStreams.FirstOrDefault(x => IsSameVideoCodec(x.Codec ?? string.Empty, VideoCodec)); - var videoTrack = videoIsRightCodec ?? videoInfo.VideoStreams[0]; - args.Logger?.ILog("Video: ", videoTrack); - - string crop = (args.Variables.ContainsKey(DetectBlackBars.CROP_KEY) ? args.Variables[DetectBlackBars.CROP_KEY] as string : string.Empty) ?? string.Empty; - if (crop != string.Empty) - crop = " -vf crop=" + crop; - - if (videoIsRightCodec == null || crop != string.Empty) - { - string codecParameters = CheckVideoCodec(FFMPEG, VideoCodecParameters); - encodeVideoParameters = $"-map 0:v:0 -c:v {codecParameters} {crop}"; - } - Extension = args.ReplaceVariables(Extension)?.EmptyAsNull() ?? "mkv"; - } - else if(string.IsNullOrEmpty(Extension) == false) - { - // vidoe is being copied so use the same extension - Extension = new FileInfo(args.WorkingFile).Extension; - if(Extension.StartsWith(".")) - Extension = Extension.Substring(1); - } - - - if (string.IsNullOrEmpty(AudioCodec) == false) - { - var bestAudio = videoInfo.AudioStreams.Where(x => System.Text.Json.JsonSerializer.Serialize(x).ToLower().Contains("commentary") == false) - .OrderBy(x => - { - if (Language != string.Empty) - { - args.Logger?.ILog("Language: " + x.Language, x); - if (string.IsNullOrEmpty(x.Language)) - return 50; // no language specified - if (x.Language?.ToLower() != Language) - return 100; // low priority not the desired language - } - return 0; - }) - .ThenByDescending(x => x.Channels) - .ThenBy(x => x.Index) - .FirstOrDefault(); - - bool audioRightCodec = bestAudio?.Codec?.ToLower() == AudioCodec && videoInfo.AudioStreams[0] == bestAudio; - args.Logger?.ILog("Best Audio: ", bestAudio == null ? "null" : (object)bestAudio); - - - if (audioRightCodec == false) - encodeAudioParameters = $"-map 0:{bestAudio!.Index} -c:a {AudioCodec}"; - else if(videoInfo.AudioStreams.Count > 1) - encodeAudioParameters = $"-map 0:{bestAudio!.Index} -c:a copy"; - } - - if(string.IsNullOrEmpty(encodeVideoParameters) && string.IsNullOrEmpty(encodeAudioParameters)) - { - args.Logger?.ILog("Video and Audio does not need to be reencoded"); - return 2; - } - - - List ffArgs = new List(); - - ffArgs.AddRange((encodeVideoParameters?.EmptyAsNull() ?? copyVideoStream).Split(" ").Where(x => string.IsNullOrEmpty(x.Trim()) == false).Select(x => x.Trim()).ToArray()); - ffArgs.AddRange((encodeAudioParameters?.EmptyAsNull() ?? copyAudioStream).Split(" ").Where(x => string.IsNullOrEmpty(x.Trim()) == false).Select(x => x.Trim()).ToArray()); - - TotalTime = videoInfo.VideoStreams[0].Duration; - args.Logger.ILog("### Total Time: " + TotalTime); - - if (videoInfo?.SubtitleStreams?.Any() == true) - { - if (SupportsSubtitles(args, videoInfo, Extension)) - { - if (Language != string.Empty) - ffArgs.AddRange(new[] { "-map", $"0:s:m:language:{Language}?", "-c:s", "copy" }); - else - ffArgs.AddRange(new[] { "-map", "0:s?", "-c:s", "copy" }); - } - else - { - args.Logger?.WLog("Unsupported subtitle for target container, subtitles will be removed."); - } - } - - - if (Encode(args, FFMPEG, ffArgs, Extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message); - return -1; - } - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoNodes/VideoNode.cs b/VideoLegacyNodes/VideoNodes/VideoNode.cs deleted file mode 100644 index 21f1978b..00000000 --- a/VideoLegacyNodes/VideoNodes/VideoNode.cs +++ /dev/null @@ -1,164 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using FileFlows.Plugin; - using System.Text.Json; - - public abstract class VideoNode : Node - { - /// - /// Gets the Node Parameters - /// - protected NodeParameters Args { get; private set; } - - - /// - /// Gets if this node is obsolete and should be phased out - /// - public override bool Obsolete => true; - - /// - /// Gets a message to show when the user tries to use this obsolete node - /// - public override string ObsoleteMessage => "This node has been replaced by the FFMPEG Builder version"; - -#if (DEBUG) - /// - /// Used for unit tests - /// - /// the args - public void SetArgs(NodeParameters args) - { - this.Args = args; - } -#endif - - /// - /// Gets the FFMPEG executable location - /// - protected string FFMPEG { get; private set; } - public override string Icon => "fas fa-video"; - - /// - /// Executed before execute, sets ffmpegexe etc - /// - /// the node parametes - /// true if successfully - public override bool PreExecute(NodeParameters args) - { - this.Args = args; - this.FFMPEG = GetFFMpegExe(); - return string.IsNullOrEmpty(this.FFMPEG) == false; - } - - private string GetFFMpegExe() - { - string ffmpeg = Args.GetToolPath("FFMpeg"); - if (string.IsNullOrEmpty(ffmpeg)) - { - Args.Logger.ELog("FFMpeg tool not found."); - return ""; - } - var fileInfo = new FileInfo(ffmpeg); - if (fileInfo.Exists == false) - { - Args.Logger.ELog("FFMpeg tool configured by ffmpeg.exe file does not exist."); - return ""; - } - return fileInfo.FullName; - } - // protected string GetFFMpegPath(NodeParameters args) - // { - // string ffmpeg = args.GetToolPath("FFMpeg"); - // if (string.IsNullOrEmpty(ffmpeg)) - // { - // args.Logger.ELog("FFMpeg tool not found."); - // return ""; - // } - // var fileInfo = new FileInfo(ffmpeg); - // if (fileInfo.Exists == false) - // { - // args.Logger.ELog("FFMpeg tool configured by ffmpeg.exe file does not exist."); - // return ""; - // } - // return fileInfo.DirectoryName; - // } - - private const string VIDEO_INFO = "VideoInfo"; - protected void SetVideoInfo(NodeParameters args, VideoInfo videoInfo, Dictionary variables) - { - if (videoInfo.VideoStreams?.Any() == false) - return; - - if (args.Parameters.ContainsKey(VIDEO_INFO)) - args.Parameters[VIDEO_INFO] = videoInfo; - else - args.Parameters.Add(VIDEO_INFO, videoInfo); - - variables.AddOrUpdate("vi.VideoInfo", videoInfo); - variables.AddOrUpdate("vi.Width", videoInfo.VideoStreams[0].Width); - variables.AddOrUpdate("vi.Height", videoInfo.VideoStreams[0].Height); - variables.AddOrUpdate("vi.Duration", videoInfo.VideoStreams[0].Duration.TotalSeconds); - variables.AddOrUpdate("vi.Video.Codec", videoInfo.VideoStreams[0].Codec); - if (videoInfo.AudioStreams?.Any() == true) - { - variables.AddOrUpdate("vi.Audio.Codec", videoInfo.AudioStreams[0].Codec?.EmptyAsNull()); - variables.AddOrUpdate("vi.Audio.Channels", videoInfo.AudioStreams[0].Channels > 0 ? (object)videoInfo.AudioStreams[0].Channels : null); - variables.AddOrUpdate("vi.Audio.Language", videoInfo.AudioStreams[0].Language?.EmptyAsNull()); - variables.AddOrUpdate("vi.Audio.Codecs", string.Join(", ", videoInfo.AudioStreams.Select(x => x.Codec).Where(x => string.IsNullOrEmpty(x) == false))); - variables.AddOrUpdate("vi.Audio.Languages", string.Join(", ", videoInfo.AudioStreams.Select(x => x.Language).Where(x => string.IsNullOrEmpty(x) == false))); - } - var resolution = ResolutionHelper.GetResolution(videoInfo.VideoStreams[0].Width, videoInfo.VideoStreams[0].Height); - if(resolution == ResolutionHelper.Resolution.r1080p) - variables.AddOrUpdate("vi.Resolution", "1080p"); - else if (resolution == ResolutionHelper.Resolution.r4k) - variables.AddOrUpdate("vi.Resolution", "4K"); - else if (resolution == ResolutionHelper.Resolution.r720p) - variables.AddOrUpdate("vi.Resolution", "720p"); - else if (resolution == ResolutionHelper.Resolution.r480p) - variables.AddOrUpdate("vi.Resolution", "480p"); - else if (videoInfo.VideoStreams[0].Width < 900 && videoInfo.VideoStreams[0].Height < 800) - variables.AddOrUpdate("vi.Resolution", "SD"); - else - variables.AddOrUpdate("vi.Resolution", videoInfo.VideoStreams[0].Width + "x" + videoInfo.VideoStreams[0].Height); - - args.UpdateVariables(variables); - } - - protected VideoInfo GetVideoInfo(NodeParameters args) - { - if (args.Parameters.ContainsKey(VIDEO_INFO) == false) - { - args.Logger.WLog("No codec information loaded, use a 'VideoFile' node first"); - return null; - } - - if(args.Parameters[VIDEO_INFO] == null) - { - args.Logger.WLog("VideoInfo not found for file"); - return null; - } - var result = args.Parameters[VIDEO_INFO] as VideoInfo; - if (result != null) - return result; - - // may be from non Legacy VideoNodes - try - { - string json = JsonSerializer.Serialize(args.Parameters[VIDEO_INFO]); - var vi = JsonSerializer.Deserialize(json); - if (vi == null) - throw new Exception("Failed to deserailize object"); - return vi; - - } - catch(Exception ex) - { - args.Logger.WLog("VideoInfo could not be deserialized: " + ex.Message); - return null; - } - } - - - - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoNodes/VideoScaler.cs b/VideoLegacyNodes/VideoNodes/VideoScaler.cs deleted file mode 100644 index c159f33a..00000000 --- a/VideoLegacyNodes/VideoNodes/VideoScaler.cs +++ /dev/null @@ -1,142 +0,0 @@ -namespace FileFlows.VideoNodes -{ - using System.ComponentModel; - using System.ComponentModel.DataAnnotations; - using System.Diagnostics; - using System.Text.RegularExpressions; - using FileFlows.Plugin; - using FileFlows.Plugin.Attributes; - - public class VideoScaler: EncodingNode - { - public override string Icon => "fas fa-search-plus"; - public override int Outputs => 2; // this node always re-encodes - - [Select(nameof(CodecOptions), 1)] - public string VideoCodec { get; set; } - - [Required] - [TextVariable(2)] - [ConditionEquals(nameof(VideoCodec), "Custom")] - public string VideoCodecParameters { get; set; } - - private static List _CodecOptions; - public static List CodecOptions - { - get - { - if (_CodecOptions == null) - { - _CodecOptions = new List - { - new ListOption { Label = "Automatic", Value = "###GROUP###"}, - new ListOption { Value = "h264", Label = "H264"}, - new ListOption { Value = "h265", Label = "H265"}, - - new ListOption { Label = "CPU Encoding", Value = "###GROUP###"}, - new ListOption { Value = "libx264", Label = "H264 (CPU)"}, - new ListOption { Value = "libx265", Label = "H265 (CPU)"}, - - new ListOption { Label = "NVIDIA Hardware Encoding", Value = "###GROUP###"}, - new ListOption { Value = "h264_nvenc", Label = "H264 (NVIDIA)"}, - new ListOption { Value = "hevc_nvenc -preset hq -crf 23", Label = "H265 (NVIDIA)"}, - - new ListOption { Label = "Intel Hardware Encoding", Value = "###GROUP###"}, - new ListOption { Value = "h264_qsv", Label = "H264 (Intel)"}, - new ListOption { Value = "hevc_qsv", Label = "H265 (Intel)"}, - - new ListOption { Label = "Custom", Value = "###GROUP###"}, - new ListOption { Value = "Custom", Label = "Custom"}, - }; - } - return _CodecOptions; - } - } - - - [DefaultValue("mkv")] - [TextVariable(4)] - public string Extension { get; set; } - - [Boolean(5)] - public bool Force { get; set; } - - - [Select(nameof(ResolutionOptions), 3)] - public string Resolution { get; set; } - - - private static List _ResolutionOptions; - public static List ResolutionOptions - { - get - { - if (_ResolutionOptions == null) - { - _ResolutionOptions = new List - { - // we use -2 here so the width is divisible by 2 and automatically scaled to - // the appropriate height, if we forced the height it could be stretched - new ListOption { Value = "640:-2", Label = "480P"}, - new ListOption { Value = "1280:-2", Label = "720P"}, - new ListOption { Value = "1920:-2", Label = "1080P"}, - new ListOption { Value = "3840:-2", Label = "4K" } - }; - } - return _ResolutionOptions; - } - } - - public override int Execute(NodeParameters args) - { - Extension = args.ReplaceVariables(Extension)?.EmptyAsNull() ?? "mkv"; - - try - { - VideoInfo videoInfo = GetVideoInfo(args); - if (videoInfo == null) - return -1; - - - if (Force == false) - { - var resolution = ResolutionHelper.GetResolution(videoInfo); - if(resolution == ResolutionHelper.Resolution.r1080p && Resolution.StartsWith("1920")) - return 2; - else if (resolution == ResolutionHelper.Resolution.r4k && Resolution.StartsWith("3840")) - return 2; - else if (resolution == ResolutionHelper.Resolution.r720p && Resolution.StartsWith("1280")) - return 2; - else if (resolution == ResolutionHelper.Resolution.r480p && Resolution.StartsWith("640")) - return 2; - } - - List ffArgs = new List() - { - "-vf", $"scale={Resolution}:flags=lanczos", - "-c:v" - }; - - string codec = VideoCodec == "Custom" && string.IsNullOrWhiteSpace(VideoCodecParameters) == false ? - VideoCodecParameters : CheckVideoCodec(FFMPEG, VideoCodec); - - foreach (string c in codec.Split(" ")) - { - if (string.IsNullOrWhiteSpace(c.Trim())) - continue; - ffArgs.Add(c.Trim()); - } - - if (Encode(args, FFMPEG, ffArgs, Extension) == false) - return -1; - - return 1; - } - catch (Exception ex) - { - args.Logger?.ELog("Failed processing VideoFile: " + ex.Message + Environment.NewLine + ex.StackTrace); - return -1; - } - } - } -} \ No newline at end of file diff --git a/VideoLegacyNodes/VideoNodes/Video_H265_AC3.cs b/VideoLegacyNodes/VideoNodes/Video_H265_AC3.cs deleted file mode 100644 index d71e3a29..00000000 --- a/VideoLegacyNodes/VideoNodes/Video_H265_AC3.cs +++ /dev/null @@ -1,140 +0,0 @@ -//namespace FileFlows.VideoNodes -//{ -// using System.ComponentModel; -// using System.Text.RegularExpressions; -// using FileFlows.Plugin; -// using FileFlows.Plugin.Attributes; - -// public class Video_H265_AC3 : EncodingNode -// { - -// [DefaultValue("eng")] -// [Text(1)] -// public string Language { get; set; } - -// [DefaultValue(21)] -// [NumberInt(2)] -// public int Crf { get; set; } -// [DefaultValue(true)] -// [Boolean(3)] -// public bool NvidiaEncoding { get; set; } -// [DefaultValue(0)] -// [NumberInt(4)] -// public int Threads { get; set; } - -// [DefaultValue(false)] -// [Boolean(5)] -// public bool NormalizeAudio { get; set; } - - -// [DefaultValue(false)] -// [Boolean(6)] -// public bool ForceRencode { get; set; } - - -// public override string Icon => "far fa-file-video"; - -// public override int Execute(NodeParameters args) -// { -// this.args = args; -// try -// { -// VideoInfo videoInfo = GetVideoInfo(args); -// if (videoInfo == null) -// return -1; - -// Language = Language?.ToLower() ?? ""; - -// // ffmpeg is one based for stream index, so video should be 1, audio should be 2 - -// var videoH265 = videoInfo.VideoStreams.FirstOrDefault(x => Regex.IsMatch(x.Codec ?? "", @"^(hevc|h(\.)?265)$", RegexOptions.IgnoreCase)); -// var videoTrack = videoH265 ?? videoInfo.VideoStreams[0]; -// args.Logger.ILog("Video: ", videoTrack); - -// var bestAudio = videoInfo.AudioStreams.Where(x => System.Text.Json.JsonSerializer.Serialize(x).ToLower().Contains("commentary") == false) -// .OrderBy(x => -// { -// if (Language != string.Empty) -// { -// args.Logger.ILog("Language: " + x.Language, x); -// if (string.IsNullOrEmpty(x.Language)) -// return 50; // no language specified -// if (x.Language?.ToLower() != Language) -// return 100; // low priority not the desired language -// } -// return 0; -// }) -// .ThenByDescending(x => x.Channels) -// //.ThenBy(x => x.CodecName.ToLower() == "ac3" ? 0 : 1) // if we do this we can get commentary tracks... -// .ThenBy(x => x.Index) -// .FirstOrDefault(); - -// bool firstAc3 = bestAudio?.Codec?.ToLower() == "ac3" && videoInfo.AudioStreams[0] == bestAudio; -// args.Logger.ILog("Best Audio: ", bestAudio == null ? (object)"null" : (object)bestAudio); - - -// string crop = args.GetParameter(DetectBlackBars.CROP_KEY) ?? ""; -// if (crop != string.Empty) -// crop = " -vf crop=" + crop; - -// if (ForceRencode == false && firstAc3 == true && videoH265 != null) -// { -// if (crop == string.Empty) -// { -// args.Logger.DLog("File is hevc with the first audio track being AC3"); -// return 2; -// } -// else -// { -// args.Logger.ILog("Video is hevc and ac3 but needs to be cropped"); -// } -// } - -// string ffmpegExe = GetFFMpegExe(args); -// if (string.IsNullOrEmpty(ffmpegExe)) -// return -1; - -// List ffArgs = new List(); - -// if (NvidiaEncoding == false && Threads > 0) -// ffArgs.AddRange(new[] { "-threads", Math.Min(Threads, 16).ToString() }); - -// if (videoH265 == null || crop != string.Empty) -// ffArgs.AddRange(new[] { "-map", "0:v:0", "-c:v", NvidiaEncoding ? "hevc_nvenc - preset hq" : "libx265")} -crf " + (Crf > 0 ? Crf : 21) + crop); -// " -// else -// ffArgs.Add($"-map 0:v:0 -c:v copy"); - -// TotalTime = videoInfo.VideoStreams[0].Duration; - -// if (NormalizeAudio) -// { -// int sampleRate = bestAudio.SampleRate > 0 ? bestAudio.SampleRate : 48_000; -// ffArgs.Add($"-map 0:{bestAudio.Index} -c:a ac3 -ar {sampleRate} -af loudnorm=I=-24:LRA=7:TP=-2.0"); -// } -// else if (bestAudio.Codec.ToLower() != "ac3") -// ffArgs.Add($"-map 0:{bestAudio.Index} -c:a ac3"); -// else -// ffArgs.Add($"-map 0:{bestAudio.Index} -c:a copy"); - -// if (Language != string.Empty) -// ffArgs.Add($"-map 0:s:m:language:{Language}? -c:s copy"); -// else -// ffArgs.Add($"-map 0:s? -c:s copy"); - - -// if (Encode(args, ffmpegExe, ffArgs) == false) -// return -1; - -// return 1; -// } -// catch (Exception ex) -// { -// args.Logger.ELog("Failed processing VideoFile: " + ex.Message); -// return -1; -// } -// } -// } - - -//} \ No newline at end of file diff --git a/VideoNodes/FfmpegBuilderNodes/FfmpegBuilderExecutor.cs b/VideoNodes/FfmpegBuilderNodes/FfmpegBuilderExecutor.cs index af86bebd..7d20d52a 100644 --- a/VideoNodes/FfmpegBuilderNodes/FfmpegBuilderExecutor.cs +++ b/VideoNodes/FfmpegBuilderNodes/FfmpegBuilderExecutor.cs @@ -1,6 +1,7 @@ using FileFlows.Plugin; using FileFlows.VideoNodes.FfmpegBuilderNodes.Models; using System.Runtime.InteropServices; +using FileFlows.VideoNodes.Helpers; namespace FileFlows.VideoNodes.FfmpegBuilderNodes { @@ -120,6 +121,12 @@ namespace FileFlows.VideoNodes.FfmpegBuilderNodes { startArgs.AddRange(GetHardwareDecodingArgs()); } + + if (ffArgs.Any(x => x.Contains("vaapi") && Helpers.VaapiHelper.VaapiLinux)) + { + startArgs.Add("-vaapi_device"); + startArgs.Add(VaapiHelper.VaapiRenderDevice); + } foreach (var file in model.InputFiles) { @@ -198,7 +205,7 @@ namespace FileFlows.VideoNodes.FfmpegBuilderNodes { if (hw == null) continue; - if (CanUseHardwareEncoding.DisabledByVariables(Args, string.Join(" ", hw))) + if (CanUseHardwareEncoding.DisabledByVariables(Args, hw)) continue; try { diff --git a/VideoNodes/Helpers/VaapiHelper.cs b/VideoNodes/Helpers/VaapiHelper.cs new file mode 100644 index 00000000..dab3cc58 --- /dev/null +++ b/VideoNodes/Helpers/VaapiHelper.cs @@ -0,0 +1,11 @@ +namespace FileFlows.VideoNodes.Helpers; + +/// +/// Helper for Vaapi +/// +class VaapiHelper +{ + internal static bool VaapiLinux => OperatingSystem.IsLinux() && File.Exists(VaapiRenderDevice); + + internal const string VaapiRenderDevice = "/dev/dri/renderD128"; +} \ No newline at end of file diff --git a/VideoNodes/LogicalNodes/CanUseHardwareEncoding.cs b/VideoNodes/LogicalNodes/CanUseHardwareEncoding.cs index 4e344a92..213fc16d 100644 --- a/VideoNodes/LogicalNodes/CanUseHardwareEncoding.cs +++ b/VideoNodes/LogicalNodes/CanUseHardwareEncoding.cs @@ -1,4 +1,6 @@ -namespace FileFlows.VideoNodes; +using FileFlows.VideoNodes.Helpers; + +namespace FileFlows.VideoNodes; /// /// Node for checking if Flow Runner has access to hardware @@ -139,7 +141,7 @@ public class CanUseHardwareEncoding:Node /// /// the node parameters /// true if can use it, otherwise false - internal static bool CanProcess_Qsv_Hevc(NodeParameters args) => CanProcess(args, "hevc_qsv -global_quality 28 -load_plugin hevc_hw"); + internal static bool CanProcess_Qsv_Hevc(NodeParameters args) => CanProcess(args, "hevc_qsv", "-global_quality", "28", "-load_plugin", "hevc_hw"); /// /// Checks if this flow runner can use Intels QSV H.264 encoder @@ -169,34 +171,34 @@ public class CanUseHardwareEncoding:Node /// the node parameters /// the parameters to check /// if a encoder/decoder has been disabled by a variable - internal static bool DisabledByVariables(NodeParameters args, string parameters) + internal static bool DisabledByVariables(NodeParameters args, string[] parameters) { - if (parameters.ToLower().Contains("nvenc")) + if (parameters.Any(x => x.ToLower().Contains("nvenc"))) { if (args.GetVariable("NoNvidia") as bool? == true) return true; if (args.GetVariable("NoNVIDIA") as bool? == true) return true; } - else if (parameters.ToLower().Contains("qsv")) + else if (parameters.Any(x => x.ToLower().Contains("qsv"))) { if (args.GetVariable("NoQSV") as bool? == true) return true; } - else if (parameters.ToLower().Contains("vaapi")) + else if (parameters.Any(x => x.ToLower().Contains("vaapi"))) { if (args.GetVariable("NoVAAPI") as bool? == true) return true; } - else if (parameters.ToLower().Contains("amf")) + else if (parameters.Any(x => x.ToLower().Contains("amf"))) { if (args.GetVariable("NoAMF") as bool? == true) return true; if (args.GetVariable("NoAMD") as bool? == true) return true; } - else if (parameters.ToLower().Contains("videotoolbox")) + else if (parameters.Any(x => x.ToLower().Contains("videotoolbox"))) { if (args.GetVariable("NoVideoToolbox") as bool? == true) return true; @@ -204,7 +206,7 @@ public class CanUseHardwareEncoding:Node return false; } - private static bool CanProcess(NodeParameters args, string encodingParams) + private static bool CanProcess(NodeParameters args, params string[] encodingParams) { if (DisabledByVariables(args, encodingParams)) return false; @@ -226,7 +228,7 @@ public class CanUseHardwareEncoding:Node /// the location of ffmpeg /// the encoding parameter to test /// true if can be processed - internal static bool CanProcess(NodeParameters args, string ffmpeg, string encodingParams) + internal static bool CanProcess(NodeParameters args, string ffmpeg, string[] encodingParams) { bool can = CanExecute(); if (can == false && encodingParams?.Contains("amf") == true) @@ -240,16 +242,49 @@ public class CanUseHardwareEncoding:Node bool CanExecute() { - string cmdArgs = $"-loglevel error -f lavfi -i color=black:s=1080x1080 -vframes 1 -an -c:v {encodingParams} -f null -\""; + bool vaapi = encodingParams.Any(x => x.Contains("vaapi")) && VaapiHelper.VaapiLinux; + List arguments = encodingParams.ToList(); + if (vaapi) + arguments.AddRange(new [] { "-vf", "'format=nv12,hwupload'", "-strict", "-2"}); + arguments.InsertRange(0, new [] + { + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "color=black:s=1080x1080", + "-vframes", + "1", + "-an", + "-c:v" + }); + if (vaapi) + { + arguments.InsertRange(0, + new[] { "-fflags", "+genpts", "-vaapi_device", VaapiHelper.VaapiRenderDevice }); + arguments.Add(Path.Combine(args.TempPath, Guid.NewGuid() + ".mkv")); + } + else + { + + arguments.AddRange(new [] + { + "-f", + "null", + "-" + }); + } var cmd = args.Process.ExecuteShellCommand(new ExecuteArgs { Command = ffmpeg, - Arguments = cmdArgs, + ArgumentList = arguments.ToArray(), Silent = true }).Result; if (cmd.ExitCode != 0 || string.IsNullOrWhiteSpace(cmd.Output) == false) { - args.Logger?.WLog($"Cant process '{encodingParams}': {cmd.Output ?? ""}"); + string asStr = string.Join(" ", arguments.Select(x => x.Contains(" ") ? "\"" + x + "\"" : x)); + args.Logger?.WLog($"Cant process '{ffmpeg} {asStr}': {cmd.Output ?? ""}"); return false; } return true; diff --git a/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs b/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs index 05357636..0c1b58eb 100644 --- a/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs +++ b/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_MetadataTests.cs @@ -109,6 +109,39 @@ public class FfmpegBuilder_MetadataTests: TestBase string log = logger.ToString(); Assert.AreEqual(1, result); } + + + [TestMethod] + public void FfmpegBuilder_Metadata_Remover_BitrateFromConvetted() + { + string file = TestFile_BasicMkv; + var logger = new TestLogger(); + var vi = new VideoInfoHelper(FfmpegPath, logger); + var vii = vi.Read(file); + var args = new NodeParameters(file, logger, false, string.Empty); + args.GetToolPathActual = (string tool) => FfmpegPath; + args.TempPath = TempPath; + args.Parameters.Add("VideoInfo", vii); + + + FfmpegBuilderStart ffStart = new(); + Assert.IsTrue(ffStart.PreExecute(args)); + Assert.AreEqual(1, ffStart.Execute(args)); + + FfmpegBuilderVideoEncode ffEncode = new(); + ffEncode.Codec = "h265"; + ffEncode.Quality = 30; + ffEncode.HardwareEncoding = false; + ffEncode.PreExecute(args); + ffEncode.Execute(args); + + FfmpegBuilderExecutor ffExecutor = new(); + ffExecutor.PreExecute(args); + int result = ffExecutor.Execute(args); + + string log = logger.ToString(); + Assert.AreEqual(1, result); + } } #endif \ No newline at end of file diff --git a/VideoNodes/Tests/_TestBase.cs b/VideoNodes/Tests/_TestBase.cs index 8b926474..54cb4f2a 100644 --- a/VideoNodes/Tests/_TestBase.cs +++ b/VideoNodes/Tests/_TestBase.cs @@ -1,5 +1,6 @@ #if(DEBUG) +using System.Runtime.InteropServices; using FileFlows.VideoNodes; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Text.Json; @@ -12,6 +13,9 @@ public abstract class TestBase public string TestPath { get; private set; } public string TempPath { get; private set; } public string FfmpegPath { get; private set; } + + public readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + public readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); [TestInitialize] public void TestInitialize() @@ -24,9 +28,15 @@ public abstract class TestBase { LoadSettings("../../../test.settings.json"); } - this.TestPath = this.TestPath?.EmptyAsNull() ?? @"d:\videos\testfiles"; - this.TempPath = this.TempPath?.EmptyAsNull() ?? @"d:\videos\temp"; - this.FfmpegPath = this.FfmpegPath?.EmptyAsNull() ?? @"C:\utils\ffmpeg\ffmpeg.exe"; + this.TestPath = this.TestPath?.EmptyAsNull() ?? (IsLinux ? "~/src/ff-files/test-files" : @"d:\videos\testfiles"); + this.TempPath = this.TempPath?.EmptyAsNull() ?? (IsLinux ? "~/src/ff-files/temp" : @"d:\videos\temp"); + this.FfmpegPath = this.FfmpegPath?.EmptyAsNull() ?? (IsLinux ? "/usr/bin/ffmpeg" : @"C:\utils\ffmpeg\ffmpeg.exe"); + + + this.TestPath = this.TestPath.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/"); + this.TempPath = this.TempPath.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/"); + this.FfmpegPath = this.FfmpegPath.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/"); + if (Directory.Exists(this.TempPath) == false) Directory.CreateDirectory(this.TempPath); } @@ -35,6 +45,9 @@ public abstract class TestBase { try { + if (File.Exists(filename) == false) + return; + string json = File.ReadAllText(filename); var settings = JsonSerializer.Deserialize(json); this.TestPath = settings.TestPath; diff --git a/VideoNodes/VideoInfoHelper.cs b/VideoNodes/VideoInfoHelper.cs index 6dbdab33..8d0785aa 100644 --- a/VideoNodes/VideoInfoHelper.cs +++ b/VideoNodes/VideoInfoHelper.cs @@ -42,6 +42,10 @@ namespace FileFlows.VideoNodes public static string GetFFMpegPath(NodeParameters args) => args.GetToolPath("FFMpeg"); public VideoInfo Read(string filename) { + #if(DEBUG) // UNIT TESTING + filename = filename.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/"); + #endif + var vi = new VideoInfo(); vi.FileName = filename; if (File.Exists(filename) == false) diff --git a/VideoNodes/VideoNodes.csproj b/VideoNodes/VideoNodes.csproj index 3dfb0102aaf1e13a4234940d8157fa0d6329d44c..8b8fb366e8317fc959f302949aeea73067f89c6b 100644 GIT binary patch literal 2141 zcmdT_U2oGc6n);L{Rbk(jGl}_B4sxaKps3WmW1P zAa8uZIDD4tNM=S91!Q-_Sb;TjsEd-TCG$Qnhyp9M6VgsM3h56O)#2q)e*P2zT>b?oO_yD@gREqMxztdmW5 z_dZNtRzhY?VmyhSLn)EB7m?@@iM~er`$>G)g71ou;XAVwP&9N45^U*A_>}dd&h8$r zaxt$A_sIT_-MxwA&Yxnx)9-iA;If+k7^vlNa?$Yv8)kE?KXap4_xPZDijTw7;q;`} zJqa&_{Hg&Dp46+OHs$cCUmf?F*YRl*Hx?WF-a1zAJzRriIP-Vb|KyzKAc0%>KTf)p zyme3nwQLuY+^c4X^!uc)VJO}f>QOyO=MZ{21=5JvrasTWx**b|(HE5YW0WIQ`q+=} zZ!Jy3ZvPl$ymVlqTPuh3ke>XVk@Y3;3p1$1t9)Q+p+DL{XjyD?uj9AJ4Z0LGgMy;p ztX8Y`#xWY|ayio8Z;X_}W-bJYlo){r@r?x|RIJRHc1XF0i z-#D$4AJH!D)(P|j|7Y_L)Or#$uyx=!Lb(Zd3r6XeTP3P#G?Oxk*Npm{=z3a0Dsqvo bE}#*Y7H`E(c$JrR7(qog0}smOuL1TGR{N-a literal 4284 zcmeH~U2hXd6o$_&mHHo6QKf)H*G>u~w81C^(xxiGBBs5mR4uWSxHxv?wM!%M+uJ_x z8Bg}Z_7V!*RhI3w=gd3r`9AZ{-+T7h{;~@jSz>?Mz^0bl&?YvvN48;GmRg(dgq5+K z@PD7xv7Pzq+-7!aUnBJZna9?#J?nxnu|JSMVKuR1cD!-v=j=T3)f|aaR#Q8&XV&98 zNNs@drG4S=guM~%(s|A7%6{=7cgs$&nishep zItMKwlEyHb!Y|^~Wy?5KJ9Mz#(Bp9e(h-=7&A_HESw+dP{TuXACI`t_OJ2cc1jBRq z&d@oxZ^^Y55gj7x^jhZ>4B4gS+IK;FA7+`uQ)jA5HRO~TpUS09NsELtWv_&FRl5`4 zp}MJxpgLOU9@*u@qvD_ke?__WD$mY*SK^G7tC|1S`WWP3wS@m=M`<-)RB6@L)#pTM zRq9h-%8yV7BJT@MnZY42ir$I)6iteS0VgOj^j$tlS_b_CPOy)#Ws3`>Gu~0})P1nd zre(XoWcP#he8yR1EZM5fhP8dkKo{9q?{@N87Nw z;BMIOyzlT*&poYbXfZK7%DutjH+_kRU_3KLb&9s2#2eYbep z=IIF*-lcP@niju;R9tIYS+Z^4R3z_}epOMn?f&)J4rf$_+rqE4&gC9o<_Vw%O9=I0)Ja`Y%B>7+KlglT>UiMGEr z)3+AY;A1!))6wGD@oAr$mQinI*D1_baoL82-n+j3xtJ{91U`&SnC7KNIX>z&VL5{8 zRW?M7aMa^hdAFFCVn0@YmVC->N;jtQQuPiMP}rADe?7imQ1vw%>GLVQL!DojrB72z^YE_(Mjp%rd!a*+pRjg-7HUf=$!LX>?zA;Ya%e*5Ohl@ z=R(x(Q?S1oU-j`g2iE&@VDlrMRNTlKn#elDL~6gSoQcY4DS4>7r98ciQTAsz=a|S* nyl8IJO}BmnI^g_~TULnglP=>+e%TsDmT0}n;##lX20HdHy~V0` diff --git a/VideoNodes/VideoNodes/EncodingNode.cs b/VideoNodes/VideoNodes/EncodingNode.cs index 78e15dca..312e8fc1 100644 --- a/VideoNodes/VideoNodes/EncodingNode.cs +++ b/VideoNodes/VideoNodes/EncodingNode.cs @@ -88,10 +88,10 @@ namespace FileFlows.VideoNodes // try find best hevc encoder foreach(string vidparam in new [] { "hevc_nvenc -preset hq", "hevc_qsv -global_quality 28 -load_plugin hevc_hw", "hevc_amf", "hevc_vaapi" }) { - if (CanUseHardwareEncoding.DisabledByVariables(Args, vidparam)) + if (CanUseHardwareEncoding.DisabledByVariables(Args, vidparam.Split(' '))) continue; - bool canProcess = CanUseHardwareEncoding.CanProcess(Args, ffmpeg, vidparam); + bool canProcess = CanUseHardwareEncoding.CanProcess(Args, ffmpeg, vidparam.Split(' ')); if (canProcess) return vidparam; } @@ -102,7 +102,7 @@ namespace FileFlows.VideoNodes // try find best hevc encoder foreach (string vidparam in new[] { "h264_nvenc", "h264_qsv", "h264_amf", "h264_vaapi" }) { - bool canProcess = CanUseHardwareEncoding.CanProcess(Args, ffmpeg, vidparam); + bool canProcess = CanUseHardwareEncoding.CanProcess(Args, ffmpeg, new [] { vidparam }); if (canProcess) return vidparam; } diff --git a/VideoNodes/test.settings.json b/VideoNodes/test.settings.json deleted file mode 100644 index 504c54f0..00000000 --- a/VideoNodes/test.settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "TestPath": "C:\\videos\\testfiles", - "TempPath": "C:\\videos\\temp", - "FfmpegPath": "C:\\Utils\\ffmpeg\\ffmpeg.exe" -} \ No newline at end of file