From 42cc6db0e29c26359a77cdf5ba25c25c858bb945 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 18 Jan 2026 02:30:56 +0100 Subject: [PATCH] added support for face filters --- CHANGELOG.md | 4 + assets/filters/beard_upper_lip.webp | Bin 0 -> 3652 bytes assets/filters/dog_brown_ear.webp | Bin 0 -> 4536 bytes assets/filters/dog_brown_nose.webp | Bin 0 -> 5904 bytes ios/Podfile.lock | 16 ++ .../camera_preview.dart | 13 +- .../camera_preview_controller_view.dart | 72 +++++- .../face_filters.dart | 33 +++ .../main_camera_controller.dart | 101 +++++++- .../face_filters/beard_filter_painter.dart | 174 +++++++++++++ .../face_filters/dog_filter_painter.dart | 242 ++++++++++++++++++ .../face_filters/face_filter_painter.dart | 44 ++++ pubspec.lock | 8 + pubspec.yaml | 4 +- 14 files changed, 696 insertions(+), 15 deletions(-) create mode 100644 assets/filters/beard_upper_lip.webp create mode 100644 assets/filters/dog_brown_ear.webp create mode 100644 assets/filters/dog_brown_nose.webp create mode 100644 lib/src/views/camera/camera_preview_components/face_filters.dart create mode 100644 lib/src/views/camera/painters/face_filters/beard_filter_painter.dart create mode 100644 lib/src/views/camera/painters/face_filters/dog_filter_painter.dart create mode 100644 lib/src/views/camera/painters/face_filters/face_filter_painter.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d3887..ffabff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.87 + +- Added basic support for face filters + ## 0.0.86 - Allows to reopen send images (if send without time limit or enabled auth) diff --git a/assets/filters/beard_upper_lip.webp b/assets/filters/beard_upper_lip.webp new file mode 100644 index 0000000000000000000000000000000000000000..058b2ed09d90d51c74c76ab03e73d741a1be63f3 GIT binary patch literal 3652 zcmV-K4!iMENk&FI4gdgGMM6+kP&il$0000G000170RT_{06|PpNKgv^00AK}4KY9c z11k~H{|WF7;oG(yOOhnXB4UgG|6&!Q0qg}3h?qpxpE4sR0HP#Gb(;tXI>C8&??8`F zXq*3EYUF)Wb;>T60Bt!M$9IM;GBMop0!Zuv5@(~t+|_&IPU3o z^B_ppqVpT{P~B4q;k6V+ZJL^{n`&BL6XxIvm2SWMN1}yOU)vYXNsi_q%$F{OxzS+g zzuYPdD#|KKuf@0~bvHGSsTrC_(+y4WqUnFdh!{#8?&*t-+Je}5bUda90unCW#&LKI zQ!{ls>mI`+Dt*napCTB#iN}DRmd>|YP2hMf8*pYxydnDZaItNj2!kb$K)eF}3_-Q& z;faKy-)TiK_0QcA2x7Jyyf-wm?MLSln1mkyaxM!GsIUNlD7ruWr<*S{c4&F+vHE{& z16`%C(-ZA?nsW@ZJ};5>m(}1`o>u)kcVWMKq8XY88klclS?tnfX85k2*-_91lvqz} z6w8R1UI!MJgzY|>p=+i`^UtK^c>?m96yC z4c*W*KLFx?n&vS*bW^>SCq(p6ss`5$%a`=lM~UuT3fNtf8SK(3f4D-eTqR}+iq-T; zCVfezH|_E4fiPHK7+#Tb17#&`SuUtW%Cnbp#T` zMl9LmRuP1MPjzu{QZZrh-*dznrMHu${#8Z{rS|R#FeG}|GLY2v?=@@8mY6S|BUq2u zS&s-~=_DSOAjY)zj6lVirRwxrs|=nM#R2Q7zw0!NOIdc z+{jUaCvpd^0{(zS45kK_tPf>;fPTQL<6`ah{%-&(f?11^i6*vdT*dg*1GlM;6GWAq zVru~e@7N7@J|VI|z%U@^jm*Je9!o8h$3yU4!eoU03`RJU2EpPe5TExRP5`=&O8f1E zymd6Dqjp!n&ty{V#mvM$?g8VX!LpR0$f6NS5|3ekL}v(Q*yRn&P%8nk{E#8sL5u~7 z5CDDvIBeW@6nCI(qm~ju$f23`02VN;VtlGP0Gv6H0Dw_|Jq2YaU^#ZC0iN z_jpeXNA4;jmK*^=g#^0BGXfEYJV0Ah*En|ZLRjtwQ`0H0?5_-P0ir&)6zLTTwHVjL zGXe>z?n9CI05(lnTiP@n+jjM|$U%2Q!(Qq$^ujzJo3uM&lyV6M+e&KC_BLSG!0X-`mpT#j7*Whet zbr@}jm562mAtYC#q_PvJ1j!`Tso-l}q`FoOOH!mX@SmptX)+L2{r;<}xf`kVAxEk7i9HlAAmu^E{%j7_W=07$IRL=0|J-~tGfIJRsYldWUN z#DEEd8*-&P}TRN3dZR4nd zErJOp0pR!qN5msqs;zGK7^aWL&hXvG_wE>}Tt+FGg?)$?=Mg($AZ(q55` zQQEvF1&Z*N!c1WA%9>j5P?QFAg1fzMzp82JYCVhMiAxMaq`VMoUTF}aI zd56`ZfhV(6XCUHj;t6QTZ<)!ZI61Tm7^o95EU_+yNzOL>IAO4aTUi1WWNrhIS(rb!#FfcRlI!nU{2qu{zc({duo~#_$cA}O! zmJ~;K5cRR<80ty@lsNRHwuy$7JCb?)`0zuQKJ>sW_xT_2>jh8!hE_lkL(`+QMDPOv zk>`#qUQa$&zx)7v4Bm_UOhN`TvA`3~)FuHohz!lgkB=WT;s!aWBtgi*CL26-NXBB3 zP$U5G2m*k6Vlbt^Z*;t-h1cN8g;hX+L;%2r-DGI@@#DwGF#OPCo!fPB+1Tz+umKQ3 zb)ii>A%-agL%_Lz)M27>o@<-z80kl~?Cm9qkl^?a1gu%%w;-PRz7$vmwBkqP9!#td z9|zX?@wZJ4>W4PFgfIqtdZ%$r;q7`+;-@zJoP{h95h*_-hAGs-BKaHL<&kht7-v!B zH(|uB>-3HEs9~*NsYfEYyhZUd}Jcyen4jsWQ-1yn~ z%wPc)9#wIG#$?W4FK3I=C}Z@bK1BIib&kRONV$>;<}ATGF#3#3Kz`U}*+t>Odi1NP zU_L&SWrHeTI^iLFH5`FfdNFip7}H(SR^+0i6(oT)^N6zJg`!i-!-b8cSYxY#m-N&D z?NCA|;&HRq-T(mp_|J4R^n4OB&ZgznA`h(gT`}B>!SGp}yv7OOf%ZW-IoAT*vqP2R zw??1^D8`>^Tk~_LL7NvXNXzaick}p~0OO5g%=(1Xvf|&gG+5t7@$AYxVKMUtkeO>` zAoYoF{}MspmH{$rOEFgq(|m*3SMAft1bf!%IF*VeV8KrTCy|Y%*~lFPmD)3P=(p0J=0Brnv2ZmFPBSG&jVh_aAkN^bnkfoL zHccVR=rs2+pKigT0xoNc0(BM9fy;^aFHtD6M67q0J#5>tYumUozt2U)*Cr(2V?Y1^2x0aW_R3jxgw*D#GNj)}gnVGmGPtB0NB!Dd WlnkhHfiZMRWfk;rn?TeI0002rmfOhy literal 0 HcmV?d00001 diff --git a/assets/filters/dog_brown_ear.webp b/assets/filters/dog_brown_ear.webp new file mode 100644 index 0000000000000000000000000000000000000000..0dfec38473f369efe1dc946ae1b3e0a59a103370 GIT binary patch literal 4536 zcmV;p5l8M)Nk&Gn5dZ*JMM6+kP&il$0000G0002t001`u06|PpNRkNv01fbfZQJrR z+P3XitdLOL(~i5lyU~u-om3g9kJR1Wy*NyZ*4@41?(Tw>Kte-9NUm#chvYbp+`dG_ z1mOR6H7bf;H8g8lb1M^qbXP*vf&`tx+-Xp>DF%Nt6m7y` zPJP53Tjjd2{XlcW1)o=RAxWLON3=uBMj8^@n!9$a4mCQO%D36QA~QtV2E?HCtp-E1 zrDwOwk`+zTQY?K2gu$P$dp5mrox>+6o%Mwj_6lP>Q1$l6yW zl$iS5IRtF7b>y{~r7qcobj`3J!0+txU{Q%fF5$MGRS5ieYx*M_)_Y}Qnmia(CQZB{ z#Ve7x!=Ut`#n08q?VTQ$ZcSE zIhegD56n(o=?1S#VgZ=7fZYKZ;8x{EH{v#MYXr9=uL8FbU{)D*Ik=q)X7yt%U^cl1 z%!W*A17;(@ZS+BK8wO@|X~V$nG%y?XiWTfG;YArR0_@IR3RcZCj9@nwy!IIbezzJ} zF^!1OsCaBMq!?{|M9@2E7E{)t5lMbvKyTR)hGk z!(m`vduu0g!$yOBtv^T)I|cr=e2_kH4kS2+vdF^_;T9cX!!sa4?K6;VJW~f59{ZRq z-5Cf`I|#DVA;wi?#r77%XF9U>LX00OR86dvLk(57NQhB$fab)35X3^7LbgDV#}%56 zLXzh}u`<>!ie{1q#X*!c6!pInveXE=s5gY^p(nZs(p*E1We3Fh6E(^n$Wx^zHbx-H zzY~)IfsR{3OL9kvSX{IeL874-Na^ns$uFQZWH=;R@(C#~LZmKGdQk_NggXf_ltHF# zng|(DFBCiIjPC@YT3lr4OCeQ*myEDDv3Nmdj9Dxdbg1>wQnAy~Xf#U2;v&OkR-{6x zqCu){n#eG_#X@PkSg!e!j8KSG?<3=3xi~=PvB$;YqQheni-!)MUMwy;yaut<(t%&w zNo6q|Zkn@ULJ0(~%N+fQOdamwDiPX@d zZWTjg)|sG;FYiNx(~6y~`>Ai;FZA4v;_-XfJxWodDd# zIqjDSAjC}@x@5)iw-LnKi%0TDzPhaIHJ zE}Dv-k7a8a7g(LqhO0vcHt_8gTvdV3t~t?6&1A%0eGOB;GJ?mK-pbM*aO&=!AIH&W ztl;<6<6;u|(wTYdZGP0n&tJ!j)58y( z>jx&XQU#7Tc?YrbHA`5~`C3jgd1}b)W98#?Tb_{j#6^5Gfa`U=W7znGt@20X*qFsu zbACD-PPVYR;*~facI)`^Eott3B@0#He2v;8nS%<}u)bz!0t0h-(+cck=sT$JhUPUF zrjYj;xc9Am_zvpEvWL27GjE{n5CrHc>KZ}Xw-P|XqZgA_Ap-k88)<7qfV|Gpgq?y6 z?FB<3==xd+Slr`Mx~58@xx=a(s8Z{t0DH%*n~6FAIX0#&zmulV1c8RWi>{+cT_g!q z<-HI~(Cd)p<~yz-=u=_f@xpfGXyc^edFV}Yc0ruK&DluJ*YZG4Y-eI-3dEN>^J-Gg zLZa^H^R1M8E)x7~%f5<`1v0^RX=|@1!}p6&@O$@#4^gonVs$?ERC*i@FUtj(Y0ikC zK>a~7-c_}uk_b2f(Q28$jgAYvL9&6G|LfrPfu{W`9AI73;K)FOd{;VV1lSA#Vbxdj zq5?^sFCi`Sv$UH7C>tWyikD>G9Wd%}83A~5)_s9e2r)N2x8UBuP}Agu=1vQ53W!2T zy1w_)8v;O06BQ~7`d#FIWe|4pw;2ikrlv{@TkgKd-?AZYXX*2a{-q9=7jpWW{A&>; z{+rdWv?(H6JJPR8A@W_fUgS@GWp<&)kF+w#T;1B!Z?tXOgoYX|-cQt`J&^j2hhqFg z%YO@E_cZv2w%`ZIU3;}lxF7uTC`7;KadYc^kOA3yn?qa8mjdCR*O^=E$NM3DpU>1v zwQC{&Msw@bW-9XYn$Wu6?nV%JutTf7_X>i+?=NeO>X8Tvk3P{F^AH?fsJ^)6-hDqv za3G?^-W!Erag(j3Duy9w%<7cTG8JnOJT}x`(jxWibU}pHZ9_r}{JauDWySUp|B&eewx6h90xz{N8^b`9e)>eKnmR>VSo`Q6$h1Dn!KED=+KNyu zhp0Q>&D$MQb0^g0o;pRP8!wO53V2EDhPlOK{rVs-yO^C5t^Kk1HTtZmb`RQ=<}{8U z9#J)!mq!((b0q~1L_J=cB3$ z`7(n;x=g`~&w2?%CU25C#Q~brZW~OdK;=16K7t_sQz&yxvZy8bs_4%uScS-e-}BTP zU;I_`7L%M#a^oO&y)Ts}jrEZcJAf6$8sGxv6@pic0nO|`L7n10IYja*!X{~yX*GyV zmYb4)LuBBmtIrm4*&y!-Myi%YGpJNt5ZO4_YOpmcZ1G(XPy)ND?OYmM@U@Hf$~9LK zoTy)~Ux^0&3ptb256V!s7-r0jJh?#10vCG7h8m65H8H>dPwwk2s5Y9^thsCaF*|&f z@5u`%zKFO+W@ct&mTnI!K}Bk_k-mQXj{&)}Gcz+Y0QfBX2^Tb|QAyIXRaI3~5nAEJ zwsRn{sgQEVkS?2I;06&RacgGZHw@P1uQ2VT8l;)kZ1f;uB1 zAHvw!bV`yncz>+p$e04wa+YNHSO68JU84C!V==&pRW z3*gAi*1CUrx80gC>1AqbqV(O(#_?>YP?*~CeR1-&A$M_V%a(iej1AeKsyIc4Ibq?l zz&Ps}O*n!2kYd1R@FWa7wY~EOv;kG!Hy4Y0$oK;=5-eOcwC)??gjTLIhuwJ(Hz3kH zz%NHUo~j*-AN2cJ`d}IAa!Zq>%rqj zifMb5#3R{(=TWhTLg$Mn$}w8i^R*{F?KXHaFw{c{C~Afs%c3oLYs-aiG`4Bg&E3@m zH3t1S{)Sdn(y3gGRrTTgSgsj=l?k-)qOv+T{<8`+WT%Vbrc!Ej3H;vBQJ=^3_bHdz zCpr2(;4&Y!C>e`6_pWLVNSEL8QFwA1ri9<>#_L?M0sjpF;7P8@owwBStkt^ADp>oY zr2?2x-gu1~2%kqi)gPuCbe!YiBAi_titKs(E01G`_iR?#3XpC@AV1%uyCzI%JExp) z{eR&J{~2XD$f?rR2FLe?T1MRkm< z^eOmJs~`F+b|?il+qf607IXEHo@iuzuY&z5|Noea;4dOL>_iJi){czF;YgzzhttPx zk5YZKWc=o?`L~d8-~yLa>v?Hc_27jgl|l*P9xy#e74pBTV3P?6J@ z|D$s-CFw5*HaJ`vY)>DFQ&JbHVTEQ3sKl9R1pkO9jOXHMr|as|dfQGhpj5 zJ#hfW;r{KvQeGd{`n{I*zV^xkdgSgBaCu-?O-l^tei$lyz$84jMmZce|Gb|l{c}U? zUS!s?6yZI2=7|cRA$Dt!;aXs;q^BpH_(vHKitN2!Q_LoG6N@vV|Cx8dt=%~A)b|3%R+STGIm9ICz^LV2aW;SDY) zxKC8KNEIJSu@`$hiV9dY{seXl>;_>VMjnFRD2gZUj6CgaRCNKN6+aGe#uAf4V_-S0 z*9k{?)1bxh&_9Yc8eCo6jLBV*$cAohM7^|a8AW0s>xt~T*)0&eHC6nuci@|{-j2dX z`_?A2JaDwl8$165Bvy6(i+~V*Vqv%g3CA?U<9>F|+)fz#PXIll>wxgNq*=^wBHJm6 zzux-3m)1B6X7%D;$|y`YZYuR0bVOp$xBLhJZ_?@R>^;?xSV=gI(w2=e_s$MF(hYcq z<7%;es73*Da6T~a=57Kvj00lLd>p?Mq#{_q{Xk0=yaq7vJCVT2Q2-rJL-q;`vxWT! zfOlmwXFQowJ%<4)=*$fq^=iWJxX=1~pTGadhO3+JIDi&v&zp(1hg1B>R~29Y0I9V9 W;k?mM5Xg3Rds(@is=+`20001pNVXLK literal 0 HcmV?d00001 diff --git a/assets/filters/dog_brown_nose.webp b/assets/filters/dog_brown_nose.webp new file mode 100644 index 0000000000000000000000000000000000000000..7716995089c34ec6d58f942344d81d52c71c0db6 GIT binary patch literal 5904 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 literal 0 HcmV?d00001 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 301b4b9..b0cfa92 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -136,6 +136,10 @@ PODS: - google_mlkit_commons (0.11.0): - Flutter - MLKitVision + - google_mlkit_face_detection (0.13.1): + - Flutter + - google_mlkit_commons + - GoogleMLKit/FaceDetection (~> 7.0.0) - GoogleAdsOnDeviceConversion (3.2.0): - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) @@ -169,6 +173,9 @@ PODS: - GoogleMLKit/BarcodeScanning (7.0.0): - GoogleMLKit/MLKitCore - MLKitBarcodeScanning (~> 6.0.0) + - GoogleMLKit/FaceDetection (7.0.0): + - GoogleMLKit/MLKitCore + - MLKitFaceDetection (~> 6.0.0) - GoogleMLKit/MLKitCore (7.0.0): - MLKitCommon (~> 12.0.0) - GoogleToolboxForMac/Defines (4.2.1) @@ -251,6 +258,9 @@ PODS: - GoogleUtilities/Logger (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitFaceDetection (6.0.0): + - MLKitCommon (~> 12.0) + - MLKitVision (~> 8.0) - MLKitVision (8.0.0): - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" @@ -357,6 +367,7 @@ DEPENDENCIES: - gal (from `.symlinks/plugins/gal/darwin`) - google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`) - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) + - google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`) - GoogleUtilities - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) @@ -398,6 +409,7 @@ SPEC REPOS: - MLImage - MLKitBarcodeScanning - MLKitCommon + - MLKitFaceDetection - MLKitVision - nanopb - PromisesObjC @@ -454,6 +466,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_mlkit_barcode_scanning/ios" google_mlkit_commons: :path: ".symlinks/plugins/google_mlkit_commons/ios" + google_mlkit_face_detection: + :path: ".symlinks/plugins/google_mlkit_face_detection/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" in_app_purchase_storekit: @@ -518,6 +532,7 @@ SPEC CHECKSUMS: gal: baecd024ebfd13c441269ca7404792a7152fde89 google_mlkit_barcode_scanning: 8f5987f244a43fe1167689c548342a5174108159 google_mlkit_commons: 2abe6a70e1824e431d16a51085cb475b672c8aab + google_mlkit_face_detection: 754da2113a1952f063c7c5dc347ac6ae8934fb77 GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 @@ -533,6 +548,7 @@ SPEC CHECKSUMS: MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56 MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2 MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d + MLKitFaceDetection: 2a593db4837db503ad3426b565e7aab045cefea5 MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 no_screenshot: 89e778ede9f1e39cc3fb9404d782a42712f2a0b2 diff --git a/lib/src/views/camera/camera_preview_components/camera_preview.dart b/lib/src/views/camera/camera_preview_components/camera_preview.dart index 5d9d4bb..01c4948 100644 --- a/lib/src/views/camera/camera_preview_components/camera_preview.dart +++ b/lib/src/views/camera/camera_preview_components/camera_preview.dart @@ -37,7 +37,18 @@ class MainCameraPreview extends StatelessWidget { .cameraController!.value.previewSize!.width, child: CameraPreview( mainCameraController.cameraController!, - child: mainCameraController.customPaint, + child: Stack( + children: [ + if (mainCameraController.customPaint != null) + Positioned.fill( + child: mainCameraController.customPaint!, + ), + if (mainCameraController.facePaint != null) + Positioned.fill( + child: mainCameraController.facePaint!, + ), + ], + ), ), ), ), diff --git a/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart index dadb1fd..ce13173 100644 --- a/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -21,6 +21,7 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.dart'; import 'package:twonly/src/utils/screenshot.dart'; import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart'; import 'package:twonly/src/views/camera/camera_preview_components/main_camera_controller.dart'; import 'package:twonly/src/views/camera/camera_preview_components/permissions_view.dart'; import 'package:twonly/src/views/camera/camera_preview_components/send_to.dart'; @@ -506,6 +507,36 @@ class _CameraPreviewViewState extends State { }); } + Future pressSideButtonLeft() async { + if (!mc.isSelectingFaceFilters) { + return pickImageFromGallery(); + } + if (mc.currentFilterType.index == 1) { + mc.setFilter(FaceFilterType.none); + setState(() { + mc.isSelectingFaceFilters = false; + }); + return; + } + mc.setFilter(mc.currentFilterType.goLeft()); + } + + Future pressSideButtonRight() async { + if (!mc.isSelectingFaceFilters) { + setState(() { + mc.isSelectingFaceFilters = true; + }); + } + if (mc.currentFilterType.index == FaceFilterType.values.length - 1) { + mc.setFilter(FaceFilterType.none); + setState(() { + mc.isSelectingFaceFilters = false; + }); + return; + } + mc.setFilter(mc.currentFilterType.goRight()); + } + Future startVideoRecording() async { if (mc.cameraController != null && mc.cameraController!.value.isRecordingVideo) { @@ -736,15 +767,19 @@ class _CameraPreviewViewState extends State { children: [ if (!_isVideoRecording) GestureDetector( - onTap: pickImageFromGallery, + onTap: pressSideButtonLeft, child: Align( child: Container( height: 50, width: 80, padding: const EdgeInsets.all(2), - child: const Center( + child: Center( child: FaIcon( - FontAwesomeIcons.photoFilm, + mc.isSelectingFaceFilters + ? mc.currentFilterType.index == 1 + ? FontAwesomeIcons.xmark + : FontAwesomeIcons.arrowLeft + : FontAwesomeIcons.photoFilm, color: Colors.white, size: 25, ), @@ -771,10 +806,39 @@ class _CameraPreviewViewState extends State { : Colors.white, ), ), + child: mc.currentFilterType.preview, ), ), ), - if (!_isVideoRecording) const SizedBox(width: 80), + if (!_isVideoRecording) + if (isFront) + GestureDetector( + onTap: pressSideButtonRight, + child: Align( + child: Container( + height: 50, + width: 80, + padding: const EdgeInsets.all(2), + child: Center( + child: FaIcon( + mc.isSelectingFaceFilters + ? mc.currentFilterType.index == + FaceFilterType + .values.length - + 1 + ? FontAwesomeIcons.xmark + : FontAwesomeIcons.arrowRight + : FontAwesomeIcons + .faceGrinTongueSquint, + color: Colors.white, + size: 25, + ), + ), + ), + ), + ) + else + const SizedBox(width: 80), ], ), ], diff --git a/lib/src/views/camera/camera_preview_components/face_filters.dart b/lib/src/views/camera/camera_preview_components/face_filters.dart new file mode 100644 index 0000000..f4826a0 --- /dev/null +++ b/lib/src/views/camera/camera_preview_components/face_filters.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/beard_filter_painter.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/dog_filter_painter.dart'; + +enum FaceFilterType { + none, + dogBrown, + beardUpperLip, +} + +extension FaceFilterTypeExtension on FaceFilterType { + FaceFilterType goRight() { + final nextIndex = (index + 1) % FaceFilterType.values.length; + return FaceFilterType.values[nextIndex]; + } + + FaceFilterType goLeft() { + final prevIndex = (index - 1 + FaceFilterType.values.length) % + FaceFilterType.values.length; + return FaceFilterType.values[prevIndex]; + } + + Widget get preview { + switch (this) { + case FaceFilterType.none: + return Container(); + case FaceFilterType.dogBrown: + return DogFilterPainter.getPreview(); + case FaceFilterType.beardUpperLip: + return BeardFilterPainter.getPreview(); + } + } +} diff --git a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart index a67d2fa..5fb9f8c 100644 --- a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -15,7 +16,11 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.dart'; import 'package:twonly/src/utils/screenshot.dart'; import 'package:twonly/src/views/camera/camera_preview_components/camera_preview_controller_view.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart'; import 'package:twonly/src/views/camera/painters/barcode_detector_painter.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/beard_filter_painter.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/dog_filter_painter.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/face_filter_painter.dart'; class ScannedVerifiedContact { ScannedVerifiedContact({ @@ -45,6 +50,22 @@ class MainCameraController { Map scannedNewProfiles = {}; String? scannedUrl; GlobalKey zoomButtonKey = GlobalKey(); + bool isSelectingFaceFilters = false; + + final BarcodeScanner _barcodeScanner = BarcodeScanner(); + final FaceDetector _faceDetector = FaceDetector( + options: FaceDetectorOptions( + enableContours: true, + enableLandmarks: true, + ), + ); + bool _isBusy = false; + bool _isBusyFaces = false; + CustomPaint? customPaint; + CustomPaint? facePaint; + + FaceFilterType _currentFilterType = FaceFilterType.beardUpperLip; + FaceFilterType get currentFilterType => _currentFilterType; Future closeCamera() async { contactsVerified = {}; @@ -73,10 +94,9 @@ class MainCameraController { selectedCameraDetails = opts.$1; cameraController = opts.$2; } - if (cameraController?.description.lensDirection == - CameraLensDirection.back) { - await cameraController?.startImageStream(_processCameraImage); - } + isSelectingFaceFilters = false; + setFilter(FaceFilterType.none); + await cameraController?.startImageStream(_processCameraImage); zoomButtonKey = GlobalKey(); setState(); return cameraController; @@ -95,13 +115,23 @@ class MainCameraController { } final tmp = cameraController; cameraController = null; + facePaint = null; + customPaint = null; await tmp!.dispose(); await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false); } - final BarcodeScanner _barcodeScanner = BarcodeScanner(); - bool _isBusy = false; - CustomPaint? customPaint; + void setFilter(FaceFilterType type) { + _currentFilterType = type; + if (_currentFilterType == FaceFilterType.none) { + faceFilterPainter = null; + facePaint = null; + _isBusyFaces = false; + } + setState(); + } + + FaceFilterPainter? faceFilterPainter; final Map _orientations = { DeviceOrientation.portraitUp: 0, @@ -113,7 +143,16 @@ class MainCameraController { void _processCameraImage(CameraImage image) { final inputImage = _inputImageFromCameraImage(image); if (inputImage == null) return; - _processImage(inputImage); + _processBarcode(inputImage); + // check if front camera is selected + if (cameraController?.description.lensDirection == + CameraLensDirection.front) { + if (_currentFilterType != FaceFilterType.none) { + _processFaces(inputImage); + } + } else { + _processBarcode(inputImage); + } } InputImage? _inputImageFromCameraImage(CameraImage image) { @@ -175,7 +214,7 @@ class MainCameraController { ); } - Future _processImage(InputImage inputImage) async { + Future _processBarcode(InputImage inputImage) async { if (_isBusy) return; _isBusy = true; final barcodes = await _barcodeScanner.processImage(inputImage); @@ -255,4 +294,48 @@ class MainCameraController { _isBusy = false; setState(); } + + Future _processFaces(InputImage inputImage) async { + if (_isBusyFaces) return; + _isBusyFaces = true; + final faces = await _faceDetector.processImage(inputImage); + if (inputImage.metadata?.size != null && + inputImage.metadata?.rotation != null && + cameraController != null) { + if (faces.isNotEmpty) { + CustomPainter? painter; + if (_currentFilterType == FaceFilterType.dogBrown) { + painter = DogFilterPainter( + faces, + inputImage.metadata!.size, + inputImage.metadata!.rotation, + cameraController!.description.lensDirection, + ); + } else if (_currentFilterType == FaceFilterType.beardUpperLip) { + painter = BeardFilterPainter( + faces, + inputImage.metadata!.size, + inputImage.metadata!.rotation, + cameraController!.description.lensDirection, + ); + } + + if (painter != null) { + facePaint = CustomPaint(painter: painter); + // Also set the correct FaceFilterPainter reference if needed for other logic, + // though currently facePaint is what's used for display. + if (painter is FaceFilterPainter) { + faceFilterPainter = painter; + } + } else { + facePaint = null; + faceFilterPainter = null; + } + } else { + facePaint = null; + } + } + _isBusyFaces = false; + setState(); + } } diff --git a/lib/src/views/camera/painters/face_filters/beard_filter_painter.dart b/lib/src/views/camera/painters/face_filters/beard_filter_painter.dart new file mode 100644 index 0000000..5451f41 --- /dev/null +++ b/lib/src/views/camera/painters/face_filters/beard_filter_painter.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/views/camera/painters/coordinates_translator.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/face_filter_painter.dart'; + +class BeardFilterPainter extends FaceFilterPainter { + BeardFilterPainter( + super.faces, + super.imageSize, + super.rotation, + super.cameraLensDirection, + ) { + _loadAssets(); + } + + static ui.Image? _beardImage; + static bool _loading = false; + + static Future _loadAssets() async { + if (_loading || _beardImage != null) return; + _loading = true; + try { + _beardImage = await _loadImage('assets/filters/beard_upper_lip.webp'); + } catch (e) { + Log.error('Failed to load filter assets: $e'); + } finally { + _loading = false; + } + } + + static Future _loadImage(String assetPath) async { + final data = await rootBundle.load(assetPath); + final list = Uint8List.view(data.buffer); + final completer = Completer(); + ui.decodeImageFromList(list, completer.complete); + return completer.future; + } + + @override + void paint(Canvas canvas, Size size) { + if (_beardImage == null) return; + + for (final face in faces) { + final noseBase = face.landmarks[FaceLandmarkType.noseBase]; + final mouthLeft = face.landmarks[FaceLandmarkType.leftMouth]; + final mouthRight = face.landmarks[FaceLandmarkType.rightMouth]; + final bottomMouth = face.landmarks[FaceLandmarkType.bottomMouth]; + + if (noseBase != null && + mouthLeft != null && + mouthRight != null && + bottomMouth != null) { + final noseX = translateX( + noseBase.position.x.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + final noseY = translateY( + noseBase.position.y.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + + final mouthLeftX = translateX( + mouthLeft.position.x.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + final mouthLeftY = translateY( + mouthLeft.position.y.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + + final mouthRightX = translateX( + mouthRight.position.x.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + final mouthRightY = translateY( + mouthRight.position.y.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + + final mouthCenterX = (mouthLeftX + mouthRightX) / 2; + final mouthCenterY = (mouthLeftY + mouthRightY) / 2; + + final beardCenterX = (noseX + mouthCenterX) / 2; + final beardCenterY = (noseY + mouthCenterY) / 2; + + final dx = mouthRightX - mouthLeftX; + final dy = mouthRightY - mouthLeftY; + final angle = atan2(dy, dx); + + final mouthWidth = sqrt(dx * dx + dy * dy); + final beardWidth = mouthWidth * 1.5; + + final yaw = face.headEulerAngleY ?? 0; + final scaleX = cos(yaw * pi / 180).abs(); + + _drawImage( + canvas, + _beardImage!, + Offset(beardCenterX, beardCenterY), + beardWidth, + angle, + scaleX, + ); + } + } + } + + void _drawImage( + Canvas canvas, + ui.Image image, + Offset position, + double width, + double rotation, + double scaleX, + ) { + canvas + ..save() + ..translate(position.dx, position.dy) + ..rotate(rotation) + ..scale(scaleX, -1); + + final srcRect = + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); + + final aspectRatio = image.width / image.height; + final dstWidth = width; + final dstHeight = width / aspectRatio; + + final dstRect = Rect.fromCenter( + center: Offset.zero, + width: dstWidth, + height: dstHeight, + ); + + canvas + ..drawImageRect(image, srcRect, dstRect, Paint()) + ..restore(); + } + + static Widget getPreview() { + return Preview( + child: Padding( + padding: const EdgeInsets.all(8), + child: Image.asset( + 'assets/filters/beard_upper_lip.webp', + fit: BoxFit.contain, + ), + ), + ); + } +} diff --git a/lib/src/views/camera/painters/face_filters/dog_filter_painter.dart b/lib/src/views/camera/painters/face_filters/dog_filter_painter.dart new file mode 100644 index 0000000..bab086f --- /dev/null +++ b/lib/src/views/camera/painters/face_filters/dog_filter_painter.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/views/camera/painters/coordinates_translator.dart'; +import 'package:twonly/src/views/camera/painters/face_filters/face_filter_painter.dart'; + +class DogFilterPainter extends FaceFilterPainter { + DogFilterPainter( + super.faces, + super.imageSize, + super.rotation, + super.cameraLensDirection, + ) { + _loadAssets(); + } + + static ui.Image? _earImage; + static ui.Image? _noseImage; + static bool _loading = false; + + static Future _loadAssets() async { + if (_loading || (_earImage != null && _noseImage != null)) return; + _loading = true; + try { + _earImage = await _loadImage('assets/filters/dog_brown_ear.webp'); + _noseImage = await _loadImage('assets/filters/dog_brown_nose.webp'); + } catch (e) { + Log.error('Failed to load filter assets: $e'); + } finally { + _loading = false; + } + } + + static Future _loadImage(String assetPath) async { + final data = await rootBundle.load(assetPath); + final list = Uint8List.view(data.buffer); + final completer = Completer(); + ui.decodeImageFromList(list, completer.complete); + return completer.future; + } + + @override + void paint(Canvas canvas, Size size) { + if (_earImage == null || _noseImage == null) return; + + for (final face in faces) { + final faceContour = face.contours[FaceContourType.face]; + final noseBase = face.landmarks[FaceLandmarkType.noseBase]; + + if (faceContour != null && noseBase != null) { + final points = faceContour.points; + if (points.isEmpty) continue; + + final upperPoints = + points.where((p) => p.y < noseBase.position.y).toList(); + + if (upperPoints.isEmpty) continue; + + Point? leftMost; + Point? rightMost; + Point? topMost; + + for (final point in upperPoints) { + if (leftMost == null || point.x < leftMost.x) { + leftMost = point; + } + if (rightMost == null || point.x > rightMost.x) { + rightMost = point; + } + if (topMost == null || point.y < topMost.y) { + topMost = point; + } + } + + if (leftMost == null || rightMost == null || topMost == null) continue; + + final leftEarX = translateX( + leftMost.x.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + final leftEarY = translateY( + topMost.y.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + + final rightEarX = translateX( + rightMost.x.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + final rightEarY = translateY( + topMost.y.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + + final noseX = translateX( + noseBase.position.x.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + final noseY = translateY( + noseBase.position.y.toDouble(), + size, + imageSize, + rotation, + cameraLensDirection, + ); + + final dx = rightEarX - leftEarX; + final dy = rightEarY - leftEarY; + + final faceWidth = sqrt(dx * dx + dy * dy) * 1.5; + final angle = atan2(dy, dx); + + final yaw = face.headEulerAngleY ?? 0; + final scaleX = cos(yaw * pi / 180).abs(); + + final earSize = faceWidth / 2.5; + + _drawImage( + canvas, + _earImage!, + Offset(leftEarX, leftEarY + earSize * 0.3), + earSize, + angle, + scaleX, + ); + + _drawImage( + canvas, + _earImage!, + Offset(rightEarX, rightEarY + earSize * 0.3), + earSize, + angle, + scaleX, + isFlipped: true, + ); + + final noseSize = faceWidth * 0.4; + _drawImage( + canvas, + _noseImage!, + Offset(noseX, noseY + noseSize * 0.1), + noseSize, + angle, + scaleX, + ); + } + } + } + + void _drawImage( + Canvas canvas, + ui.Image image, + Offset position, + double size, + double rotation, + double scaleX, { + bool isFlipped = false, + }) { + canvas + ..save() + ..translate(position.dx, position.dy) + ..rotate(rotation); + if (isFlipped) { + canvas.scale(-scaleX, -1); + } else { + canvas.scale(scaleX, -1); + } + + final srcRect = + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); + final aspectRatio = image.width / image.height; + final dstWidth = size; + final dstHeight = size / aspectRatio; + + final dstRect = Rect.fromCenter( + center: Offset.zero, + width: dstWidth, + height: dstHeight, + ); + + canvas + ..drawImageRect(image, srcRect, dstRect, Paint()) + ..restore(); + } + + static Widget getPreview() { + return Preview( + child: Stack( + alignment: Alignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 25), + child: Image.asset( + 'assets/filters/dog_brown_nose.webp', + width: 25, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/filters/dog_brown_ear.webp', + width: 20, + ), + const SizedBox(width: 15), + Transform.scale( + scaleX: -1, + child: Image.asset( + 'assets/filters/dog_brown_ear.webp', + width: 20, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/views/camera/painters/face_filters/face_filter_painter.dart b/lib/src/views/camera/painters/face_filters/face_filter_painter.dart new file mode 100644 index 0000000..b32ec7d --- /dev/null +++ b/lib/src/views/camera/painters/face_filters/face_filter_painter.dart @@ -0,0 +1,44 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; + +abstract class FaceFilterPainter extends CustomPainter { + FaceFilterPainter( + this.faces, + this.imageSize, + this.rotation, + this.cameraLensDirection, + ); + + final List faces; + final Size imageSize; + final InputImageRotation rotation; + final CameraLensDirection cameraLensDirection; + + @override + bool shouldRepaint(covariant FaceFilterPainter oldDelegate) { + return oldDelegate.imageSize != imageSize || + oldDelegate.faces != faces || + oldDelegate.rotation != rotation || + oldDelegate.cameraLensDirection != cameraLensDirection; + } +} + +class Preview extends StatelessWidget { + const Preview({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey.withValues(alpha: 0.2), + ), + child: Center( + child: child, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7df1a10..cc26ee5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -868,6 +868,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + google_mlkit_face_detection: + dependency: "direct main" + description: + name: google_mlkit_face_detection + sha256: f336737d5b8a86797fd4368f42a5c26aeaa9c6dcc5243f0a16b5f6f663cfb70a + url: "https://pub.dev" + source: hosted + version: "0.13.1" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ce61f24..a0d05a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.0.85+85 +version: 0.0.86+86 environment: sdk: ^3.6.0 @@ -110,6 +110,7 @@ dependencies: hand_signature: ^3.0.3 flutter_sharing_intent: ^2.0.4 no_screenshot: ^0.3.1 + google_mlkit_face_detection: ^0.13.1 dependency_overrides: dots_indicator: @@ -203,5 +204,6 @@ flutter: - assets/animated_icons/ - assets/animations/ - assets/passwords/ + - assets/filters/ - CHANGELOG.md