From 8349a28ed89656f1ba946769876380b99bef1bcc Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 8 Jul 2023 22:43:11 -0400 Subject: [PATCH] refactor(server): modularize `getFfmpegOptions` (#3138) * refactored `getFfmpegOptions` refactor transcoding, make separate service * fixed enum casing * use `Logger` instead of `console.log` * review suggestions * use enum for `getHandler` * fixed formatting * Update server/src/domain/media/media.util.ts Co-authored-by: Jason Rasmussen * Update server/src/domain/media/media.util.ts Co-authored-by: Jason Rasmussen * More specific imports, renamed codec classes * simplified code * removed unused import * added tests * added base implementation for bitrate and threads --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 90 +++++-- mobile/openapi/.openapi-generator/FILES | 9 + mobile/openapi/README.md | Bin 17791 -> 17909 bytes mobile/openapi/doc/AudioCodec.md | Bin 0 -> 376 bytes mobile/openapi/doc/SystemConfigFFmpegDto.md | Bin 685 -> 758 bytes mobile/openapi/doc/TranscodePolicy.md | Bin 0 -> 381 bytes mobile/openapi/doc/VideoCodec.md | Bin 0 -> 376 bytes mobile/openapi/lib/api.dart | Bin 5650 -> 5748 bytes mobile/openapi/lib/api_client.dart | Bin 17908 -> 18179 bytes mobile/openapi/lib/api_helper.dart | Bin 3948 -> 4252 bytes mobile/openapi/lib/model/audio_codec.dart | Bin 0 -> 2625 bytes .../lib/model/system_config_f_fmpeg_dto.dart | Bin 8310 -> 5101 bytes .../openapi/lib/model/transcode_policy.dart | Bin 0 -> 2929 bytes mobile/openapi/lib/model/video_codec.dart | Bin 0 -> 2630 bytes mobile/openapi/test/audio_codec_test.dart | Bin 0 -> 417 bytes .../test/system_config_f_fmpeg_dto_test.dart | Bin 1423 -> 1440 bytes .../openapi/test/transcode_policy_test.dart | Bin 0 -> 427 bytes mobile/openapi/test/video_codec_test.dart | Bin 0 -> 417 bytes server/immich-openapi-specs.json | 51 ++-- server/src/domain/media/media.repository.ts | 12 + server/src/domain/media/media.service.spec.ts | 232 ++++++++++++++++-- server/src/domain/media/media.service.ts | 131 ++-------- server/src/domain/media/media.util.ts | 191 ++++++++++++++ .../dto/system-config-ffmpeg.dto.ts | 17 +- .../system-config/system-config.core.ts | 10 +- .../system-config.service.spec.ts | 15 +- .../infra/entities/system-config.entity.ts | 20 +- server/src/infra/infra.module.ts | 2 +- .../infra/repositories/media.repository.ts | 23 +- server/src/microservices/app.service.ts | 2 +- server/test/fixtures.ts | 10 +- web/src/api/open-api/api.ts | 90 +++++-- .../settings/ffmpeg/ffmpeg-settings.svelte | 24 +- 33 files changed, 701 insertions(+), 228 deletions(-) create mode 100644 mobile/openapi/doc/AudioCodec.md create mode 100644 mobile/openapi/doc/TranscodePolicy.md create mode 100644 mobile/openapi/doc/VideoCodec.md create mode 100644 mobile/openapi/lib/model/audio_codec.dart create mode 100644 mobile/openapi/lib/model/transcode_policy.dart create mode 100644 mobile/openapi/lib/model/video_codec.dart create mode 100644 mobile/openapi/test/audio_codec_test.dart create mode 100644 mobile/openapi/test/transcode_policy_test.dart create mode 100644 mobile/openapi/test/video_codec_test.dart create mode 100644 server/src/domain/media/media.util.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 277d676daa..3d661f9917 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -746,6 +746,21 @@ export const AssetTypeEnum = { export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; +/** + * + * @export + * @enum {string} + */ + +export const AudioCodec = { + Mp3: 'mp3', + Aac: 'aac', + Opus: 'opus' +} as const; + +export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec]; + + /** * * @export @@ -2411,24 +2426,30 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'threads': number; + /** + * + * @type {VideoCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetVideoCodec': VideoCodec; + /** + * + * @type {AudioCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetAudioCodec': AudioCodec; + /** + * + * @type {TranscodePolicy} + * @memberof SystemConfigFFmpegDto + */ + 'transcode': TranscodePolicy; /** * * @type {string} * @memberof SystemConfigFFmpegDto */ 'preset': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetVideoCodec': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetAudioCodec': string; /** * * @type {string} @@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'twoPass': boolean; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'transcode': SystemConfigFFmpegDtoTranscodeEnum; } -export const SystemConfigFFmpegDtoTranscodeEnum = { - All: 'all', - Optimal: 'optimal', - Required: 'required', - Disabled: 'disabled' -} as const; - -export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum]; /** * @@ -2749,6 +2756,22 @@ export const TimeGroupEnum = { export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; +/** + * + * @export + * @enum {string} + */ + +export const TranscodePolicy = { + All: 'all', + Optimal: 'optimal', + Required: 'required', + Disabled: 'disabled' +} as const; + +export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy]; + + /** * * @export @@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto { */ 'authStatus': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const VideoCodec = { + H264: 'h264', + Hevc: 'hevc', + Vp9: 'vp9' +} as const; + +export type VideoCodec = typeof VideoCodec[keyof typeof VideoCodec]; + + /** * APIKeyApi - axios parameter creator diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 26eeb1c6b6..9862f98c4a 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -29,6 +29,7 @@ doc/AssetIdsDto.md doc/AssetIdsResponseDto.md doc/AssetResponseDto.md doc/AssetTypeEnum.md +doc/AudioCodec.md doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/ChangePasswordDto.md @@ -108,6 +109,7 @@ doc/TagResponseDto.md doc/TagTypeEnum.md doc/ThumbnailFormat.md doc/TimeGroupEnum.md +doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md doc/UpdateTagDto.md @@ -117,6 +119,7 @@ doc/UserApi.md doc/UserCountResponseDto.md doc/UserResponseDto.md doc/ValidateAccessTokenResponseDto.md +doc/VideoCodec.md git_push.sh lib/api.dart lib/api/album_api.dart @@ -164,6 +167,7 @@ lib/model/asset_ids_dto.dart lib/model/asset_ids_response_dto.dart lib/model/asset_response_dto.dart lib/model/asset_type_enum.dart +lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart lib/model/change_password_dto.dart lib/model/check_duplicate_asset_dto.dart @@ -233,6 +237,7 @@ lib/model/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart lib/model/time_group_enum.dart +lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_dto.dart lib/model/update_tag_dto.dart @@ -241,6 +246,7 @@ lib/model/usage_by_user_dto.dart lib/model/user_count_response_dto.dart lib/model/user_response_dto.dart lib/model/validate_access_token_response_dto.dart +lib/model/video_codec.dart pubspec.yaml test/add_assets_dto_test.dart test/add_assets_response_dto_test.dart @@ -268,6 +274,7 @@ test/asset_ids_dto_test.dart test/asset_ids_response_dto_test.dart test/asset_response_dto_test.dart test/asset_type_enum_test.dart +test/audio_codec_test.dart test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/change_password_dto_test.dart @@ -347,6 +354,7 @@ test/tag_response_dto_test.dart test/tag_type_enum_test.dart test/thumbnail_format_test.dart test/time_group_enum_test.dart +test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_dto_test.dart test/update_tag_dto_test.dart @@ -356,3 +364,4 @@ test/user_api_test.dart test/user_count_response_dto_test.dart test/user_response_dto_test.dart test/validate_access_token_response_dto_test.dart +test/video_codec_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 42ecf4177dfc9cf2caf09d8c38ff0dab13fb4bf5..a78726e089f99e56859b06113cf219435672d3cd 100644 GIT binary patch delta 120 zcmez0#rUGKxIgo%Z;3atV$!1>5YaB!PXZd&7_R|tb? zq4r9fl$S{yb+9&`@QmBPSe`j`ZE{f@hm6)NnV}vk4_&LMt~Z}=-sZfDf{pBgJV{&)|Cx^f-~xUZ BbF}~f literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md index e2dcb45db1ca87279cd267e82282bedcbd938225..a08261e797ba87da0d81d16a245cbe037aa5aa92 100644 GIT binary patch delta 137 zcmZ3>`i*si2;*cUMp5}_Ev>N3l+=9Z{FKyWEv;A$1WzwFMRRf>qZ~+$V`)kzLJfik sR`Y;STob4!q$n}3I2mY8Kz>eUawW(xG$F8RZbse79gOjlIhd9L0Qul8OaK4? delta 50 zcmeyyx|Vf=2;<~)j4G3*82KkFGl@(NViW<>Zy8g#w6sc!67!0a^HV0vGKo(PWLgRU Dg&Yq( diff --git a/mobile/openapi/doc/TranscodePolicy.md b/mobile/openapi/doc/TranscodePolicy.md new file mode 100644 index 0000000000000000000000000000000000000000..bf6b88cd3afcd71496d6f2b8b8a9da5730effac4 GIT binary patch literal 381 zcma)1v1&sx4BYh?Ci;u<}wtL5K8G10%y)@YZ6<=rwoODeC_K&uU#4~){~yz z^Q({}1rwe2Z0V|BoF(4mwgadt+_AAJ?}Wn^qvH;BxWQD3BPBE6IX@*ed2*nt2$y3ioGYNEzu8!6vIKufQDRx@Xga0K`{muYzbtMSe_k#ZS9E>zX)&RzJG#AF((iXyH`jl? z0gRC!b7B4LUGn|A5#OpuZLCZSW79&F@`4)OIGLxil$F$e(%q}7HrAg!AgPAA0#4b(+xE2V+Wlv}P-qiy0N7baL{1 z9y+vZ3@cqz`on~g5vQyQ8nZD%TLpsOz9qg-l)E*4QPqCeS%W=eX&k!^KACie?%#JF zW4FF&lb$CW46VY>qx*<{Gb7!U<rZMkkdyC#!bCY`8 zKy1M<3+!o&sGByP9wfygz-`rjI8hbHrN|`ULH8v)>!Ijfz2{y3F4eWJsV8R5CGbZH+O{&5rvK0dqGUo^@c z*LtiHh9dUv1%1QB#GTpsct!hILpeT(TWs^ss9z~Plx-k72Lq0tYo(2gd9>vY(DS{@ Iyk`;p7iADurT_o{ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index 2cb29e0ce6e68b1da05b7c4f1e143b0c07f25f02..7f21d9d6e1d28cf8901b10712a9ed95812129b4f 100644 GIT binary patch delta 415 zcmez7@K$}pWyZ;G89h0Q67!0a^HWkMD>8{pzRajF*_z3H@_Z)c$gs?m)O=^4OtL~r zVo`c(2|~b{i%UVlu{0$UMIIplmJflM9FU)rnOvz*0^?8K%cL?{lG$u>1M~XHek|#e z4Om4dE3lYM-o=tO*_E|^vH~01Ik}51a`I<3PhprLwkino z9z`8=jUlm zj^>b@{E%BN7~v6wWEN0Gbdfs5B!mL>SWQJ8U|>K1+(}jnXuh9p#H%(rncHcyBhR7D d5xjbAlcxw9BEr5-SZ4AmVFR{WYpz-@E&xMTkwpLi literal 8310 zcmd5>ZExeo5&rI9u?HBDj4E=QPlXfNabvsnHD?ERz5s$~gsmk-xh;^y|p>yfy+7kBp$ z;@$n#?aja5fQ%@=Ph({!-;DqA%@I9jPerL@G%r;&&vJPts-n^|jbtvDvM?k6oGn+S zGQAvezK%CKT2&@e^50daz+S{le6C`J|BX}{!jCJc%2dDT$C$W#H(zKf8QP*C3&p1_^{DNE`KPCdG>U3 zvs}riD^rRuM?#zeh4<1_s?Z`9gf`^Z)>HbmZopV#Og_!pMg~QLz&8CH_c_0X8UN~OI_xb$;zTn&NBY>Z<$fCk$t;n zUH$}Id$&|v=mf{3&F2%SKma-;07U)W-QGTbFe)pa1mC*3(!K5WUbyApewIo>BeNt_SKHb#Eux8L}i$aY!kS6^8hVluKRh$lovd&A*MoBnH?fKjG>_pQC%h1?42 z7+v?3b~|Vyur${C-VziPSQ=|>mY_ExZalTgoGPtAw$Scrz49!Hf7sF_*(*FvEMmPN zPlN6({&mEpKv~E{Y8|T$1vo!FIb9>L>*|XuH3tL+tI>$C8e;Ha281zQYDFB-s<74FQc5>|C8aYdy95YOy=olnO zJ1d?h0OM)Gil+(Cc$%;>YFOIjH~RU)+?XZj z!jx}ySpXDH8uP4(bMXt7#6)aSe|i3v{ohhd)c{ZpW}x&brnBv^3nXr#L&k^(Dt_(| zb-@kf-JM>>fOwZN?DR4QHFO!nPKPmQD6oXJ4zbf*pmYmeGOx@Ap@ryrp+Z}fDFrTG=&x-q>_M!_Iuqstdi%whhW2=r!*D9U zE=0?+Ey*~Ewb`=qA(skVK~X){e~=*#%75^IGdSq#X9DVe{;*le?-01phvY>g!A^4W zhHDgJaZd0ulXsmaBm2b`ANzE8pL3@sXV*pc%IGo-wxtEXIZ#?)=#dtj=|E|Lp(QQ& z*TZpB{?6-CEue1q3E&UXq@T0(p)cBX5$6JA5ohQo&0i*(%h1UeebdC&69pa1BkeS z!;0_a07n*iz&biiXDHN+} z<5fDQr%Y;pCDIRH>p<07X=6*=P}Ck31F|VIev%t1Q7k{(DV^X2J%hD&FWeoEl;bQ^ zC3jNm%q*}(jzyh*uRgwJTClntic$+aH1{BpAweny+{kr7= zT!v8(aG?p9q&bK6H8iz=t^AC^MJinVNvJVgy=rc~WE|N^M;%1KN~JL=tSVBe@(s;? zI3B^Fsh#1sFXi%4DtZ-%EZ0oBV1ni5;KgmWJIz2^Z@)#3HFyXIuW~B$4O!6+A8O^C`|G=p^hM4o80QI7IyDZ$eL~negd! zw*NJ|8z0Q`_Onx0Vvmh@x|C{oGRE8%6Q#QT31``z6WaH__^%h1?f3ofs?63o{vS(D z#9JHAC=Qc?(NLob=bs#G>Pg~4z#mBCGj#ay5g1;PNb;dWJcMc&4qqetCK?h<*@9+% zmW0yA#NshdJ`wdHQpX1;iH`Z&oH37Sh_Lx1#!oZwsK(&p0yAv-`RDbbkXJY;{ipmc zH?WF^`~BI1Z6}$8I_I@II$l}Vwu2ep?%9Yon-cMi3(@`Wps)zD;!kl4OF@t;*LP?? zG5kSMqbg)-SOR6|Y44k0cSUK79yXR#=qjOc^&CPr6b8v|!C&z32vWJpmFZagqg+c2 zh(^@SbzW`T)R*7wWbZivzni?{V}Yp`|J*3kYo$uXmt3H;EZR^|+0Z!<&bh&uzDPG> zO}Bx>%b#uqeiFn_gwO;~YSc7V8w4RbP-H@M!b&bN3`;#CM2{ap6)2QMlE{$fDor&C z1eZC4tyZBjq6KnoI^}+~UI-WZy~fBWeE(2u80nexTTcS0-Lek8)lh3xXvu`-- zNr~wfBo5A;Ru4Gc81z(B^PiL9Ym?W=aXeeHd`f=Rp*&NxCC;bxSBdjXL0T&3F-8(+ z_04&P@ut>C6IsFrl|QB?m+Gma9^BgNaPYDCRKQxfbY&w$C}wq^iDS|qS7Z*}m%8BA il!?6b1I#a%28rc>*xm`)?O&}AE_ymrE<(`e9^-e_h{|;U diff --git a/mobile/openapi/lib/model/transcode_policy.dart b/mobile/openapi/lib/model/transcode_policy.dart new file mode 100644 index 0000000000000000000000000000000000000000..c490b5cfffdc7f4a585255e65418896d0d8e4543 GIT binary patch literal 2929 zcmai0U2oeq6n*!vxB-U70X%!_(~vH81H{=fv`Mi#4})O{j6}s;Wl|%l7)Fu*zH>=Y zNo1uB5L-0w_dWMq&1R$7jLvVDH$VTexL*8yzFb_;<<-Z(f1JZ8=PhJ5Q!0Q%fysHO2qC(P)%c!Z})6p_WrX*Tv7<)%UG1dgNqC2Tahkha<@eNW+*gJjw0 zYAd&Q(z4xBX;IM^Z#&6g=7V{V)*=Xqw3pt*hw%xWy^ltNXm|aWGrE6?GJBP`(Tl30 zvwa0#KhX<$Q+u^Vbi+FB?SNhfiB~k!{eWtroVcq{`eC2xnl;;Ee^f3YfRn3@ za|-IQI*yc%HD7Um*Q*{LLSJ7b96IIT`}dyc`xU$q!N(*#w7JJaN+*lQ6eFM3A9dG zp3l#QiLvJbJQ)XekVRh;Ziy`xY}}g&;?x5Z@7$e%j9b1CJ0#*#pJAE&Mojo!gxVu4^YajGNi<8}k+m(g9^Syb|P_ zc>A14a^WP!t$EtEupi14?jkKR!L$l#WxlG^3LGe=6?6QmW|GJbbkjb1T{lTQsTQ&n zVEa>)IQ?;zj=CB?uLeta1`eIu`-Wd31E9voxB9CFoZ>4_L;)r*&c-SI#5Iqv0LjO9 qIGDMd;{(aYG5P|0RMLZC`@GlV69BvASw_t8F7Bz!pE&Lv)B69&-_QX7 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/video_codec.dart b/mobile/openapi/lib/model/video_codec.dart new file mode 100644 index 0000000000000000000000000000000000000000..784c4acb51a9ba35d80f26a45d149edae2b388a2 GIT binary patch literal 2630 zcmai0{cqz$5dA%W#Z*GqR0zaWJG#A_({FcIH`jli z0LIAoxv+k6o_u>g;9K>mjg@I(Y+9&NUQn%TC-YR6axJwVwfAaW8S9Up5amW}U0T&X zwep`@Ssd45jn7J0e0ymfj9Yj7cxJToLi<$ZaHt9;Z8+|ZIxCfyE-j6I1kA65PJj3& z%~ry?!2s?&nSx9uwWw5r|F?s|AS;D)^e4=lpG__^`Z6FIBlnx=SP3slkd_;gx?a;p z`4#z~ z*ALRN$wFyS(kE{_WH9sIJV|TeACTsyH}PV4Mw55ZXn=OFe|1LhzXZ8nWli9#^Y6c- z$;BY94Q^SS#SxK zi(Rz>>o39zWI8zeX@`2ZfDa;0WE8IuWxPTRe6b8+G=_Eur&L(84!q@CbS;>QrDTS8 zu=%!WB@#K2n%ik!~h`u}+Q_`a~8^L5us1T)- zqvx~Gq-}3l>FUz&XN-(EXI0RUjS)I4F#P5X@qMD)t?{#U<+rUh*fW-fvEks8S!d|( zed{r#^|GEFPdFHQg`G$D5q&ZtU6*Cw7*Py85qaKG70(ephw$6)cxR=rt!__w!wC&r zdM-Mmg8%2iGfUW*y27B*!9$#PXoIIrXhKJErIv(i6(&BDJc|7uBX61+8wHC(W9Var zm(=z}F1(-zk$q-#|8#O7F?;P2idl%v8@qk2skrCTF2?C}av(Q*hM_)jy!K`3zi^FG zL`sIc{w7W?s0v?1HS6Z~Dzv(TMlM<|LdBO01}~Wo(MyXp4P}X+bOWX_@Md$1-dJ*z zdRs$p!7yv=af_&%Hl7|N#UkY7>MWDa6)>{paptSc@oirlGT|gOyXT@QUoVV=wLB^u z3r36P<#QB`7YArwKgYm$zQg9S5Kz|(kkI~dGACWC{h%4)xwLd?A8Y=F6iPlkz1N>L z3LbZRtP{o}cJBp!#ni;5+4^`z{a8ggKFC{a^iQZ@DLu5UCp&v1j;?FOjmvqk=l0Ok KozA>v8T}V(u2v}k literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/audio_codec_test.dart b/mobile/openapi/test/audio_codec_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..a6c61661d621cb5e4a296ab0d9f00fd01bba8d83 GIT binary patch literal 417 zcmZvYUrWO<6vf~3DXvf5pf0*6*$^zYlYy=v-GfgdG`C%_O=A8iBKz(pN_goJA zPEHa^38hdRoBHyhI#ka^Q&p6YVHd%1{MBJTvZ{&u~(UXx^Hue3$h zY0Ek};0A&U4h6#jM^rwayc~_Tem22}5B}+x37%Pe3)058;$u|9ZQLHMi-%h`A8)mC zo-5C!h=sC~Xn*2vwrr3J9UHXjBj!(Bv70N_p15^{{N52V!`oNh<3<})+(;T{m0q1~ z{~;cor%5Itl_u{XehCyoNPVvbw&Yz)0V9`cv4k%|0D7x~S!4Xbn9?_YX4Yop;!;qk)wJf~s^tOzhjALz delta 55 zcmZ3$-Os&YHq+!d7WT<3Ea{WiFl7MQypvU#Gd9PuSTIiJWo8Eoa7>=Z9KpqIoTWC1qP>tFrJGaqDFL z5Ao!D7Rw2Up;0>sCxPcMleskw)#BZ#1_o4mHis`E06I3Koh1?e!zC%4fa82E#8G?$ DKo^X! literal 0 HcmV?d00001 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 5f664730f0..b35660fda8 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4929,6 +4929,14 @@ "OTHER" ] }, + "AudioCodec": { + "type": "string", + "enum": [ + "mp3", + "aac", + "opus" + ] + }, "AuthDeviceResponseDto": { "type": "object", "properties": { @@ -6347,13 +6355,16 @@ "threads": { "type": "integer" }, - "preset": { - "type": "string" - }, "targetVideoCodec": { - "type": "string" + "$ref": "#/components/schemas/VideoCodec" }, "targetAudioCodec": { + "$ref": "#/components/schemas/AudioCodec" + }, + "transcode": { + "$ref": "#/components/schemas/TranscodePolicy" + }, + "preset": { "type": "string" }, "targetResolution": { @@ -6364,27 +6375,18 @@ }, "twoPass": { "type": "boolean" - }, - "transcode": { - "type": "string", - "enum": [ - "all", - "optimal", - "required", - "disabled" - ] } }, "required": [ "crf", "threads", - "preset", "targetVideoCodec", "targetAudioCodec", + "transcode", + "preset", "targetResolution", "maxBitrate", - "twoPass", - "transcode" + "twoPass" ] }, "SystemConfigJobDto": { @@ -6604,6 +6606,15 @@ "month" ] }, + "TranscodePolicy": { + "type": "string", + "enum": [ + "all", + "optimal", + "required", + "disabled" + ] + }, "UpdateAlbumDto": { "type": "object", "properties": { @@ -6804,6 +6815,14 @@ "required": [ "authStatus" ] + }, + "VideoCodec": { + "type": "string", + "enum": [ + "h264", + "hevc", + "vp9" + ] } } } diff --git a/server/src/domain/media/media.repository.ts b/server/src/domain/media/media.repository.ts index c3d7dc0e41..c6ca835df4 100644 --- a/server/src/domain/media/media.repository.ts +++ b/server/src/domain/media/media.repository.ts @@ -39,10 +39,22 @@ export interface CropOptions { } export interface TranscodeOptions { + inputOptions: string[]; outputOptions: string[]; twoPass: boolean; } +export interface BitrateDistribution { + max: number; + target: number; + min: number; + unit: string; +} + +export interface VideoCodecSWConfig { + getOptions(stream: VideoStreamInfo): TranscodeOptions; +} + export interface IMediaRepository { // image resize(input: string | Buffer, output: string, options: ResizeOptions): Promise; diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 010e68a23b..8a5f1e297f 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -1,4 +1,4 @@ -import { AssetType, SystemConfigKey } from '@app/infra/entities'; +import { AssetType, SystemConfigKey, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { assetEntityStub, newAssetRepositoryMock, @@ -104,6 +104,13 @@ describe(MediaService.name, () => { }); describe('handleGenerateJpegThumbnail', () => { + it('should skip thumbnail generation if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalledWith(); + }); + it('should generate a thumbnail for an image', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id }); @@ -142,15 +149,22 @@ describe(MediaService.name, () => { }); describe('handleGenerateWebpThumbnail', () => { + it('should skip thumbnail generation if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.save).not.toHaveBeenCalledWith(); + }); + it('should skip thumbnail generate if resize path is missing', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); - await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id }); + await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.noResizePath.id }); expect(mediaMock.resize).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); - await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id }); + await sut.handleGenerateWebpThumbnail({ id: assetEntityStub.image.id }); expect(mediaMock.resize).toHaveBeenCalledWith( '/uploads/user-id/thumbs/path.ext', @@ -162,6 +176,12 @@ describe(MediaService.name, () => { }); describe('handleGenerateThumbhashThumbnail', () => { + it('should skip thumbhash generation if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id }); + expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); + }); + it('should skip thumbhash generation if resize path is missing', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]); await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id }); @@ -219,6 +239,20 @@ describe(MediaService.name, () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); }); + it('should skip transcoding if asset not found', async () => { + assetMock.getByIds.mockResolvedValue([]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.probe).not.toHaveBeenCalled(); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + + it('should skip transcoding if non-video asset', async () => { + assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); + await sut.handleVideoConversion({ id: assetEntityStub.image.id }); + expect(mediaMock.probe).not.toHaveBeenCalled(); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); @@ -232,6 +266,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -261,13 +296,14 @@ describe(MediaService.name, () => { it('should transcode when set to all', async () => { mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -283,12 +319,13 @@ describe(MediaService.name, () => { it('should transcode when optimal and too big', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -306,7 +343,7 @@ describe(MediaService.name, () => { it('should not scale resolution if no target resolution', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }, + { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, ]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); @@ -314,6 +351,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -329,13 +367,14 @@ describe(MediaService.name, () => { it('should transcode with alternate scaling video is vertical', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -352,13 +391,14 @@ describe(MediaService.name, () => { it('should transcode when audio doesnt match target', async () => { mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -375,13 +415,14 @@ describe(MediaService.name, () => { it('should transcode when container doesnt match target', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -404,6 +445,22 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).not.toHaveBeenCalled(); }); + it('should not transcode if transcoding is disabled', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + + it('should not transcode if target codec is invalid', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + it('should set max bitrate if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); @@ -413,6 +470,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -441,6 +499,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -466,6 +525,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -480,11 +540,12 @@ describe(MediaService.name, () => { ); }); - it('should configure preset for vp9', async () => { + it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, + { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, ]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); await sut.handleVideoConversion({ id: assetEntityStub.video.id }); @@ -492,6 +553,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec vp9', '-acodec aac', @@ -500,7 +562,64 @@ describe(MediaService.name, () => { '-vf scale=-2:720', '-cpu-used 5', '-row-mt 1', - '-threads 2', + '-b:v 3104k', + '-minrate 1552k', + '-maxrate 4500k', + ], + twoPass: true, + }, + ); + }); + + it('should configure preset for vp9', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, + { key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec vp9', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-cpu-used 2', + '-row-mt 1', + '-crf 23', + '-b:v 0', + ], + twoPass: false, + }, + ); + }); + + it('should not configure preset for vp9 if invalid', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, + { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec vp9', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-row-mt 1', '-crf 23', '-b:v 0', ], @@ -512,7 +631,7 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, ]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); @@ -521,6 +640,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec vp9', '-acodec aac', @@ -538,7 +658,7 @@ describe(MediaService.name, () => { ); }); - it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => { + it('should disable thread pooling for h264 if thread limit is above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]); assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); @@ -547,6 +667,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/asset-id.mp4', { + inputOptions: [], outputOptions: [ '-vcodec h264', '-acodec aac', @@ -563,5 +684,86 @@ describe(MediaService.name, () => { }, ); }); + + it('should omit thread flags for h264 if thread limit is at or below 0', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec h264', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-preset ultrafast', + '-crf 23', + ], + twoPass: false, + }, + ); + }); + + it('should disable thread pooling for hevc if thread limit is above 0', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec hevc', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-preset ultrafast', + '-threads 2', + '-x265-params "pools=none"', + '-x265-params "frame-threads=2"', + '-crf 23', + ], + twoPass: false, + }, + ); + }); + + it('should omit thread flags for hevc if thread limit is at or below 0', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_THREADS, value: 0 }, + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, + ]); + assetMock.getByIds.mockResolvedValue([assetEntityStub.video]); + await sut.handleVideoConversion({ id: assetEntityStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/asset-id.mp4', + { + inputOptions: [], + outputOptions: [ + '-vcodec hevc', + '-acodec aac', + '-movflags faststart', + '-fps_mode passthrough', + '-vf scale=-2:720', + '-preset ultrafast', + '-crf 23', + ], + twoPass: false, + }, + ); + }); }); }); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 91f25df877..cfc04fba11 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -1,5 +1,5 @@ -import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities'; -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { AssetEntity, AssetType, TranscodePolicy, VideoCodec } from '@app/infra/entities'; +import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common'; import { join } from 'path'; import { IAssetRepository, WithoutProperty } from '../asset'; import { usePagination } from '../domain.util'; @@ -9,6 +9,7 @@ import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config import { SystemConfigCore } from '../system-config/system-config.core'; import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant'; import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository'; +import { H264Config, HEVCConfig, VP9Config } from './media.util'; @Injectable() export class MediaService { @@ -82,7 +83,7 @@ export class MediaService { return true; } - async handleGenerateWepbThumbnail({ id }: IEntityJob) { + async handleGenerateWebpThumbnail({ id }: IEntityJob) { const [asset] = await this.assetRepository.getByIds([id]); if (!asset || !asset.resizePath) { return false; @@ -152,11 +153,16 @@ export class MediaService { return false; } - const outputOptions = this.getFfmpegOptions(mainVideoStream, config); - const twoPass = this.eligibleForTwoPass(config); + let transcodeOptions; + try { + transcodeOptions = this.getCodecConfig(config).getOptions(mainVideoStream); + } catch (err) { + this.logger.error(`An error occurred while configuring transcoding options: ${err}`); + return false; + } - this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`); - await this.mediaRepository.transcode(input, output, { outputOptions, twoPass }); + this.logger.log(`Start encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); + await this.mediaRepository.transcode(input, output, transcodeOptions); this.logger.log(`Encoding success ${asset.id}`); @@ -199,16 +205,16 @@ export class MediaService { const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes; switch (ffmpegConfig.transcode) { - case TranscodePreset.DISABLED: + case TranscodePolicy.DISABLED: return false; - case TranscodePreset.ALL: + case TranscodePolicy.ALL: return true; - case TranscodePreset.REQUIRED: + case TranscodePolicy.REQUIRED: return !allTargetsMatching; - case TranscodePreset.OPTIMAL: + case TranscodePolicy.OPTIMAL: return !allTargetsMatching || isLargerThanTargetRes; default: @@ -216,99 +222,16 @@ export class MediaService { } } - private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) { - const options = [ - `-vcodec ${ffmpeg.targetVideoCodec}`, - `-acodec ${ffmpeg.targetAudioCodec}`, - // Makes a second pass moving the moov atom to the beginning of - // the file for improved playback speed. - '-movflags faststart', - '-fps_mode passthrough', - ]; - - // video dimensions - const videoIsRotated = Math.abs(stream.rotation) === 90; - const scalingEnabled = ffmpeg.targetResolution !== 'original'; - const targetResolution = Number.parseInt(ffmpeg.targetResolution); - const isVideoVertical = stream.height > stream.width || videoIsRotated; - const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`; - const shouldScale = scalingEnabled && Math.min(stream.height, stream.width) > targetResolution; - - // video codec - const isVP9 = ffmpeg.targetVideoCodec === 'vp9'; - const isH264 = ffmpeg.targetVideoCodec === 'h264'; - const isH265 = ffmpeg.targetVideoCodec === 'hevc'; - - // transcode efficiency - const limitThreads = ffmpeg.threads > 0; - const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0; - const constrainMaximumBitrate = maxBitrateValue > 0; - const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided - - if (shouldScale) { - options.push(`-vf scale=${scaling}`); + private getCodecConfig(config: SystemConfigFFmpegDto) { + switch (config.targetVideoCodec) { + case VideoCodec.H264: + return new H264Config(config); + case VideoCodec.HEVC: + return new HEVCConfig(config); + case VideoCodec.VP9: + return new VP9Config(config); + default: + throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`); } - - if (isH264 || isH265) { - options.push(`-preset ${ffmpeg.preset}`); - } - - if (isVP9) { - // vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest - const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; - const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads - if (speed >= 0) { - options.push(`-cpu-used ${speed}`); - } - options.push('-row-mt 1'); // better multithreading - } - - if (limitThreads) { - options.push(`-threads ${ffmpeg.threads}`); - - // x264 and x265 handle threads differently than one might expect - // https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools - if (isH264 || isH265) { - options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`); - options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`); - } - } - - // two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate - if (constrainMaximumBitrate && ffmpeg.twoPass) { - const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod - const minBitrateValue = targetBitrateValue / 2; - - options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`); - options.push(`-minrate ${minBitrateValue}${bitrateUnit}`); - options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`); - } else if (constrainMaximumBitrate || isVP9) { - // for vp9, these flags work for both one-pass and two-pass - options.push(`-crf ${ffmpeg.crf}`); - if (isVP9) { - options.push(`-b:v ${maxBitrateValue}${bitrateUnit}`); - } else { - options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`); - // -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate - // needed for -maxrate to be enforced - options.push(`-bufsize ${maxBitrateValue * 2}${bitrateUnit}`); - } - } else { - options.push(`-crf ${ffmpeg.crf}`); - } - - return options; - } - - private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) { - if (!ffmpeg.twoPass) { - return false; - } - - const isVP9 = ffmpeg.targetVideoCodec === 'vp9'; - const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0; - const constrainMaximumBitrate = maxBitrateValue > 0; - - return constrainMaximumBitrate || isVP9; } } diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts new file mode 100644 index 0000000000..bee22e9e65 --- /dev/null +++ b/server/src/domain/media/media.util.ts @@ -0,0 +1,191 @@ +import { SystemConfigFFmpegDto } from '../system-config/dto'; +import { BitrateDistribution, TranscodeOptions, VideoCodecSWConfig, VideoStreamInfo } from './media.repository'; + +class BaseConfig implements VideoCodecSWConfig { + constructor(protected config: SystemConfigFFmpegDto) {} + + getOptions(stream: VideoStreamInfo) { + const options = { + inputOptions: this.getBaseInputOptions(), + outputOptions: this.getBaseOutputOptions(), + twoPass: this.eligibleForTwoPass(), + } as TranscodeOptions; + const filters = this.getFilterOptions(stream); + if (filters.length > 0) { + options.outputOptions.push(`-vf ${filters.join(',')}`); + } + options.outputOptions.push(...this.getPresetOptions()); + options.outputOptions.push(...this.getThreadOptions()); + options.outputOptions.push(...this.getBitrateOptions()); + + return options; + } + + getBaseInputOptions(): string[] { + return []; + } + + getBaseOutputOptions() { + return [ + `-vcodec ${this.config.targetVideoCodec}`, + `-acodec ${this.config.targetAudioCodec}`, + // Makes a second pass moving the moov atom to the beginning of + // the file for improved playback speed. + '-movflags faststart', + '-fps_mode passthrough', + ]; + } + + getFilterOptions(stream: VideoStreamInfo) { + const options = []; + if (this.shouldScale(stream)) { + options.push(`scale=${this.getScaling(stream)}`); + } + + return options; + } + + getPresetOptions() { + return [`-preset ${this.config.preset}`]; + } + + getBitrateOptions() { + const bitrates = this.getBitrateDistribution(); + if (this.eligibleForTwoPass()) { + return [ + `-b:v ${bitrates.target}${bitrates.unit}`, + `-minrate ${bitrates.min}${bitrates.unit}`, + `-maxrate ${bitrates.max}${bitrates.unit}`, + ]; + } else if (bitrates.max > 0) { + // -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate + return [ + `-crf ${this.config.crf}`, + `-maxrate ${bitrates.max}${bitrates.unit}`, + `-bufsize ${bitrates.max * 2}${bitrates.unit}`, + ]; + } else { + return [`-crf ${this.config.crf}`]; + } + } + + getThreadOptions(): Array { + if (this.config.threads <= 0) { + return []; + } + return [`-threads ${this.config.threads}`]; + } + + eligibleForTwoPass() { + if (!this.config.twoPass) { + return false; + } + + return this.isBitrateConstrained() || this.config.targetVideoCodec === 'vp9'; + } + + getBitrateDistribution() { + const max = this.getMaxBitrateValue(); + const target = Math.ceil(max / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod + const min = target / 2; + const unit = this.getBitrateUnit(); + + return { max, target, min, unit } as BitrateDistribution; + } + + getTargetResolution(stream: VideoStreamInfo) { + if (this.config.targetResolution === 'original') { + return Math.min(stream.height, stream.width); + } + + return Number.parseInt(this.config.targetResolution); + } + + shouldScale(stream: VideoStreamInfo) { + return Math.min(stream.height, stream.width) > this.getTargetResolution(stream); + } + + getScaling(stream: VideoStreamInfo) { + const targetResolution = this.getTargetResolution(stream); + return this.isVideoVertical(stream) ? `${targetResolution}:-2` : `-2:${targetResolution}`; + } + + isVideoRotated(stream: VideoStreamInfo) { + return Math.abs(stream.rotation) === 90; + } + + isVideoVertical(stream: VideoStreamInfo) { + return stream.height > stream.width || this.isVideoRotated(stream); + } + + isBitrateConstrained() { + return this.getMaxBitrateValue() > 0; + } + + getBitrateUnit() { + const maxBitrate = this.getMaxBitrateValue(); + return this.config.maxBitrate.trim().substring(maxBitrate.toString().length); // use inputted unit if provided + } + + getMaxBitrateValue() { + return Number.parseInt(this.config.maxBitrate) || 0; + } + + getPresetIndex() { + const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; + return presets.indexOf(this.config.preset); + } +} + +export class H264Config extends BaseConfig { + getThreadOptions() { + if (this.config.threads <= 0) { + return []; + } + return [ + ...super.getThreadOptions(), + '-x264-params "pools=none"', + `-x264-params "frame-threads=${this.config.threads}"`, + ]; + } +} + +export class HEVCConfig extends BaseConfig { + getThreadOptions() { + if (this.config.threads <= 0) { + return []; + } + return [ + ...super.getThreadOptions(), + '-x265-params "pools=none"', + `-x265-params "frame-threads=${this.config.threads}"`, + ]; + } +} + +export class VP9Config extends BaseConfig { + getPresetOptions() { + const speed = Math.min(this.getPresetIndex(), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads + if (speed >= 0) { + return [`-cpu-used ${speed}`]; + } + return []; + } + + getBitrateOptions() { + const bitrates = this.getBitrateDistribution(); + if (this.eligibleForTwoPass()) { + return [ + `-b:v ${bitrates.target}${bitrates.unit}`, + `-minrate ${bitrates.min}${bitrates.unit}`, + `-maxrate ${bitrates.max}${bitrates.unit}`, + ]; + } + + return [`-crf ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`]; + } + + getThreadOptions() { + return ['-row-mt 1', ...super.getThreadOptions()]; + } +} diff --git a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts index 1a641828d3..01f9f9ca7f 100644 --- a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts +++ b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts @@ -1,4 +1,4 @@ -import { TranscodePreset } from '@app/infra/entities'; +import { AudioCodec, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; @@ -20,11 +20,13 @@ export class SystemConfigFFmpegDto { @IsString() preset!: string; - @IsString() - targetVideoCodec!: string; + @IsEnum(VideoCodec) + @ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec }) + targetVideoCodec!: VideoCodec; - @IsString() - targetAudioCodec!: string; + @IsEnum(AudioCodec) + @ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec }) + targetAudioCodec!: AudioCodec; @IsString() targetResolution!: string; @@ -35,6 +37,7 @@ export class SystemConfigFFmpegDto { @IsBoolean() twoPass!: boolean; - @IsEnum(TranscodePreset) - transcode!: TranscodePreset; + @IsEnum(TranscodePolicy) + @ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy }) + transcode!: TranscodePolicy; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index dcec26690f..0c440835cd 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -1,9 +1,11 @@ import { + AudioCodec, SystemConfig, SystemConfigEntity, SystemConfigKey, SystemConfigValue, - TranscodePreset, + TranscodePolicy, + VideoCodec, } from '@app/infra/entities'; import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as _ from 'lodash'; @@ -19,12 +21,12 @@ const defaults = Object.freeze({ crf: 23, threads: 0, preset: 'ultrafast', - targetVideoCodec: 'h264', - targetAudioCodec: 'aac', + targetVideoCodec: VideoCodec.H264, + targetAudioCodec: AudioCodec.AAC, targetResolution: '720', maxBitrate: '0', twoPass: false, - transcode: TranscodePreset.REQUIRED, + transcode: TranscodePolicy.REQUIRED, }, job: { [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index e18eb296e9..54018df792 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -1,4 +1,11 @@ -import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities'; +import { + AudioCodec, + SystemConfig, + SystemConfigEntity, + SystemConfigKey, + TranscodePolicy, + VideoCodec, +} from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '@test'; import { IJobRepository, JobName, QueueName } from '../job'; @@ -28,12 +35,12 @@ const updatedConfig = Object.freeze({ crf: 30, threads: 0, preset: 'ultrafast', - targetAudioCodec: 'aac', + targetAudioCodec: AudioCodec.AAC, targetResolution: '720', - targetVideoCodec: 'h264', + targetVideoCodec: VideoCodec.H264, maxBitrate: '0', twoPass: false, - transcode: TranscodePreset.REQUIRED, + transcode: TranscodePolicy.REQUIRED, }, oauth: { autoLaunch: true, diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 8d2a5c5b5c..8046546132 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -51,24 +51,36 @@ export enum SystemConfigKey { STORAGE_TEMPLATE = 'storageTemplate.template', } -export enum TranscodePreset { +export enum TranscodePolicy { ALL = 'all', OPTIMAL = 'optimal', REQUIRED = 'required', DISABLED = 'disabled', } +export enum VideoCodec { + H264 = 'h264', + HEVC = 'hevc', + VP9 = 'vp9', +} + +export enum AudioCodec { + MP3 = 'mp3', + AAC = 'aac', + OPUS = 'opus', +} + export interface SystemConfig { ffmpeg: { crf: number; threads: number; preset: string; - targetVideoCodec: string; - targetAudioCodec: string; + targetVideoCodec: VideoCodec; + targetAudioCodec: AudioCodec; targetResolution: string; maxBitrate: string; twoPass: boolean; - transcode: TranscodePreset; + transcode: TranscodePolicy; }; job: Record; oauth: { diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 20bac55d55..060c64ae35 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -65,7 +65,6 @@ const providers: Provider[] = [ { provide: IJobRepository, useClass: JobRepository }, { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, - { provide: IMediaRepository, useClass: MediaRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, { provide: ISearchRepository, useClass: TypesenseRepository }, @@ -74,6 +73,7 @@ const providers: Provider[] = [ { provide: IStorageRepository, useClass: FilesystemProvider }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ITagRepository, useClass: TagRepository }, + { provide: IMediaRepository, useClass: MediaRepository }, { provide: IUserRepository, useClass: UserRepository }, { provide: IUserTokenRepository, useClass: UserTokenRepository }, ]; diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts index b73b61aaeb..4b0345faa4 100644 --- a/server/src/infra/repositories/media.repository.ts +++ b/server/src/infra/repositories/media.repository.ts @@ -1,4 +1,5 @@ import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain'; +import { Logger } from '@nestjs/common'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import fs from 'fs/promises'; import sharp from 'sharp'; @@ -7,6 +8,8 @@ import { promisify } from 'util'; const probe = promisify(ffmpeg.ffprobe); export class MediaRepository implements IMediaRepository { + private logger = new Logger(MediaRepository.name); + crop(input: string, options: CropOptions): Promise { return sharp(input, { failOnError: false }) .extract({ @@ -47,7 +50,10 @@ export class MediaRepository implements IMediaRepository { `-vf scale='min(${size},iw)':'min(${size},ih)':force_original_aspect_ratio=increase`, ]) .output(output) - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', resolve) .run(); }); @@ -87,7 +93,10 @@ export class MediaRepository implements IMediaRepository { ffmpeg(input, { niceness: 10 }) .outputOptions(options.outputOptions) .output(output) - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', resolve) .run(); }); @@ -102,7 +111,10 @@ export class MediaRepository implements IMediaRepository { .addOptions('-passlogfile', output) .addOptions('-f null') .output('/dev/null') // first pass output is not saved as only the .log file is needed - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', () => { // second pass ffmpeg(input, { niceness: 10 }) @@ -110,7 +122,10 @@ export class MediaRepository implements IMediaRepository { .addOptions('-pass', '2') .addOptions('-passlogfile', output) .output(output) - .on('error', reject) + .on('error', (err, stdout, stderr) => { + this.logger.error(stderr); + reject(err); + }) .on('end', () => fs.unlink(`${output}-0.log`)) .on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true })) .on('end', resolve) diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 079fd40d30..a8f30e1888 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -60,7 +60,7 @@ export class AppService { [JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), [JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data), - [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data), + [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWebpThumbnail(data), [JobName.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index f1adb8a761..72a05a328a 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -19,6 +19,7 @@ import { AssetEntity, AssetFaceEntity, AssetType, + AudioCodec, ExifEntity, PartnerEntity, PersonEntity, @@ -27,9 +28,10 @@ import { SystemConfig, TagEntity, TagType, - TranscodePreset, + TranscodePolicy, UserEntity, UserTokenEntity, + VideoCodec, } from '@app/infra/entities'; const today = new Date(); @@ -685,12 +687,12 @@ export const systemConfigStub = { crf: 23, threads: 0, preset: 'ultrafast', - targetAudioCodec: 'aac', + targetAudioCodec: AudioCodec.AAC, targetResolution: '720', - targetVideoCodec: 'h264', + targetVideoCodec: VideoCodec.H264, maxBitrate: '0', twoPass: false, - transcode: TranscodePreset.REQUIRED, + transcode: TranscodePolicy.REQUIRED, }, job: { [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c1a8f7f222..1292c74816 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -746,6 +746,21 @@ export const AssetTypeEnum = { export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; +/** + * + * @export + * @enum {string} + */ + +export const AudioCodec = { + Mp3: 'mp3', + Aac: 'aac', + Opus: 'opus' +} as const; + +export type AudioCodec = typeof AudioCodec[keyof typeof AudioCodec]; + + /** * * @export @@ -2411,24 +2426,30 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'threads': number; + /** + * + * @type {VideoCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetVideoCodec': VideoCodec; + /** + * + * @type {AudioCodec} + * @memberof SystemConfigFFmpegDto + */ + 'targetAudioCodec': AudioCodec; + /** + * + * @type {TranscodePolicy} + * @memberof SystemConfigFFmpegDto + */ + 'transcode': TranscodePolicy; /** * * @type {string} * @memberof SystemConfigFFmpegDto */ 'preset': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetVideoCodec': string; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'targetAudioCodec': string; /** * * @type {string} @@ -2447,22 +2468,8 @@ export interface SystemConfigFFmpegDto { * @memberof SystemConfigFFmpegDto */ 'twoPass': boolean; - /** - * - * @type {string} - * @memberof SystemConfigFFmpegDto - */ - 'transcode': SystemConfigFFmpegDtoTranscodeEnum; } -export const SystemConfigFFmpegDtoTranscodeEnum = { - All: 'all', - Optimal: 'optimal', - Required: 'required', - Disabled: 'disabled' -} as const; - -export type SystemConfigFFmpegDtoTranscodeEnum = typeof SystemConfigFFmpegDtoTranscodeEnum[keyof typeof SystemConfigFFmpegDtoTranscodeEnum]; /** * @@ -2749,6 +2756,22 @@ export const TimeGroupEnum = { export type TimeGroupEnum = typeof TimeGroupEnum[keyof typeof TimeGroupEnum]; +/** + * + * @export + * @enum {string} + */ + +export const TranscodePolicy = { + All: 'all', + Optimal: 'optimal', + Required: 'required', + Disabled: 'disabled' +} as const; + +export type TranscodePolicy = typeof TranscodePolicy[keyof typeof TranscodePolicy]; + + /** * * @export @@ -3027,6 +3050,21 @@ export interface ValidateAccessTokenResponseDto { */ 'authStatus': boolean; } +/** + * + * @export + * @enum {string} + */ + +export const VideoCodec = { + H264: 'h264', + Hevc: 'hevc', + Vp9: 'vp9' +} as const; + +export type VideoCodec = typeof VideoCodec[keyof typeof VideoCodec]; + + /** * APIKeyApi - axios parameter creator diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 0b3d3b9818..6112419c0a 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -3,7 +3,7 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { api, SystemConfigFFmpegDto, SystemConfigFFmpegDtoTranscodeEnum } from '@api'; + import { api, AudioCodec, SystemConfigFFmpegDto, TranscodePolicy, VideoCodec } from '@api'; import SettingButtonsRow from '../setting-buttons-row.svelte'; import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingSelect from '../setting-select.svelte'; @@ -113,9 +113,9 @@ desc="Opus is the highest quality option, but has lower compatibility with old devices or software." bind:value={ffmpegConfig.targetAudioCodec} options={[ - { value: 'aac', text: 'aac' }, - { value: 'mp3', text: 'mp3' }, - { value: 'opus', text: 'opus' }, + { value: AudioCodec.Aac, text: 'aac' }, + { value: AudioCodec.Mp3, text: 'mp3' }, + { value: AudioCodec.Opus, text: 'opus' }, ]} name="acodec" isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)} @@ -126,9 +126,9 @@ desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files." bind:value={ffmpegConfig.targetVideoCodec} options={[ - { value: 'h264', text: 'h264' }, - { value: 'hevc', text: 'hevc' }, - { value: 'vp9', text: 'vp9' }, + { value: VideoCodec.H264, text: 'h264' }, + { value: VideoCodec.Hevc, text: 'hevc' }, + { value: VideoCodec.Vp9, text: 'vp9' }, ]} name="vcodec" isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)} @@ -167,22 +167,22 @@ />