Compare commits
79 Commits
auth-v4.0.
...
cli-v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19eb1bdb22 | ||
|
|
f11493842e | ||
|
|
405c0c343f | ||
|
|
ed907c71f8 | ||
|
|
29a72ac4a1 | ||
|
|
2722b50cc0 | ||
|
|
36079aa2dc | ||
|
|
891af00454 | ||
|
|
783c0c48ef | ||
|
|
ed1c9df007 | ||
|
|
f64c0dcc86 | ||
|
|
9d1332bff1 | ||
|
|
000fe87ebb | ||
|
|
6344a3c640 | ||
|
|
18a0b18a13 | ||
|
|
d4cdfc8834 | ||
|
|
aa2b81ad7e | ||
|
|
df17b11573 | ||
|
|
8ca3b80e94 | ||
|
|
345cc2f34f | ||
|
|
c4f70c370e | ||
|
|
e8b692b5ad | ||
|
|
7b552a1ee3 | ||
|
|
5d6ac29d71 | ||
|
|
8a031360c5 | ||
|
|
c9fd0183e7 | ||
|
|
6753f1e9f7 | ||
|
|
806098961b | ||
|
|
21e45e8138 | ||
|
|
1de1273391 | ||
|
|
8ea7481a98 | ||
|
|
12da709445 | ||
|
|
5c601ab2cc | ||
|
|
87bdab027e | ||
|
|
50f4878d0f | ||
|
|
523336d644 | ||
|
|
4b7104bf4e | ||
|
|
6a8ca4c2cf | ||
|
|
2e6c7d29e4 | ||
|
|
b7f86b3e89 | ||
|
|
384b4d2c35 | ||
|
|
e6d7d2298c | ||
|
|
6139ed45cd | ||
|
|
6662f51a5f | ||
|
|
1108fa9f79 | ||
|
|
2b02ea7409 | ||
|
|
cdca58eb3c | ||
|
|
0381dee786 | ||
|
|
1c727131ad | ||
|
|
944070eb23 | ||
|
|
f2b86ff1e1 | ||
|
|
a14160f799 | ||
|
|
dcca546e5a | ||
|
|
bb0bdf113e | ||
|
|
a323c7b31b | ||
|
|
2d46b70d8f | ||
|
|
e695f2eccb | ||
|
|
1942935c3c | ||
|
|
cef85ddd9f | ||
|
|
341ef58970 | ||
|
|
983cfe4482 | ||
|
|
2ae23dfa3d | ||
|
|
b269fddac2 | ||
|
|
ca5be3518b | ||
|
|
b85a90e5dd | ||
|
|
a4c47ffbd4 | ||
|
|
4ee9815971 | ||
|
|
5f873a0f7b | ||
|
|
d02da225f8 | ||
|
|
a8c7dd52ba | ||
|
|
84900159ae | ||
|
|
6ed0ad806e | ||
|
|
c1b6458e2e | ||
|
|
53b7ea6203 | ||
|
|
4fe7ec6257 | ||
|
|
bdacd1058e | ||
|
|
7fb31eee0a | ||
|
|
f1adcd4573 | ||
|
|
130b2757a9 |
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var AppVersion = "0.2.0"
|
||||
var AppVersion = "0.2.1"
|
||||
|
||||
func main() {
|
||||
cliDBPath, err := GetCLIConfigPath()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
158
docs/docs/self-hosting/guides/db-migration.md
Normal file
158
docs/docs/self-hosting/guides/db-migration.md
Normal 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()`.
|
||||
@@ -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
|
||||
|
||||
|
||||
21
mobile/lib/generated/intl/messages_fr.dart
generated
21
mobile/lib/generated/intl/messages_fr.dart
generated
@@ -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":
|
||||
|
||||
26
mobile/lib/generated/intl/messages_id.dart
generated
26
mobile/lib/generated/intl/messages_id.dart
generated
@@ -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(
|
||||
|
||||
2
mobile/lib/generated/intl/messages_it.dart
generated
2
mobile/lib/generated/intl/messages_it.dart
generated
@@ -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":
|
||||
|
||||
21
mobile/lib/generated/intl/messages_pl.dart
generated
21
mobile/lib/generated/intl/messages_pl.dart
generated
@@ -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":
|
||||
|
||||
2
mobile/lib/generated/intl/messages_sv.dart
generated
2
mobile/lib/generated/intl/messages_sv.dart
generated
@@ -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,
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -196,6 +196,7 @@ export default function Deduplicate() {
|
||||
activeCollectionID={ALL_SECTION}
|
||||
fileToCollectionsMap={fileToCollectionsMap}
|
||||
collectionNameMap={collectionNameMap}
|
||||
selectable={true}
|
||||
/>
|
||||
)}
|
||||
<DeduplicateOptions
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -562,6 +562,7 @@ export default function PublicCollectionGallery() {
|
||||
setFilesDownloadProgressAttributesCreator={
|
||||
setFilesDownloadProgressAttributesCreator
|
||||
}
|
||||
selectable={downloadEnabled}
|
||||
/>
|
||||
{blockingLoad && (
|
||||
<LoadingOverlay>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user