From 3210784b7e56d63a24250c2a5f185d6239bf6cd3 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 18 Sep 2026 13:53:34 +0200 Subject: [PATCH 1/4] feat(preview): generate previews for AVIF images libgd reads AVIF wherever the distribution built it against libavif, and exif_imagetype() has reported IMAGETYPE_AVIF since PHP 8.2, below the 8.3 this server requires. Both were missing from OC\Image, so an AVIF had no thumbnail anywhere: the Files grid showed a generic icon for a photo. The provider is enabled by default alongside the other formats libgd reads, and guarded by imagetypes() the way WebP is, so a build without libavif logs and falls through rather than failing. Imaginary is deliberately left alone. The shipped aio-imaginary answers 406 Unsupported media type for an AVIF while handling HEIC from the same container, so routing AVIF to it would replace a working preview with a failing one. Writing is added beside reading. An image loaded as AVIF would otherwise fall to the default branch of the output switch and be written as a PNG under whatever name the caller chose. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + lib/private/Image.php | 19 +++++++++++ lib/private/Preview/AVIF.php | 20 +++++++++++ lib/private/PreviewManager.php | 3 ++ tests/data/testimage.avif | Bin 0 -> 15349 bytes tests/lib/Preview/AVIFTest.php | 35 ++++++++++++++++++++ 7 files changed, 79 insertions(+) create mode 100644 lib/private/Preview/AVIF.php create mode 100644 tests/data/testimage.avif create mode 100644 tests/lib/Preview/AVIFTest.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 52e2333085ca2..66ca381856dd8 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2133,6 +2133,7 @@ 'OC\\PhoneNumberUtil' => $baseDir . '/lib/private/PhoneNumberUtil.php', 'OC\\PreviewManager' => $baseDir . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => $baseDir . '/lib/private/PreviewNotAvailableException.php', + 'OC\\Preview\\AVIF' => $baseDir . '/lib/private/Preview/AVIF.php', 'OC\\Preview\\BMP' => $baseDir . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => $baseDir . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => $baseDir . '/lib/private/Preview/Bitmap.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f29ac210c3700..c90056cac88cf 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2174,6 +2174,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\PhoneNumberUtil' => __DIR__ . '/../../..' . '/lib/private/PhoneNumberUtil.php', 'OC\\PreviewManager' => __DIR__ . '/../../..' . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/PreviewNotAvailableException.php', + 'OC\\Preview\\AVIF' => __DIR__ . '/../../..' . '/lib/private/Preview/AVIF.php', 'OC\\Preview\\BMP' => __DIR__ . '/../../..' . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => __DIR__ . '/../../..' . '/lib/private/Preview/Bitmap.php', diff --git a/lib/private/Image.php b/lib/private/Image.php index 015f42bfe6b22..85f3e94a2b3e4 100644 --- a/lib/private/Image.php +++ b/lib/private/Image.php @@ -248,6 +248,9 @@ private function _output(?string $filePath = null, ?string $mimeType = null): bo case 'image/webp': $imageType = IMAGETYPE_WEBP; break; + case 'image/avif': + $imageType = IMAGETYPE_AVIF; + break; default: throw new \Exception('Image::_output(): "' . $mimeType . '" is not supported when forcing a specific output format'); } @@ -281,6 +284,9 @@ private function _output(?string $filePath = null, ?string $mimeType = null): bo case IMAGETYPE_WEBP: $retVal = imagewebp($this->resource, null, $this->getWebpQuality()); break; + case IMAGETYPE_AVIF: + $retVal = imageavif($this->resource, $filePath); + break; default: $retVal = imagepng($this->resource, $filePath); } @@ -747,6 +753,19 @@ public function loadFromFile($imagePath = false) { $this->logger->debug('Image->loadFromFile, WEBP images not supported: ' . $imagePath, ['app' => 'core']); } break; + case IMAGETYPE_AVIF: + if (imagetypes() & IMG_AVIF) { + if (!$this->checkImageSize($imagePath)) { + return false; + } + // An animated AVIF decodes to its first frame, which is the + // right thing for a preview, and a sequence libgd cannot read + // returns false and is handled below like any other failure + $this->resource = @imagecreatefromavif($imagePath); + } else { + $this->logger->debug('Image->loadFromFile, AVIF images not supported: ' . $imagePath, ['app' => 'core']); + } + break; /* case IMAGETYPE_TIFF_II: // (intel byte order) break; diff --git a/lib/private/Preview/AVIF.php b/lib/private/Preview/AVIF.php new file mode 100644 index 0000000000000..8111998713f47 --- /dev/null +++ b/lib/private/Preview/AVIF.php @@ -0,0 +1,20 @@ +defaultProviders = $this->config->getSystemValue('enabledPreviewProviders', array_merge([ @@ -317,6 +319,7 @@ protected function registerCoreProviders(): void { $this->registerCoreProvider(BMP::class, '/image\/bmp/'); $this->registerCoreProvider(XBitmap::class, '/image\/x-xbitmap/'); $this->registerCoreProvider(WebP::class, '/image\/webp/'); + $this->registerCoreProvider(AVIF::class, '/image\/avif/'); $this->registerCoreProvider(Krita::class, '/application\/x-krita/'); $this->registerCoreProvider(MP3::class, '/audio\/mpeg$/'); $this->registerCoreProvider(OpenDocument::class, '/application\/vnd.oasis.opendocument.*/'); diff --git a/tests/data/testimage.avif b/tests/data/testimage.avif new file mode 100644 index 0000000000000000000000000000000000000000..fede073100c572d926a348a212f29c92d1be1eea GIT binary patch literal 15349 zcmXwgQ;;ak&h6N?ZDWsZ+k0%=wr$(CZQHhO&)of;TYtJLSV?vA)GsRy002N>;^c0p z?_zEO@K64;*5)RR*5>*qG6IYO|Da22V<-Lpa{q+T%*e{&|GNMHcIJjo|L6aog|jnv zvi@HH_)p`RTiF`^7YPFZ0RB7wX#hZE007Q|e?En|xy}EY{x5^|FE9rBulQe$z6%4R zkgb*N|9V-N+d0_%!==pa3~m2e6mv&An;+rOKE0KokILFndo*82bD{>i_=KmdUPLH0teuaDhV#rR~3hM_jj1dec&4j!w`|8-1AWXDgFEl6Yk!&ZIXO{=1eKTq<-8DRNyn zboDs66H2SKtwC@cZQo|iRXQ8Vo~{4HGs`_eZz>I;aFA1SrGbhd4$!wPhxJDCj6%2K zjs^%zawq|y*UK$o)zo9Bl-Sv)UO-T%ncLC;sokA$dcRAjmmKt!kFxusk3rXq!Dtf& zF=C~>(Ofp{LgIX9WeDVZ@@*^HC=Xk*=LEbgr6wdfuO?#9$e@CKq-CLcDy6gsJ?u9WviKk{s6oekV36V z5XKp?H%jp)&iOV`Ada*M|9k)jFKf>$Fe9-tSWvgHKz+>K?UJIFW~ao*%-XB;a9h~70SWXXLv zbAVlHxEIk1apt)syKF>~WsMXCwrzk5wpUmMfrvaA*{(SC*g20A*GdTq4)=4mCg+2r zh3zp=7D7XTNr5>0+d%qwZX$nNiK&aT{0GSQC=dfdbm?RSb=vYy?z(pMB}O9$_Ln|# z!CsQDgKR;X!^=x)_J9>OWlQS|$2~XsmwfY*`8d+*^B#)`3;Qt;);}nR4vMT>NCZPc zGb_3%qRRda>dJr{2a7~DfYQT|gDi8(Hs2MjHaKP*Rk^_( zJC_~unE+kvaNT!=(9<${8Y@f_Oa=_JgnF07omEdfpkZnx4HjuqR(nwC;8#JVQo+lL z$|Hi)Tqz*W5mHAE=kY6Bv*iiuJEk=vTw+BN(RB{{dt8o>5e$aa@ejOBrr{(f;6~$l-5!0O+&jD1IA_Hq z)0J6=;-45Z@_LS)ZC*(x>(E39By}KqYYKe^!nUjG!4O5dGbH>&HY1!I(U0SijWYq3 zE5&0vI;DCRNOUXWhOXKgJ*fQ}GOa4vIGp?boY^Q1-R+v|N6EyAy_J;yxl;}|Z2U!k zV2cU!O)_OHS|A-!YI0i0UYn#u+Q>z{V%+!J`$<)RCe4DV`J2Z*D+YGxDcY)|JKd+m zOLq?@gS$u_j5mE;It@Zw_aBwZkZ2td^VfZdPy%?-vf|6#l?Ddu*s;x7U*iK3?t6F9 z&$K4$nb%b1Fx7G-C8V#l21O9uV<}xXOtDmJ-KFN7^J>WH$5x#X`W4)VJ5bTnx2r8J z{mj-032P9rE!!#HhOkYWDAYJGtTr(&P(n(3zn<5}s2N6Y+q)t&L!OMfPWDi)r3B=3 z=*r~>p4$~Mtwsg=gNR&=ZclC`{IDJ~qVh1h-1W7Jvxq2}w}vC?I7E4a_~EWs_P~1o zT@lz{PoS!ep=IUsb`Xpc`T3Y@X5$pP5$1|AGX*===)b{0s0ros+`jywiI&gAW(WN@ zVxzLz7douxy~I0nT6@>1wur_;e)l@*u3H%=>Vvfes{~Bvf%CMBjszR;+(D4i2ovZJ zbc0)}Aw^$LIlf|9BJ(Va@*|3}RKjIWMB8gX3#L=Wxxr&SbOPV0rNOi|T9X2mZoiz6 zvlisLQjnTI^m>&abtvhR=|mb*1(&L)3Q@FS%OEsJci={p5}-9Jdy^$?VElx4OWk8J zVx5I$ISm77V0Te4cx^R(Z+v^~nhDxszwkV_q;=Hdg58R5&c=PmKWGpA^1L6&96&0^ z0qQFrg>8*pQRx1r&3%9_tS2bLy!1t7vcGnXXfWsh32 zDzxA@0Mw$CINR@{v%Kj$x03N>gPCO&3Sk#@^K@L`PPIM1vVQcxc0TNeW;?R0E=B-p zytG%}=TC%)wf^oi&jtjb@j_488%=3|d{!^ zM&gTE!yNBv-89&5K!bK$0IzjFD@{X`$g1@=_;u;Xcc1RWmvg62H+tv&y4VHQ4~f;waAROo6?M=v`H-KPJuH^ z`Wk?cL&cFT_F;wHD&7P-IKL%1Ybgowu}RP)F{;e`OZ_kz%*6PH6;MV|e`KwMoBwF< zCT!i9_~K){|MM#eT@4G#Oo$e+gP7alvI%61XOE!>v7V>7!f|B6R*p->4v11&t+y~2 ztKQFE!tb9UhDK8F1b)utFQ+r+FeXxrZP09$jV(Y@%usKy`I--|7CvS=noVq=xgVP| z{lt3cO}aGDa)0@o=`3%y8&c?MHe$pE=8-n3A;+})b;v2y-$f9$$R)D<@yx?5TXy&P zXI>|gSo#>(*j8YDZ}N_z0rnLM_*a3`os?d!}bSE1wKsdlMMKoN-(@ow;{;k z>LHGW`%*c@BC3_X>;%YZARUnO_i(J$UcwSQY zU3A#TU^?U5|Jw26SH#vzv|%8TkHQHTjpUi3mCk zmpwE^4zAV=46HH`QmbJkgtd4AFfk&)J%st$9^Zs{vzPiH#J(BU8|uqAdSSq}6IoM&?bR{o5+RVY^K4S6 zz6xDwQN}6XXcL8CU(kk(Ny`!$`woR%Qw6!5x!G<6LVCk(yCG>uRCViq-{;=?A zggi^zR>MKDexn3?=JfCQZYgvNIVHSOmVgl4jasY92=WVUi14OVnd;9Z4S zs2IP+G_r6>V8HeAo2=GIb59C+_iN0HKw$^NBKc@yy^u2MD7sRm#>A0jyrhfE zeYf^P{)z!C5e`BD7t(twXromM(ny1pi+|(d{J-{iK)KMaUdn%`B+=wS#|EX?1e60H z1_B~5e$v6$avSq(BDM^56#4(;dG;#cM`o*p`#dcRh-^(jAn|E%dmu}8Us#B*(CQOV zi(qzx+kx1d;HEi{7|(|Blg()qX3D9#IemI-A+J;kk)`PE8>_KFqK}xPez)-!Q5^bl zu{?&aw3~^oEU;+C$UzV2jX57E_oCOHlhyfTLB4YMwGEw!r4`tGn)(euyz1#79yFGK zbfpv$iW;D3*1y6#3SWXw#au9B4JH`O_cvrc@gg?najin(%hw9ZnY2hJg%t;+pd@#* zYg0zm5{f!;cP|d@UG64uQ^Y@@k#x{I*5Hx5XL${8HjY^f#fI2i&&|Q+gn8trRsM8R zLKEJ?@(=IT4nW)8iT3+Mfjd5VoFFxXX>g5K_*Pi?t8?(t#zc(c{`twlY-eawaum!p zyCGdBm2auo{L)0~uV!OwBNqi(VTE#`-35YG zlJX$p{b=5YbUQFhYfL|{nf$)hP`kO$`UHi5nBm<#yrR z#$=?_5>oDcm`Y|X8@oC_=Go-sK>4JS@OP-&slOqqqTQ_rN`G@S$y{i7@~6IP$063D zRc`fn#I;AMJJf-zsz5X33?NL#0XBV`Ih*I(@b>r^ZH%9dzw<=rihddpp0cYetg-I(Q;qcnr4nL@k+>Pm)${6{E}A)Ey-xmYi3!6o&!Mo94ot)fp&^oP(Tx3x zk=?uspfsvS1m!vu=s&GOLzbf6aAC@tugxvZO;#e9=1PBxa(iC$N(jpqdaiDRwh-(b$ynZJo3ap8i}7ZZ?T2Vm5%W3NGvf0n z$=7zEy8Fnz;Kvq&-BR+s3dpZSOh)xA8$W5_35hck2lx{s-@F^BAfrrr4)Hvmo@iJk zRVb`%8sr+JhDuRI`uCfMew@9{5jkSgh?suf5?(JBlS!a}q;jBHLWA}(H}L3w#H zAtUe_xMHtZk-3?Iy=o z5n-cRyOBeliQBh&8b1CVf&(sq>+$x`D41uckvp@VC}+q2aE^G}6Gc5diIE*&uC4k3 zi$pm0xnja(Gz$pr?Yj5=cKy#&&VduTkgmg2T1OfgEU!$gWovTy6kl!!dGJ%`Hf`EK z-BqNNoFL_XYP9O`ui5SaVr?88)~!O0Q5^1HPn8#zJw9vR4RBsp0eb8%wJdD~G>vV) zSTj0jtHs5tCjtL!a@pi1k;6>3ZBOKA0BGO0YvEE`zPMjGrv3#33B=tz%nQYGy0yz zauXm6*bCZQ)AJ^6GQH7{(2`Rh1g3d063$&iBEUl96)0ui%rKh%=v^ipMo7@hR~=%M zk@$isZz;^*HCBB(qO5z!zCMR9XHa;H)vi89Yicw~>90E>2{q`zr%(AgmIkc}V@t=MxD<2YHok>I5>u=WalBOPXKSQGl0+d2ACv0;AyQ$jj{QWaQ)y zC_e}uR9HKM!mF`t&*sopxghde7!&hi(96XGkmzD2JoD0jl`qDnw}|Ve3B$xkb%~--`+VcfV?zOhy|T+{7k8!>-gSthvk$V zfS$_BPY)j<%5%qK5L2|mpz+Ey8tbINPBtj#jR5=6ug37SCNK#p4F$DYQP*Gr@z+A|d z<#RB2Cs)=;lU0&{k!n<~*D8V;i#z5LP*^SO)_ZdBM`~kKo?Z! z4wG$C@Y8dn&|%7QPC`Rg?a0J6B)+ySbgbh|R4+V_gw%32Fh43q}Z6Df0Ixd2l*m zw{x4%5bF%AE^onwuy_(*EDod;Va%mTT$?JrrvbT}d@HHnH(ycT{Ex@Rofd1=Ni-?F zx8`!~L)g>l8;12H-&xM9q#$rJE-b9yRp|+F`97KK`t*1MT-3L?`7S;8}(ROohLZVrqFx@;S{? z*3-dp?&BzcB@e74dzK!HMiC42EXWo58%h}$_{q?1AXc4b30o5NkE|*H5Gdgw?FBkq zkFN2D8feZ(o^br*UVT$4KM3akNC@a0B+(F@Diuwy?4c{5ZeGTkP`}W3_J)ivw!~To?M`he)SzuPsY`A>-5RD+=`| zN>vs~l(Vq3KZ7ns(-crT)#mEJCfp-fZRhk!d*E&Rr{#mKxn+t8K*|7{5nUushTXcu z4v#6ZM=7qdZsmYSZV>_Qu}2CJAc)j_{x5S^u+*j^7vJNk@Y{yUD`H8sI5_R8H#Tck zX4cPto6m}qdQ}_D+Yt8?Pa+&1jYaTAzxTL5X(8=0D3Ic+HyPxn>UI9ikMVH(Z^Ck_ zt1O+kZs_*u8AcH>*KpD|^RPrso)N|iLOxd9NS&#iu%eLR`tQM%{q-5~tB$CC_yM?K zpjn2PQA(lVo?~rzeeP*zn zCHxTGhgiZwMN{K9WdqkqJ7U+$J)xAq#xflsa(;}yBhh5T%Pa;P#j@R(uG*%(xa#6U zgcQ(hh4nO4OXIJwoSl*GED^u(E(c1fsi`)o!m`IimJblRylL?MIK%yWDQnNOX|0&k zN7u{DwTBrS=HTD3e_}gv|3ePTNt=k)*e)4D(=!2QDp?zP;6QC{u}kmW?Zj}3oMPig zE37WTKNC1vBz&f3?%G&=sadR!4lZ6uaLu@-3W0Ey>g6)gQW*9t6hp_%${Hn6AQaM? z3P_6#Lfpsp%jOs=$$Of%)l`?64068}oq-LeVB=veUlC!t;~YkjIlO*Cx>@pMRfZcC8erkqR z&43J!0ec#@vlazH=IIS9$+T(CIezfQqwL({;$p!fbw&^A z5g!CUisU)M0vkY=um`jY!7IsGWc(;8=e{;%!TXeR51W{kaTbsTNnr4gXA@0Kg)n6% zN~a}L12k*IlK+AbvSkqF*Al8|AO;|?;}X@(0j$`eAyJMdPt6^RAUHShXG9xvFNe$d zHD0XF&G>+O3=N{+y6&|pQxj@XUg>V?l73XjHYGbTV%$7&LQgClmCD+9C&w5(&J$y6jjLts+3_yArUU zr!D*kzck6{i-2`-8OQzu^)CqDL4@~dUpNY?(8>*HMRF9pHGzkPQ{i*{u?fczB z)1dRo4|W`XixePSQF^UcXo!qjB;Uo&T_J0Wu2jD!c`H4MFk;@nmB*oiXIl}>h>hE3N`FKv3-W1dgb{aqx__6#}d5@at{s#gN&Y2Enkj-_*p+n8M;;L zBHf`FR{Fv#j<0Zv3Vw92O6dhw8EO=UH}j4ZQRR8U;t;lUwoz(vyRzYVlN{60ENM;5 z*}wAgGGl%jW&Nx}o6!W-@Rmr2jiV>9jik$Gt^x|Ygi(Yh>{xw=Sp`z*SnUhA@1wQ= z&blL3WSN#Hq^Oyd?30?uB7`C#?ISK<*eys@Jmm9JmRL)l$lZ9^#!cwfm1w#@Z+*)G zDIrCdT=kOoR^z%mdN6gOj}=Ah^pIT!$}r`vgKJMH2g~hw%{H>Vp6zks3Y0$?LP4|q z!I1>>xX=JZ{G_~J8TdN-&9SXWpau~&s$8mR(@@?19+EL6NyhJq7V%OXwz;t<< zfbJ|?-=P}n9V@99>A?LoxaA*G0@1)wh-#EA5K)N$-6kn@V(qNhC{^r|k zdVN1ZJ{=|nnhjR;h0&qO5~D>iUa;ee5KP=C9kzhJ5 zQ(g{bt2d@MG^^on7IFF(hKmy_7;cZxZ+d_I1YM#ZP|gS)>~ik93^IZrlkM@>7zj0? zutu7G592topZ69I)0iO1p=f;D%CzcC^}X%>8i~G=f^D?&H=)LEcm&%AqsPhwD@-nd z>>BnFQ{7HC{X?mA)d))lQ>kEbVvZM{C~DJ2X1~USik@B3E;fMUb~46~gyh7do-09!i6#%eatR#c--D6gS{ zkZ6!03QiX`8FXFuGz%sSnAB4GW+pX(jX{s?_u{#cL6w=hW9364pgg}g-se4AEzn4A zvj2|U9yj=JVAA^SaJc?4I_*LeDzhabxIBH|%(;Qe`%U^*nd*GqropQAE6tdA^?@Ym zHPL5HzyEth5n&thQORhq&qrfsAFX?(MtnXRhZ6J&in9i$DS}~T4Wa~--)1$Y|AYYe zZ)TE%V7CX-6D>`vlNa$0yTw{PZsob4rL4regsDA}n<@cyLpmQCOu5~B*$w;&5>8$UaSk({x{5!jQma@FO`%#p3Ey8fW zy&*Hi3^CsPbRiuB65ANOwGj*Bl&75$;82dzfifm-(}NaJzeqv=iV|vxPzH^)7EI{k>41eFG^arbKwSCwnow>)fjq>wcTS>2rf~s6! z3+OR{DMtW15_=-nox&UW(lG-*eVBtMb_;j1MFeMqiR3k7D||4tJKolk zL&w*z2RbEN8**o?2a{i@mQqo$gc4{fknAN+IBDRJ9RwJrp1F63zGJpF~iggTA*}~E|mi|4D zOHKJ`6EL+bjS9tkT24^-#eU+v&5SS)VVCR6KY+`Q)~i>hH^>1yNI2bmV0B)5@K;24 zMp@ff%wng!N(M%H+4k+y@krCH<m3k84spLD(PYU7hf5zDVgv4?r19#AeX(?E) z8-QiPGXk!wXqQ9nG&@HF`cWl@r|;Mx8O=1>c1Yo*V;^FjX>- zRoci`ke!gE8Nn3jk&$jCfOdmTtl+9zp)6#wzWU>010H`oLfZ0zCBZJ?;daHyMRSdpi#kimW7T?O^& zwxT20_!zR4a8}U$c-befDwN@yc-u5^OW&aLiw?=Qg}-z$H5f5;n#qC1)4i)6h_yg( zchKvO`P?0GW_b#I!LTIuO9jX&+>_hi%(|gBL|l7Wgdejbs5_LIWJFTX&Wz$RJYD#p zRd=oCJbyg(A%n%IKtS8KNnd#0*i4G#IZ(u0l@c)JKU z&3@I!*gkiqxS(Fv7)C!wwk2g89Q5qJvBH#nT&bfpFoC@4dj59u8h(7?fQUC@x6UpUcL7(C9CP>w%^#Qr@b@tsSd&0T5z?tq?g3 zjtBihz|--)V3maVx7CRj!ZLaN;9p|BPjg|Fc|IeG^Um-u>{VcfuT3AJ=K~z{`wt44 zLLN~0LUw;L3!FY20yT<@cPQWe(0GX~Dm{z+5ClL1&?j{54YL@hf!Vv6kGTz6)#3XI z8<*C~G$+_eV{*Xk8GP=R|9aTEF-;>ujR0Z9yeW$JBmjy>E!4_;%31XAKz_(8jv?%g z$MonH_~5mR88b+0fGx_b)+ic+X2z97@MA!qKO>5ZNC{-BnZ69So?#{)^JD~x!45MS z`wFaY)KgI_w3>Kj*P20#qwwnpWsYi(R}od;xD?4B88I(-cONyt(k87FkS zPYm04@cpxiPFx2dcZpnCyz1^!2X|~A9-&<#O1nfgCK-W+M?-7U0=;=Wm~esr6IO8+ zCRL=FTn$-#>oN6h7y}jtG=x)^k?`x)&hJgBoj@%zT(m;iQJVn_=(%1#ciJIA>+UM1 zvX->dqsUiJvRif3&V5r254HS5dKFq@%Z7+^u)W#>|_?7l@OdNYHb3G?F;$i7F5azw{|QbuGjsobl6VN zg3D1;21MEAfmkM~DFZ6|Bqukk(Y@mZ8>g5t#vRjOYN8(6WP)TGIEeOT$-h;nBvBqs z?iBi;pW%*9)^s9??@)WgQ4qH;qRz^eoNKt$`d6GvN~PYyW?=6)9JUp}Gnj_L>nif{ z{#iVtngYHNwxzK~M^IL$dTb_?kO{(Q( zuV$Ur=OQ}jqQ*5plv8&&;j_PcmLpjg3i*<9c&CQub15&01(SNW>)L>HD9uXbWzFZB zIEc7q$bg1CAf=QmY`<`}0Gz`{ZNdT*tzuTjp^ahJJU+Do z2-`K{Zp41CMcQo*XaGLljtP5rzd8Gdb|82NDV%?MYWsN_@Ymi!8JK&t_MUiC@*_I5 zO$4%l5+iRoeDAGDV!6u`CLjB&Y}K8Vq8RvgK`=GLu)=xs4t|hCN>H`GT-+&Zp!3@O zw;lMMEHPD^@uvLB_*Bl?sP17?J!o&g3nna@GS7o~fC9RT=`CmC4Xh>?C^9hn4tVoS zCm`}N&kUQ+uE;U4ywkXETKwZ6sE*cfQ4r=<>c7~en*2b$0XPkg#IL*z8)O)NI$9#&QePV~Y?akJr0~w10$)USr7xS4vcVfm| znp5M1ouXp7Uh^H0oDAyyUN>PuPE52q+ctN^BPt+lUMZ(UPdFh$PCoK|Oc2JK%<@8N=q(@Z)FMom`BzL1p9;HWDwD*NN(-{3Je#$t1R`Hn z&8`N5tLBPCLOlnnle)#MVrY3D#}U8ePnDdme-xbi>VC~e$(BsY(uni#Tx!$r-k;T- zwko}o7kB2#`+VQNDabN!o3M8|zq&{g&!Co;iVDq-9{uR9;3xP?64 z3J|SUVh)54HZpm^Uq-T@`}DIgWT%<JsJ`?K5Xqc(>wIA35CG zbD8kWleMTu1j!lL?pJ7a4#f+BqlD`C0p#J$_(Vm3{Y75+CU?BKSq zz0M;cpPU38vIam-4~8xbGIZFiP@eV!h|j3HqZ0eTiz&?kd(Tc5_>6ZlS^6+ zCFC`q2$mJfC;e(5#vCWBr668>5R|~pWwRJBc=9$uUW%~vi}(;QGe?XASOIdQIy%+M zOY~cAb^hL+^X0bryEID0u9*j-UR4!zJhr2mn46DjL)Wd?1Kgm|f2%3MlPR{bF$0lO z_Ym=uH0%PX8%<$ot2lrdIBBfTfO_*YzA?OWr|+-tq?J`1YYjXcQ0b~2V7{w$s3%Bp zs^ROdY@s$14HP1`ma&a9O0yu^rmsXW+SB}qRULzAWKQTvQ#`HDox7;HxF_f6N8B6R3Z@=PgvUgTNt@ejYQMYF<-k14mx$ z97iSt9*Hv0yh+8clRMR$hCv^eIi#DxIn8eCyr~6;;J?phJy!=+cp73HxB3Ror1P1s zdkP^X)Y@XmDJ;oL?Q0<+*3&3J<+BNl8vy*DT@kigNdzijL>iZ&@*Q*TO`!;XbAzN- z{>COo8*Bn7XF1F|1C5BgeYUnSF(9~PA!bTHNjd7?`$~r;?Y}5ulClF)SBhRh(9nri zk@Drzwd!*(cKyv0OF4+4B!QD2kn$EFTg)(KxJE2RZIj^Y8eQ&VN|T6e7e&`v=ohUA zxnIoc|7MKa9v?VHpG8x2Fw>7VIh01sXYa9bYKF=l1P}@(4ve@*5O%d`3%ZG%cBd-3 zO;SX1%0?kmwoImL3Jiy*xjK;(UM+-Y7QGdNuW*38H9@TK@On1AD0%W~eSj!qFGMkz zoT+)vTup~v+pM0_3m-~)yM^7zEZVeFRWdIZcfj1s2Kh&W#L4bOj^nFnRzc>hEx#VJjZPfWr+Vw3 zQ`ju=U{;Z#abrmBZ#<(y=xLD9mz>kx`;`~0PU5Z@`&mjP8w-R|tZsVt#W0)Lkm_--GmrW<8ye@b98ikO}QA9XYCj(+4EC7>UymG{ShBNK1geRCB*_WEGIP@!q|gtCc%j{Pb?E6utYu3&XQ~zpK0}mA ze@tJnWPwu`Jwwn&LAEC%t^0{We*s-gU8R3WvJ^=%+{z6Ds8aG}>wq#=%F(f`$$cik zxuKo+oO=Urh6$HN$fL8pZh7B7=|t5&4Kmei{^n@#buqbkWA1R)ro*{R=v?-R@|xyX z;9U*Ay&4Cx-dL)l+i7@A6aZn|CU3~_c+L(yoi9G`sde=E8;mLdCc?#YR|w&qWPd-j+uDWn z1Z`wAZYmEn&2+cu?=HHA?Dl|~_=bvqLGb_0s!-}n_vy5p7x^%xY8v$aD;`ceD-dCj z2K{CJSmitNb5hMCJ-TFN)c&J5vN#h(MzsGNC#|wb@JEa8wN_t zy=~-ns6#_gJZ0Z`ja%lGjXb{ycz{&ErDLw_06|83bz@rCx|=3CP3R|gD2nPSh*s~@ zm&X-&14Zyjin9VMeGgyKA|tY6aUec^aj={oP%fhKFhh7V>_B zprv9|06_;Y#r_r+#}d4USo)>~;rqf4pU0=_!ZXXPpFraJ^vQM9FJ)>GzRfKw`HraG zlA?E-X1gatZ;3ODTx9vP(X4vNwE9TuzHY7EL3LCQV9kpND z6EzGXX)~>|VPdA^82}@{-|xcje93u_NdP_`KIN(bawPz z`?O5iH4!N8`a!P_efmN2*141pRZ8ZEA<=H?7l!-ocj~xHVQK@h^euTii}pdE#cX;w ztzH(USJ&kf-og&SH{cYY4Q(LM91NHO>dsxl4bV(RVZ0;Np!At=I`9|!{RW~Zr2*qq z(lpm}|ND4gtbuFl?9QRM4;flB8&P=C_Ez&iawpT5WGE+yzt`))MVt)mI*}l%l%+{Ai~Su z@&3bTtfK&l8RkP@^nQFIe^|qrKHfs{CB19p0D!6#JDB_@IcZYn1}2?EKhZfjNYg{SDKJ9#NATu~wxC#VH49z;g*58CyKrYGTgjm%eL&{(Rqy z@L0SgA|AG$H6lvcwa1fPTfq_rPt4G!0V$Fi^*|GkY)liZ;=bGsc}%^FKQb{~K+=>L zRda?98RNRbUw#vI*E79xg4oq;(LYvpxO7_pe~^ubW~^{5 zXA$eqOh_u25gVQ(jRQm+f>53+d`CTOB&p5vtj9x$Zg`903S%U9K^;KVU{7x(Qw@E% z8Hq#)g(_IxzMb?*DT1b>&evlPf6=YZB>Z-iibeBeu+yoVA?#YpU)FL^GM<>_xw$2}w5A;J>tF>t1eR zJnxYv3EOK7>llgP2$(U3F_C?{M)jMZ6kjhZ`Qrr6%8%o5kn^{mNSr^-;}w!Bzk@n7^$6kI4=e))LfT56w$yFaIyE>AB?e_fZ?Kd zE1sP4HZQIQ3^lk&Bw~y0S#S37C|FGQJoZFB3fS^~-ZWFWccd3 zVzi8rCab3WkeqR)dr;+p>GbE;?pb6DAx!A4jKbBkElp1svB>f%_Ci>ATA=6;Gzzb^ zQ8MN%JmFZD`0JO>m-dCD&BMC}P8Fyj@T*!7)zyRoujpLrU_(+{s5G2T!x3?c-~R(T CJ6vx7 literal 0 HcmV?d00001 diff --git a/tests/lib/Preview/AVIFTest.php b/tests/lib/Preview/AVIFTest.php new file mode 100644 index 0000000000000..8a1e71ed61614 --- /dev/null +++ b/tests/lib/Preview/AVIFTest.php @@ -0,0 +1,35 @@ +markTestSkipped('libgd has no AVIF support. Skipping tests'); + } + + parent::setUp(); + + $fileName = 'testimage.avif'; + $this->imgPath = $this->prepareTestFile($fileName, \OC::$SERVERROOT . '/tests/data/' . $fileName); + $this->width = 1680; + $this->height = 1050; + $this->provider = new AVIF(); + } +} From 5c80c75206042b6de5312bb4600464f8c1e0b952 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 18 Sep 2026 14:04:51 +0200 Subject: [PATCH 2/4] fix(reuse): license the AVIF test fixture The fixture is a re-encode of testimage.jpg, so it carries that image's copyright rather than being new work, and belongs in the same block. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- tests/data/REUSE.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index 3a19ce29f8384..34ea37845a0ef 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -34,7 +34,7 @@ SPDX-FileCopyrightText = "2012 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] -path = ["testavatar.png", "testimage.gif", "testimage.jpg", "testimage.png"] +path = ["testavatar.png", "testimage.avif", "testimage.gif", "testimage.jpg", "testimage.png"] precedence = "aggregate" SPDX-FileCopyrightText = "2013 ownCloud, Inc." SPDX-License-Identifier = "AGPL-3.0-only" From 1d780448e0871b28c535963eb1bf9de81a12b161 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Sat, 19 Sep 2026 08:59:19 +0200 Subject: [PATCH 3/4] feat(preview): read AVIF through ImageMagick where libgd cannot libgd carries AVIF only where the distribution built it against libavif. Where it did not, ImageMagick often still can, and the server already depends on it for HEIC. The two are the same container with different codecs, which is what the libvips author points out in h2non/imaginary#337: HEIF holds h265 for HEIC and AV1 for AVIF, and ImageMagick reads both through libheif. So HEIC's provider becomes a Heif base parameterised by the format it asks ImageMagick for and the one it checks the build carries, and HEIC and AVIFImagick are what is left of it. Like the other ImageMagick providers this one is off until an admin enables it, so libgd stays the default path and nothing changes for an installation that never had the extension. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + lib/private/Preview/AVIFImagick.php | 41 +++++ lib/private/Preview/HEIC.php | 134 +--------------- lib/private/Preview/Heif.php | 168 ++++++++++++++++++++ lib/private/PreviewManager.php | 2 + tests/lib/Preview/AVIFImagickTest.php | 54 +++++++ tests/lib/Preview/AVIFTest.php | 6 + tests/lib/Preview/AvifPreviewTrait.php | 84 ++++++++++ 9 files changed, 366 insertions(+), 127 deletions(-) create mode 100644 lib/private/Preview/AVIFImagick.php create mode 100644 lib/private/Preview/Heif.php create mode 100644 tests/lib/Preview/AVIFImagickTest.php create mode 100644 tests/lib/Preview/AvifPreviewTrait.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 66ca381856dd8..db585c038b745 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2134,6 +2134,7 @@ 'OC\\PreviewManager' => $baseDir . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => $baseDir . '/lib/private/PreviewNotAvailableException.php', 'OC\\Preview\\AVIF' => $baseDir . '/lib/private/Preview/AVIF.php', + 'OC\\Preview\\AVIFImagick' => $baseDir . '/lib/private/Preview/AVIFImagick.php', 'OC\\Preview\\BMP' => $baseDir . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => $baseDir . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => $baseDir . '/lib/private/Preview/Bitmap.php', @@ -2146,6 +2147,7 @@ 'OC\\Preview\\Generator' => $baseDir . '/lib/private/Preview/Generator.php', 'OC\\Preview\\GeneratorHelper' => $baseDir . '/lib/private/Preview/GeneratorHelper.php', 'OC\\Preview\\HEIC' => $baseDir . '/lib/private/Preview/HEIC.php', + 'OC\\Preview\\Heif' => $baseDir . '/lib/private/Preview/Heif.php', 'OC\\Preview\\IMagickSupport' => $baseDir . '/lib/private/Preview/IMagickSupport.php', 'OC\\Preview\\Illustrator' => $baseDir . '/lib/private/Preview/Illustrator.php', 'OC\\Preview\\Image' => $baseDir . '/lib/private/Preview/Image.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index c90056cac88cf..83f4ece61a404 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2175,6 +2175,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\PreviewManager' => __DIR__ . '/../../..' . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/PreviewNotAvailableException.php', 'OC\\Preview\\AVIF' => __DIR__ . '/../../..' . '/lib/private/Preview/AVIF.php', + 'OC\\Preview\\AVIFImagick' => __DIR__ . '/../../..' . '/lib/private/Preview/AVIFImagick.php', 'OC\\Preview\\BMP' => __DIR__ . '/../../..' . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => __DIR__ . '/../../..' . '/lib/private/Preview/Bitmap.php', @@ -2187,6 +2188,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Preview\\Generator' => __DIR__ . '/../../..' . '/lib/private/Preview/Generator.php', 'OC\\Preview\\GeneratorHelper' => __DIR__ . '/../../..' . '/lib/private/Preview/GeneratorHelper.php', 'OC\\Preview\\HEIC' => __DIR__ . '/../../..' . '/lib/private/Preview/HEIC.php', + 'OC\\Preview\\Heif' => __DIR__ . '/../../..' . '/lib/private/Preview/Heif.php', 'OC\\Preview\\IMagickSupport' => __DIR__ . '/../../..' . '/lib/private/Preview/IMagickSupport.php', 'OC\\Preview\\Illustrator' => __DIR__ . '/../../..' . '/lib/private/Preview/Illustrator.php', 'OC\\Preview\\Image' => __DIR__ . '/../../..' . '/lib/private/Preview/Image.php', diff --git a/lib/private/Preview/AVIFImagick.php b/lib/private/Preview/AVIFImagick.php new file mode 100644 index 0000000000000..e0da9ac9ac241 --- /dev/null +++ b/lib/private/Preview/AVIFImagick.php @@ -0,0 +1,41 @@ +isAvailable($file)) { - return null; - } - - $tmpPath = $this->getLocalFile($file); - if ($tmpPath === false) { - Server::get(LoggerInterface::class)->error( - 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), - ['app' => 'core'] - ); - return null; - } - - // Creates \Imagick object from the heic file - try { - $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY); - $bp->setFormat('jpg'); - } catch (\Exception $e) { - Server::get(LoggerInterface::class)->error( - 'File: ' . $file->getPath() . ' Imagick says:', - [ - 'exception' => $e, - 'app' => 'core', - ] - ); - return null; - } - - $this->cleanTmpFiles(); - - //new bitmap image object - $image = new Image(); - $image->loadFromData((string)$bp); - //check if image object is valid - return $image->valid() ? $image : null; - } - - /** - * Returns a preview of maxX times maxY dimensions in JPG format - * - * * The default resolution is already 72dpi, no need to change it for a bitmap output - * * It's possible to have proper colour conversion using profileimage(). - * ICC profiles are here: http://www.color.org/srgbprofiles.xalter - * * It's possible to Gamma-correct an image via gammaImage() - * - * @param string $tmpPath the location of the file to convert - * @param int $maxX - * @param int $maxY - * - * @return \Imagick - * - * @throws \Exception - */ - private function getResizedPreview($tmpPath, $maxX, $maxY) { - $bp = new \Imagick(); - - // Some HEIC files just contain (or at least are identified as) other formats - // like JPEG. We just need to check if the image is safe to process. - $bp->pingImage('heic:' . $tmpPath . '[0]'); - $mimeType = $bp->getImageMimeType(); - if (!preg_match('/^image\/(x-)?(png|jpeg|gif|bmp|tiff|webp|hei(f|c)|avif)$/', $mimeType)) { - throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); - } - - // Layer 0 contains either the bitmap or a flat representation of all vector layers - $bp->readImage('heic:' . $tmpPath . '[0]'); - - // Fix orientation from EXIF - $bp->autoOrient(); - - $bp->setImageFormat('jpg'); - - $bp = $this->resize($bp, $maxX, $maxY); - - return $bp; - } - - /** - * Returns a resized \Imagick object - * - * If you want to know more on the various methods available to resize an - * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im - * - * @param \Imagick $bp - * @param int $maxX - * @param int $maxY - * - * @return \Imagick - */ - private function resize($bp, $maxX, $maxY) { - [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry()); - - // We only need to resize a preview which doesn't fit in the maximum dimensions - if ($previewWidth > $maxX || $previewHeight > $maxY) { - // If we want a small image (thumbnail) let's be most space- and time-efficient - if ($maxX <= 500 && $maxY <= 500) { - $bp->thumbnailImage($maxY, $maxX, true); - $bp->stripImage(); - } else { - // A bigger image calls for some better resizing algorithm - // According to http://www.imagemagick.org/Usage/filter/#lanczos - // the catrom filter is almost identical to Lanczos2, but according - // to https://www.php.net/manual/en/imagick.resizeimage.php it is - // significantly faster - $bp->resizeImage($maxX, $maxY, \Imagick::FILTER_CATROM, 1, true); - } - } - - return $bp; + protected function queryFormat(): string { + return 'HEIC'; } } diff --git a/lib/private/Preview/Heif.php b/lib/private/Preview/Heif.php new file mode 100644 index 0000000000000..deb1cf1e982b2 --- /dev/null +++ b/lib/private/Preview/Heif.php @@ -0,0 +1,168 @@ +queryFormat(), \Imagick::queryFormats($this->queryFormat()), true); + } + + /** + * {@inheritDoc} + */ + #[\Override] + public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { + if (!$this->isAvailable($file)) { + return null; + } + + $tmpPath = $this->getLocalFile($file); + if ($tmpPath === false) { + Server::get(LoggerInterface::class)->error( + 'Failed to get local file to generate thumbnail for: ' . $file->getPath(), + ['app' => 'core'] + ); + return null; + } + + // Creates \Imagick object from the file + try { + $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY); + $bp->setFormat('jpg'); + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->error( + 'File: ' . $file->getPath() . ' Imagick says:', + [ + 'exception' => $e, + 'app' => 'core', + ] + ); + return null; + } + + $this->cleanTmpFiles(); + + //new bitmap image object + $image = new Image(); + $image->loadFromData((string)$bp); + //check if image object is valid + return $image->valid() ? $image : null; + } + + /** + * Returns a preview of maxX times maxY dimensions in JPG format + * + * * The default resolution is already 72dpi, no need to change it for a bitmap output + * * It's possible to have proper colour conversion using profileimage(). + * ICC profiles are here: http://www.color.org/srgbprofiles.xalter + * * It's possible to Gamma-correct an image via gammaImage() + * + * @param string $tmpPath the location of the file to convert + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + * + * @throws \Exception + */ + private function getResizedPreview($tmpPath, $maxX, $maxY) { + $bp = new \Imagick(); + + // Some files just contain (or at least are identified as) other formats + // like JPEG. We just need to check if the image is safe to process. + $bp->pingImage($this->formatHint() . ':' . $tmpPath . '[0]'); + $mimeType = $bp->getImageMimeType(); + if (!preg_match('/^image\/(x-)?(png|jpeg|gif|bmp|tiff|webp|hei(f|c)|avif)$/', $mimeType)) { + throw new \Exception('File mime type does not match the preview provider: ' . $mimeType); + } + + // Layer 0 contains either the bitmap or a flat representation of all vector layers + $bp->readImage($this->formatHint() . ':' . $tmpPath . '[0]'); + + // Fix orientation from EXIF + $bp->autoOrient(); + + $bp->setImageFormat('jpg'); + + $bp = $this->resize($bp, $maxX, $maxY); + + return $bp; + } + + /** + * Returns a resized \Imagick object + * + * If you want to know more on the various methods available to resize an + * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im + * + * @param \Imagick $bp + * @param int $maxX + * @param int $maxY + * + * @return \Imagick + */ + private function resize($bp, $maxX, $maxY) { + [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry()); + + // We only need to resize a preview which doesn't fit in the maximum dimensions + if ($previewWidth > $maxX || $previewHeight > $maxY) { + // If we want a small image (thumbnail) let's be most space- and time-efficient + if ($maxX <= 500 && $maxY <= 500) { + $bp->thumbnailImage($maxY, $maxX, true); + $bp->stripImage(); + } else { + // A bigger image calls for some better resizing algorithm + // According to http://www.imagemagick.org/Usage/filter/#lanczos + // the catrom filter is almost identical to Lanczos2, but according + // to https://www.php.net/manual/en/imagick.resizeimage.php it is + // significantly faster + $bp->resizeImage($maxX, $maxY, \Imagick::FILTER_CATROM, 1, true); + } + } + + return $bp; + } +} diff --git a/lib/private/PreviewManager.php b/lib/private/PreviewManager.php index 874daa0fb1af2..d8020cc356fe6 100644 --- a/lib/private/PreviewManager.php +++ b/lib/private/PreviewManager.php @@ -11,6 +11,7 @@ use Closure; use OC\AppFramework\Bootstrap\Coordinator; use OC\Preview\AVIF; +use OC\Preview\AVIFImagick; use OC\Preview\BMP; use OC\Preview\Db\PreviewMapper; use OC\Preview\EMF; @@ -337,6 +338,7 @@ protected function registerCoreProviders(): void { 'EPS' => ['mimetype' => '/application\/postscript/', 'class' => Postscript::class], 'TTF' => ['mimetype' => '/application\/(?:font-sfnt|x-font$)/', 'class' => Font::class], 'HEIC' => ['mimetype' => '/image\/(x-)?hei(f|c)/', 'class' => HEIC::class], + 'AVIF' => ['mimetype' => '/image\/avif/', 'class' => AVIFImagick::class], 'TGA' => ['mimetype' => '/image\/(x-)?t(ar)?ga/', 'class' => TGA::class], 'SGI' => ['mimetype' => '/image\/(x-)?sgi/', 'class' => SGI::class], ]; diff --git a/tests/lib/Preview/AVIFImagickTest.php b/tests/lib/Preview/AVIFImagickTest.php new file mode 100644 index 0000000000000..cc7e9b92624ef --- /dev/null +++ b/tests/lib/Preview/AVIFImagickTest.php @@ -0,0 +1,54 @@ +markTestSkipped('ImageMagick is not installed. Skipping tests'); + } + if (!in_array('AVIF', \Imagick::queryFormats('AVIF'), true)) { + $this->markTestSkipped('ImageMagick was built without AVIF. Skipping tests'); + } + + $fileName = 'testimage.avif'; + $sourcePath = \OC::$SERVERROOT . '/tests/data/' . $fileName; + + // Reporting the coder is not the same as being able to use it: the + // libheif delegate may be missing, or policy.xml may have disabled + // it, in which case decoding throws and the tests would fail rather + // than skip. Decode once for real before committing to them. + try { + (new \Imagick())->readImage('avif:' . $sourcePath . '[0]'); + } catch (\ImagickException $e) { + $this->markTestSkipped('ImageMagick cannot decode AVIF here: ' . $e->getMessage() . '. Skipping tests'); + } + + parent::setUp(); + + $this->imgPath = $this->prepareTestFile($fileName, $sourcePath); + $this->width = 1680; + $this->height = 1050; + $this->provider = new AVIFImagick(); + } + + public function testPreviewCarriesThePicture(): void { + $this->assertPreviewShowsThePicture(); + } +} diff --git a/tests/lib/Preview/AVIFTest.php b/tests/lib/Preview/AVIFTest.php index 8a1e71ed61614..979b46297ff64 100644 --- a/tests/lib/Preview/AVIFTest.php +++ b/tests/lib/Preview/AVIFTest.php @@ -16,6 +16,8 @@ */ #[\PHPUnit\Framework\Attributes\Group('DB')] class AVIFTest extends Provider { + use AvifPreviewTrait; + #[\Override] protected function setUp(): void { // libgd is built against libavif only where the distribution chose to, @@ -32,4 +34,8 @@ protected function setUp(): void { $this->height = 1050; $this->provider = new AVIF(); } + + public function testPreviewCarriesThePicture(): void { + $this->assertPreviewShowsThePicture(); + } } diff --git a/tests/lib/Preview/AvifPreviewTrait.php b/tests/lib/Preview/AvifPreviewTrait.php new file mode 100644 index 0000000000000..5a8fb4fbc2846 --- /dev/null +++ b/tests/lib/Preview/AvifPreviewTrait.php @@ -0,0 +1,84 @@ +assertNotFalse($image, 'the preview is not a readable image'); + + $width = imagesx($image); + $height = imagesy($image); + $stepX = max(1, intdiv($width, 16)); + $stepY = max(1, intdiv($height, 16)); + + $red = $green = $blue = 0; + $samples = 0; + for ($y = 0; $y < $height; $y += $stepY) { + for ($x = 0; $x < $width; $x += $stepX) { + $colour = imagecolorat($image, $x, $y); + $red += ($colour >> 16) & 0xFF; + $green += ($colour >> 8) & 0xFF; + $blue += $colour & 0xFF; + $samples++; + } + } + imagedestroy($image); + + return [$red / $samples, $green / $samples, $blue / $samples]; + } + + /** + * Generate a preview and assert it carries the picture the file holds. + */ + protected function assertPreviewShowsThePicture(): void { + $file = new File(Server::get(IRootFolder::class), $this->rootView, $this->imgPath); + $preview = $this->provider->getThumbnail($file, 256, 256); + + $this->assertNotNull($preview, 'no preview was produced'); + $this->assertTrue($preview->valid()); + // Smaller than it was, so something actually resized it + $this->assertLessThanOrEqual(256, $preview->width()); + $this->assertLessThanOrEqual(256, $preview->height()); + + // The fixture is a re-encode of testimage.jpg, so the two hold the + // same picture: a strong magenta whose average survives both the + // encoding and the scaling. A blank or black canvas misses by ~250. + $expected = $this->meanColour(file_get_contents(\OC::$SERVERROOT . '/tests/data/testimage.jpg')); + $actual = $this->meanColour($preview->data()); + + foreach ([0 => 'red', 1 => 'green', 2 => 'blue'] as $channel => $name) { + $this->assertEqualsWithDelta( + $expected[$channel], + $actual[$channel], + $this->tolerance, + "the preview's average $name is not the picture's", + ); + } + } +} From 05c7ed301678ef739e3166459109d12106926116 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Sat, 19 Sep 2026 09:21:28 +0200 Subject: [PATCH 4/4] test(preview): skip the libgd AVIF tests where libgd cannot read one Reading an AVIF takes more than a libgd built against libavif. OC\Image picks its decoder from exif_imagetype() and guards it with getimagesize(), so a build where either does not know the format never reaches imagecreatefromavif() however capable libgd is. Checking only imagetypes() let the tests run on a runner that then produced no preview, which is where they were failing. The skip names which part was missing, rather than saying AVIF is unsupported and leaving the reason to be worked out again. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- tests/lib/Preview/AVIFTest.php | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/lib/Preview/AVIFTest.php b/tests/lib/Preview/AVIFTest.php index 979b46297ff64..48d91aecbe8e9 100644 --- a/tests/lib/Preview/AVIFTest.php +++ b/tests/lib/Preview/AVIFTest.php @@ -20,16 +20,30 @@ class AVIFTest extends Provider { #[\Override] protected function setUp(): void { - // libgd is built against libavif only where the distribution chose to, - // so a build without it reports no AVIF and cannot decode the fixture - if (!(imagetypes() & IMG_AVIF)) { - $this->markTestSkipped('libgd has no AVIF support. Skipping tests'); + $fileName = 'testimage.avif'; + $sourcePath = \OC::$SERVERROOT . '/tests/data/' . $fileName; + + // Reading an AVIF takes more than a libgd built against libavif. + // OC\Image picks its decoder from exif_imagetype(), and guards it + // with getimagesize(), so a build where either does not know the + // format never reaches imagecreatefromavif() however capable libgd + // is. Ask for the whole path rather than for one part of it, and + // say which part was missing when it is not there. + $probe = new \OCP\Image(); + $probe->loadFromFile($sourcePath); + if (!$probe->valid()) { + $this->markTestSkipped(sprintf( + 'libgd cannot read AVIF here (IMG_AVIF=%s, exif_imagetype=%s, getimagesize type=%s, imagecreatefromavif=%s). Skipping tests', + (imagetypes() & IMG_AVIF) ? 'yes' : 'no', + var_export(@exif_imagetype($sourcePath), true), + var_export(@getimagesize($sourcePath)[2] ?? false, true), + @imagecreatefromavif($sourcePath) === false ? 'failed' : 'ok', + )); } parent::setUp(); - $fileName = 'testimage.avif'; - $this->imgPath = $this->prepareTestFile($fileName, \OC::$SERVERROOT . '/tests/data/' . $fileName); + $this->imgPath = $this->prepareTestFile($fileName, $sourcePath); $this->width = 1680; $this->height = 1050; $this->provider = new AVIF();