Compare commits

...

79 Commits

Author SHA1 Message Date
Neeraj Gupta
19eb1bdb22 [cli] Handle decryption for new libsodium wrapper (#3374)
## Description

## Tests
2024-09-21 06:51:49 +05:30
Neeraj Gupta
f11493842e [cli] Handle decryption for new libsodium wrapper 2024-09-21 06:50:33 +05:30
Manav Rathi
405c0c343f [desktop] Clustering WIP - Part x/x (#3364)
The sync scaffolding is mostly in place now.
2024-09-20 17:52:57 +05:30
Manav Rathi
ed907c71f8 Let tsc know 2024-09-20 17:45:49 +05:30
Ashil
29a72ac4a1 [auth] Add onPressed color for Custom Keyboard in App Lock (#3361)
## Description

## Tests
2024-09-20 17:23:58 +05:30
Manav Rathi
2722b50cc0 Sequence 2024-09-20 17:22:29 +05:30
Aman Raj Singh Mourya
36079aa2dc [auth] Add onPressed color for Custom Keyboard in App Lock 2024-09-20 17:20:46 +05:30
Aman Raj Singh Mourya
891af00454 [mob][photos] Add onPressed State Color for Custom Keyboard in App Lock (#3360)
## Description

## Tests
2024-09-20 17:03:16 +05:30
Aman Raj Singh Mourya
783c0c48ef [mob][photos] On press color for custom keyboard 2024-09-20 16:50:32 +05:30
Manav Rathi
ed1c9df007 Funnel the same way 2024-09-20 16:50:02 +05:30
Manav Rathi
f64c0dcc86 Move into worker for now 2024-09-20 16:13:49 +05:30
Manav Rathi
9d1332bff1 Prune 2024-09-20 16:04:22 +05:30
Manav Rathi
000fe87ebb Prune unused 2024-09-20 16:00:59 +05:30
Manav Rathi
6344a3c640 bona fide 2024-09-20 15:55:46 +05:30
Manav Rathi
18a0b18a13 Auto debug 2024-09-20 15:47:40 +05:30
Neeraj Gupta
d4cdfc8834 [mob] HotFix: Show discover section for all users (#3358)
## Description

## Tests
2024-09-20 15:37:34 +05:30
Neeraj Gupta
aa2b81ad7e [mob] HotFix: Show discover section for all users 2024-09-20 15:36:45 +05:30
Manav Rathi
df17b11573 Make the animation fit the page better 2024-09-20 15:33:01 +05:30
Manav Rathi
8ca3b80e94 Match the (temp) search placeholder message 2024-09-20 14:00:53 +05:30
Manav Rathi
345cc2f34f Fix the UI updates 2024-09-20 14:00:53 +05:30
Manav Rathi
c4f70c370e Integrate clustering progress into ML status 2024-09-20 14:00:53 +05:30
Manav Rathi
e8b692b5ad Prep for clustering updates 2024-09-20 14:00:53 +05:30
Manav Rathi
7b552a1ee3 count 2024-09-20 14:00:53 +05:30
Manav Rathi
5d6ac29d71 Remove no-longer used hdbscan code
We'll follow mobile's linear clustering.
2024-09-20 14:00:52 +05:30
Manav Rathi
8a031360c5 Remove the debug scaffolding 2024-09-20 14:00:52 +05:30
Vishnu Mohandas
c9fd0183e7 [doc] Document limitations (#3356) 2024-09-20 11:52:53 +05:30
vishnukvmd
6753f1e9f7 [doc] Document limitations 2024-09-19 23:11:29 -07:00
Manav Rathi
806098961b [web] Transcode videos if they are smaller than 100 MB (#3355)
Fixes: https://github.com/ente-io/ente/issues/2581
2024-09-20 11:29:56 +05:30
Manav Rathi
21e45e8138 [web] Transcode videos if they are smaller than 100 MB
Fixes: https://github.com/ente-io/ente/issues/2581
2024-09-20 11:04:21 +05:30
Vishnu Mohandas
1de1273391 Update export.md 2024-09-20 00:14:52 +05:30
Manav Rathi
8ea7481a98 [desktop] Cgroups WIP - Part x/x (#3353) 2024-09-19 20:55:29 +05:30
Manav Rathi
12da709445 lf 2024-09-19 20:50:16 +05:30
Manav Rathi
5c601ab2cc We no longer have a maxHeight 2024-09-19 20:45:24 +05:30
Manav Rathi
87bdab027e Snapshot 2024-09-19 20:45:24 +05:30
Manav Rathi
50f4878d0f Doc 2024-09-19 20:45:24 +05:30
Manav Rathi
523336d644 Unalias 2024-09-19 20:45:24 +05:30
Manav Rathi
4b7104bf4e Inline 2024-09-19 20:45:24 +05:30
Manav Rathi
6a8ca4c2cf Inline 2024-09-19 20:45:24 +05:30
Manav Rathi
2e6c7d29e4 Inline 2024-09-19 20:45:24 +05:30
Manav Rathi
b7f86b3e89 T 2024-09-19 20:45:24 +05:30
Manav Rathi
384b4d2c35 [infra] Copycat db - Include pg_restore (#3352) 2024-09-19 20:39:04 +05:30
Manav Rathi
e6d7d2298c Update 2024-09-19 20:38:27 +05:30
Manav Rathi
6139ed45cd Revert "Remove postgres dep not needed in production"
This reverts commit e695f2eccb.
2024-09-19 20:33:42 +05:30
Manav Rathi
6662f51a5f [deskop] People WIP - Part x/x (#3351) 2024-09-19 18:30:08 +05:30
Manav Rathi
1108fa9f79 fix npe 2024-09-19 18:26:38 +05:30
Manav Rathi
2b02ea7409 filter 2024-09-19 18:26:19 +05:30
Manav Rathi
cdca58eb3c Tweak 2024-09-19 18:07:02 +05:30
Manav Rathi
0381dee786 buttons 2024-09-19 17:56:26 +05:30
Manav Rathi
1c727131ad Use a button 2024-09-19 17:22:00 +05:30
Manav Rathi
944070eb23 The blur is needed 2024-09-19 17:19:29 +05:30
Manav Rathi
f2b86ff1e1 Propagate 2024-09-19 17:19:29 +05:30
Manav Rathi
a14160f799 Person mode - 1 2024-09-19 17:19:29 +05:30
Manav Rathi
dcca546e5a [meta] [infra] Rename workflow file to use same extension as the rest (#3350) 2024-09-19 17:15:26 +05:30
Manav Rathi
bb0bdf113e [meta] [infra] Rename workflow file to use same extension as the rest 2024-09-19 17:13:11 +05:30
Manav Rathi
a323c7b31b [infra] copycat-db: update deps (#3349) 2024-09-19 17:11:36 +05:30
Manav Rathi
2d46b70d8f Update to latest scw 2024-09-19 17:10:39 +05:30
Manav Rathi
e695f2eccb Remove postgres dep not needed in production 2024-09-19 17:07:40 +05:30
Manav Rathi
1942935c3c [web] Show the focus-visible state on delete autofocus (#3348) 2024-09-19 17:01:02 +05:30
Manav Rathi
cef85ddd9f Add outline offset to the focus-visible indicator 2024-09-19 16:56:09 +05:30
Manav Rathi
341ef58970 Fix focus visible on opening dialog 2024-09-19 16:53:00 +05:30
Neeraj Gupta
983cfe4482 [mob][photos] Remove trigger to send logs from grant permissions screen (#3345)
## Description

This was added to debug an issue. The issue is resolved, so removing it.
2024-09-19 15:36:55 +05:30
Neeraj Gupta
2ae23dfa3d [docs] Update auth export.md (#3347)
## Description

## Tests
2024-09-19 15:25:28 +05:30
Neeraj Gupta
b269fddac2 Update export.md 2024-09-19 15:24:21 +05:30
Manav Rathi
ca5be3518b [server] Postgres 12 => 15 in sample docker compose file (+ add migration guide) (#3342) 2024-09-19 15:13:13 +05:30
ashilkn
b85a90e5dd [mob][photos] Remove unused debouncer 2024-09-19 14:36:41 +05:30
ashilkn
a4c47ffbd4 [mob][photos] Remove trigger to send logs from grand permissions screen 2024-09-19 14:33:58 +05:30
Neeraj Gupta
4ee9815971 [server] Increase waittime on delete error (#3344)
## Description

## Tests
2024-09-19 11:56:28 +05:30
Neeraj Gupta
5f873a0f7b Increase waittime on error 2024-09-19 11:55:54 +05:30
Neeraj Gupta
d02da225f8 [server] Slow down crons (#3343)
## Description

## Tests
2024-09-19 11:50:22 +05:30
Neeraj Gupta
a8c7dd52ba [server] Slow down crons 2024-09-19 11:49:48 +05:30
Manav Rathi
84900159ae Fix typos 2024-09-19 10:34:07 +05:30
Manav Rathi
6ed0ad806e pg 15 2024-09-19 10:30:58 +05:30
Manav Rathi
c1b6458e2e [web/desktop] Make images on deduplicate selectable (#3333)
The duplicate images page has a image preselected but there is no
checkbox available.
When unselecting this image there is no possibility to do a new
selection.

This PR adds the same checkbox like on the gallery to select images on
the deduplication page.
2024-09-19 09:52:58 +05:30
Trekky12
53b7ea6203 Make images on deduplicate selectable
The duplicate images page has a image preselected but there is no
checkbox available.
When unselecting this image there is no possibility to do a new
selection.

This commit adds the same checkbox like on the gallery to select images
on the deduplication page.
2024-09-18 19:50:08 +02:00
Manav Rathi
4fe7ec6257 Add hint on how to remove the temporary container 2024-09-05 09:43:49 +05:30
Manav Rathi
bdacd1058e 16 => 15 for now 2024-09-05 09:41:38 +05:30
Manav Rathi
7fb31eee0a Punctuation 2024-09-05 09:41:38 +05:30
Manav Rathi
f1adcd4573 Add macOS example 2024-09-05 09:41:37 +05:30
Manav Rathi
130b2757a9 [docs] Postgres 12 => 16 migration guide 2024-09-05 09:41:37 +05:30
52 changed files with 1073 additions and 1378 deletions

View File

@@ -140,12 +140,13 @@ class CustomPinKeypad extends StatelessWidget {
}
}
class _Button extends StatelessWidget {
class _Button extends StatefulWidget {
final String number;
final String text;
final VoidCallback? onTap;
final bool muteButton;
final Widget? icon;
const _Button({
required this.number,
required this.text,
@@ -154,47 +155,78 @@ class _Button extends StatelessWidget {
this.icon,
});
@override
State<_Button> createState() => _ButtonState();
}
class _ButtonState extends State<_Button> {
bool isPressed = false;
void _onTapDown(TapDownDetails details) {
setState(() {
isPressed = true;
});
}
void _onTapUp(TapUpDetails details) async {
setState(() {
isPressed = false;
});
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(6),
color: muteButton
? colorScheme.fillFaintPressed
: icon == null
? colorScheme.backgroundElevated2
: null,
),
child: Center(
child: muteButton
? const SizedBox.shrink()
: icon != null
? Container(
child: icon,
)
: Container(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
number,
style: textTheme.h3,
),
Text(
text,
style: textTheme.tinyBold,
),
],
onTap: widget.onTap,
onTapDown: _onTapDown,
onTapUp: _onTapUp,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
child: Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(6),
color: isPressed
? colorScheme.backgroundElevated
: widget.muteButton
? colorScheme.fillFaintPressed
: widget.icon == null
? colorScheme.backgroundElevated2
: null,
),
child: Center(
child: widget.muteButton
? const SizedBox.shrink()
: widget.icon != null
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 10,
),
child: widget.icon,
)
: Container(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.number,
style: textTheme.h3,
),
Text(
widget.text,
style: textTheme.tinyBold,
),
],
),
),
),
),
),
),
),

View File

@@ -15,7 +15,7 @@ import (
"strings"
)
var AppVersion = "0.2.0"
var AppVersion = "0.2.1"
func main() {
cliDBPath, err := GetCLIConfigPath()

View File

@@ -9,7 +9,7 @@ import (
)
func MapRemoteAuthEntityToString(ctx context.Context, authEntity models.AuthEntity, authKey []byte) (*string, error) {
_, decrypted, err := eCrypto.DecryptChaChaBase64(*authEntity.EncryptedData, authKey, *authEntity.Header)
_, decrypted, err := eCrypto.DecryptChaChaBase64Auth(*authEntity.EncryptedData, authKey, *authEntity.Header)
if err != nil {
return nil, fmt.Errorf("failed to decrypt auth enityt %s: %v", authEntity.ID, err)
}

View File

@@ -254,6 +254,10 @@ export const sidebar = [
text: "Using external S3",
link: "/self-hosting/guides/external-s3",
},
{
text: "DB migration",
link: "/self-hosting/guides/db-migration",
},
],
},
{

View File

@@ -60,6 +60,21 @@ As mentioned above, the auth data is encrypted using a key that's derived by
using user provided password & kdf params. For encryption, we are using
`XChaCha20-Poly1305` algorithm.
## Automated backups
You can use [Ente's CLI](https://github.com/ente-io/ente/tree/main/cli#readme)
to automatically backup your Auth codes.
To export your data, add an account using `ente account add` command. In the
first step, specify `auth` as the app name. At a later point, CLI will also ask
you specify the path where it should write the exported codes.
You can change the export directory using following command
```
ente account update --app auth --email <email> --dir <path>
```
## How to use the exported data
- **Ente Authenticator app**: You can directly import the codes in the Ente
@@ -67,9 +82,9 @@ using user provided password & kdf params. For encryption, we are using
> Settings -> Data -> Import Codes -> Ente Encrypted export.
- **Decrypt using Ente CLI** : Download the latest version of
[Ente CLI](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0), and run
the following command
- **Decrypt using Ente CLI** : Download the latest version of [Ente
CLI](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0), and run the
following command
```
./ente auth decrypt <export_file> <output_file>

View File

@@ -176,3 +176,7 @@ you can gain more value out of a single subscription.
## Is there a forever-free plan?
Yes, we offer 5 GB of storage for free.
## What are the limitations of the free plan?
You cannot share albums, or setup a family while you are on a free plan.

View File

@@ -93,3 +93,8 @@ implemented, are in various blog posts announcing these features.
We are now working on the other requested features around sharing, including
comments and reactions.
## Limitations
Sharing is only available to paid customers. This limitation safeguards against
potential platform abuse.

View File

@@ -0,0 +1,158 @@
---
title: DB Migration
description:
Migrating your self hosted Postgres 12 database to newer Postgres versions
---
# Migrating Postgres 12 to 15
The old sample docker compose file used Postgres 12, which is now nearing end of
life, so we've updated it to Postgres 15. Postgres major versions changes
require a migration step. This document mentions some approaches you can use.
> [!TIP]
>
> Ente itself does not use any specific Postgres 12 or Postgres 15 features, and
> will talk to either happily. It should also work with newer Postgres versions,
> but let us know if you run into any problems and we'll update this page.
### Taking a backup
`docker compose exec` allows us to run a command against a running container. We
can use it to run the `pg_dumpall` command on the postgres container to create a
plaintext backup.
Assuming your cluster is already running, and you are in the `ente/server`
directory, you can run the following (this command uses the default credentials,
you'll need to change these to match your setup):
```sh
docker compose exec postgres env PGPASSWORD=pgpass PGUSER=pguser PG_DB=ente_db pg_dumpall >pg12.backup.sql
```
This will produce a `pg12.backup.sql` in your current directory. You can open it
in a text editor (it can be huge!) to verify that it looks correct.
We won't be needing this file, this backup is recommended just in case something
goes amiss with the actual migration.
> If you need to restore from this plaintext backup, you could subsequently run
> something like:
>
> ```sh
> docker compose up postgres
> cat pg12.backup.sql | docker compose exec -T postgres env PGPASSWORD=pgpass psql -U pguser -d ente_db
> ```
## The migration
At the high level, the steps are
1. Stop your cluster.
2. Start just the postgres container after changing the image to
`pgautoupgrade/pgautoupgrade:15-bookworm`.
3. Once the in-place migration completes, stop the container, and change the
image to `postgres:15`.
#### 1. Stop the cluster
Stop your running Ente cluster.
```sh
docker compose down
```
#### 2. Run `pgautoupgrade`
Modify your `compose.yaml`, changing the image for the "postgres" container from
"postgres:12" to "pgautoupgrade/pgautoupgrade:15-bookworm"
```diff
diff a/server/compose.yaml b/server/compose.yaml
postgres:
- image: postgres:12
+ image: pgautoupgrade/pgautoupgrade:15-bookworm
ports:
```
[pgautoupgrade](https://github.com/pgautoupgrade/docker-pgautoupgrade) is a
community docker image that performs an in-place migration.
After making the change, run only the `postgres` container in the cluster
```sh
docker compose up postgres
```
The container will start and peform an in-place migration. Once it is done, it
will start postgres normally. You should see something like this is the logs
```
postgres-1 | Automatic upgrade process finished with no errors reported
...
postgres-1 | ... starting PostgreSQL 15...
```
At this point, you can stop the container (`CTRL-C`).
#### 3. Finish by changing image
Modify `compose.yaml` again, changing the image to "postgres:15".
```diff
diff a/server/compose.yaml b/server/compose.yaml
postgres:
- image: pgautoupgrade/pgautoupgrade:15-bookworm
+ image: postgres:15
ports:
```
And cleanup the temporary containers by
```sh
docker compose down --remove-orphans
```
Migration is now complete. You can start your Ente cluster normally.
```sh
docker compose up
```
## Migration elsewhere
The above instructions are for Postgres running inside docker, as the sample
docker compose file does. There are myriad other ways to run Postgres, and the
migration sequence then will depend on your exact setup.
Two common approaches are
1. Backup and restore, the `pg_dumpall` + `psql` import sequence described in
[Taking a backup](#taking-a-backup) above.
2. In place migrations using `pg_upgrade`, which is what the
[pgautoupgrade](#the-migration) migration above does under the hood.
The first method, backup and restore, is low tech and will work similarly in
most setups. The second method is more efficient, but requires a bit more
careful preparation.
As another example, here is how one can migrate 12 to 15 when running Postgres
on macOS, installed using Homebrew.
1. Stop your postgres. Make sure there are no more commands shown by
`ps aux | grep '[p]ostgres'`.
2. Install postgres15.
3. Migrate data using `pg_upgrade`:
```sh
/opt/homebrew/Cellar/postgresql@15/15.8/bin/pg_upgrade -b /opt/homebrew/Cellar/postgresql@12/12.18_1/bin -B /opt/homebrew/Cellar/postgresql@15/15.8/bin/ -d /opt/homebrew/var/postgresql@12 -D /opt/homebrew/var/postgresql@15
```
4. Start postgres 15 and verify version using `SELECT VERSION()`.

View File

@@ -3,21 +3,16 @@ FROM ubuntu:latest
RUN apt-get update && apt-get install -y curl gnupg
RUN apt-get install -y tini
# Install pg_dump (via Postgres client)
# Install Postgres client (needed for restores, and for local testing)
# https://www.postgresql.org/download/linux/ubuntu/
#
# We don't need it for production backups, but this is useful for local testing.
RUN \
apt-get install -y lsb-release && \
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
apt-get update && \
apt-get -y install postgresql-client-12
# We don't have specific dependencies on Postgres, so just use the latest.
RUN apt-get update && apt-get -y install postgresql-client
# Install SCW CLI
# Latest release: https://github.com/scaleway/scaleway-cli/releases/latest
RUN \
export VERSION="2.32.1" && \
export VERSION="2.34.0" && \
curl -o /usr/local/bin/scw -L "https://github.com/scaleway/scaleway-cli/releases/download/v${VERSION}/scaleway-cli_${VERSION}_linux_amd64" && \
chmod +x /usr/local/bin/scw

View File

@@ -675,6 +675,27 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage(
"Désactiver la double-authentification..."),
"discord": MessageLookupByLibrary.simpleMessage("Discord"),
"discover": MessageLookupByLibrary.simpleMessage("Découverte"),
"discover_babies": MessageLookupByLibrary.simpleMessage("Bébés"),
"discover_celebrations": MessageLookupByLibrary.simpleMessage("Fêtes"),
"discover_food": MessageLookupByLibrary.simpleMessage("Alimentation"),
"discover_greenery": MessageLookupByLibrary.simpleMessage("Plantes"),
"discover_hills": MessageLookupByLibrary.simpleMessage("Montagnes"),
"discover_identity": MessageLookupByLibrary.simpleMessage("Identité"),
"discover_memes": MessageLookupByLibrary.simpleMessage("Mèmes"),
"discover_notes": MessageLookupByLibrary.simpleMessage("Notes"),
"discover_pets":
MessageLookupByLibrary.simpleMessage("Animaux de compagnie"),
"discover_receipts": MessageLookupByLibrary.simpleMessage("Recettes"),
"discover_screenshots":
MessageLookupByLibrary.simpleMessage("Captures d\'écran "),
"discover_selfies": MessageLookupByLibrary.simpleMessage("Selfies"),
"discover_sunset":
MessageLookupByLibrary.simpleMessage("Coucher du soleil"),
"discover_visiting_cards":
MessageLookupByLibrary.simpleMessage("Carte de Visite"),
"discover_wallpapers":
MessageLookupByLibrary.simpleMessage("Fonds d\'écran"),
"dismiss": MessageLookupByLibrary.simpleMessage("Rejeter"),
"distanceInKMUnit": MessageLookupByLibrary.simpleMessage("km"),
"doNotSignOut":

View File

@@ -565,6 +565,19 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage(
"Menonaktifkan autentikasi dua langkah..."),
"discord": MessageLookupByLibrary.simpleMessage("Discord"),
"discover_babies": MessageLookupByLibrary.simpleMessage("Bayi"),
"discover_food": MessageLookupByLibrary.simpleMessage("Makanan"),
"discover_hills": MessageLookupByLibrary.simpleMessage("Bukit"),
"discover_identity": MessageLookupByLibrary.simpleMessage("Identitas"),
"discover_memes": MessageLookupByLibrary.simpleMessage("Meme"),
"discover_notes": MessageLookupByLibrary.simpleMessage("Catatan"),
"discover_pets": MessageLookupByLibrary.simpleMessage("Peliharaan"),
"discover_screenshots":
MessageLookupByLibrary.simpleMessage("Tangkapan layar"),
"discover_selfies": MessageLookupByLibrary.simpleMessage("Swafoto"),
"discover_sunset": MessageLookupByLibrary.simpleMessage("Senja"),
"discover_wallpapers":
MessageLookupByLibrary.simpleMessage("Gambar latar"),
"distanceInKMUnit": MessageLookupByLibrary.simpleMessage("km"),
"doNotSignOut":
MessageLookupByLibrary.simpleMessage("Jangan keluarkan akun"),
@@ -792,6 +805,8 @@ class MessageLookup extends MessageLookupByLibrary {
"kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
"Harap bantu kami dengan informasi ini"),
"language": MessageLookupByLibrary.simpleMessage("Bahasa"),
"lastUpdated":
MessageLookupByLibrary.simpleMessage("Terakhir diperbaharui"),
"leave": MessageLookupByLibrary.simpleMessage("Tinggalkan"),
"leaveAlbum": MessageLookupByLibrary.simpleMessage("Tinggalkan album"),
"leaveFamily":
@@ -897,6 +912,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Pindah ke sampah"),
"movingFilesToAlbum": MessageLookupByLibrary.simpleMessage(
"Memindahkan file ke album..."),
"name": MessageLookupByLibrary.simpleMessage("Nama"),
"networkConnectionRefusedErr": MessageLookupByLibrary.simpleMessage(
"Tidak dapat terhubung dengan Ente, silakan coba lagi setelah beberapa saat. Jika masalah berlanjut, harap hubungi dukungan."),
"networkHostLookUpErr": MessageLookupByLibrary.simpleMessage(
@@ -904,6 +920,7 @@ class MessageLookup extends MessageLookupByLibrary {
"never": MessageLookupByLibrary.simpleMessage("Tidak pernah"),
"newAlbum": MessageLookupByLibrary.simpleMessage("Album baru"),
"newToEnte": MessageLookupByLibrary.simpleMessage("Baru di Ente"),
"newest": MessageLookupByLibrary.simpleMessage("Terbaru"),
"no": MessageLookupByLibrary.simpleMessage("Tidak"),
"noAlbumsSharedByYouYet": MessageLookupByLibrary.simpleMessage(
"Belum ada album yang kamu bagikan"),
@@ -1040,11 +1057,15 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Kebijakan Privasi"),
"privateBackups":
MessageLookupByLibrary.simpleMessage("Cadangan pribadi"),
"privateSharing":
MessageLookupByLibrary.simpleMessage("Berbagi secara privat"),
"publicLinkCreated":
MessageLookupByLibrary.simpleMessage("Link publik dibuat"),
"publicLinkEnabled":
MessageLookupByLibrary.simpleMessage("Link publik aktif"),
"radius": MessageLookupByLibrary.simpleMessage("Radius"),
"raiseTicket":
MessageLookupByLibrary.simpleMessage("Buat tiket dukungan"),
"rateTheApp": MessageLookupByLibrary.simpleMessage("Nilai app ini"),
"rateUs": MessageLookupByLibrary.simpleMessage("Beri kami nilai"),
"rateUsOnStore": m47,
@@ -1197,6 +1218,8 @@ class MessageLookup extends MessageLookupByLibrary {
"sendLink": MessageLookupByLibrary.simpleMessage("Kirim link"),
"serverEndpoint":
MessageLookupByLibrary.simpleMessage("Endpoint server"),
"sessionExpired":
MessageLookupByLibrary.simpleMessage("Sesi telah berakhir"),
"setAPassword": MessageLookupByLibrary.simpleMessage("Atur sandi"),
"setAs": MessageLookupByLibrary.simpleMessage("Pasang sebagai"),
"setCover": MessageLookupByLibrary.simpleMessage("Ubah sampul"),
@@ -1295,6 +1318,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Keluarga"),
"storageBreakupYou": MessageLookupByLibrary.simpleMessage("Kamu"),
"storageInGB": m60,
"storageLimitExceeded": MessageLookupByLibrary.simpleMessage(
"Batas penyimpanan terlampaui"),
"storageUsageInfo": m61,
"strongStrength": MessageLookupByLibrary.simpleMessage("Kuat"),
"subAlreadyLinkedErrMessage": m62,
@@ -1405,6 +1430,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Pembaruan tersedia"),
"updatingFolderSelection": MessageLookupByLibrary.simpleMessage(
"Memperbaharui pilihan folder..."),
"upgrade": MessageLookupByLibrary.simpleMessage("Tingkatkan"),
"uploadingFilesToAlbum":
MessageLookupByLibrary.simpleMessage("Mengunggah file ke album..."),
"upto50OffUntil4thDec": MessageLookupByLibrary.simpleMessage(

View File

@@ -1549,6 +1549,8 @@ class MessageLookup extends MessageLookupByLibrary {
"subAlreadyLinkedErrMessage": m62,
"subWillBeCancelledOn": m63,
"subscribe": MessageLookupByLibrary.simpleMessage("Iscriviti"),
"subscribeToEnableSharing": MessageLookupByLibrary.simpleMessage(
"È necessario un abbonamento a pagamento attivo per abilitare la condivisione."),
"subscription": MessageLookupByLibrary.simpleMessage("Abbonamento"),
"success": MessageLookupByLibrary.simpleMessage("Operazione riuscita"),
"successfullyArchived":

View File

@@ -657,6 +657,27 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage(
"Uwierzytelnianie dwustopniowe jest wyłączane..."),
"discord": MessageLookupByLibrary.simpleMessage("Discord"),
"discover": MessageLookupByLibrary.simpleMessage("Odkryj"),
"discover_babies": MessageLookupByLibrary.simpleMessage("Niemowlęta"),
"discover_celebrations":
MessageLookupByLibrary.simpleMessage("Uroczystości"),
"discover_food": MessageLookupByLibrary.simpleMessage("Jedzenie"),
"discover_greenery": MessageLookupByLibrary.simpleMessage("Zieleń"),
"discover_hills": MessageLookupByLibrary.simpleMessage("Wzgórza"),
"discover_identity": MessageLookupByLibrary.simpleMessage("Tożsamość"),
"discover_memes": MessageLookupByLibrary.simpleMessage("Memy"),
"discover_notes": MessageLookupByLibrary.simpleMessage("Notatki"),
"discover_pets":
MessageLookupByLibrary.simpleMessage("Zwierzęta domowe"),
"discover_receipts": MessageLookupByLibrary.simpleMessage("Paragony"),
"discover_screenshots":
MessageLookupByLibrary.simpleMessage("Zrzuty ekranu"),
"discover_selfies": MessageLookupByLibrary.simpleMessage("Selfie"),
"discover_sunset":
MessageLookupByLibrary.simpleMessage("Zachód słońca"),
"discover_visiting_cards":
MessageLookupByLibrary.simpleMessage("Wizytówki"),
"discover_wallpapers": MessageLookupByLibrary.simpleMessage("Tapety"),
"dismiss": MessageLookupByLibrary.simpleMessage("Odrzuć"),
"distanceInKMUnit": MessageLookupByLibrary.simpleMessage("km"),
"doNotSignOut":

View File

@@ -232,6 +232,8 @@ class MessageLookup extends MessageLookupByLibrary {
"disableDownloadWarningTitle":
MessageLookupByLibrary.simpleMessage("Vänligen notera:"),
"discord": MessageLookupByLibrary.simpleMessage("Discord"),
"discover_notes": MessageLookupByLibrary.simpleMessage("Anteckningar"),
"discover_receipts": MessageLookupByLibrary.simpleMessage("Kvitton"),
"doThisLater": MessageLookupByLibrary.simpleMessage("Gör detta senare"),
"done": MessageLookupByLibrary.simpleMessage("Klar"),
"dropSupportEmail": m22,

View File

@@ -180,7 +180,7 @@ class SearchService {
Future<List<GenericSearchResult>> getMagicSectionResults(
BuildContext context,
) async {
if (localSettings.isMLIndexingEnabled && flagService.internalUser) {
if (localSettings.isMLIndexingEnabled) {
return MagicCacheService.instance.getMagicGenericSearchResult(context);
} else {
return <GenericSearchResult>[];

View File

@@ -7,9 +7,7 @@ import 'package:photo_manager/photo_manager.dart';
import "package:photos/generated/l10n.dart";
import 'package:photos/services/sync_service.dart';
import "package:photos/theme/ente_theme.dart";
import "package:photos/utils/debouncer.dart";
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/email_util.dart";
import "package:photos/utils/photo_manager_util.dart";
import "package:styled_text/styled_text.dart";
@@ -21,88 +19,67 @@ class GrantPermissionsWidget extends StatefulWidget {
}
class _GrantPermissionsWidgetState extends State<GrantPermissionsWidget> {
final _debouncer = Debouncer(const Duration(milliseconds: 500));
final Logger _logger = Logger("_GrantPermissionsWidgetState");
@override
void dispose() {
_debouncer.cancelDebounceTimer();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).brightness == Brightness.light;
return Scaffold(
body: SingleChildScrollView(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onScaleEnd: (details) {
_debouncer.run(() async {
unawaited(
triggerSendLogs(
"support@ente.io",
"Stuck on grant permission screen on ${Platform.operatingSystem}",
null,
),
);
});
},
child: Padding(
padding: const EdgeInsets.only(top: 20, bottom: 120),
child: Column(
children: [
const SizedBox(
height: 24,
),
Center(
child: Stack(
alignment: Alignment.center,
children: [
isLightMode
? Image.asset(
'assets/loading_photos_background.png',
color: Colors.white.withOpacity(0.4),
colorBlendMode: BlendMode.modulate,
)
: Image.asset(
'assets/loading_photos_background_dark.png',
),
Center(
child: Column(
children: [
const SizedBox(height: 42),
Image.asset(
"assets/gallery_locked.png",
height: 160,
),
],
),
child: Padding(
padding: const EdgeInsets.only(top: 20, bottom: 120),
child: Column(
children: [
const SizedBox(
height: 24,
),
Center(
child: Stack(
alignment: Alignment.center,
children: [
isLightMode
? Image.asset(
'assets/loading_photos_background.png',
color: Colors.white.withOpacity(0.4),
colorBlendMode: BlendMode.modulate,
)
: Image.asset(
'assets/loading_photos_background_dark.png',
),
Center(
child: Column(
children: [
const SizedBox(height: 42),
Image.asset(
"assets/gallery_locked.png",
height: 160,
),
],
),
],
),
),
],
),
const SizedBox(height: 36),
Padding(
padding: const EdgeInsets.fromLTRB(40, 0, 40, 0),
child: StyledText(
text: S.of(context).entePhotosPerm,
style: Theme.of(context)
.textTheme
.headlineSmall!
.copyWith(fontWeight: FontWeight.w700),
tags: {
'i': StyledTextTag(
style: Theme.of(context)
.textTheme
.headlineSmall!
.copyWith(fontWeight: FontWeight.w400),
),
},
),
),
const SizedBox(height: 36),
Padding(
padding: const EdgeInsets.fromLTRB(40, 0, 40, 0),
child: StyledText(
text: S.of(context).entePhotosPerm,
style: Theme.of(context)
.textTheme
.headlineSmall!
.copyWith(fontWeight: FontWeight.w700),
tags: {
'i': StyledTextTag(
style: Theme.of(context)
.textTheme
.headlineSmall!
.copyWith(fontWeight: FontWeight.w400),
),
},
),
],
),
),
],
),
),
),

View File

@@ -133,12 +133,13 @@ class CustomPinKeypad extends StatelessWidget {
}
}
class _Button extends StatelessWidget {
class _Button extends StatefulWidget {
final String number;
final String text;
final VoidCallback? onTap;
final bool muteButton;
final Widget? icon;
const _Button({
required this.number,
required this.text,
@@ -147,30 +148,59 @@ class _Button extends StatelessWidget {
this.icon,
});
@override
State<_Button> createState() => _ButtonState();
}
class _ButtonState extends State<_Button> {
bool isPressed = false;
void _onTapDown(TapDownDetails details) {
setState(() {
isPressed = true;
});
}
void _onTapUp(TapUpDetails details) async {
setState(() {
isPressed = false;
});
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(6),
color: muteButton
? colorScheme.fillFaintPressed
: icon == null
? colorScheme.backgroundElevated2
: null,
color: isPressed
? colorScheme.backgroundElevated
: widget.muteButton
? colorScheme.fillFaintPressed
: widget.icon == null
? colorScheme.backgroundElevated2
: null,
),
child: Center(
child: muteButton
child: widget.muteButton
? const SizedBox.shrink()
: icon != null
: widget.icon != null
? Container(
child: icon,
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 10,
),
child: widget.icon,
)
: Container(
padding: const EdgeInsets.all(4),
@@ -178,11 +208,11 @@ class _Button extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
number,
widget.number,
style: textTheme.h3,
),
Text(
text,
widget.text,
style: textTheme.tinyBold,
),
],

View File

@@ -12,7 +12,7 @@ description: ente photos application
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.9.41+941
version: 0.9.42+942
publish_to: none
environment:

View File

@@ -893,14 +893,14 @@ func setupAndStartCrons(userAuthRepo *repo.UserAuthRepository, publicCollectionR
}
})
schedule(c, "@every 2m", func() {
schedule(c, "@every 10m", func() {
fileController.CleanupDeletedFiles()
})
schedule(c, "@every 101s", func() {
embeddingCtrl.CleanupDeletedEmbeddings()
})
schedule(c, "@every 10m", func() {
schedule(c, "@every 17m", func() {
trashController.DropFileMetadataCron()
})
@@ -926,7 +926,7 @@ func setupAndStartCrons(userAuthRepo *repo.UserAuthRepository, publicCollectionR
trashController.ProcessEmptyTrashRequests()
})
schedule(c, "@every 30m", func() {
schedule(c, "@every 45m", func() {
// delete unclaimed codes older than 60 minutes
_ = castDb.DeleteUnclaimedCodes(context.Background(), timeUtil.MicrosecondsBeforeMinutes(60))
dataCleanupCtrl.DeleteDataCron()

View File

@@ -30,7 +30,7 @@ services:
command: "TCP-LISTEN:3200,fork,reuseaddr TCP:minio:3200"
postgres:
image: postgres:12
image: postgres:15
ports:
- 5432:5432
environment:

View File

@@ -689,7 +689,7 @@ func (c *FileController) CleanupDeletedFiles() {
defer func() {
c.LockController.ReleaseLock(DeletedObjectQueueLock)
}()
items, err := c.QueueRepo.GetItemsReadyForDeletion(repo.DeleteObjectQueue, 2000)
items, err := c.QueueRepo.GetItemsReadyForDeletion(repo.DeleteObjectQueue, 1000)
if err != nil {
log.WithError(err).Error("Failed to fetch items from queue")
return

View File

@@ -43,7 +43,7 @@ func (c *Controller) delete(i int) {
if err != nil {
// Sleep in proportion to the (arbitrary) index to space out the
// workers further.
time.Sleep(time.Duration(i+1) * time.Minute)
time.Sleep(time.Duration(i+5) * time.Minute)
}
}
}

View File

@@ -17,7 +17,6 @@
"exifreader": "^4",
"fast-srp-hap": "^2.0.4",
"ffmpeg-wasm": "file:./thirdparty/ffmpeg-wasm",
"hdbscan": "0.0.1-alpha.5",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1",
"localforage": "^1.9.0",

View File

@@ -1,48 +1,75 @@
import { useIsMobileWidth } from "@/base/hooks";
import {
IconButtonWithBG,
Overlay,
SpaceBetweenFlex,
} from "@ente/shared/components/Container";
import useWindowSize from "@ente/shared/hooks/useWindowSize";
import ArchiveIcon from "@mui/icons-material/Archive";
import ExpandMore from "@mui/icons-material/ExpandMore";
import Favorite from "@mui/icons-material/FavoriteRounded";
import LinkIcon from "@mui/icons-material/Link";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import PeopleIcon from "@mui/icons-material/People";
import PushPin from "@mui/icons-material/PushPin";
import { Box, IconButton, Typography, styled } from "@mui/material";
import CollectionListBarCard from "components/Collections/CollectionListBar/CollectionCard";
import Tooltip from "@mui/material/Tooltip";
import { CollectionTile } from "components/Collections/styledComponents";
import {
CollectionListBarWrapper,
CollectionListWrapper,
} from "components/Collections/styledComponents";
IMAGE_CONTAINER_MAX_WIDTH,
MIN_COLUMNS,
} from "components/PhotoList/constants";
import { t } from "i18next";
import memoize from "memoize-one";
import React, { useEffect, useRef, useState } from "react";
import AutoSizer from "react-virtualized-auto-sizer";
import {
FixedSizeList as List,
ListChildComponentProps,
areEqual,
} from "react-window";
import { CollectionSummary } from "types/collection";
import { FixedSizeList, ListChildComponentProps, areEqual } from "react-window";
import { CollectionSummary, CollectionSummaryType } from "types/collection";
import { ALL_SECTION, COLLECTION_LIST_SORT_BY } from "utils/collection";
import CollectionListSortBy from "../CollectionListSortBy";
import CollectionCard from "./CollectionCard";
import CollectionListSortBy from "./CollectionListSortBy";
interface CollectionListBarProps {
activeCollectionID?: number;
/**
* `true` if we're currently in the hidden section.
*/
isInHiddenSection: boolean;
/**
* The ID of the currently active collection (if any)
*/
activeCollectionID?: number;
/**
* Called when the user changes the active collection.
*/
setActiveCollectionID: (id?: number) => void;
collectionSummaries: CollectionSummary[];
showAllCollections: () => void;
/**
* Called when the user selects the option to show a modal with all the
* collections.
*/
onShowAllCollections: () => void;
/**
* The sort order that should be used for showing the collections in the
* bar.
*/
collectionListSortBy: COLLECTION_LIST_SORT_BY;
/**
* Called when the user changes the sort order.
*/
setCollectionListSortBy: (v: COLLECTION_LIST_SORT_BY) => void;
/**
* Massaged data about the collections that should be shown in the bar.
*/
collectionSummaries: CollectionSummary[];
}
export const CollectionListBar: React.FC<CollectionListBarProps> = ({
isInHiddenSection,
activeCollectionID,
setActiveCollectionID,
collectionSummaries,
showAllCollections,
isInHiddenSection,
setCollectionListSortBy,
onShowAllCollections,
collectionListSortBy,
setCollectionListSortBy,
collectionSummaries,
}) => {
const windowSize = useWindowSize();
const isMobile = useIsMobileWidth();
@@ -131,7 +158,7 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
activeSortBy={collectionListSortBy}
disableBG
/>
<IconButton onClick={showAllCollections}>
<IconButton onClick={onShowAllCollections}>
<ExpandMore />
</IconButton>
</Box>
@@ -144,7 +171,7 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
)}
<AutoSizer disableHeight>
{({ width }) => (
<List
<FixedSizeList
ref={collectionListRef}
outerRef={collectionListWrapperRef}
itemData={itemData}
@@ -157,7 +184,7 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
useIsScrolling
>
{CollectionCardContainer}
</List>
</FixedSizeList>
)}
</AutoSizer>
{!onFarRight && (
@@ -175,7 +202,7 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
setSortBy={setCollectionListSortBy}
activeSortBy={collectionListSortBy}
/>
<IconButtonWithBG onClick={showAllCollections}>
<IconButtonWithBG onClick={onShowAllCollections}>
<ExpandMore />
</IconButtonWithBG>
</Box>
@@ -185,6 +212,22 @@ export const CollectionListBar: React.FC<CollectionListBarProps> = ({
);
};
const CollectionListWrapper = styled(Box)`
position: relative;
overflow: hidden;
height: 86px;
width: 100%;
`;
const CollectionListBarWrapper = styled(Box)`
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
padding: 0 4px;
}
margin-bottom: 16px;
border-bottom: 1px solid ${({ theme }) => theme.palette.divider};
`;
interface ItemData {
collectionSummaries: CollectionSummary[];
activeCollectionID?: number;
@@ -276,3 +319,117 @@ const ScrollButtonRight = styled(ScrollButtonBase)`
text-align: left;
transform: translate(50%, 0%);
`;
interface CollectionListBarCardProps {
collectionSummary: CollectionSummary;
activeCollectionID: number;
onCollectionClick: (collectionID: number) => void;
isScrolling?: boolean;
}
const CollectionListBarCard = (props: CollectionListBarCardProps) => {
const { activeCollectionID, collectionSummary, onCollectionClick } = props;
return (
<Box>
<CollectionCard
collectionTile={CollectionBarTile}
coverFile={collectionSummary.coverFile}
onClick={() => {
onCollectionClick(collectionSummary.id);
}}
>
<CollectionCardText collectionName={collectionSummary.name} />
<CollectionCardIcon collectionType={collectionSummary.type} />
</CollectionCard>
{activeCollectionID === collectionSummary.id && <ActiveIndicator />}
</Box>
);
};
const ActiveIndicator = styled("div")`
height: 3px;
background-color: ${({ theme }) => theme.palette.primary.main};
margin-top: 18px;
border-radius: 2px;
`;
const CollectionBarTile = styled(CollectionTile)`
width: 90px;
height: 64px;
`;
const CollectionBarTileText = styled(Overlay)`
padding: 4px;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.5) 86.46%
);
`;
const CollectionBarTileIcon = styled(Overlay)`
padding: 4px;
display: flex;
justify-content: flex-start;
align-items: flex-end;
& > .MuiSvgIcon-root {
font-size: 20px;
}
`;
function CollectionCardText({ collectionName }) {
return (
<CollectionBarTileText>
<TruncateText text={collectionName} />
</CollectionBarTileText>
);
}
function CollectionCardIcon({ collectionType }) {
return (
<CollectionBarTileIcon>
{collectionType === CollectionSummaryType.favorites && <Favorite />}
{collectionType === CollectionSummaryType.archived && (
<ArchiveIcon
sx={(theme) => ({
color: theme.colors.white.muted,
})}
/>
)}
{collectionType === CollectionSummaryType.outgoingShare && (
<PeopleIcon />
)}
{(collectionType === CollectionSummaryType.incomingShareViewer ||
collectionType ===
CollectionSummaryType.incomingShareCollaborator) && (
<PeopleIcon />
)}
{collectionType === CollectionSummaryType.sharedOnlyViaLink && (
<LinkIcon />
)}
{collectionType === CollectionSummaryType.pinned && <PushPin />}
</CollectionBarTileIcon>
);
}
const TruncateText = ({ text }) => {
return (
<Tooltip title={text}>
<Box height={"2.1em"} overflow="hidden">
<Ellipse variant="small" sx={{ wordBreak: "break-word" }}>
{text}
</Ellipse>
</Box>
</Tooltip>
);
};
const Ellipse = styled(Typography)`
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; //number of lines to show
line-clamp: 2;
-webkit-box-orient: vertical;
`;

View File

@@ -1,100 +0,0 @@
import ArchiveIcon from "@mui/icons-material/Archive";
import Favorite from "@mui/icons-material/FavoriteRounded";
import LinkIcon from "@mui/icons-material/Link";
import PeopleIcon from "@mui/icons-material/People";
import PushPin from "@mui/icons-material/PushPin";
import { Box, Typography, styled } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { CollectionSummary, CollectionSummaryType } from "types/collection";
import CollectionCard from "../CollectionCard";
import {
ActiveIndicator,
CollectionBarTile,
CollectionBarTileIcon,
CollectionBarTileText,
} from "../styledComponents";
interface Iprops {
collectionSummary: CollectionSummary;
activeCollectionID: number;
onCollectionClick: (collectionID: number) => void;
isScrolling?: boolean;
}
const CollectionListBarCard = (props: Iprops) => {
const { activeCollectionID, collectionSummary, onCollectionClick } = props;
return (
<Box>
<CollectionCard
collectionTile={CollectionBarTile}
coverFile={collectionSummary.coverFile}
onClick={() => {
onCollectionClick(collectionSummary.id);
}}
>
<CollectionCardText collectionName={collectionSummary.name} />
<CollectionCardIcon collectionType={collectionSummary.type} />
</CollectionCard>
{activeCollectionID === collectionSummary.id && <ActiveIndicator />}
</Box>
);
};
function CollectionCardText({ collectionName }) {
return (
<CollectionBarTileText>
<TruncateText text={collectionName} />
</CollectionBarTileText>
);
}
function CollectionCardIcon({ collectionType }) {
return (
<CollectionBarTileIcon>
{collectionType === CollectionSummaryType.favorites && <Favorite />}
{collectionType === CollectionSummaryType.archived && (
<ArchiveIcon
sx={(theme) => ({
color: theme.colors.white.muted,
})}
/>
)}
{collectionType === CollectionSummaryType.outgoingShare && (
<PeopleIcon />
)}
{(collectionType === CollectionSummaryType.incomingShareViewer ||
collectionType ===
CollectionSummaryType.incomingShareCollaborator) && (
<PeopleIcon />
)}
{collectionType === CollectionSummaryType.sharedOnlyViaLink && (
<LinkIcon />
)}
{collectionType === CollectionSummaryType.pinned && <PushPin />}
</CollectionBarTileIcon>
);
}
export default CollectionListBarCard;
const TruncateText = ({ text }) => {
return (
<Tooltip title={text}>
<Box height={"2.1em"} overflow="hidden">
<Ellipse variant="small" sx={{ wordBreak: "break-word" }}>
{text}
</Ellipse>
</Box>
</Tooltip>
);
};
const Ellipse = styled(Typography)`
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; //number of lines to show
line-clamp: 2;
-webkit-box-orient: vertical;
`;

View File

@@ -150,12 +150,12 @@ export default function Collections(props: Iprops) {
isInHiddenSection={isInHiddenSection}
activeCollectionID={activeCollectionID}
setActiveCollectionID={setActiveCollectionID}
onShowAllCollections={openAllCollections}
collectionListSortBy={collectionListSortBy}
setCollectionListSortBy={setCollectionListSortBy}
collectionSummaries={sortedCollectionSummaries.filter((x) =>
shouldBeShownOnCollectionBar(x.type),
)}
showAllCollections={openAllCollections}
setCollectionListSortBy={setCollectionListSortBy}
collectionListSortBy={collectionListSortBy}
/>
<AllCollections

View File

@@ -1,25 +1,5 @@
import { Overlay } from "@ente/shared/components/Container";
import { Box, styled } from "@mui/material";
import {
IMAGE_CONTAINER_MAX_WIDTH,
MIN_COLUMNS,
} from "components/PhotoList/constants";
export const CollectionListWrapper = styled(Box)`
position: relative;
overflow: hidden;
height: 86px;
width: 100%;
`;
export const CollectionListBarWrapper = styled(Box)`
padding: 0 24px;
@media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) {
padding: 0 4px;
}
margin-bottom: 16px;
border-bottom: 1px solid ${({ theme }) => theme.palette.divider};
`;
export const CollectionInfoBarWrapper = styled(Box)`
width: 100%;
@@ -51,42 +31,11 @@ export const CollectionTile = styled("div")`
user-select: none;
`;
export const ActiveIndicator = styled("div")`
height: 3px;
background-color: ${({ theme }) => theme.palette.primary.main};
margin-top: 18px;
border-radius: 2px;
`;
export const CollectionBarTile = styled(CollectionTile)`
width: 90px;
height: 64px;
`;
export const AllCollectionTile = styled(CollectionTile)`
width: 150px;
height: 150px;
`;
export const CollectionBarTileText = styled(Overlay)`
padding: 4px;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.5) 86.46%
);
`;
export const CollectionBarTileIcon = styled(Overlay)`
padding: 4px;
display: flex;
justify-content: flex-start;
align-items: flex-end;
& > .MuiSvgIcon-root {
font-size: 20px;
}
`;
export const AllCollectionTileText = styled(Overlay)`
padding: 8px;
background: linear-gradient(

View File

@@ -66,6 +66,7 @@ interface Props {
setIsPhotoSwipeOpen?: (value: boolean) => void;
isInHiddenSection?: boolean;
setFilesDownloadProgressAttributesCreator?: SetFilesDownloadProgressAttributesCreator;
selectable?: boolean;
}
const PhotoFrame = ({
@@ -86,6 +87,7 @@ const PhotoFrame = ({
setIsPhotoSwipeOpen,
isInHiddenSection,
setFilesDownloadProgressAttributesCreator,
selectable,
}: Props) => {
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
@@ -267,6 +269,7 @@ const PhotoFrame = ({
)(!checked);
}
};
const getThumbnail = (
item: EnteFile,
index: number,
@@ -277,7 +280,7 @@ const PhotoFrame = ({
file={item}
updateURL={updateURL(index)}
onClick={onThumbnailClick(index)}
selectable={enableDownload}
selectable={selectable}
onSelect={handleSelect(
item.id,
item.ownerID === galleryContext.user?.id,
@@ -489,7 +492,9 @@ const PhotoFrame = ({
);
fetching[item.id] = true;
const srcURL = await DownloadManager.getFileForPreview(item, true);
const srcURL = await DownloadManager.getFileForPreview(item, {
forceConvertVideos: true,
});
try {
await updateSrcURL(index, item.id, srcURL, true);

View File

@@ -510,6 +510,7 @@ export function PhotoList({
height: height - 48,
};
};
const getVacuumItem = (timeStampList) => {
let footerHeight;
if (publicCollectionGalleryContext.accessedThroughSharedURL) {
@@ -619,6 +620,7 @@ export function PhotoList({
),
};
};
/**
* Checks and merge multiple dates into a single row.
*

View File

@@ -1,581 +0,0 @@
import { SelectionBar } from "@/base/components/Navbar";
import { pt } from "@/base/i18n";
import {
faceCrop,
wipClusterDebugPageContents,
type ClusterDebugPageContents,
} from "@/new/photos/services/ml";
import {
type ClusterFace,
type ClusteringOpts,
type ClusteringProgress,
type OnClusteringProgress,
} from "@/new/photos/services/ml/cluster";
import { faceDirection } from "@/new/photos/services/ml/face";
import type { EnteFile } from "@/new/photos/types/file";
import {
FlexWrapper,
FluidContainer,
VerticallyCentered,
} from "@ente/shared/components/Container";
import BackButton from "@mui/icons-material/ArrowBackOutlined";
import {
Box,
Button,
Checkbox,
FormControlLabel,
IconButton,
LinearProgress,
Stack,
styled,
TextField,
Typography,
} from "@mui/material";
import { useFormik, type FormikProps } from "formik";
import { useRouter } from "next/router";
import { AppContext } from "pages/_app";
import React, {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import AutoSizer from "react-virtualized-auto-sizer";
import {
areEqual,
VariableSizeList,
type ListChildComponentProps,
} from "react-window";
// TODO-Cluster Temporary component for debugging
export default function ClusterDebug() {
const { startLoading, finishLoading, showNavBar } = useContext(AppContext);
// The clustering result.
const [clusterRes, setClusterRes] = useState<
ClusterDebugPageContents | undefined
>();
// Keep the loading state callback as a ref instead of state to prevent
// rerendering when the progress gets updated during clustering.
const onProgressRef = useRef<OnClusteringProgress | undefined>();
// Keep the form state at the top level otherwise it gets reset as we
// scroll.
const formik = useFormik<ClusteringOpts>({
initialValues: {
minBlur: 10,
minScore: 0.8,
minClusterSize: 2,
joinThreshold: 0.76,
earlyExitThreshold: 0.9,
batchSize: 10000,
offsetIncrement: 7500,
badFaceHeuristics: true,
},
onSubmit: (values) =>
cluster(
{
minBlur: toFloat(values.minBlur),
minScore: toFloat(values.minScore),
minClusterSize: toFloat(values.minClusterSize),
joinThreshold: toFloat(values.joinThreshold),
earlyExitThreshold: toFloat(values.earlyExitThreshold),
batchSize: toFloat(values.batchSize),
offsetIncrement: toFloat(values.offsetIncrement),
badFaceHeuristics: values.badFaceHeuristics,
},
(progress: ClusteringProgress) =>
onProgressRef.current?.(progress),
),
});
const cluster = useCallback(
async (opts: ClusteringOpts, onProgress: OnClusteringProgress) => {
setClusterRes(undefined);
startLoading();
setClusterRes(await wipClusterDebugPageContents(opts, onProgress));
finishLoading();
},
[startLoading, finishLoading],
);
useEffect(() => showNavBar(true), []);
return (
<>
<Container>
<AutoSizer>
{({ height, width }) => (
<ClusterList {...{ width, height, clusterRes }}>
<OptionsForm {...{ formik, onProgressRef }} />
</ClusterList>
)}
</AutoSizer>
</Container>
<Options />
</>
);
}
// Formik converts nums to a string on edit.
const toFloat = (n: number | string) =>
typeof n == "string" ? parseFloat(n) : n;
const Options: React.FC = () => {
const router = useRouter();
const close = () => router.push("/gallery");
return (
<SelectionBar>
<FluidContainer>
<IconButton onClick={close}>
<BackButton />
</IconButton>
<Box sx={{ marginInline: "auto" }}>{pt("Face Clusters")}</Box>
</FluidContainer>
</SelectionBar>
);
};
const Container = styled("div")`
display: block;
flex: 1;
width: 100%;
flex-wrap: wrap;
overflow: hidden;
.pswp-thumbnail {
display: inline-block;
}
`;
type OptionsFormProps = LoaderProps & {
formik: FormikProps<ClusteringOpts>;
};
const OptionsForm: React.FC<OptionsFormProps> = ({ formik, onProgressRef }) => {
return (
<Stack>
<Typography paddingInline={1}>Parameters</Typography>
<MemoizedForm {...formik} />
{formik.isSubmitting && <Loader {...{ onProgressRef }} />}
</Stack>
);
};
const MemoizedForm = memo(
({
values,
handleSubmit,
handleChange,
isSubmitting,
}: FormikProps<ClusteringOpts>) => (
<form onSubmit={handleSubmit}>
<Stack>
<Stack
direction="row"
gap={1}
sx={{ ".MuiFormControl-root": { flex: "1" } }}
>
<TextField
name="minBlur"
label="minBlur"
value={values.minBlur}
size="small"
onChange={handleChange}
/>
<TextField
name="minScore"
label="minScore"
value={values.minScore}
size="small"
onChange={handleChange}
/>
<TextField
name="minClusterSize"
label="minClusterSize"
value={values.minClusterSize}
size="small"
onChange={handleChange}
/>
<TextField
name="joinThreshold"
label="joinThreshold"
value={values.joinThreshold}
size="small"
onChange={handleChange}
/>
<TextField
name="earlyExitThreshold"
label="earlyExitThreshold"
value={values.earlyExitThreshold}
size="small"
onChange={handleChange}
/>
<TextField
name="batchSize"
label="batchSize"
value={values.batchSize}
size="small"
onChange={handleChange}
/>
<TextField
name="offsetIncrement"
label="offsetIncrement"
value={values.offsetIncrement}
size="small"
onChange={handleChange}
/>
</Stack>
<Stack direction="row" justifyContent={"space-between"} p={1}>
<FormControlLabel
control={
<Checkbox
name={"badFaceHeuristics"}
checked={values.badFaceHeuristics}
size="small"
onChange={handleChange}
/>
}
label={
<Typography color="text.secondary">
Bad face heuristics
</Typography>
}
/>
<Button
color="secondary"
type="submit"
disabled={isSubmitting}
>
Cluster
</Button>
</Stack>
</Stack>
</form>
),
);
interface LoaderProps {
onProgressRef: React.MutableRefObject<OnClusteringProgress | undefined>;
}
const Loader: React.FC<LoaderProps> = ({ onProgressRef }) => {
const [progress, setProgress] = useState<ClusteringProgress>({
completed: 0,
total: 0,
});
onProgressRef.current = setProgress;
const { completed, total } = progress;
return (
<VerticallyCentered mt={4} gap={2}>
<Stack
direction="row"
gap={1}
alignItems={"center"}
paddingInline={"1rem"}
sx={{
width: "100%",
"& div": {
flex: 1,
},
}}
>
<Box sx={{ mr: 1 }}>
<LinearProgress
variant="determinate"
value={
total > 0
? Math.round((completed / total) * 100)
: 0
}
/>
</Box>
<Typography
variant="small"
sx={{
minWidth: "10rem",
textAlign: "right",
}}
>{`${completed} / ${total}`}</Typography>
</Stack>
</VerticallyCentered>
);
};
type ClusterListProps = ClusterResHeaderProps & {
height: number;
width: number;
};
const ClusterList: React.FC<React.PropsWithChildren<ClusterListProps>> = ({
width,
height,
clusterRes,
children,
}) => {
const [items, setItems] = useState<Item[]>([]);
const listRef = useRef(null);
const columns = useMemo(
() => Math.max(Math.floor(getFractionFittableColumns(width)), 4),
[width],
);
const shrinkRatio = getShrinkRatio(width, columns);
const listItemHeight = 120 * shrinkRatio + 24 + 4;
useEffect(() => {
setItems(clusterRes ? itemsFromClusterRes(clusterRes, columns) : []);
}, [columns, clusterRes]);
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [items]);
const itemSize = (index: number) =>
index === 0
? 140
: index === 1
? 110
: Array.isArray(items[index - 2])
? listItemHeight
: 36;
return (
<VariableSizeList
height={height}
width={width}
ref={listRef}
itemData={{ items, clusterRes, columns, shrinkRatio, children }}
itemCount={2 + items.length}
itemSize={itemSize}
overscanCount={3}
>
{ClusterListItemRenderer}
</VariableSizeList>
);
};
type Item = string | FaceWithFile[];
const itemsFromClusterRes = (
clusterRes: ClusterDebugPageContents,
columns: number,
) => {
const { clusterPreviewsWithFile, unclusteredFacesWithFile } = clusterRes;
const result: Item[] = [];
for (let index = 0; index < clusterPreviewsWithFile.length; index++) {
const { clusterSize, faces } = clusterPreviewsWithFile[index];
result.push(`cluster size ${clusterSize.toFixed(2)}`);
let lastIndex = 0;
while (lastIndex < faces.length) {
result.push(faces.slice(lastIndex, lastIndex + columns));
lastIndex += columns;
}
}
if (unclusteredFacesWithFile.length) {
result.push(`•• unclustered faces ${unclusteredFacesWithFile.length}`);
let lastIndex = 0;
while (lastIndex < unclusteredFacesWithFile.length) {
result.push(
unclusteredFacesWithFile.slice(lastIndex, lastIndex + columns),
);
lastIndex += columns;
}
}
return result;
};
const getFractionFittableColumns = (width: number) =>
(width - 2 * getGapFromScreenEdge(width) + 4) / (120 + 4);
const getGapFromScreenEdge = (width: number) => (width > 4 * 120 ? 24 : 4);
const getShrinkRatio = (width: number, columns: number) =>
(width - 2 * getGapFromScreenEdge(width) - (columns - 1) * 4) /
(columns * 120);
// It in necessary to define the item renderer otherwise it gets recreated every
// time the parent rerenders, causing the form to lose its submitting state.
const ClusterListItemRenderer = React.memo<ListChildComponentProps>(
({ index, style, data }) => {
const { clusterRes, columns, shrinkRatio, items, children } = data;
if (index == 0) return <div style={style}>{children}</div>;
if (index == 1)
return (
<div style={style}>
<ClusterResHeader clusterRes={clusterRes} />
</div>
);
const item = items[index - 2];
return (
<ListItem style={style}>
<ListContainer columns={columns} shrinkRatio={shrinkRatio}>
{!Array.isArray(item) ? (
<LabelContainer span={columns}>{item}</LabelContainer>
) : (
item.map((f, i) => (
<FaceItem key={i.toString()} faceWithFile={f} />
))
)}
</ListContainer>
</ListItem>
);
},
areEqual,
);
interface ClusterResHeaderProps {
clusterRes: ClusterDebugPageContents | undefined;
}
const ClusterResHeader: React.FC<ClusterResHeaderProps> = ({ clusterRes }) => {
if (!clusterRes) return null;
const {
totalFaceCount,
filteredFaceCount,
clusteredFaceCount,
unclusteredFaceCount,
timeTakenMs,
clusters,
} = clusterRes;
return (
<Stack m={1}>
<Typography mb={1} variant="small">
{`${clusters.length} clusters in ${(timeTakenMs / 1000).toFixed(0)} seconds • ${totalFaceCount} faces ${filteredFaceCount} filtered ${clusteredFaceCount} clustered ${unclusteredFaceCount} unclustered`}
</Typography>
<Typography variant="small" color="text.muted">
Showing only top 30 clusters, bottom 30 clusters, and
unclustered faces.
</Typography>
<Typography variant="small" color="text.muted">
For each cluster showing only up to 50 faces, sorted by cosine
similarity to its highest scoring face.
</Typography>
<Typography variant="small" color="text.muted">
Below each face is its blur, score, cosineSimilarity, direction.
Bad faces are outlined.
</Typography>
</Stack>
);
};
const ListItem = styled("div")`
display: flex;
justify-content: center;
`;
const ListContainer = styled(Box, {
shouldForwardProp: (propName) => propName != "shrinkRatio",
})<{
columns: number;
shrinkRatio: number;
}>`
display: grid;
grid-template-columns: ${({ columns, shrinkRatio }) =>
`repeat(${columns},${120 * shrinkRatio}px)`};
grid-column-gap: 4px;
width: 100%;
padding: 4px;
`;
const ListItemContainer = styled(FlexWrapper)<{ span: number }>`
grid-column: span ${(props) => props.span};
`;
const LabelContainer = styled(ListItemContainer)`
color: ${({ theme }) => theme.colors.text.muted};
height: 32px;
`;
interface FaceItemProps {
faceWithFile: FaceWithFile;
}
interface FaceWithFile {
face: ClusterFace;
enteFile: EnteFile;
cosineSimilarity?: number;
wasMerged?: boolean;
}
const FaceItem: React.FC<FaceItemProps> = ({ faceWithFile }) => {
const { face, enteFile, cosineSimilarity } = faceWithFile;
const { faceID, isBadFace } = face;
const [objectURL, setObjectURL] = useState<string | undefined>();
useEffect(() => {
let didCancel = false;
let thisObjectURL: string | undefined;
void faceCrop(faceID, enteFile).then((blob) => {
if (blob && !didCancel)
setObjectURL((thisObjectURL = URL.createObjectURL(blob)));
});
return () => {
didCancel = true;
if (thisObjectURL) URL.revokeObjectURL(thisObjectURL);
};
}, [faceID, enteFile]);
const fd = faceDirection(face.detection);
const d = fd == "straight" ? "•" : fd == "left" ? "←" : "→";
return (
<FaceChip
style={{
outline: isBadFace ? `1px solid rosybrown` : undefined,
outlineOffset: "2px",
}}
>
{objectURL && (
<img
style={{
objectFit: "cover",
width: "100%",
height: "100%",
}}
src={objectURL}
/>
)}
<Stack direction="row" justifyContent="space-between">
<Typography variant="small" color="text.muted">
{`b${face.blur.toFixed(0)} `}
</Typography>
<Typography variant="small" color="text.muted">
{`s${face.score.toFixed(1)}`}
</Typography>
{cosineSimilarity && (
<Typography variant="small" color="text.muted">
{`c${cosineSimilarity.toFixed(1)}`}
</Typography>
)}
<Typography variant="small" color="text.muted">
{`d${d}`}
</Typography>
</Stack>
</FaceChip>
);
};
const FaceChip = styled(Box)`
width: 120px;
height: 120px;
`;

View File

@@ -196,6 +196,7 @@ export default function Deduplicate() {
activeCollectionID={ALL_SECTION}
fileToCollectionsMap={fileToCollectionsMap}
collectionNameMap={collectionNameMap}
selectable={true}
/>
)}
<DeduplicateOptions

View File

@@ -14,7 +14,7 @@ import {
getLocalFiles,
getLocalTrashedFiles,
} from "@/new/photos/services/files";
import { wipHasSwitchedOnceCmpAndSet } from "@/new/photos/services/ml";
import type { Person } from "@/new/photos/services/ml/cgroups";
import {
filterSearchableFiles,
setSearchCollectionsAndFiles,
@@ -314,23 +314,24 @@ export default function Gallery() {
const closeAuthenticateUserModal = () =>
setAuthenticateUserModalView(false);
// `true` if we're displaying the hidden section.
//
// - The search bar is replaced by a navbar with a back button.
// - The collections bar shows only the hidden collections.
// - The gallery itself shows hidden items.
const [isInHiddenSection, setIsInHiddenSection] = useState(false);
// If set, then display files belonging to this person.
//
// - The collections bar is replaced with a people bar.
// - The gallery itself shows files which contain this person.
const [person, setPerson] = useState<Person | undefined>();
const [
filesDownloadProgressAttributesList,
setFilesDownloadProgressAttributesList,
] = useState<FilesDownloadProgressAttributes[]>([]);
const openHiddenSection: GalleryContextType["openHiddenSection"] = (
callback,
) => {
authenticateUser(() => {
setIsInHiddenSection(true);
setActiveCollectionID(HIDDEN_ITEMS_SECTION);
callback?.();
});
};
const [isClipSearchResult, setIsClipSearchResult] =
useState<boolean>(false);
@@ -538,6 +539,9 @@ export default function Gallery() {
filteredFiles = await filterSearchableFiles(
selectedSearchOption.suggestion,
);
} else if (person) {
const pfSet = new Set(person.fileIDs);
filteredFiles = files.filter((f) => pfSet.has(f.id));
} else {
filteredFiles = getUniqueFiles(
(isInHiddenSection ? hiddenFiles : files).filter((item) => {
@@ -613,6 +617,7 @@ export default function Gallery() {
selectedSearchOption,
activeCollectionID,
archivedCollections,
person,
]);
const selectAll = (e: KeyboardEvent) => {
@@ -675,16 +680,6 @@ export default function Gallery() {
};
}, [selectAll, clearSelection]);
useEffect(() => {
// TODO-Cluster
if (process.env.NEXT_PUBLIC_ENTE_WIP_CL_AUTO) {
setTimeout(() => {
if (!wipHasSwitchedOnceCmpAndSet())
router.push("cluster-debug");
}, 2000);
}
}, []);
const fileToCollectionsMap = useMemoSingleThreaded(() => {
return constructFileToCollectionMap(files);
}, [files]);
@@ -1010,11 +1005,29 @@ export default function Gallery() {
setExportModalView(false);
};
const openHiddenSection: GalleryContextType["openHiddenSection"] = (
callback,
) => {
authenticateUser(() => {
setIsInHiddenSection(true);
setActiveCollectionID(HIDDEN_ITEMS_SECTION);
callback?.();
});
};
const exitHiddenSection = () => {
setIsInHiddenSection(false);
setActiveCollectionID(ALL_SECTION);
};
const handleSelectPeople = () => {
console.log("onSelectPeople");
};
if (person) {
log.debug(() => ["person", person]);
}
return (
<GalleryContext.Provider
value={{
@@ -1097,6 +1110,8 @@ export default function Gallery() {
isInSearchMode={isInSearchMode}
setIsInSearchMode={setIsInSearchMode}
onSelectSearchOption={handleSelectSearchOption}
onSelectPeople={handleSelectPeople}
onSelectPerson={setPerson}
/>
)}
</NavbarBase>
@@ -1195,6 +1210,7 @@ export default function Gallery() {
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
selectable={true}
/>
)}
{selected.count > 0 &&

View File

@@ -562,6 +562,7 @@ export default function PublicCollectionGallery() {
setFilesDownloadProgressAttributesCreator={
setFilesDownloadProgressAttributesCreator
}
selectable={downloadEnabled}
/>
{blockingLoad && (
<LoadingOverlay>

View File

@@ -30,5 +30,9 @@ export const preFileInfoSync = async () => {
* libraries after initial login), and the `preFileInfoSync`, which is called
* before doing the file sync and thus should run immediately after login.
*/
export const sync = () =>
Promise.all([syncMapEnabled(), mlSync(), searchDataSync()]);
export const sync = async () => {
await Promise.all([syncMapEnabled(), searchDataSync()]);
// ML sync might take a very long time for initial indexing, so don't wait
// for it to finish.
void mlSync();
};

View File

@@ -209,9 +209,6 @@ For more details, see [translations.md](translations.md).
> provides affine transforms, while `matrix` is for performing computations
> on matrices, say inverting them or performing their decomposition.
- [hdbscan](https://github.com/shaileshpandit/hdbscan-js) is used for face
clustering.
## Auth app specific
- [otpauth](https://github.com/hectorm/otpauth) is used for the generation of

View File

@@ -12,5 +12,6 @@ export const RippleDisabledButton: React.FC<ButtonProps> = (props) => (
export const FocusVisibleButton = styled(RippleDisabledButton)`
&.Mui-focusVisible {
outline: 1px solid ${(props) => props.theme.colors.stroke.base};
outline-offset: 2px;
}
`;

View File

@@ -1,13 +1,14 @@
import { EnteDrawer } from "@/base/components/EnteDrawer";
import { MenuItemGroup, MenuSectionTitle } from "@/base/components/Menu";
import { Titlebar } from "@/base/components/Titlebar";
import { pt, ut } from "@/base/i18n";
import { ut } from "@/base/i18n";
import log from "@/base/log";
import {
disableML,
enableML,
mlStatusSnapshot,
mlStatusSubscribe,
wipCluster,
wipClusterEnable,
type MLStatus,
} from "@/new/photos/services/ml";
@@ -27,7 +28,6 @@ import {
type DialogProps,
} from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, { useEffect, useState, useSyncExternalStore } from "react";
import { Trans } from "react-i18next";
import type { NewAppContextPhotos } from "../types/context";
@@ -316,7 +316,7 @@ const ManageML: React.FC<ManageMLProps> = ({
break;
case "clustering":
// TODO-Cluster
status = pt("Grouping faces");
status = t("people");
break;
default:
status = t("indexing_status_done");
@@ -338,10 +338,6 @@ const ManageML: React.FC<ManageMLProps> = ({
});
};
// TODO-Cluster
const router = useRouter();
const wipClusterDebug = () => router.push("/cluster-debug");
return (
<Stack px={"16px"} py={"20px"} gap={4}>
<Stack gap={3}>
@@ -392,12 +388,12 @@ const ManageML: React.FC<ManageMLProps> = ({
label={ut(
"Create clusters • internal only option",
)}
onClick={wipClusterDebug}
onClick={() => void wipCluster()}
/>
</MenuItemGroup>
<MenuSectionTitle
title={ut(
"Create and show in-memory clusters (not saved or synced). You can also view them in the search dropdown later.",
"Create in-memory clusters (not saved or synced). You can also view them in the search dropdown later.",
)}
/>
</Box>

View File

@@ -20,9 +20,11 @@ export const SearchPeopleList: React.FC<SearchPeopleListProps> = ({
}) => {
const isMobileWidth = useIsMobileWidth();
return (
<SearchFaceChipContainer>
<SearchPeopleContainer
sx={{ justifyContent: people.length > 3 ? "center" : "start" }}
>
{people.slice(0, isMobileWidth ? 6 : 7).map((person) => (
<SearchFaceChip
<SearchPeopleButton
key={person.id}
onClick={() => onSelectPerson(person)}
>
@@ -31,35 +33,41 @@ export const SearchPeopleList: React.FC<SearchPeopleListProps> = ({
enteFile={person.displayFaceFile}
placeholderDimension={87}
/>
</SearchFaceChip>
</SearchPeopleButton>
))}
</SearchFaceChipContainer>
</SearchPeopleContainer>
);
};
const SearchFaceChipContainer = styled("div")`
const SearchPeopleContainer = styled("div")`
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 5px;
margin-block: 16px;
/* On very small (~ < 375px) mobile screens 6 faces won't fit in 2 rows.
Clip the third one. */
overflow: hidden;
margin-block: 12px;
`;
const SearchFaceChip = styled("div")`
const SearchPeopleButton = styled("button")(
({ theme }) => `
/* Reset some button defaults */
border: 0;
padding: 0;
/* Button should do this for us, but it isn't working inside the select */
cursor: pointer;
width: 87px;
height: 87px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
& > img {
width: 100%;
height: 100%;
}
`;
:hover {
outline: 1px solid ${theme.colors.stroke.faint};
outline-offset: 2px;
}
`,
);
const FaceChipContainer = styled("div")`
display: flex;
@@ -188,6 +196,10 @@ const FaceCropImageView: React.FC<FaceCropImageViewProps> = ({
) : (
<Skeleton
variant="circular"
animation="wave"
sx={{
backgroundColor: (theme) => theme.colors.background.elevated2,
}}
width={placeholderDimension}
height={placeholderDimension}
/>

View File

@@ -68,6 +68,14 @@ export interface SearchBarProps {
* Set or clear the selected {@link SearchOption}.
*/
onSelectSearchOption: (o: SearchOption | undefined) => void;
/**
* Select a option to view details about all people.
*/
onSelectPeople: () => void;
/**
* Select a person.
*/
onSelectPerson: (person: Person) => void;
}
/**
@@ -88,7 +96,7 @@ export interface SearchBarProps {
export const SearchBar: React.FC<SearchBarProps> = ({
setIsInSearchMode,
isInSearchMode,
onSelectSearchOption,
...rest
}) => {
const isMobileWidth = useIsMobileWidth();
@@ -99,7 +107,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({
{isMobileWidth && !isInSearchMode ? (
<MobileSearchArea onSearch={showSearchInput} />
) : (
<SearchInput {...{ isInSearchMode, onSelectSearchOption }} />
<SearchInput {...{ isInSearchMode }} {...rest} />
)}
</Box>
);
@@ -121,6 +129,8 @@ const MobileSearchArea: React.FC<MobileSearchAreaProps> = ({ onSearch }) => (
const SearchInput: React.FC<Omit<SearchBarProps, "setIsInSearchMode">> = ({
isInSearchMode,
onSelectSearchOption,
onSelectPeople,
onSelectPerson,
}) => {
// A ref to the top level Select.
const selectRef = useRef<SelectInstance<SearchOption> | null>(null);
@@ -166,16 +176,25 @@ const SearchInput: React.FC<Omit<SearchBarProps, "setIsInSearchMode">> = ({
};
const resetSearch = () => {
// Dismiss the search menu if it is open.
selectRef.current?.blur();
// Clear all our state.
setValue(null);
setInputValue("");
// Let our parent know.
onSelectSearchOption(undefined);
};
const handleSelectCGroup = (value: SearchOption) => {
// Dismiss the search menu.
selectRef.current?.blur();
setValue(value);
onSelectSearchOption(undefined);
const handleSelectPeople = () => {
resetSearch();
onSelectPeople();
};
const handleSelectPerson = (person: Person) => {
resetSearch();
onSelectPerson(person);
};
const handleFocus = () => {
@@ -207,7 +226,10 @@ const SearchInput: React.FC<Omit<SearchBarProps, "setIsInSearchMode">> = ({
placeholder={t("search_hint")}
noOptionsMessage={({ inputValue }) =>
shouldShowEmptyState(inputValue) ? (
<EmptyState onSelectCGroup={handleSelectCGroup} />
<EmptyState
onSelectPeople={handleSelectPeople}
onSelectPerson={handleSelectPerson}
/>
) : null
}
/>
@@ -358,15 +380,24 @@ const shouldShowEmptyState = (inputValue: string) => {
};
interface EmptyStateProps {
/** Called when the user selects a cgroup shown in the empty state view. */
onSelectCGroup: (value: SearchOption) => void;
/**
* Called when the user selects the people banner in the empty state view.
*/
onSelectPeople: () => void;
/**
* Called when the user selects a particular person shown in the empty state
* view. */
onSelectPerson: (person: Person) => void;
}
/**
* The view shown in the menu area when the user has not typed anything in the
* search box.
*/
const EmptyState: React.FC<EmptyStateProps> = () => {
const EmptyState: React.FC<EmptyStateProps> = ({
onSelectPeople,
onSelectPerson,
}) => {
const mlStatus = useSyncExternalStore(mlStatusSubscribe, mlStatusSnapshot);
const people = useSyncExternalStore(peopleSubscribe, peopleSnapshot);
@@ -388,6 +419,7 @@ const EmptyState: React.FC<EmptyStateProps> = () => {
label = t("indexing_fetching", mlStatus);
break;
case "clustering":
// TODO-Cluster
label = t("indexing_people", mlStatus);
break;
case "done":
@@ -395,38 +427,53 @@ const EmptyState: React.FC<EmptyStateProps> = () => {
break;
}
const handleSelectPerson = (person: Person) => console.log(person);
return (
<Box sx={{ textAlign: "left" }}>
{people && people.length > 0 && (
<>
<PeopleHeader />
<SearchPeopleList
people={people}
onSelectPerson={handleSelectPerson}
/>
<PeopleHeader onClick={onSelectPeople} />
<SearchPeopleList {...{ people, onSelectPerson }} />
</>
)}
<Typography variant="mini" sx={{ my: "2px" }}>
<Typography variant="mini" sx={{ my: "4px" }}>
{label}
</Typography>
</Box>
);
};
const PeopleHeader: React.FC = () => {
// TODO-Cluster
const handleClick = () => console.log("click");
return (
<Stack direction="row" sx={{ cursor: "pointer" }} onClick={handleClick}>
interface PeopleHeaderProps {
onClick: () => void;
}
const PeopleHeader: React.FC<PeopleHeaderProps> = ({ onClick }) => (
<PeopleHeaderButton {...{ onClick }}>
<Stack direction="row" color="text.muted">
<Typography color="text.base" variant="large">
{t("people")}
</Typography>
<ChevronRightIcon />
</Stack>
);
};
</PeopleHeaderButton>
);
const PeopleHeaderButton = styled("button")(
({ theme }) => `
/* Reset some button defaults that are affecting us */
background: transparent;
border: 0;
padding: 0;
font: inherit;
/* Button should do this for us, but it isn't working inside the select */
cursor: pointer;
/* The color for the chevron */
color: ${theme.colors.stroke.muted};
/* Hover indication */
&& :hover {
color: ${theme.colors.stroke.base};
}
`,
);
const Option: React.FC<OptionProps<SearchOption, false>> = (props) => (
<SelectComponents.Option {...props}>

View File

@@ -170,12 +170,19 @@ class DownloadManagerImpl {
}
}
/**
* The `forceConvertVideos` option is true when the user presses the
* "Convert" button. It bypasses the preflight check we use to see if the
* browser can already play the video, and instead always does the
* transcoding.
*/
getFileForPreview = async (
file: EnteFile,
forceConvert = false,
opts?: { forceConvertVideos?: boolean },
): Promise<SourceURLs | undefined> => {
this.ensureInitialized();
try {
const forceConvertVideos = opts?.forceConvertVideos ?? false;
const getFileForPreviewPromise = async () => {
const fileBlob = await new Response(
await this.getFile(file, true),
@@ -191,11 +198,14 @@ class DownloadManagerImpl {
file,
fileBlob,
originalFileURL as string,
forceConvert,
forceConvertVideos,
);
return converted;
};
if (forceConvert || !this.fileConversionPromises.has(file.id)) {
if (
forceConvertVideos ||
!this.fileConversionPromises.has(file.id)
) {
this.fileConversionPromises.set(
file.id,
getFileForPreviewPromise(),
@@ -439,7 +449,7 @@ async function getRenderableFileURL(
file: EnteFile,
fileBlob: Blob,
originalFileURL: string,
forceConvert: boolean,
forceConvertVideos: boolean,
): Promise<SourceURLs> {
const existingOrNewObjectURL = (convertedBlob: Blob | null | undefined) =>
convertedBlob
@@ -468,7 +478,7 @@ async function getRenderableFileURL(
break;
}
case FileType.livePhoto: {
url = await getRenderableLivePhotoURL(file, fileBlob, forceConvert);
url = await getRenderableLivePhotoURL(file, fileBlob);
isOriginal = false;
isRenderable = false;
type = "livePhoto";
@@ -478,7 +488,7 @@ async function getRenderableFileURL(
const convertedBlob = await getPlayableVideo(
file.metadata.title,
fileBlob,
forceConvert,
forceConvertVideos,
);
const convertedURL = existingOrNewObjectURL(convertedBlob);
url = convertedURL;
@@ -502,7 +512,6 @@ async function getRenderableFileURL(
async function getRenderableLivePhotoURL(
file: EnteFile,
fileBlob: Blob,
forceConvert: boolean,
): Promise<LivePhotoSourceURL | undefined> {
const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob);
@@ -524,8 +533,7 @@ async function getRenderableLivePhotoURL(
const convertedVideoBlob = await getPlayableVideo(
livePhoto.videoFileName,
videoBlob,
forceConvert,
true,
false,
);
if (!convertedVideoBlob) return undefined;
return URL.createObjectURL(convertedVideoBlob);
@@ -544,26 +552,35 @@ async function getRenderableLivePhotoURL(
async function getPlayableVideo(
videoNameTitle: string,
videoBlob: Blob,
forceConvert = false,
runOnWeb = false,
forceConvert: boolean,
) {
try {
const isPlayable = await isPlaybackPossible(
URL.createObjectURL(videoBlob),
);
if (isPlayable && !forceConvert) {
return videoBlob;
} else {
if (!forceConvert && !runOnWeb && !isDesktop) {
return null;
}
const converted = async () => {
try {
log.info(`Converting video ${videoNameTitle} to mp4`);
const convertedVideoData = await ffmpeg.convertToMP4(videoBlob);
return new Blob([convertedVideoData], { type: "video/mp4" });
} catch (e) {
log.error("Video conversion failed", e);
return null;
}
};
// If we've been asked to force convert, do it regardless of anything else.
if (forceConvert) return converted();
const isPlayable = await isPlaybackPossible(URL.createObjectURL(videoBlob));
if (isPlayable) return videoBlob;
// The browser doesn't think it can play this video, try transcoding.
if (isDesktop) {
return converted();
} else {
// Don't try to transcode on the web if the file is too big.
if (videoBlob.size > 100 * 1024 * 1024 /* 100 MB */) {
return null;
} else {
return converted();
}
} catch (e) {
log.error("Video conversion failed", e);
return null;
}
}

View File

@@ -1,10 +1,11 @@
import { masterKeyFromSession } from "@/base/session-store";
import { fileIDFromFaceID, wipClusterEnable } from ".";
import { wipClusterEnable } from ".";
import type { EnteFile } from "../../types/file";
import { getLocalFiles } from "../files";
import { pullCGroups } from "../user-entity";
import type { FaceCluster } from "./cluster";
import { getClusterGroups, getFaceIndexes } from "./db";
import { fileIDFromFaceID } from "./face";
/**
* A cgroup ("cluster group") is a group of clusters (possibly containing just a
@@ -78,12 +79,15 @@ export interface CGroup {
}
/**
* A massaged version of {@link CGroup} suitable for being shown in the UI.
* A massaged version of {@link CGroup} or a {@link FaceCluster} suitable for
* being shown in the UI.
*
* We transform both both remote cluster groups and local-only face clusters
* into the same "person" object that can be shown in the UI.
*
* The cgroups synced with remote do not directly correspond to "people".
* CGroups represent both positive and negative feedback, where the negations
* are specifically feedback meant so that we do not show the corresponding
* cluster in the UI.
* CGroups represent both positive and negative feedback (i.e, the user does not
* wish a particular cluster group to be shown in the UI).
*
* So while each person has an underlying cgroups, not all cgroups have a
* corresponding person.
@@ -95,13 +99,15 @@ export interface CGroup {
*/
export interface Person {
/**
* Nanoid of the underlying {@link CGroup}.
* Nanoid of the underlying {@link CGroup} or {@link FaceCluster}.
*/
id: string;
/**
* The name of the person.
*
* This will only be set for named cgroups.
*/
name: string;
name: string | undefined;
/**
* IDs of the (unique) files in which this face occurs.
*/
@@ -117,22 +123,6 @@ export interface Person {
displayFaceFile: EnteFile;
}
// TODO-Cluster remove me
/**
* A {@link CGroup} annotated with various in-memory state to make it easier for
* the upper layers of our code to directly use it.
*/
export type AnnotatedCGroup = CGroup & {
/**
* Locally determined ID of the "best" face that should be used as the
* display face, to represent this cluster group in the UI.
*
* This property is not synced with remote. For more details, see
* {@link avatarFaceID}.
*/
displayFaceID: string | undefined;
};
/**
* Fetch existing cgroups for the user from remote and save them to DB.
*/
@@ -144,8 +134,12 @@ export const syncCGroups = async () => {
await pullCGroups(masterKey);
};
export type NamedPerson = Omit<Person, "name"> & {
name: string;
};
/**
* Construct in-memory "people" from the cgroups present locally.
* Construct in-memory {@link NamedPerson}s from the cgroups present locally.
*
* This function is meant to run after files, cgroups and faces have been synced
* with remote. It then uses all the information in the local DBs to construct
@@ -154,7 +148,7 @@ export const syncCGroups = async () => {
* @return A list of {@link Person}s, sorted by the number of files that they
* reference.
*/
export const updatedPeople = async () => {
export const namedPeopleFromCGroups = async (): Promise<NamedPerson[]> => {
if (!process.env.NEXT_PUBLIC_ENTE_WIP_CL) return [];
if (!(await wipClusterEnable())) return [];

View File

@@ -1,35 +0,0 @@
import { Hdbscan, type DebugInfo } from "hdbscan";
/**
* Each "cluster" is a list of indexes of the embeddings belonging to that
* particular cluster.
*/
export type EmbeddingCluster = number[];
export interface ClusterHdbscanResult {
clusters: EmbeddingCluster[];
noise: number[];
debugInfo?: DebugInfo;
}
/**
* Cluster the given {@link embeddings} using hdbscan.
*/
export const clusterHdbscan = (
embeddings: number[][],
): ClusterHdbscanResult => {
const hdbscan = new Hdbscan({
input: embeddings,
minClusterSize: 3,
minSamples: 5,
clusterSelectionEpsilon: 0.6,
clusterSelectionMethod: "leaf",
debug: false,
});
return {
clusters: hdbscan.getClusters(),
noise: hdbscan.getNoise(),
debugInfo: hdbscan.getDebugInfo(),
};
};

View File

@@ -1,10 +1,15 @@
import { assertionFailed } from "@/base/assert";
import { newNonSecureID } from "@/base/id-worker";
import log from "@/base/log";
import { ensure } from "@/utils/ensure";
import { wait } from "@/utils/promise";
import type { EnteFile } from "../../types/file";
import type { AnnotatedCGroup } from "./cgroups";
import { faceDirection, type Face, type FaceIndex } from "./face";
import type { Person } from "./cgroups";
import {
faceDirection,
fileIDFromFaceID,
type Face,
type FaceIndex,
} from "./face";
import { dotProduct } from "./math";
/**
@@ -26,24 +31,22 @@ export interface FaceCluster {
faces: string[];
}
export interface ClusteringOpts {
minBlur: number;
minScore: number;
minClusterSize: number;
joinThreshold: number;
earlyExitThreshold: number;
batchSize: number;
offsetIncrement: number;
badFaceHeuristics: boolean;
}
const clusteringOptions = {
minBlur: 10,
minScore: 0.8,
minClusterSize: 2,
joinThreshold: 0.76,
earlyExitThreshold: 0.9,
batchSize: 10000,
offsetIncrement: 7500,
badFaceHeuristics: true,
};
export interface ClusteringProgress {
completed: number;
total: number;
}
export type OnClusteringProgress = (progress: ClusteringProgress) => void;
/** A {@link Face} annotated with data needed during clustering. */
export type ClusterFace = Omit<Face, "embedding"> & {
embedding: Float32Array;
@@ -65,12 +68,18 @@ export interface ClusterPreviewFace {
* Generates clusters from the given faces using a batched form of linear
* clustering, with a bit of lookback (and a dollop of heuristics) to get the
* clusters to merge across batches.
*
* [Note: Draining the event loop during clustering]
*
* The clustering is a synchronous operation, but we make it async to
* artificially drain the worker's event loop after each mini-batch so that
* other interactions with the worker (where this code runs) do not get stalled
* while clustering is in progress.
*/
export const clusterFaces = (
export const clusterFaces = async (
faceIndexes: FaceIndex[],
localFiles: EnteFile[],
opts: ClusteringOpts,
onProgress: OnClusteringProgress,
onProgress: (progress: ClusteringProgress) => void,
) => {
const {
minBlur,
@@ -81,7 +90,7 @@ export const clusterFaces = (
batchSize,
offsetIncrement,
badFaceHeuristics,
} = opts;
} = clusteringOptions;
const t = Date.now();
const localFileByID = new Map(localFiles.map((f) => [f.id, f]));
@@ -137,7 +146,7 @@ export const clusterFaces = (
clusters,
};
const newState = clusterBatchLinear(
const newState = await clusterBatchLinear(
batch,
oldState,
joinThreshold,
@@ -168,76 +177,18 @@ export const clusterFaces = (
(a, b) => b.faces.length - a.faces.length,
);
// Convert into the data structure we're using to debug/visualize.
const clusterPreviewClusters =
sortedClusters.length < 60
? sortedClusters
: sortedClusters.slice(0, 30).concat(sortedClusters.slice(-30));
const clusterPreviews = clusterPreviewClusters.map((cluster) => {
const faces = cluster.faces.map((id) => ensure(faceForFaceID.get(id)));
const topFace = faces.reduce((top, face) =>
top.score > face.score ? top : face,
);
const previewFaces: ClusterPreviewFace[] = faces.map((face) => {
const csim = dotProduct(topFace.embedding, face.embedding);
return { face, cosineSimilarity: csim, wasMerged: false };
});
return {
clusterSize: cluster.faces.length,
faces: previewFaces
.sort((a, b) => b.cosineSimilarity - a.cosineSimilarity)
.slice(0, 50),
};
});
// TODO-Cluster
// This isn't really part of the clustering, but help the main thread out by
// pre-computing temporary in-memory people, one per cluster.
const people = toPeople(sortedClusters, localFileByID, faceForFaceID);
// TODO-Cluster - Currently we're not syncing with remote or saving anything
// locally, so cgroups will be empty. Create a temporary (unsaved, unsynced)
// cgroup, one per cluster.
const cgroups: AnnotatedCGroup[] = [];
for (const cluster of sortedClusters) {
const faces = cluster.faces.map((id) => ensure(faceForFaceID.get(id)));
const topFace = faces.reduce((top, face) =>
top.score > face.score ? top : face,
);
cgroups.push({
id: cluster.id,
name: undefined,
assigned: [cluster],
isHidden: false,
avatarFaceID: undefined,
displayFaceID: topFace.faceID,
});
}
// TODO-Cluster the total face count is only needed during debugging
let totalFaceCount = 0;
for (const fi of faceIndexes) totalFaceCount += fi.faces.length;
const filteredFaceCount = faces.length;
const clusteredFaceCount = clusterIDForFaceID.size;
const unclusteredFaceCount = filteredFaceCount - clusteredFaceCount;
const unclusteredFaces = faces.filter(
({ faceID }) => !clusterIDForFaceID.has(faceID),
);
const timeTakenMs = Date.now() - t;
log.info(
`Clustered ${faces.length} faces into ${sortedClusters.length} clusters, ${faces.length - clusterIDForFaceID.size} faces remain unclustered (${timeTakenMs} ms)`,
`Generated ${sortedClusters.length} clusters from ${faces.length} faces (${clusteredFaceCount} clustered ${faces.length - clusteredFaceCount} unclustered) (${timeTakenMs} ms)`,
);
return {
totalFaceCount,
filteredFaceCount,
clusteredFaceCount,
unclusteredFaceCount,
localFileByID,
clusterPreviews,
clusters: sortedClusters,
cgroups,
unclusteredFaces: unclusteredFaces,
timeTakenMs,
};
return { clusters: sortedClusters, people };
};
/**
@@ -289,28 +240,13 @@ const isSidewaysFace = (face: Face) =>
/** Generate a new cluster ID. */
const newClusterID = () => newNonSecureID("cluster_");
/**
* Extract the fileID of the {@link EnteFile} to which the face belongs from its
* faceID.
*
* TODO-Cluster - duplicated with ml/index.ts
*/
const fileIDFromFaceID = (faceID: string) => {
const fileID = parseInt(faceID.split("_")[0] ?? "");
if (isNaN(fileID)) {
assertionFailed(`Ignoring attempt to parse invalid faceID ${faceID}`);
return undefined;
}
return fileID;
};
interface ClusteringState {
clusterIDForFaceID: Map<string, string>;
clusterIndexForFaceID: Map<string, number>;
clusters: FaceCluster[];
}
const clusterBatchLinear = (
const clusterBatchLinear = async (
faces: ClusterFace[],
oldState: ClusteringState,
joinThreshold: number,
@@ -331,7 +267,11 @@ const clusterBatchLinear = (
// For each face in the batch
for (const [i, fi] of faces.entries()) {
if (i % 100 == 0) onProgress({ completed: i, total: faces.length });
if (i % 100 == 0) {
onProgress({ completed: i, total: faces.length });
// See: [Note: Draining the event loop during clustering]
await wait(0);
}
// If the face is already part of a cluster, then skip it.
if (state.clusterIDForFaceID.has(fi.faceID)) continue;
@@ -385,3 +325,43 @@ const clusterBatchLinear = (
return state;
};
/**
* Construct a {@link Person} object for each cluster.
*/
const toPeople = (
clusters: FaceCluster[],
localFileByID: Map<number, EnteFile>,
faceForFaceID: Map<string, ClusterFace>,
): Person[] =>
clusters
.map((cluster) => {
const faces = cluster.faces.map((id) =>
ensure(faceForFaceID.get(id)),
);
const faceIDs = cluster.faces;
const fileIDs = faceIDs.map((faceID) =>
ensure(fileIDFromFaceID(faceID)),
);
const topFace = faces.reduce((top, face) =>
top.score > face.score ? top : face,
);
const displayFaceID = topFace.faceID;
const displayFaceFileID = ensure(fileIDFromFaceID(displayFaceID));
const displayFaceFile = ensure(
localFileByID.get(displayFaceFileID),
);
return {
id: cluster.id,
name: undefined,
faceIDs,
fileIDs: [...new Set(fileIDs)],
displayFaceID,
displayFaceFile,
};
})
.sort((a, b) => b.faceIDs.length - a.faceIDs.length);

View File

@@ -7,6 +7,7 @@
//
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { assertionFailed } from "@/base/assert";
import type { ElectronMLWorker } from "@/base/types/ipc";
import type { EnteFile } from "@/new/photos/types/file";
import { Matrix } from "ml-matrix";
@@ -149,7 +150,7 @@ export interface Face {
* Finally, this face ID is not completely opaque. It consists of underscore
* separated components, the first of which is the ID of the
* {@link EnteFile} to which this face belongs. Client code can rely on this
* structure and can parse it if needed.
* structure and can parse it if needed using {@link fileIDFromFaceID}.
*/
faceID: string;
/**
@@ -228,6 +229,19 @@ export interface Box {
height: number;
}
/**
* Extract the fileID of the {@link EnteFile} to which the face belongs from its
* faceID.
*/
export const fileIDFromFaceID = (faceID: string) => {
const fileID = parseInt(faceID.split("_")[0] ?? "");
if (isNaN(fileID)) {
assertionFailed(`Ignoring attempt to parse invalid faceID ${faceID}`);
return undefined;
}
return fileID;
};
/**
* Index faces in the given file.
*

View File

@@ -3,7 +3,6 @@
*/
import { isDesktop } from "@/base/app";
import { assertionFailed } from "@/base/assert";
import { blobCache } from "@/base/blob-cache";
import { ensureElectron } from "@/base/electron";
import { isDevBuild } from "@/base/env";
@@ -19,14 +18,7 @@ import { isInternalUser } from "../feature-flags";
import { getRemoteFlag, updateRemoteFlag } from "../remote-store";
import { setSearchPeople } from "../search";
import type { UploadItem } from "../upload/types";
import { syncCGroups, updatedPeople, type Person } from "./cgroups";
import {
type ClusterFace,
type ClusteringOpts,
type ClusterPreviewFace,
type FaceCluster,
type OnClusteringProgress,
} from "./cluster";
import { namedPeopleFromCGroups, syncCGroups, type Person } from "./cgroups";
import { regenerateFaceCrops } from "./crop";
import { clearMLDB, getFaceIndex, getIndexableAndIndexedCounts } from "./db";
import { MLWorker } from "./worker";
@@ -59,6 +51,11 @@ class MLState {
*/
comlinkWorker: Promise<ComlinkWorker<typeof MLWorker>> | undefined;
/**
* `true` if a sync is currently in progress.
*/
isSyncing = false;
/**
* Subscriptions to {@link MLStatus} updates.
*
@@ -85,6 +82,20 @@ class MLState {
*/
peopleSnapshot: Person[] | undefined;
/**
* Cached in-memory copy of people generated from local clusters.
*
* Part of {@link peopleSnapshot}.
*/
peopleLocal: Person[] = [];
/**
* Cached in-memory copy of people generated from remote cgroups.
*
* Part of {@link peopleSnapshot}.
*/
peopleRemote: Person[] = [];
/**
* In flight face crop regeneration promises indexed by the IDs of the files
* whose faces we are regenerating.
@@ -101,9 +112,7 @@ const worker = () =>
const createComlinkWorker = async () => {
const electron = ensureElectron();
const delegate = {
workerDidProcessFileOrIdle,
};
const delegate = { workerDidUpdateStatus };
// Obtain a message port from the Electron layer.
const messagePort = await createMLWorker(electron);
@@ -223,6 +232,7 @@ export const disableML = async () => {
await updateIsMLEnabledRemote(false);
setIsMLEnabledLocal(false);
_state.isMLEnabled = false;
_state.isSyncing = false;
await terminateMLWorker();
triggerStatusUpdate();
};
@@ -304,11 +314,36 @@ export const mlStatusSync = async () => {
* least once prior to calling this in the sync sequence.
*/
export const mlSync = async () => {
if (_state.isMLEnabled) {
await Promise.all([worker().then((w) => w.sync()), syncCGroups()]).then(
updatePeople,
);
}
if (!_state.isMLEnabled) return;
if (_state.isSyncing) return;
_state.isSyncing = true;
// Dependency order for the sync
//
// files -> faces -> cgroups -> clusters
//
// Fetch indexes, or index locally if needed.
await worker().then((w) => w.sync());
// Fetch existing cgroups.
await syncCGroups();
// Generate local clusters
// TODO-Cluster
// Warning - this is heavily WIP
wipClusterLocalOnce();
// Update our in-memory snapshot of people.
const namedPeople = await namedPeopleFromCGroups();
_state.peopleRemote = namedPeople;
updatePeopleSnapshot();
// Notify the search subsystem of the update. Since the search only used
// named cgroups, we only give it the people we got from cgroups.
setSearchPeople(namedPeople);
_state.isSyncing = false;
};
/**
@@ -343,119 +378,25 @@ export const wipClusterEnable = async (): Promise<boolean> =>
(await isInternalUser());
// // TODO-Cluster temporary state here
let _wip_isClustering = false;
let _wip_people: Person[] | undefined;
let _wip_hasSwitchedOnce = false;
export const wipHasSwitchedOnceCmpAndSet = () => {
if (_wip_hasSwitchedOnce) return true;
export const wipClusterLocalOnce = () => {
if (!process.env.NEXT_PUBLIC_ENTE_WIP_CL_AUTO) return;
if (_wip_hasSwitchedOnce) return;
_wip_hasSwitchedOnce = true;
return false;
void wipCluster();
};
export interface ClusterPreviewWithFile {
clusterSize: number;
faces: ClusterPreviewFaceWithFile[];
}
export type ClusterPreviewFaceWithFile = ClusterPreviewFace & {
enteFile: EnteFile;
};
export interface ClusterDebugPageContents {
totalFaceCount: number;
filteredFaceCount: number;
clusteredFaceCount: number;
unclusteredFaceCount: number;
timeTakenMs: number;
clusters: FaceCluster[];
clusterPreviewsWithFile: ClusterPreviewWithFile[];
unclusteredFacesWithFile: {
face: ClusterFace;
enteFile: EnteFile;
}[];
}
export const wipClusterDebugPageContents = async (
opts: ClusteringOpts,
onProgress: OnClusteringProgress,
): Promise<ClusterDebugPageContents> => {
export const wipCluster = async () => {
if (!(await wipClusterEnable())) throw new Error("Not implemented");
log.info("clustering", opts);
_wip_isClustering = true;
_wip_people = undefined;
triggerStatusUpdate();
const {
localFileByID,
clusterPreviews,
clusters,
cgroups,
unclusteredFaces,
...rest
} = await worker().then((w) => w.clusterFaces(opts, proxy(onProgress)));
const { people } = await worker().then((w) => w.clusterFaces());
const fileForFace = ({ faceID }: { faceID: string }) =>
ensure(localFileByID.get(ensure(fileIDFromFaceID(faceID))));
const clusterPreviewsWithFile = clusterPreviews.map(
({ clusterSize, faces }) => ({
clusterSize,
faces: faces.map(({ face, ...rest }) => ({
face,
enteFile: fileForFace(face),
...rest,
})),
}),
);
const unclusteredFacesWithFile = unclusteredFaces.map((face) => ({
face,
enteFile: fileForFace(face),
}));
const clusterByID = new Map(clusters.map((c) => [c.id, c]));
const people = cgroups
// TODO-Cluster
.map((cgroup) => ({ ...cgroup, name: cgroup.id }))
.map((cgroup) => {
if (!cgroup.name) return undefined;
const faceID = ensure(cgroup.displayFaceID);
const fileID = ensure(fileIDFromFaceID(faceID));
const file = ensure(localFileByID.get(fileID));
const faceIDs = cgroup.assigned
.map(({ id }) => ensure(clusterByID.get(id)))
.flatMap((cluster) => cluster.faces);
const fileIDs = faceIDs
.map((faceID) => fileIDFromFaceID(faceID))
.filter((fileID) => fileID !== undefined);
return {
id: cgroup.id,
name: cgroup.name,
faceIDs,
fileIDs: [...new Set(fileIDs)],
displayFaceID: faceID,
displayFaceFile: file,
};
})
.filter((c) => !!c)
.sort((a, b) => b.faceIDs.length - a.faceIDs.length);
_wip_isClustering = false;
_wip_people = people;
_state.peopleLocal = people;
updatePeopleSnapshot();
triggerStatusUpdate();
triggerPeopleUpdate();
return {
clusters,
clusterPreviewsWithFile,
unclusteredFacesWithFile,
...rest,
};
};
export type MLStatus =
@@ -544,6 +485,19 @@ const setMLStatusSnapshot = (snapshot: MLStatus) => {
const getMLStatus = async (): Promise<MLStatus> => {
if (!_state.isMLEnabled) return { phase: "disabled" };
const w = await worker();
// The worker has a clustering progress set iff it is clustering. This
// overrides other behaviours.
const clusteringProgress = await w.clusteringProgess;
if (clusteringProgress) {
return {
phase: "clustering",
nSyncedFiles: clusteringProgress.completed,
nTotalFiles: clusteringProgress.total,
};
}
const { indexedCount, indexableCount } =
await getIndexableAndIndexedCounts();
@@ -555,11 +509,9 @@ const getMLStatus = async (): Promise<MLStatus> => {
// indexable count.
let phase: MLStatus["phase"];
const state = await (await worker()).state;
const state = await w.state;
if (state == "indexing" || state == "fetching") {
phase = state;
} else if (_wip_isClustering) {
phase = "clustering";
} else if (state == "init" || indexableCount > 0) {
phase = "scheduled";
} else {
@@ -595,7 +547,7 @@ const setInterimScheduledStatus = () => {
setMLStatusSnapshot({ phase: "scheduled", nSyncedFiles, nTotalFiles });
};
const workerDidProcessFileOrIdle = throttled(updateMLStatusSnapshot, 2000);
const workerDidUpdateStatus = throttled(updateMLStatusSnapshot, 2000);
/**
* A function that can be used to subscribe to updates to {@link Person}s.
@@ -628,51 +580,16 @@ export const peopleSubscribe = (onChange: () => void): (() => void) => {
* have any people (this is distinct from the case where the user has ML enabled
* but doesn't have any named "person" clusters so far).
*/
export const peopleSnapshot = (): Person[] | undefined => {
const result = _state.peopleSnapshot;
// We don't have it yet, trigger an update.
// if (!result) triggerPeopleUpdate();
return result;
};
export const peopleSnapshot = () => _state.peopleSnapshot;
/**
* Trigger an asynchronous and unconditional update of the people snapshot.
*/
const triggerPeopleUpdate = () => void updatePeopleSnapshot();
/** Unconditional update of the people snapshot. */
const updatePeopleSnapshot = async () => setPeopleSnapshot(await getPeople());
const updatePeopleSnapshot = () =>
setPeopleSnapshot(_state.peopleRemote.concat(_state.peopleLocal));
const setPeopleSnapshot = (snapshot: Person[] | undefined) => {
_state.peopleSnapshot = snapshot;
_state.peopleListeners.forEach((l) => l());
};
/**
* Compute the list of people.
*
* TODO-Cluster this is a placeholder function and might not be needed since
* people might be updated in a push based manner.
*/
const getPeople = async (): Promise<Person[] | undefined> => {
if (!_state.isMLEnabled) return [];
// TODO-Cluster additional check for now as it is heavily WIP.
if (!process.env.NEXT_PUBLIC_ENTE_WIP_CL) return [];
if (!(await wipClusterEnable())) return [];
return _wip_people;
};
/**
* Update our in-memory list of people, also notifying the search subsystem of
* the update.
*/
const updatePeople = async () => {
const people = await updatedPeople();
// TODO-Cluster:
// _wip_people = people;
setSearchPeople(people);
};
/**
* Use CLIP to perform a natural language search over image embeddings.
*
@@ -700,20 +617,6 @@ export const unidentifiedFaceIDs = async (
return index?.faces.map((f) => f.faceID) ?? [];
};
/**
* Extract the fileID of the {@link EnteFile} to which the face belongs from its
* faceID.
*/
// TODO-Cluster
export const fileIDFromFaceID = (faceID: string) => {
const fileID = parseInt(faceID.split("_")[0] ?? "");
if (isNaN(fileID)) {
assertionFailed(`Ignoring attempt to parse invalid faceID ${faceID}`);
return undefined;
}
return fileID;
};
/**
* Return the cached face crop for the given face, regenerating it if needed.
*

View File

@@ -3,15 +3,16 @@
*/
/**
* Callbacks invoked by the worker at various points in the indexing pipeline to
* notify the main thread of events it might be interested in.
* Callbacks invoked by the worker at various points in the indexing and
* clustering pipeline to notify the main thread of events it might be
* interested in.
*/
export interface MLWorkerDelegate {
/**
* Called whenever the worker processes a file during indexing (either
* successfully or with errors), or when in goes into the "idle" state.
* Called whenever the worker does some action that might need the UI state
* indicating the indexing or clustering status to be updated.
*/
workerDidProcessFileOrIdle: () => void;
workerDidUpdateStatus: () => void;
}
/**

View File

@@ -25,11 +25,7 @@ import {
indexCLIP,
type CLIPIndex,
} from "./clip";
import {
clusterFaces,
type ClusteringOpts,
type OnClusteringProgress,
} from "./cluster";
import { clusterFaces, type ClusteringProgress } from "./cluster";
import { saveFaceCrops } from "./crop";
import {
getFaceIndexes,
@@ -101,6 +97,8 @@ interface IndexableItem {
export class MLWorker {
/** The last known state of the worker. */
public state: WorkerState = "init";
/** If the worker is currently clustering, then its last known progress. */
public clusteringProgess: ClusteringProgress | undefined;
private electron: ElectronMLWorker | undefined;
private delegate: MLWorkerDelegate | undefined;
@@ -246,7 +244,7 @@ export class MLWorker {
this.state = "idle";
this.idleDuration = Math.min(this.idleDuration * 2, idleDurationMax);
this.idleTimeout = setTimeout(scheduleTick, this.idleDuration * 1000);
this.delegate?.workerDidProcessFileOrIdle();
this.delegate?.workerDidUpdateStatus();
}
/** Return the next batch of items to backfill (if any). */
@@ -280,14 +278,25 @@ export class MLWorker {
}));
}
// TODO-Cluster
async clusterFaces(opts: ClusteringOpts, onProgress: OnClusteringProgress) {
return clusterFaces(
/**
* Run face clustering on all faces.
*
* This should only be invoked when the face indexing (including syncing
* with remote) is complete so that we cluster the latest set of faces.
*/
async clusterFaces() {
const result = await clusterFaces(
await getFaceIndexes(),
await getAllLocalFiles(),
opts,
onProgress,
(progress) => this.updateClusteringProgress(progress),
);
this.updateClusteringProgress(undefined);
return result;
}
private updateClusteringProgress(progress: ClusteringProgress | undefined) {
this.clusteringProgess = progress;
this.delegate?.workerDidUpdateStatus();
}
}
@@ -347,7 +356,7 @@ const indexNextBatch = async (
await Promise.race(tasks);
// Let the main thread now we're doing something.
delegate?.workerDidProcessFileOrIdle();
delegate?.workerDidUpdateStatus();
// Let us drain the microtask queue. This also gives a chance for other
// interactive tasks like `clipMatches` to run.

View File

@@ -4,7 +4,7 @@ import { ComlinkWorker } from "@/base/worker/comlink-worker";
import { FileType } from "@/media/file-type";
import i18n, { t } from "i18next";
import { clipMatches, isMLEnabled, isMLSupported } from "../ml";
import type { Person } from "../ml/cgroups";
import type { NamedPerson } from "../ml/cgroups";
import type {
LabelledFileType,
LabelledSearchDateComponents,
@@ -58,9 +58,9 @@ export const setSearchCollectionsAndFiles = (cf: SearchCollectionsAndFiles) =>
void worker().then((w) => w.setCollectionsAndFiles(cf));
/**
* Set the people that we should search across.
* Set the (named) people that we should search across.
*/
export const setSearchPeople = (people: Person[]) =>
export const setSearchPeople = (people: NamedPerson[]) =>
void worker().then((w) => w.setPeople(people));
/**

View File

@@ -2,7 +2,7 @@ import { HTTPError } from "@/base/http";
import type { Location } from "@/base/types";
import type { Collection } from "@/media/collection";
import { fileCreationPhotoDate, fileLocation } from "@/media/file-metadata";
import type { Person } from "@/new/photos/services/ml/cgroups";
import type { NamedPerson } from "@/new/photos/services/ml/cgroups";
import type { EnteFile } from "@/new/photos/types/file";
import { ensure } from "@/utils/ensure";
import { nullToUndefined } from "@/utils/transform";
@@ -37,7 +37,7 @@ export class SearchWorker {
collections: [],
files: [],
};
private people: Person[] = [];
private people: NamedPerson[] = [];
/**
* Fetch any state we might need when the actual search happens.
@@ -62,9 +62,9 @@ export class SearchWorker {
}
/**
* Set the people that we should search across.
* Set the (named) people that we should search across.
*/
setPeople(people: Person[]) {
setPeople(people: NamedPerson[]) {
this.people = people;
}
@@ -122,7 +122,7 @@ const suggestionsForString = (
re: RegExp,
searchString: string,
{ collections, files }: SearchCollectionsAndFiles,
people: Person[],
people: NamedPerson[],
{ locale, holidays, labelledFileTypes }: LocalizedSearchData,
locationTags: LocationTag[],
cities: City[],
@@ -196,7 +196,10 @@ const fileCaptionSuggestion = (
: [];
};
const peopleSuggestions = (re: RegExp, people: Person[]): SearchSuggestion[] =>
const peopleSuggestions = (
re: RegExp,
people: NamedPerson[],
): SearchSuggestion[] =>
people
.filter((p) => re.test(p.name))
.map((person) => ({ type: "person", person, label: person.name }));

View File

@@ -5,9 +5,10 @@ import {
DialogContent,
DialogProps,
Typography,
type ButtonBaseActions,
} from "@mui/material";
import { t } from "i18next";
import React from "react";
import React, { useRef } from "react";
import DialogIcon from "./DialogIcon";
import DialogTitleWithCloseButton, {
dialogCloseHandler,
@@ -33,6 +34,21 @@ export default function DialogBox({
titleCloseButton,
...props
}: IProps) {
// Sometimes we wish to autoFocus on the primary action button in the dialog
// (e.g. in the delete confirmation) so that the user can use their keyboard
// to quickly select it.
//
// To allow this, MUI buttons provide an `autoFocus` prop. However, while
// the button does get auto focused, it doesn't show the visual
// focus-visible state until the user does an keyboard action.
//
// Below is the current best workaround to get the focused button to also
// show the focus-visible state. It uses the onEnter callback of the
// transition to focus on the ref to the auto focused button (if any).
//
// https://github.com/mui/material-ui/issues/8438
const proceedButtonRef = useRef<ButtonBaseActions | null>(null);
if (!attributes) {
return <></>;
}
@@ -43,11 +59,17 @@ export default function DialogBox({
onClose: onClose,
});
const handleDialogEnter = () => {
if (attributes.proceed?.autoFocus)
proceedButtonRef?.current.focusVisible();
};
return (
<DialogBoxBase
open={open}
maxWidth={size}
onClose={handleClose}
TransitionProps={{ onEnter: handleDialogEnter }}
{...props}
>
{attributes.icon && <DialogIcon icon={attributes.icon} />}
@@ -87,6 +109,7 @@ export default function DialogBox({
)}
{attributes.proceed && (
<FocusVisibleButton
action={proceedButtonRef}
size="large"
color={attributes.proceed?.variant}
onClick={() => {

View File

@@ -2671,13 +2671,6 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
hdbscan@0.0.1-alpha.5:
version "0.0.1-alpha.5"
resolved "https://registry.yarnpkg.com/hdbscan/-/hdbscan-0.0.1-alpha.5.tgz#8b0cd45243fa60d2fe83e31f1e8bc939ff374c0d"
integrity sha512-Jv92UaFFRAMcK8GKhyxlSGvkf5pf9Y9HpmRQyyWfWop5nm2zs2NmgGG3wOCYo5zy1AeZFtVJjgbpaPjR0IsR/Q==
dependencies:
kd-tree-javascript "^1.0.3"
heic-convert@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/heic-convert/-/heic-convert-2.1.0.tgz#7f764529e37591ae263ef49582d1d0c13491526e"
@@ -3121,11 +3114,6 @@ jszip@^3.10.1:
readable-stream "~2.3.6"
setimmediate "^1.0.5"
kd-tree-javascript@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/kd-tree-javascript/-/kd-tree-javascript-1.0.3.tgz#ab5239ed44e347e10065590fd479e947bedff96c"
integrity sha512-7oSugmaxTCJFqey11rlTSEQD3hGDnRgROMj9MEREvDGV8SlIFwN7x3jJRyFoi+mjO0+4wuSuaDLS1reNQHP7uA==
keyv@^4.5.3:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"