Merge branch 'main' into mobile-panorama-fix
This commit is contained in:
@@ -18,29 +18,33 @@ AppStore](appstore-privacy-disclosure.png){width=620px}
|
||||
## Data Linked to You
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
>
|
||||
> Only if you choose to create an account to backup your codes are the following
|
||||
> details collected.
|
||||
|
||||
### Contact Info
|
||||
|
||||
This is your email address, used for account creation and communication.
|
||||
|
||||
### User Content
|
||||
|
||||
This are your 2FA secrets, end-to-end encrypted with a key that only you have
|
||||
access to.
|
||||
|
||||
### Identifiers
|
||||
|
||||
This is your user ID generated by our server during sign up.
|
||||
|
||||
## Data Not Linked to You
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
>
|
||||
> Only if you opt-in to **Crash reporting** are the following details collected.
|
||||
|
||||
### Diagnostics
|
||||
These are anonymized error reports and other diagnostics data that make it easier
|
||||
for us to detect and fix any issues.
|
||||
|
||||
These are anonymized error reports and other diagnostics data that make it
|
||||
easier for us to detect and fix any issues.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,5 +52,5 @@ for us to detect and fix any issues.
|
||||
|
||||
Ente Auth collects no data by default.
|
||||
|
||||
For more details, please refer to our [full privacy
|
||||
policy](https://ente.io/privacy).
|
||||
For more details, please refer to our
|
||||
[full privacy policy](https://ente.io/privacy).
|
||||
|
||||
@@ -101,7 +101,7 @@ to Ente Authenticator!
|
||||
> the codes yet, ignore this section.
|
||||
>
|
||||
> If the export itself failed, try using
|
||||
> [**method 1**](#method-1-use-neerajs-export-tool) instead.
|
||||
> [**method 1**](#method-1-use-neeraj-s-export-tool) instead.
|
||||
|
||||
Usually, you should be able to import Bitwarden exports directly into Ente
|
||||
Authenticator. In case this didn't work for whatever reason, I've written a
|
||||
@@ -170,7 +170,7 @@ depending on which method you used to export your codes.
|
||||
5. Select the JSON file that was made earlier.
|
||||
|
||||
If this didn't work, refer to
|
||||
[**method 2.1**](#method-21-if-the-export-worked-but-the-import-didnt).<br><br>
|
||||
[**method 2.1**](#method-2-1-if-the-export-worked-but-the-import-didn-t).<br><br>
|
||||
|
||||
And that's it! You have now successfully migrated from Authy to Ente
|
||||
Authenticator.
|
||||
|
||||
@@ -59,4 +59,5 @@ If you run into any issues during this migration, please reach out to
|
||||
> JSON files and stich them together with corresponding files. However, one case
|
||||
> this will not work is when Google has split the export into multiple parts,
|
||||
> and did not put the JSON file associated with an image in the same exported
|
||||
> zip.
|
||||
> zip. So the best move is to unzip all of the items into a single folder, and
|
||||
> to drop that folder into our desktop app.
|
||||
|
||||
@@ -23,10 +23,12 @@ canvas.
|
||||
## Desktop
|
||||
|
||||
The only known case where thumbnails might be missing on desktop is when
|
||||
uploading **videos** during a Google Takeout or folder sync on **Intel macOS**
|
||||
machines. This is because the bundled ffmpeg that we use does not work with
|
||||
Rosetta. For images, we are able to fallback to other mechanisms for generating
|
||||
the thumbnails, but for videos because of their potentially huge size, the app
|
||||
uploading **videos** during a Google Takeout or watched folder sync on **Intel
|
||||
macOS** machines. This is because the bundled ffmpeg that we use does not work
|
||||
on Intel machines.
|
||||
|
||||
For images, we are able to fallback to other mechanisms for generating the
|
||||
thumbnails, but for videos because of their potentially huge size, the app
|
||||
doesn't try the fallback to avoid running out of memory.
|
||||
|
||||
In such cases, you will need to use the following workaround:
|
||||
@@ -39,10 +41,11 @@ In such cases, you will need to use the following workaround:
|
||||
2. Copy or symlink it to
|
||||
`/Applications/ente.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg`.
|
||||
|
||||
Even without the workaround, thumbnail generation during video uploads via the
|
||||
normal folder selection or drag and drop will work fine (since in this case we
|
||||
have access to the video's data directly without reading it from a zip and can
|
||||
thus use the fallback).
|
||||
Alternatively, you can drag and drop the videos. Even without the above
|
||||
workaround, thumbnail generation during video uploads via the normal folder
|
||||
selection or drag and drop will work fine, since in those case we have access to
|
||||
the video's data directly without reading it from a zip and can thus use the
|
||||
fallback.
|
||||
|
||||
## Regenerating thumbnails
|
||||
|
||||
@@ -50,6 +53,5 @@ There is currently no functionality to regenerate thumbnails in the above cases.
|
||||
You will need to upload the affected files again.
|
||||
|
||||
Ente skips over files that have already been uploaded, so you can drag and drop
|
||||
the original folder or zip again after removing the files without thumbnails
|
||||
(and fixing the issue on web or adding the workaround on Intel macOS), and it'll
|
||||
only upload the files that are necessary.
|
||||
the original folder or zip again after removing the files without thumbnails,
|
||||
and it'll only upload the files that are necessary.
|
||||
|
||||
@@ -45,8 +45,10 @@ By default, you only need to configure the endpoint for the first bucket.
|
||||
> instance uses these to perform replication.
|
||||
>
|
||||
> However, in a self hosted setup replication is off by default (you can turn it
|
||||
> on if you want). When replication is turned off, only the first bucket is
|
||||
> used, and you can remove the other two if you wish or just ignore them.
|
||||
> on if you want). When replication is turned off, only the first bucket (it
|
||||
> must be named `b2-eu-cen`) is used, and you can ignore the other two. Use the
|
||||
> `hot_bucket` option if you'd like to set one of the other predefined buckets
|
||||
> as the "first" bucket.
|
||||
|
||||
The `endpoint` for the first bucket in the starter `credentials.yaml` is
|
||||
`localhost:3200`. The way this works then is that both museum (`2`) and minio
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class FileSwipeLockEvent extends Event {
|
||||
final bool shouldSwipeLock;
|
||||
|
||||
FileSwipeLockEvent(this.shouldSwipeLock);
|
||||
}
|
||||
7
mobile/lib/events/guest_view_event.dart
Normal file
7
mobile/lib/events/guest_view_event.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class GuestViewEvent extends Event {
|
||||
final bool isGuestView;
|
||||
final bool swipeLocked;
|
||||
GuestViewEvent(this.isGuestView, this.swipeLocked);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "package:logging/logging.dart";
|
||||
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/events/people_changed_event.dart";
|
||||
import "package:photos/face/model/person.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
@@ -32,9 +33,9 @@ import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import "package:photos/ui/components/bottom_action_bar/selection_action_button_widget.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
// import 'package:photos/ui/sharing/manage_links_widget.dart';
|
||||
import "package:photos/ui/sharing/show_images_prevew.dart";
|
||||
import "package:photos/ui/tools/collage/collage_creator_page.dart";
|
||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||
import "package:photos/ui/viewer/location/update_location_data_widget.dart";
|
||||
import 'package:photos/utils/delete_file_util.dart';
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
@@ -271,7 +272,13 @@ class _FileSelectionActionsWidgetState
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
SelectionActionButton(
|
||||
icon: Icons.lock,
|
||||
labelText: "Guest view",
|
||||
onTap: _onGuestViewClick,
|
||||
),
|
||||
);
|
||||
items.add(
|
||||
SelectionActionButton(
|
||||
icon: Icons.grid_view_outlined,
|
||||
@@ -559,6 +566,23 @@ class _FileSelectionActionsWidgetState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onGuestViewClick() async {
|
||||
final List<EnteFile> selectedFiles = widget.selectedFiles.files.toList();
|
||||
final page = DetailPage(
|
||||
DetailPageConfiguration(
|
||||
selectedFiles,
|
||||
null,
|
||||
0,
|
||||
"guest_view",
|
||||
),
|
||||
);
|
||||
routeToPage(context, page, forceCustomPageRoute: true).ignore();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Bus.instance.fire(GuestViewEvent(true, false));
|
||||
});
|
||||
widget.selectedFiles.clearAll();
|
||||
}
|
||||
|
||||
Future<void> _onArchiveClick() async {
|
||||
await changeVisibility(
|
||||
context,
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/errors.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/file_swipe_lock_event.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
@@ -89,9 +89,9 @@ class _DetailPageState extends State<DetailPage> {
|
||||
bool _hasLoadedTillEnd = false;
|
||||
final _enableFullScreenNotifier = ValueNotifier(false);
|
||||
bool _isFirstOpened = true;
|
||||
bool isFileSwipeLocked = false;
|
||||
late final StreamSubscription<FileSwipeLockEvent>
|
||||
_fileSwipeLockEventSubscription;
|
||||
bool isGuestView = false;
|
||||
bool swipeLocked = false;
|
||||
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -102,17 +102,18 @@ class _DetailPageState extends State<DetailPage> {
|
||||
_selectedIndexNotifier.value = widget.config.selectedIndex;
|
||||
_preloadEntries();
|
||||
_pageController = PageController(initialPage: _selectedIndexNotifier.value);
|
||||
_fileSwipeLockEventSubscription =
|
||||
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
|
||||
_guestViewEventSubscription =
|
||||
Bus.instance.on<GuestViewEvent>().listen((event) {
|
||||
setState(() {
|
||||
isFileSwipeLocked = event.shouldSwipeLock;
|
||||
isGuestView = event.isGuestView;
|
||||
swipeLocked = event.swipeLocked;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fileSwipeLockEventSubscription.cancel();
|
||||
_guestViewEventSubscription.cancel();
|
||||
_pageController.dispose();
|
||||
_enableFullScreenNotifier.dispose();
|
||||
_selectedIndexNotifier.dispose();
|
||||
@@ -141,12 +142,12 @@ class _DetailPageState extends State<DetailPage> {
|
||||
" files .",
|
||||
);
|
||||
return PopScope(
|
||||
canPop: !isFileSwipeLocked,
|
||||
canPop: !isGuestView,
|
||||
onPopInvoked: (didPop) async {
|
||||
if (isFileSwipeLocked) {
|
||||
if (isGuestView) {
|
||||
final authenticated = await _requestAuthentication();
|
||||
if (authenticated) {
|
||||
Bus.instance.fire(FileSwipeLockEvent(false));
|
||||
Bus.instance.fire(GuestViewEvent(false, false));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -179,7 +180,7 @@ class _DetailPageState extends State<DetailPage> {
|
||||
_files![selectedIndex],
|
||||
_onEditFileRequested,
|
||||
widget.config.mode == DetailPageMode.minimalistic &&
|
||||
!isFileSwipeLocked,
|
||||
!isGuestView,
|
||||
onFileRemoved: _onFileRemoved,
|
||||
userID: Configuration.instance.getUserID(),
|
||||
enableFullScreenNotifier: _enableFullScreenNotifier,
|
||||
@@ -298,9 +299,10 @@ class _DetailPageState extends State<DetailPage> {
|
||||
} else {
|
||||
_selectedIndexNotifier.value = index;
|
||||
}
|
||||
Bus.instance.fire(GuestViewEvent(isGuestView, swipeLocked));
|
||||
_preloadEntries();
|
||||
},
|
||||
physics: _shouldDisableScroll || isFileSwipeLocked
|
||||
physics: _shouldDisableScroll || swipeLocked
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const FastScrollPhysics(speedFactor: 4.0),
|
||||
controller: _pageController,
|
||||
|
||||
@@ -6,7 +6,7 @@ import "package:local_auth/local_auth.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:media_extension/media_extension.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/file_swipe_lock_event.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
@@ -51,9 +51,8 @@ class FileAppBar extends StatefulWidget {
|
||||
class FileAppBarState extends State<FileAppBar> {
|
||||
final _logger = Logger("FadingAppBar");
|
||||
final List<Widget> _actions = [];
|
||||
late final StreamSubscription<FileSwipeLockEvent>
|
||||
_fileSwipeLockEventSubscription;
|
||||
bool _isFileSwipeLocked = false;
|
||||
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
|
||||
bool isGuestView = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FileAppBar oldWidget) {
|
||||
@@ -66,17 +65,17 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fileSwipeLockEventSubscription =
|
||||
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
|
||||
_guestViewEventSubscription =
|
||||
Bus.instance.on<GuestViewEvent>().listen((event) {
|
||||
setState(() {
|
||||
_isFileSwipeLocked = event.shouldSwipeLock;
|
||||
isGuestView = event.isGuestView;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fileSwipeLockEventSubscription.cancel();
|
||||
_guestViewEventSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -124,19 +123,19 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
child: AppBar(
|
||||
clipBehavior: Clip.none,
|
||||
key: ValueKey(_isFileSwipeLocked),
|
||||
key: ValueKey(isGuestView),
|
||||
iconTheme: const IconThemeData(
|
||||
color: Colors.white,
|
||||
), //same for both themes
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
_isFileSwipeLocked
|
||||
isGuestView
|
||||
? _requestAuthentication()
|
||||
: Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
actions: shouldShowActions && !_isFileSwipeLocked ? _actions : [],
|
||||
actions: shouldShowActions && !isGuestView ? _actions : [],
|
||||
elevation: 0,
|
||||
backgroundColor: const Color(0x00000000),
|
||||
),
|
||||
@@ -306,7 +305,7 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
const Text("Swipe lock"),
|
||||
const Text("Guest view"),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -329,7 +328,7 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
} else if (value == 5) {
|
||||
await _handleUnHideRequest(context);
|
||||
} else if (value == 6) {
|
||||
await _onSwipeLock();
|
||||
await _onTapGuestView();
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -413,9 +412,9 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSwipeLock() async {
|
||||
Future<void> _onTapGuestView() async {
|
||||
if (await LocalAuthentication().isDeviceSupported()) {
|
||||
Bus.instance.fire(FileSwipeLockEvent(!_isFileSwipeLocked));
|
||||
Bus.instance.fire(GuestViewEvent(true, true));
|
||||
} else {
|
||||
await showErrorDialog(
|
||||
context,
|
||||
@@ -432,7 +431,7 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
"Please authenticate to view more photos and videos.",
|
||||
);
|
||||
if (hasAuthenticated) {
|
||||
Bus.instance.fire(FileSwipeLockEvent(false));
|
||||
Bus.instance.fire(GuestViewEvent(false, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/file_swipe_lock_event.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
@@ -45,25 +45,24 @@ class FileBottomBar extends StatefulWidget {
|
||||
|
||||
class FileBottomBarState extends State<FileBottomBar> {
|
||||
final GlobalKey shareButtonKey = GlobalKey();
|
||||
bool _isFileSwipeLocked = false;
|
||||
late final StreamSubscription<FileSwipeLockEvent>
|
||||
_fileSwipeLockEventSubscription;
|
||||
bool isGuestView = false;
|
||||
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
|
||||
int? lastFileGenID;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fileSwipeLockEventSubscription =
|
||||
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
|
||||
_guestViewEventSubscription =
|
||||
Bus.instance.on<GuestViewEvent>().listen((event) {
|
||||
setState(() {
|
||||
_isFileSwipeLocked = event.shouldSwipeLock;
|
||||
isGuestView = event.isGuestView;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fileSwipeLockEventSubscription.cancel();
|
||||
_guestViewEventSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -188,9 +187,9 @@ class FileBottomBarState extends State<FileBottomBar> {
|
||||
valueListenable: widget.enableFullScreenNotifier,
|
||||
builder: (BuildContext context, bool isFullScreen, _) {
|
||||
return IgnorePointer(
|
||||
ignoring: isFullScreen || _isFileSwipeLocked,
|
||||
ignoring: isFullScreen || isGuestView,
|
||||
child: AnimatedOpacity(
|
||||
opacity: isFullScreen || _isFileSwipeLocked ? 0 : 1,
|
||||
opacity: isFullScreen || isGuestView ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
child: Align(
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/file_swipe_lock_event.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
@@ -48,9 +48,8 @@ class _VideoWidgetState extends State<VideoWidget> {
|
||||
final _progressNotifier = ValueNotifier<double?>(null);
|
||||
bool _isPlaying = false;
|
||||
final EnteWakeLock _wakeLock = EnteWakeLock();
|
||||
bool _isFileSwipeLocked = false;
|
||||
late final StreamSubscription<FileSwipeLockEvent>
|
||||
_fileSwipeLockEventSubscription;
|
||||
bool isGuestView = false;
|
||||
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -80,10 +79,10 @@ class _VideoWidgetState extends State<VideoWidget> {
|
||||
}
|
||||
});
|
||||
}
|
||||
_fileSwipeLockEventSubscription =
|
||||
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
|
||||
_guestViewEventSubscription =
|
||||
Bus.instance.on<GuestViewEvent>().listen((event) {
|
||||
setState(() {
|
||||
_isFileSwipeLocked = event.shouldSwipeLock;
|
||||
isGuestView = event.isGuestView;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -132,7 +131,7 @@ class _VideoWidgetState extends State<VideoWidget> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fileSwipeLockEventSubscription.cancel();
|
||||
_guestViewEventSubscription.cancel();
|
||||
removeCallBack(widget.file);
|
||||
_videoPlayerController?.dispose();
|
||||
_chewieController?.dispose();
|
||||
@@ -188,7 +187,7 @@ class _VideoWidgetState extends State<VideoWidget> {
|
||||
? _getVideoPlayer()
|
||||
: _getLoadingWidget();
|
||||
final contentWithDetector = GestureDetector(
|
||||
onVerticalDragUpdate: _isFileSwipeLocked
|
||||
onVerticalDragUpdate: isGuestView
|
||||
? null
|
||||
: (d) => {
|
||||
if (d.delta.dy > dragSensitivity)
|
||||
|
||||
@@ -8,7 +8,7 @@ import "package:media_kit/media_kit.dart";
|
||||
import "package:media_kit_video/media_kit_video.dart";
|
||||
import "package:photos/core/constants.dart";
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/file_swipe_lock_event.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/events/pause_video_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
@@ -47,9 +47,8 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
|
||||
late StreamSubscription<bool> playingStreamSubscription;
|
||||
bool _isAppInFG = true;
|
||||
late StreamSubscription<PauseVideoEvent> pauseVideoSubscription;
|
||||
bool _isFileSwipeLocked = false;
|
||||
late final StreamSubscription<FileSwipeLockEvent>
|
||||
_fileSwipeLockEventSubscription;
|
||||
bool isGuestView = false;
|
||||
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -94,10 +93,10 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
|
||||
pauseVideoSubscription = Bus.instance.on<PauseVideoEvent>().listen((event) {
|
||||
player.pause();
|
||||
});
|
||||
_fileSwipeLockEventSubscription =
|
||||
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
|
||||
_guestViewEventSubscription =
|
||||
Bus.instance.on<GuestViewEvent>().listen((event) {
|
||||
setState(() {
|
||||
_isFileSwipeLocked = event.shouldSwipeLock;
|
||||
isGuestView = event.isGuestView;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -113,7 +112,7 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fileSwipeLockEventSubscription.cancel();
|
||||
_guestViewEventSubscription.cancel();
|
||||
pauseVideoSubscription.cancel();
|
||||
removeCallBack(widget.file);
|
||||
_progressNotifier.dispose();
|
||||
@@ -159,7 +158,7 @@ class _VideoWidgetNewState extends State<VideoWidgetNew>
|
||||
),
|
||||
fullscreen: const MaterialVideoControlsThemeData(),
|
||||
child: GestureDetector(
|
||||
onVerticalDragUpdate: _isFileSwipeLocked
|
||||
onVerticalDragUpdate: isGuestView
|
||||
? null
|
||||
: (d) => {
|
||||
if (d.delta.dy > dragSensitivity)
|
||||
|
||||
@@ -9,8 +9,7 @@ import 'package:photos/core/cache/thumbnail_in_memory_cache.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import "package:photos/events/file_swipe_lock_event.dart";
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import "package:photos/events/files_updated_event.dart";
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
@@ -26,6 +25,7 @@ class ZoomableImage extends StatefulWidget {
|
||||
final String? tagPrefix;
|
||||
final Decoration? backgroundDecoration;
|
||||
final bool shouldCover;
|
||||
final bool isGuestView;
|
||||
|
||||
const ZoomableImage(
|
||||
this.photo, {
|
||||
@@ -34,6 +34,7 @@ class ZoomableImage extends StatefulWidget {
|
||||
required this.tagPrefix,
|
||||
this.backgroundDecoration,
|
||||
this.shouldCover = false,
|
||||
this.isGuestView = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -54,9 +55,6 @@ class _ZoomableImageState extends State<ZoomableImage> {
|
||||
bool _isZooming = false;
|
||||
PhotoViewController _photoViewController = PhotoViewController();
|
||||
final _scaleStateController = PhotoViewScaleStateController();
|
||||
bool _isFileSwipeLocked = false;
|
||||
late final StreamSubscription<FileSwipeLockEvent>
|
||||
_fileSwipeLockEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -72,17 +70,10 @@ class _ZoomableImageState extends State<ZoomableImage> {
|
||||
debugPrint("isZooming = $_isZooming, currentState $value");
|
||||
// _logger.info('is reakky zooming $_isZooming with state $value');
|
||||
};
|
||||
_fileSwipeLockEventSubscription =
|
||||
Bus.instance.on<FileSwipeLockEvent>().listen((event) {
|
||||
setState(() {
|
||||
_isFileSwipeLocked = event.shouldSwipeLock;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fileSwipeLockEventSubscription.cancel();
|
||||
_photoViewController.dispose();
|
||||
_scaleStateController.dispose();
|
||||
super.dispose();
|
||||
@@ -159,7 +150,7 @@ class _ZoomableImageState extends State<ZoomableImage> {
|
||||
}
|
||||
|
||||
final GestureDragUpdateCallback? verticalDragCallback =
|
||||
_isZooming || _isFileSwipeLocked
|
||||
_isZooming || widget.isGuestView
|
||||
? null
|
||||
: (d) => {
|
||||
if (!_isZooming)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dart:async";
|
||||
import "dart:io";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -5,6 +6,8 @@ import 'package:logging/logging.dart';
|
||||
import "package:media_kit/media_kit.dart";
|
||||
import "package:media_kit_video/media_kit_video.dart";
|
||||
import 'package:motion_photos/motion_photos.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
@@ -23,11 +26,11 @@ class ZoomableLiveImageNew extends StatefulWidget {
|
||||
|
||||
const ZoomableLiveImageNew(
|
||||
this.enteFile, {
|
||||
Key? key,
|
||||
super.key,
|
||||
this.shouldDisableScroll,
|
||||
required this.tagPrefix,
|
||||
this.backgroundDecoration,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ZoomableLiveImageNew> createState() => _ZoomableLiveImageNewState();
|
||||
@@ -43,6 +46,9 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
|
||||
late final _player = Player();
|
||||
VideoController? _videoController;
|
||||
|
||||
bool isGuestView = false;
|
||||
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_enteFile = widget.enteFile;
|
||||
@@ -52,6 +58,12 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
|
||||
if (_enteFile.isLivePhoto && _enteFile.isUploaded) {
|
||||
LocalFileUpdateService.instance.checkLivePhoto(_enteFile).ignore();
|
||||
}
|
||||
_guestViewEventSubscription =
|
||||
Bus.instance.on<GuestViewEvent>().listen((event) {
|
||||
setState(() {
|
||||
isGuestView = event.isGuestView;
|
||||
});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -83,6 +95,7 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
|
||||
tagPrefix: widget.tagPrefix,
|
||||
shouldDisableScroll: widget.shouldDisableScroll,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
isGuestView: isGuestView,
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
@@ -98,6 +111,7 @@ class _ZoomableLiveImageNewState extends State<ZoomableLiveImageNew>
|
||||
_videoController!.player.stop();
|
||||
_videoController!.player.dispose();
|
||||
}
|
||||
_guestViewEventSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
X-Download-Options: noopen
|
||||
X-Frame-Options: deny
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Referrer-Policy: same-origin
|
||||
Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-eval' blob:; manifest-src 'self'; child-src 'self' blob:; object-src 'none'; connect-src 'self' https://*.ente.io data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ ; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; report-uri https://csp-reporter.ente.io; report-to https://csp-reporter.ente.io;
|
||||
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
X-Download-Options: noopen
|
||||
X-Frame-Options: deny
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Referrer-Policy: same-origin
|
||||
Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' blob: data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'unsafe-eval' blob:; manifest-src 'self'; child-src 'self' blob:; object-src 'none'; connect-src 'self' https://*.ente.io data: blob: https://ente-prod-eu.s3.eu-central-003.backblazeb2.com https://ente-prod-v3.s3.eu-central-2.wasabisys.com/ ; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; report-uri https://csp-reporter.ente.io; report-to https://csp-reporter.ente.io;
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
cancelSubscription,
|
||||
getLocalUserSubscription,
|
||||
hasAddOnBonus,
|
||||
hasMobileSubscription,
|
||||
hasPaidSubscription,
|
||||
hasStripeSubscription,
|
||||
isOnFreePlan,
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
isUserSubscribedPlan,
|
||||
manageFamilyMethod,
|
||||
planForSubscription,
|
||||
planSelectionOutcome,
|
||||
updatePaymentMethod,
|
||||
updateSubscription,
|
||||
} from "utils/billing";
|
||||
@@ -177,58 +177,63 @@ function PlanSelectorCard(props: PlanSelectorCardProps) {
|
||||
}, []);
|
||||
|
||||
async function onPlanSelect(plan: Plan) {
|
||||
if (
|
||||
!hasPaidSubscription(subscription) &&
|
||||
!isSubscriptionCancelled(subscription)
|
||||
) {
|
||||
try {
|
||||
props.setLoading(true);
|
||||
await billingService.buySubscription(plan.stripeID);
|
||||
} catch (e) {
|
||||
props.setLoading(false);
|
||||
switch (planSelectionOutcome(subscription)) {
|
||||
case "buyPlan":
|
||||
try {
|
||||
props.setLoading(true);
|
||||
await billingService.buySubscription(plan.stripeID);
|
||||
} catch (e) {
|
||||
props.setLoading(false);
|
||||
appContext.setDialogMessage({
|
||||
title: t("ERROR"),
|
||||
content: t("SUBSCRIPTION_PURCHASE_FAILED"),
|
||||
close: { variant: "critical" },
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "updateSubscriptionToPlan":
|
||||
appContext.setDialogMessage({
|
||||
title: t("ERROR"),
|
||||
content: t("SUBSCRIPTION_PURCHASE_FAILED"),
|
||||
close: { variant: "critical" },
|
||||
title: t("update_subscription_title"),
|
||||
content: t("UPDATE_SUBSCRIPTION_MESSAGE"),
|
||||
proceed: {
|
||||
text: t("UPDATE_SUBSCRIPTION"),
|
||||
action: updateSubscription.bind(
|
||||
null,
|
||||
plan,
|
||||
appContext.setDialogMessage,
|
||||
props.setLoading,
|
||||
props.closeModal,
|
||||
),
|
||||
variant: "accent",
|
||||
},
|
||||
close: { text: t("cancel") },
|
||||
});
|
||||
}
|
||||
} else if (hasStripeSubscription(subscription)) {
|
||||
appContext.setDialogMessage({
|
||||
title: t("update_subscription_title"),
|
||||
content: t("UPDATE_SUBSCRIPTION_MESSAGE"),
|
||||
proceed: {
|
||||
text: t("UPDATE_SUBSCRIPTION"),
|
||||
action: updateSubscription.bind(
|
||||
null,
|
||||
plan,
|
||||
appContext.setDialogMessage,
|
||||
props.setLoading,
|
||||
props.closeModal,
|
||||
break;
|
||||
|
||||
case "cancelOnMobile":
|
||||
appContext.setDialogMessage({
|
||||
title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"),
|
||||
content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"),
|
||||
close: { variant: "secondary" },
|
||||
});
|
||||
break;
|
||||
|
||||
case "contactSupport":
|
||||
appContext.setDialogMessage({
|
||||
title: t("MANAGE_PLAN"),
|
||||
content: (
|
||||
<Trans
|
||||
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
|
||||
components={{
|
||||
a: <Link href="mailto:support@ente.io" />,
|
||||
}}
|
||||
values={{ emailID: "support@ente.io" }}
|
||||
/>
|
||||
),
|
||||
variant: "accent",
|
||||
},
|
||||
close: { text: t("cancel") },
|
||||
});
|
||||
} else if (hasMobileSubscription(subscription)) {
|
||||
appContext.setDialogMessage({
|
||||
title: t("CANCEL_SUBSCRIPTION_ON_MOBILE"),
|
||||
content: t("CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE"),
|
||||
close: { variant: "secondary" },
|
||||
});
|
||||
} else {
|
||||
appContext.setDialogMessage({
|
||||
title: t("MANAGE_PLAN"),
|
||||
content: (
|
||||
<Trans
|
||||
i18nKey={"MAIL_TO_MANAGE_SUBSCRIPTION"}
|
||||
components={{
|
||||
a: <Link href="mailto:support@ente.io" />,
|
||||
}}
|
||||
values={{ emailID: "support@ente.io" }}
|
||||
/>
|
||||
),
|
||||
close: { variant: "secondary" },
|
||||
});
|
||||
close: { variant: "secondary" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ import { getSubscriptionPurchaseSuccessMessage } from "utils/ui";
|
||||
import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family";
|
||||
|
||||
const PAYMENT_PROVIDER_STRIPE = "stripe";
|
||||
const PAYMENT_PROVIDER_APPSTORE = "appstore";
|
||||
const PAYMENT_PROVIDER_PLAYSTORE = "playstore";
|
||||
const FREE_PLAN = "free";
|
||||
const THIRTY_DAYS_IN_MICROSECONDS = 30 * 24 * 60 * 60 * 1000 * 1000;
|
||||
|
||||
@@ -31,6 +29,51 @@ enum RESPONSE_STATUS {
|
||||
fail = "fail",
|
||||
}
|
||||
|
||||
export type PlanSelectionOutcome =
|
||||
| "buyPlan"
|
||||
| "updateSubscriptionToPlan"
|
||||
| "cancelOnMobile"
|
||||
| "contactSupport";
|
||||
|
||||
/**
|
||||
* Return the outcome that should happen when the user selects a paid plan on
|
||||
* the plan selection screen.
|
||||
*
|
||||
* @param subscription Their current subscription details.
|
||||
*/
|
||||
export const planSelectionOutcome = (
|
||||
subscription: Subscription | undefined,
|
||||
) => {
|
||||
// This shouldn't happen, but we need this case to handle missing types.
|
||||
if (!subscription) return "buyPlan";
|
||||
|
||||
// The user is a on a free plan and can buy the plan they selected.
|
||||
if (subscription.productID == "free") return "buyPlan";
|
||||
|
||||
// Their existing subscription has expired. They can buy a new plan.
|
||||
if (subscription.expiryTime < Date.now() * 1000) return "buyPlan";
|
||||
|
||||
// -- The user already has an active subscription to a paid plan.
|
||||
|
||||
// Using stripe
|
||||
if (subscription.paymentProvider == "stripe") {
|
||||
// Update their existing subscription to the new plan.
|
||||
return "updateSubscriptionToPlan";
|
||||
}
|
||||
|
||||
// Using one of the mobile app stores
|
||||
if (
|
||||
subscription.paymentProvider == "appstore" ||
|
||||
subscription.paymentProvider == "playstore"
|
||||
) {
|
||||
// They need to cancel first on the mobile app stores.
|
||||
return "cancelOnMobile";
|
||||
}
|
||||
|
||||
// Some other bespoke case. They should contact support.
|
||||
return "contactSupport";
|
||||
};
|
||||
|
||||
export function hasPaidSubscription(subscription: Subscription) {
|
||||
return (
|
||||
subscription &&
|
||||
@@ -92,15 +135,6 @@ export function hasStripeSubscription(subscription: Subscription) {
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMobileSubscription(subscription: Subscription) {
|
||||
return (
|
||||
hasPaidSubscription(subscription) &&
|
||||
subscription.paymentProvider.length > 0 &&
|
||||
(subscription.paymentProvider === PAYMENT_PROVIDER_APPSTORE ||
|
||||
subscription.paymentProvider === PAYMENT_PROVIDER_PLAYSTORE)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasExceededStorageQuota(userDetails: UserDetails) {
|
||||
const bonusStorage = userDetails.storageBonus ?? 0;
|
||||
if (isPartOfFamily(userDetails.familyData)) {
|
||||
|
||||
@@ -193,6 +193,9 @@ For more details, see [translations.md](translations.md).
|
||||
- [zod](https://github.com/colinhacks/zod) is used for runtime typechecking
|
||||
(e.g. verifying that API responses match the expected TypeScript shape).
|
||||
|
||||
- [nanoid](https://github.com/ai/nanoid) is used for generating unique
|
||||
identifiers.
|
||||
|
||||
- [debounce](https://github.com/sindresorhus/debounce) and its
|
||||
promise-supporting sibling
|
||||
[pDebounce](https://github.com/sindresorhus/p-debounce) are used for
|
||||
|
||||
@@ -24,6 +24,7 @@ export const CustomHead: React.FC<CustomHeadProps> = ({ title }) => {
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
17
web/packages/base/id-worker.ts
Normal file
17
web/packages/base/id-worker.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { customAlphabet } from "nanoid/non-secure";
|
||||
import { alphabet } from "./id";
|
||||
|
||||
const nanoid = customAlphabet(alphabet, 22);
|
||||
|
||||
/**
|
||||
* This is a variant of the regular {@link newID} that can be used in web
|
||||
* workers.
|
||||
*
|
||||
* Web workers don't have access to a secure random generator, so we need to use
|
||||
* the non-secure variant.
|
||||
* https://github.com/ai/nanoid?tab=readme-ov-file#web-workers
|
||||
*
|
||||
* For many of our use cases, where we're not using these IDs for cryptographic
|
||||
* operations, this is okay. We also have an increased alphabet length.
|
||||
*/
|
||||
export const newNonSecureID = (prefix: string) => prefix + nanoid();
|
||||
24
web/packages/base/id.ts
Normal file
24
web/packages/base/id.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
/**
|
||||
* Remove _ and - from the default set to have better looking IDs that can also
|
||||
* be selected in the editor quickly ("-" prevents this), and which we can
|
||||
* prefix unambigously ("_" is used for that).
|
||||
*
|
||||
* To compensate, increase length from the default of 21 to 22.
|
||||
*
|
||||
* To play around with these, use https://zelark.github.io/nano-id-cc/
|
||||
*/
|
||||
export const alphabet =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
const nanoid = customAlphabet(alphabet, 22);
|
||||
|
||||
/**
|
||||
* Generate a new random identifier with the given prefix.
|
||||
*
|
||||
* Internally this uses [nanoids](https://github.com/ai/nanoid).
|
||||
*
|
||||
* See {@link newNonSecureID} for a variant that can be used in web workers.
|
||||
*/
|
||||
export const newID = (prefix: string) => prefix + nanoid();
|
||||
@@ -12,6 +12,7 @@
|
||||
"i18next": "^23.11",
|
||||
"i18next-resources-to-backend": "^1.2",
|
||||
"is-electron": "^2.2",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "^14.2",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
|
||||
134
web/packages/new/photos/services/ml/cluster-new.ts
Normal file
134
web/packages/new/photos/services/ml/cluster-new.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { newNonSecureID } from "@/base/id-worker";
|
||||
import log from "@/base/log";
|
||||
import { ensure } from "@/utils/ensure";
|
||||
import type { FaceIndex } from "./face";
|
||||
import { dotProduct } from "./math";
|
||||
|
||||
/**
|
||||
* A cluster is an set of faces.
|
||||
*
|
||||
* Each cluster has an id so that a Person (a set of clusters) can refer to it.
|
||||
*/
|
||||
export interface Cluster {
|
||||
/**
|
||||
* A randomly generated ID to uniquely identify this cluster.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* An unordered set of ids of the faces that belong to the cluster.
|
||||
*
|
||||
* For ergonomics of transportation and persistence this is an array but it
|
||||
* should conceptually be thought of as a set.
|
||||
*/
|
||||
faceIDs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Person is a set of clusters, with some attached metadata.
|
||||
*
|
||||
* The person is the user visible concept. It consists of a set of clusters,
|
||||
* each of which itself is a set of faces.
|
||||
*
|
||||
* For ease of transportation, the Person entity on remote looks like
|
||||
*
|
||||
* { name, clusters: { cluster_id, face_ids }}
|
||||
*
|
||||
* That is, it has the clusters embedded within itself.
|
||||
*/
|
||||
export interface Person {
|
||||
/**
|
||||
* A randomly generated ID to uniquely identify this person.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* An optional name assigned by the user to this person.
|
||||
*/
|
||||
name: string | undefined;
|
||||
/**
|
||||
* An unordered set of ids of the clusters that belong to this person.
|
||||
*
|
||||
* For ergonomics of transportation and persistence this is an array but it
|
||||
* should conceptually be thought of as a set.
|
||||
*/
|
||||
clusterIDs: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster faces into groups.
|
||||
*
|
||||
* [Note: Face clustering algorithm]
|
||||
*
|
||||
* 1. clusters = []
|
||||
* 2. For each face, find its nearest neighbour in the embedding space from
|
||||
* amongst the faces that have already been clustered.
|
||||
* 3. If no such neighbour is found within our threshold, create a new cluster.
|
||||
* 4. Otherwise assign this face to the same cluster as its nearest neighbour.
|
||||
*
|
||||
* [Note: Face clustering feedback]
|
||||
*
|
||||
* This user can tweak the output of the algorithm by providing feedback. They
|
||||
* can perform the following actions:
|
||||
*
|
||||
* 1. Move a cluster from one person to another.
|
||||
* 2. Break a cluster.
|
||||
*
|
||||
*/
|
||||
export const clusterFaces = (faceIndexes: FaceIndex[]) => {
|
||||
const t = Date.now();
|
||||
|
||||
const faces = [...faceIDAndEmbeddings(faceIndexes)];
|
||||
|
||||
const clusters: Cluster[] = [];
|
||||
const clusterIndexByFaceID = new Map<string, number>();
|
||||
for (const [i, { faceID, embedding }] of faces.entries()) {
|
||||
let j = 0;
|
||||
for (; j < i; j++) {
|
||||
// Can't find a better way for avoiding the null assertion.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const n = faces[j]!;
|
||||
|
||||
// TODO-ML: The distance metric and the thresholds are placeholders.
|
||||
|
||||
// The vectors are already normalized, so we can directly use their
|
||||
// dot product as their cosine similarity.
|
||||
const csim = dotProduct(embedding, n.embedding);
|
||||
if (csim > 0.5) {
|
||||
// Found a neighbour near enough. Add this face to the
|
||||
// neighbour's cluster and call it a day.
|
||||
const ci = ensure(clusterIndexByFaceID.get(n.faceID));
|
||||
clusters[ci]?.faceIDs.push(faceID);
|
||||
clusterIndexByFaceID.set(faceID, ci);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (j == i) {
|
||||
// We didn't find a neighbour. Create a new cluster with this face.
|
||||
const cluster = {
|
||||
id: newNonSecureID("cluster_"),
|
||||
faceIDs: [faceID],
|
||||
};
|
||||
clusters.push(cluster);
|
||||
clusterIndexByFaceID.set(faceID, clusters.length);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(() => ["ml/cluster", { faces, clusters, clusterIndexByFaceID }]);
|
||||
log.debug(
|
||||
() =>
|
||||
`Clustered ${faces.length} faces into ${clusters.length} clusters (${Date.now() - t} ms)`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* A generator function that returns a stream of {faceID, embedding} values,
|
||||
* flattening all the all the faces present in the given {@link faceIndices}.
|
||||
*/
|
||||
function* faceIDAndEmbeddings(faceIndices: FaceIndex[]) {
|
||||
for (const fi of faceIndices) {
|
||||
for (const f of fi.faces) {
|
||||
yield { faceID: f.faceID, embedding: f.embedding };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,14 @@ export const faceIndex = async (fileID: number) => {
|
||||
return db.get("face-index", fileID);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all face indexes present locally.
|
||||
*/
|
||||
export const faceIndexes = async () => {
|
||||
const db = await mlDB();
|
||||
return await db.getAll("face-index");
|
||||
};
|
||||
|
||||
/**
|
||||
* Return all CLIP indexes present locally.
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { isDesktop } from "@/base/app";
|
||||
import { blobCache } from "@/base/blob-cache";
|
||||
import { ensureElectron } from "@/base/electron";
|
||||
import { isDevBuild } from "@/base/env";
|
||||
import log from "@/base/log";
|
||||
import type { Electron } from "@/base/types/ipc";
|
||||
import { ComlinkWorker } from "@/base/worker/comlink-worker";
|
||||
@@ -16,8 +17,14 @@ import { proxy, transfer } from "comlink";
|
||||
import { isInternalUser } from "../feature-flags";
|
||||
import { getRemoteFlag, updateRemoteFlag } from "../remote-store";
|
||||
import type { UploadItem } from "../upload/types";
|
||||
import { clusterFaces } from "./cluster-new";
|
||||
import { regenerateFaceCrops } from "./crop";
|
||||
import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db";
|
||||
import {
|
||||
clearMLDB,
|
||||
faceIndex,
|
||||
faceIndexes,
|
||||
indexableAndIndexedCounts,
|
||||
} from "./db";
|
||||
import { MLWorker } from "./worker";
|
||||
import type { CLIPMatches } from "./worker-types";
|
||||
|
||||
@@ -255,6 +262,8 @@ const mlSync = async () => {
|
||||
triggerStatusUpdate();
|
||||
|
||||
if (_isMLEnabled) void worker().then((w) => w.sync());
|
||||
// TODO-ML
|
||||
if (_isMLEnabled) void wipCluster();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -279,6 +288,16 @@ export const indexNewUpload = (enteFile: EnteFile, uploadItem: UploadItem) => {
|
||||
void worker().then((w) => w.onUpload(enteFile, uploadItem));
|
||||
};
|
||||
|
||||
/**
|
||||
* WIP! Don't enable, dragon eggs are hatching here.
|
||||
*/
|
||||
export const wipCluster = async () => {
|
||||
if (!isDevBuild || !(await isInternalUser())) return;
|
||||
if (!process.env.NEXT_PUBLIC_ENTE_WIP_CL) return;
|
||||
|
||||
clusterFaces(await faceIndexes());
|
||||
};
|
||||
|
||||
export type MLStatus =
|
||||
| { phase: "disabled" /* The ML remote flag is off */ }
|
||||
| {
|
||||
|
||||
@@ -3590,6 +3590,11 @@ nanoid@^3.3.6, nanoid@^3.3.7:
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||
|
||||
nanoid@^5.0.7:
|
||||
version "5.0.7"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.7.tgz#6452e8c5a816861fd9d2b898399f7e5fd6944cc6"
|
||||
integrity sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
|
||||
Reference in New Issue
Block a user