From 8ae729f53da3e6c3cc320be5e1281be3ef8105c1 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 14 Mar 2026 15:20:17 +0100 Subject: [PATCH] reintroducing face filters --- assets/filters/beard_upper_lip_green.webp | Bin 0 -> 3990 bytes assets/filters/dog_brown_ear.webp | Bin 0 -> 4536 bytes assets/filters/dog_brown_nose.webp | Bin 0 -> 5904 bytes .../camera_preview.dart | 4 + .../camera_preview_controller_view.dart | 124 +++++++-- .../face_filters.dart | 33 +++ .../main_camera_controller.dart | 89 +++++++ .../face_filters/beard_filter_painter.dart | 191 ++++++++++++++ .../face_filters/dog_filter_painter.dart | 243 ++++++++++++++++++ .../face_filters/face_filter_painter.dart | 44 ++++ pubspec.lock | 8 + pubspec.yaml | 2 + 12 files changed, 720 insertions(+), 18 deletions(-) create mode 100644 assets/filters/beard_upper_lip_green.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/camera_preview_components/painters/face_filters/beard_filter_painter.dart create mode 100644 lib/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart create mode 100644 lib/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart diff --git a/assets/filters/beard_upper_lip_green.webp b/assets/filters/beard_upper_lip_green.webp new file mode 100644 index 0000000000000000000000000000000000000000..4046f6516fbf8f9fcdddde615784149d25588890 GIT binary patch literal 3990 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 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 zcmV+r7w_m&Nk&Ep7XScPMM6+kP&il$0000G0002T0052v06|PpNb3v$01Y7p2?C%9 zBclHiF9tvo2qfpvy+cG~7Lp`MQY6VeGP9s*@c;j#<;Kd4@P%tvitH_70w%!Qwe4vn ziMI7TuOvGtv$(pdCo?nioIjtLc{W2)Rz`%wmfrW7kQfma6Fx=61R(rBXAqQlGX_s3 zA`GG%C<}-(NE9Jb^b{au!4rleloaMEd9RQn!c;_F0>pdW}O)p;8)J23rn85IPA6$r20{Yhk`5k*s`m?z8458%n?H zHnaK8rBa6N(gt63b(1V3)7V(MF}+^~qsg9sn}$Bg(&FuARp#qq zvDijyiwEmz@p{#7#~#$sd9JM{pBmyrMnq;{!2#(!y{WAzzgb#wOzczM2-%bo6Z$+h zc6q9pl?Z?K`M5aW22=XxOtH(w*Ov{{hl&0v2FBzzo&CeZ@@5${$Us9-W(WYFl0(@k zD4{VkqDO4{VinMXcj(T#v-Cv`G=7K(z7`M|5K30-@OAOVuq%jJBzO2yPX z1bsLNOp;7Sv+&(FUxcD$qf9tV3KVuNRx>|;m|LsfYJ4Dw3?xP2!l=6VB{7o$0Z=$( zIha7rXSpx_Zfin7I&wsm4NOAW)rU7O+bIm(Ljj;vFXQaNS$@4RHTQbt1WYm8Dvscr zpBfqs5~92C-$jkv$w$QxKJK%y8LD*5NJ3H!%opFmR%Qr@;EpQ1?b_Ha;@NVhxNrpG zSP@WY;Oe`JSA$7H03dKzrGSKpckIvRd5&pv=@=0u3CUS?VTz0h?zBK3hn+Y3?iUz+ za2#V}%qR=)C=e0cbE(xSO80*LT^B@%M<;~YB*+gFBXI8pNf;-rpUtWjNJoT#jGVdI z;%FlySjq4y$~?`u^Ee)2D9f_5FPdUx46LOfA{I|KQ;pEE0Lf;P%-x(3!I}zAd-oz) z8+arFIVuKMAtP8@My;1mAAf zSct)XvYV!hb?~srB$J)&m!=Y;RRRJ%E?;z?kF&%>5`lyUl)<{U`sviZd)a}9Vjuxo zEW!^oGwT-9z@PZi;Lu?R6vaf`(J!tbS~tjZ|EON5frlU@qgj@GpD6Fk#Lf{u}@H zYMNZwYjKXuWZ(kyWVia_Cq8Kq#61e`B+LpC=?Zkazx?68(GvDoBvcmaL6>q-rcE_7+<%bfrgnn8&pGK9K=GE# zl!pouk9OhbnteHPEx{G<~yJ*!>6yfZ4hTePjG~^Sq<(CpfdUchX4T zfKM0Sj#cD6fV*%TvqqqE=jZwIjk4(;0tC0yxX?LF1s7vZnY=3rP9_IO4!W0A+otWWX1~Z^52@HKm<+pNo_IoO@^}Eptu?O~DG{f%cH6Tq`Ry*!ZfQ8F z1NQY3vs=)kef84PX=Tx#eOq)3CGz|o{j>tQ;cSxPMo49qb{n2nVw?AAH-&COEOQ;o ziG?7aRv)JuA*sBbn|NAT)8@Md-^ESn_04%n)M*tQ>idsVbzMit7AiQo!^fy+!VOg- zoH)IurY@ixP`NbM=>@TrsID8J%Wc95P9)E}xQ^=FYUT+B_^h@Gx+)muqE9%%gWC2` z;A)HMvMY%vn8g%fq^^>@=7LjPBA6sxOoZ@QZdI|6*dmLZKTE8gj3u?-Pm1cmP;Fo@7d0j-BheVX^B^Gbl2fQ&%~^es1DP*0#gE6LBRO4=xNk z#UhPw&gV&2AkK*BB*!VXEZ%TK0%0wk zU}7q2p&PW(>H>ChNwLc$aeZL;}#>4fDB=b`vsnY&+Ab6)HsyH`my0XC#k}%n1{Nq?0-}5xbUW zC2piP{p-iGS<(>^ASB0bVcq+Z)0CT%7)J%ozg?V}=|-`A@YUl49u5C*Zr$W{_sgGc z-~7b;eyAw(Ap{){T7B_ml;K8P;_Q_i$560v8=Secc=2}+zx%WA`O_c1c>KlpeEaI> zF6CpG>qyAb%R%p*H!@3I!`5_oAq!B0kE@x{zW(ug@&5g$iE;^S~5{#z^Glvr^5*o?b7zb6=-vhQ;fczH75O-!isDvp6t0 zHda>|w`8=oIYUZ398qvpxiJ?!(-1C5P)kTnM4qrld$HaME_fPp}Ue&FM2OxQkkLxz>LN;3@d%MG)2cm@`#I6ss6g431 zp%m{Iu#JBGt6&EM_bp=J<6S@)*2uQd)mzNw5|15zMjn% zr7-Q6II5oa)7=25<7XdpfwVV*qfQOky4O9Xk_~(D+<8IcGdl6_cJYnD-3zL6Y*jv6osc{T+54xl&5I9`XW@(XT>wo>^Y%x#Z z9>{#+lX28NP$i}ApO98C@?IhoJ~1zrW?loq0|Jaz=8FYB|CgL+8N5Fxe@A4vL+`Xixux4?dEACkytq!x*>TI2W0e&kn7>yMvFAHmP z;clwdA?z1kcjFX(XYP}g#k6_8*p6{eP+;49mD|F9;9acIZXt#|5SIEbp?U~M1U?S- z?$=SrF#@^+sYPq!Q+w#Cj-mSkA}Z1}rf2mB-c4JlsQ)GkfIOT`wn)U}5{|08t;_Ho zk8A7#fw)z)U6`E>bNjA$YZvZcfTF_0{-a^5V1Nn>!l^(g0ZnvMAPhp6*#*6YaC{}7cDDU^H4Bfa0uoFR z0goZ3mt7a*1cUYj5D^+T#a}E(OlS&8MZv^e$WU_#M>#yUrmmbl9NOq%o)rMuVv6Ah z^E9~>#9k}L>Ea_xPcD*sTkL4E3>Bt{9)Y8+(8N~f@m*_pIEzQkCabvoL5-tB*n5v{ zF~jh%BK6xj)rw$X6(k(+RgkvwN zdJUZsGjDC4YRgE=7+c^VMG;YHOfVuy3Brd_h;X9N9O$gcOXLXQP(=u$%mN^S_;896 z7#WTnWH1cEfeVBPi1@II5CMpQi2v6M09H^qAod9W0B}J7odGJy0FMAZokpEWrKBRE zDO5~ouoH=64Z0#XLo526m|Jr*1ezSFp{@eQie-{4_|NsAIU;(Z7 z^T5jdD0dR<`b4%V7){+_}dUo4d#HC`^~Sw%1v! zt4+N2a(1R+!#Avu{gcb&n^hB~ML>V|^(uOy75sldA)Zx$Hqc&n&E3ELzv>J`s3%|| zIV5{tsn%t%&yydzn8g`L1Yi8XHDU%DeLbYt*_(-)6M4_wv_P_SGCWdOvEDYI>bqo= znl#N4j7R9Gryko3p#4;X&-@$C(tP5U9=A_PjUM69f&X1!0KPad==sB-V$P_7;NAGcxR1aR6BJQUiMw??Buu z8k)Rzhn(!elKW<5%JY{yr}OU}_Q7YeRv2quHJ3xW z7~?pQ&g8Ou4u9u;=rxt?U6K1%K#)%)KRA~2Bl5BEaJJW~+$*hanN#iHM|DWgFq2}U zZDZ1NQ{e6@UY~OKXD49%`CV_VIA<)0D6Z3K46MN^{0092`KmhCf ztIP3w-5iel0hVX0#8mqcMcg|>rM~K{;7MI3vkb)`LIlhD1_0E!{3Q~=IlbG>9}R?u z&LOYhRI&%~aUYo~*Qf87SP{u(`LqVXr7y)1%S+2V%{41|)#!I4%=kf_1>u{VX)Fw! zZPOP?L+XCXn}i;i?oxCo*6y`WT;M3!M>b~?T+#$AoxqGk=wCxL@JsRd>C6>C|3J)R z!Jj%~U-rp}Kv2G#GBRSLLKYQgWzLl<)+ZJPDlTmWPu-n#IHzK$qWCTwwRB7AJp1{6 z8)a#jN_c-OiQUZvn&NS3cE#vC+dl4^_>d_U$nDgXW=d@UhCO8nGUq^fMMSJrBL{%79mtm4IuCdW>UpQ= z9R2PNaST9TS(yn=WK$;X8Tj_U`kIsD+?L^w+;nQKN2P5M|ry0^!F@Dvjdv5}$VU_!^gwb;QpoRuhBi zPQN#ZErJ|lx$#i~FV-S)v{QTS_TH$6a3q9Z?L?Aj+Zz!I^BAW5^sa)x{R|AzOsraaWBL%4uJ3N(@3RseuykmODX_VNymhePLwn@^k z5CaRmQ=_ro(;PK(E+v#W^Kl+PYN4C3j^E0g`E}5oCfQ2E&Gxq}La#ADNtA%4$i!00 zE|HmRRNU*Q!+^JQdxNFjGYE*PF?rmOz1%bpd5Jpya>5BR596x8YSrpOzPt+%j~r?P z808e!f0o!;E1>MwPt*7f)ASBgU@5d42TY@!N#eeM+%3#-ss86%@eP_Ly=lUPF-}^q zc=#dBW8VY#k+UwoP1c%nz_*ZUehcPrP9N5l<#F$|p8Eo9LA*l?(K%Dy5{cL`Op*`k z9n#Mi(2eJ@)0#l3=%VwlUD!DeAP*tTX^5R~Annj~U63Xu0$Hfa`+sBsHy~V+xp;~y z-4_U#!7_IB33bkP0N0Ya|w#b1g zr%T0O@=qO<>{$)gDK&?qI?493G{qCsqXiaDBjRK&Bf^``2NElB>P5J^o$b{1y=}W zOt*v9BW<*d50zZbE;8ZscAG>QrHFfP+oUtPl9`3LHxI7 z@<|boPAFxSV*P)jxDa(deyj2Rt?Jh3YZocCD?8{ZcZU~ZC58(Q*JwiqJPXK$)tvow zZGTJ|AH6o43GU9n;v0QhIqDynqu|gYII5@y8%wc@#t2-X%W=C227ZZw zi54}C_?->M|NBm{ILJ$g0SaY{9J;wc(tv|isMj%EKrA6B+5h`{RpS~wrKIaSj!(C* z@A>ltu&-)UoNSLL>w)8HL!nj>@gPA^e^oZ56J%99a_8JtJ@7pwSWhOTC}dZN0GHPm zm}5VI9xEUyP7l3QRb}JH5uXY;Wr{IYnXQ*+gqw2ao_jH_a%PU-sCf?dxZQUSxN7Gg zHw4MZv5nqGlVP8;U6twGy&PYB1TzeyIY*pGs=rYBDo|#Z{%wlI@C8mD0AU9lAtrQw zien$j{=&6D?=lXaQRb4kei^_NdUe&&*i}UQcS(0eYN{@t1 z29^AGnDJwIM#p#n1d+Nw+toATnOn3z01JWySbI^G=MEpP`{`W2|9>>n;;rW0U1qjG0001L;7k7i literal 0 HcmV?d00001 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 ec2d40e..1f196ea 100644 --- a/lib/src/views/camera/camera_preview_components/camera_preview.dart +++ b/lib/src/views/camera/camera_preview_components/camera_preview.dart @@ -46,6 +46,10 @@ class MainCameraPreview extends StatelessWidget { 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 3c9608c..5e48ce4 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 @@ -22,6 +22,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'; @@ -457,6 +458,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) { @@ -693,27 +724,84 @@ class _CameraPreviewViewState extends State { ), ), const SizedBox(height: 30), - GestureDetector( - onTap: takePicture, - // onLongPress: startVideoRecording, - key: keyTriggerButton, - child: Align( - child: Container( - height: 100, - width: 100, - clipBehavior: Clip.antiAliasWithSaveLayer, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - width: 7, - color: mc.isVideoRecording - ? Colors.red - : Colors.white, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!mc.isVideoRecording) + GestureDetector( + onTap: pressSideButtonLeft, + child: Align( + child: Container( + height: 50, + width: 80, + padding: const EdgeInsets.all(2), + child: Center( + child: FaIcon( + mc.isSelectingFaceFilters + ? mc.currentFilterType.index == 1 + ? FontAwesomeIcons.xmark + : FontAwesomeIcons.arrowLeft + : FontAwesomeIcons.photoFilm, + color: Colors.white, + size: 25, + ), + ), + ), + ), + ), + GestureDetector( + onTap: takePicture, + // onLongPress: startVideoRecording, + key: keyTriggerButton, + child: Align( + child: Container( + height: 100, + width: 100, + clipBehavior: Clip.antiAliasWithSaveLayer, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + width: 7, + color: mc.isVideoRecording + ? Colors.red + : Colors.white, + ), + ), + child: mc.currentFilterType.preview, ), ), ), - ), + if (!mc.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..179f679 --- /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/camera_preview_components/painters/face_filters/beard_filter_painter.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart'; + +enum FaceFilterType { + none, + dogBrown, + beardUpperLipGreen, +} + +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.beardUpperLipGreen: + return BeardFilterPainter.getPreview(this); + } + } +} 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 a6332f9..73d1cb0 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 @@ -7,6 +7,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:permission_handler/permission_handler.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; @@ -18,7 +19,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/camera_preview_components/painters/barcode_detector_painter.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart'; class ScannedVerifiedContact { ScannedVerifiedContact({required this.contact, required this.verificationOk}); @@ -44,6 +49,7 @@ class MainCameraController { GlobalKey zoomButtonKey = GlobalKey(); GlobalKey cameraPreviewKey = GlobalKey(); + bool isSelectingFaceFilters = false; bool isSharePreviewIsShown = false; bool isVideoRecording = false; DateTime? timeSharedLinkWasSetWithQr; @@ -61,10 +67,21 @@ class MainCameraController { } final BarcodeScanner _barcodeScanner = BarcodeScanner(); + final FaceDetector _faceDetector = FaceDetector( + options: FaceDetectorOptions( + enableContours: true, + enableLandmarks: true, + ), + ); bool _isBusy = false; + bool _isBusyFaces = false; CustomPaint? customPaint; + CustomPaint? facePaint; Offset? focusPointOffset; + FaceFilterType _currentFilterType = FaceFilterType.none; + FaceFilterType get currentFilterType => _currentFilterType; + Future closeCamera() async { contactsVerified = {}; scannedNewProfiles = {}; @@ -154,7 +171,10 @@ class MainCameraController { ..cameraLoaded = true ..cameraId = cameraId; + facePaint = null; customPaint = null; + isSelectingFaceFilters = false; + setFilter(FaceFilterType.none); zoomButtonKey = GlobalKey(); setState(); } @@ -191,6 +211,18 @@ class MainCameraController { setState(); } + 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, DeviceOrientation.landscapeLeft: 90, @@ -205,6 +237,15 @@ class MainCameraController { final inputImage = _inputImageFromCameraImage(image); if (inputImage == null) return; _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) { @@ -360,4 +401,52 @@ 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; + switch (_currentFilterType) { + case FaceFilterType.dogBrown: + painter = DogFilterPainter( + faces, + inputImage.metadata!.size, + inputImage.metadata!.rotation, + cameraController!.description.lensDirection, + ); + case FaceFilterType.beardUpperLipGreen: + painter = BeardFilterPainter( + _currentFilterType, + faces, + inputImage.metadata!.size, + inputImage.metadata!.rotation, + cameraController!.description.lensDirection, + ); + case FaceFilterType.none: + break; + } + + 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/camera_preview_components/painters/face_filters/beard_filter_painter.dart b/lib/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart new file mode 100644 index 0000000..635f3a2 --- /dev/null +++ b/lib/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'dart:io'; +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/camera_preview_components/face_filters.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart'; + +class BeardFilterPainter extends FaceFilterPainter { + BeardFilterPainter( + FaceFilterType beardType, + super.faces, + super.imageSize, + super.rotation, + super.cameraLensDirection, + ) { + _loadAssets(beardType); + } + + static FaceFilterType? _lastLoadedBeardType; + static ui.Image? _beardImage; + static bool _loading = false; + + static String getAssetPath(FaceFilterType beardType) { + switch (beardType) { + case FaceFilterType.beardUpperLipGreen: + return 'assets/filters/beard_upper_lip_green.webp'; + case FaceFilterType.dogBrown: + case FaceFilterType.none: + return ''; + } + } + + static Future _loadAssets(FaceFilterType beardType) async { + if ((_loading || _beardImage != null) && + _lastLoadedBeardType == beardType) { + return; + } + _loading = true; + try { + _beardImage = await _loadImage(getAssetPath(beardType)); + } 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, Platform.isAndroid ? -1 : 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(FaceFilterType beardType) { + return Preview( + child: Padding( + padding: const EdgeInsets.all(8), + child: Image.asset( + getAssetPath(beardType), + fit: BoxFit.contain, + ), + ), + ); + } +} diff --git a/lib/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart b/lib/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart new file mode 100644 index 0000000..3643c33 --- /dev/null +++ b/lib/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:io'; +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/camera_preview_components/painters/coordinates_translator.dart'; +import 'package:twonly/src/views/camera/camera_preview_components/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, Platform.isAndroid ? -1 : 1); + } else { + canvas.scale(scaleX, Platform.isAndroid ? -1 : 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/camera_preview_components/painters/face_filters/face_filter_painter.dart b/lib/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart new file mode 100644 index 0000000..b32ec7d --- /dev/null +++ b/lib/src/views/camera/camera_preview_components/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 e0fcc73..e918588 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -868,6 +868,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + google_mlkit_face_detection: + dependency: "direct main" + description: + name: google_mlkit_face_detection + sha256: "7b6ddcc69dbd6fbfa313fb2d974ad0f0c3a0d1657560f0da6be465baf1889687" + url: "https://pub.dev" + source: hosted + version: "0.13.2" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index caaf36c..ea64295 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -105,6 +105,7 @@ dependencies: flutter_volume_controller: ^1.3.4 gal: ^2.3.1 google_mlkit_barcode_scanning: ^0.14.1 + google_mlkit_face_detection: ^0.13.1 pro_video_editor: ^1.6.1 dependency_overrides: @@ -201,6 +202,7 @@ flutter: - assets/icons/ - assets/animated_icons/ - assets/animations/ + - assets/filters/ - assets/passwords/ - CHANGELOG.md