();
+
+ 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 (
+
+ {objectURL && (
+
+ )}
+
+
+ {`b${face.blur.toFixed(0)} `}
+
+
+ {`s${face.score.toFixed(1)}`}
+
+ {cosineSimilarity && (
+
+ {`c${cosineSimilarity.toFixed(1)}`}
+
+ )}
+
+ {`d${d}`}
+
+
+
+ );
+};
+
+const FaceChip = styled(Box)`
+ width: 120px;
+ height: 120px;
`;
diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx
index 1876fca7f4..74273eb014 100644
--- a/web/apps/photos/src/pages/gallery/index.tsx
+++ b/web/apps/photos/src/pages/gallery/index.tsx
@@ -6,10 +6,7 @@ import {
getLocalFiles,
getLocalTrashedFiles,
} from "@/new/photos/services/files";
-import {
- wipClusterEnable,
- wipHasSwitchedOnceCmpAndSet,
-} from "@/new/photos/services/ml";
+import { wipHasSwitchedOnceCmpAndSet } from "@/new/photos/services/ml";
import { EnteFile } from "@/new/photos/types/file";
import { mergeMetadata } from "@/new/photos/utils/file";
import { CenteredFlex } from "@ente/shared/components/Container";
@@ -677,11 +674,8 @@ export default function Gallery() {
// TODO-Cluster
if (process.env.NEXT_PUBLIC_ENTE_WIP_CL_AUTO) {
setTimeout(() => {
- if (!wipHasSwitchedOnceCmpAndSet()) {
- void wipClusterEnable().then(
- (y) => y && router.push("cluster-debug"),
- );
- }
+ if (!wipHasSwitchedOnceCmpAndSet())
+ router.push("cluster-debug");
}, 2000);
}
}, []);
diff --git a/web/packages/base/locales/ar-SA/translation.json b/web/packages/base/locales/ar-SA/translation.json
index 968e1627bd..d4d7e31068 100644
--- a/web/packages/base/locales/ar-SA/translation.json
+++ b/web/packages/base/locales/ar-SA/translation.json
@@ -33,14 +33,15 @@
"ENTER_ENC_PASSPHRASE": "الرجاء إدخال كلمة المرور التي يمكننا استخدامها لتشفير بياناتك",
"PASSPHRASE_DISCLAIMER": "نحن لا نخزن كلمة مرورك، لذا إذا نسيتها، لن نتمكن من مساعدتك في استرداد بياناتك دون مفتاح الاسترداد.",
"WELCOME_TO_ENTE_HEADING": "مرحبا بك في ",
- "WELCOME_TO_ENTE_SUBHEADING": "",
- "WHERE_YOUR_BEST_PHOTOS_LIVE": "",
+ "WELCOME_TO_ENTE_SUBHEADING": "تخزين الصور ومشاركتها بشكل مشفر من طرف إلى طرف",
+ "WHERE_YOUR_BEST_PHOTOS_LIVE": "أين تعيش أفضل صورك",
"KEY_GENERATION_IN_PROGRESS_MESSAGE": "جار توليد مفاتيح التشفير...",
"PASSPHRASE_HINT": "كلمة المرور",
"CONFIRM_PASSPHRASE": "تأكيد كلمة المرور",
"REFERRAL_CODE_HINT": "كيف سمعت عن Ente؟ (اختياري)",
- "REFERRAL_INFO": "",
+ "REFERRAL_INFO": "نحن لا نتتبع عمليات تثبيت التطبيق، سيكون من المفيد لنا أن تخبرنا أين وجدتنا!",
"PASSPHRASE_MATCH_ERROR": "كلمات المرور غير متطابقة",
+ "create_albums": "",
"CREATE_COLLECTION": "ألبوم جديد",
"ENTER_ALBUM_NAME": "اسم الألبوم",
"CLOSE_OPTION": "إغلاق (Esc)",
@@ -58,10 +59,10 @@
"FILE_UPLOAD": "تحميل الملف",
"UPLOAD_STAGE_MESSAGE": {
"0": "الإعداد للتحميل",
- "1": "",
- "2": "",
- "3": "",
- "4": "",
+ "1": "قراءة ملفات بيانات تعريف جوجل",
+ "2": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} البيانات الملفات الوصفية المستخرجة",
+ "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} ملفات معالجة",
+ "4": "إلغاء التحميلات المتبقية",
"5": "اكتمل النسخ الاحتياطي"
},
"FILE_NOT_UPLOADED_LIST": "لم يتم تحميل الملفات التالية",
@@ -71,227 +72,227 @@
"ACCOUNT_EXISTS": "لديك حساب بالفعل",
"CREATE": "إنشاء",
"DOWNLOAD": "تنزيل",
- "DOWNLOAD_OPTION": "",
- "DOWNLOAD_FAVORITES": "",
- "DOWNLOAD_UNCATEGORIZED": "",
- "DOWNLOAD_HIDDEN_ITEMS": "",
- "COPY_OPTION": "",
- "TOGGLE_FULLSCREEN": "",
- "ZOOM_IN_OUT": "",
- "PREVIOUS": "",
- "NEXT": "",
- "title_photos": "",
- "title_auth": "",
- "title_accounts": "",
- "UPLOAD_FIRST_PHOTO": "",
- "IMPORT_YOUR_FOLDERS": "",
- "UPLOAD_DROPZONE_MESSAGE": "",
+ "DOWNLOAD_OPTION": "تنزيل (D)",
+ "DOWNLOAD_FAVORITES": "تنزيل المفضلات",
+ "DOWNLOAD_UNCATEGORIZED": "التنزيل غير المصنف",
+ "DOWNLOAD_HIDDEN_ITEMS": "تنزيل العناصر المخفية",
+ "COPY_OPTION": "نسخ كـ PNG (Ctrl/Cmd - C)",
+ "TOGGLE_FULLSCREEN": "تبديل ملء الشاشة (F)",
+ "ZOOM_IN_OUT": "تكبير/تصغير",
+ "PREVIOUS": "السابق (←)",
+ "NEXT": "التالي (→)",
+ "title_photos": "صور Ente",
+ "title_auth": "مصادقة Ente",
+ "title_accounts": "حسابات Ente",
+ "UPLOAD_FIRST_PHOTO": "تحميل صورتك الأولى",
+ "IMPORT_YOUR_FOLDERS": "استيراد مجلداتك",
+ "UPLOAD_DROPZONE_MESSAGE": "إسقاط للنسخ الاحتياطي للملفاتك",
"WATCH_FOLDER_DROPZONE_MESSAGE": "",
- "TRASH_FILES_TITLE": "",
- "TRASH_FILE_TITLE": "",
- "DELETE_FILES_TITLE": "",
- "DELETE_FILES_MESSAGE": "",
- "DELETE": "",
- "DELETE_OPTION": "",
- "FAVORITE_OPTION": "",
- "UNFAVORITE_OPTION": "",
- "MULTI_FOLDER_UPLOAD": "",
- "UPLOAD_STRATEGY_CHOICE": "",
- "UPLOAD_STRATEGY_SINGLE_COLLECTION": "",
- "OR": "",
- "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "",
- "SESSION_EXPIRED_MESSAGE": "",
- "SESSION_EXPIRED": "",
- "PASSWORD_GENERATION_FAILED": "",
- "CHANGE_PASSWORD": "",
- "password_changed_elsewhere": "",
- "password_changed_elsewhere_message": "",
- "GO_BACK": "",
- "RECOVERY_KEY": "",
- "SAVE_LATER": "",
- "SAVE": "",
+ "TRASH_FILES_TITLE": "حذف الملفات؟",
+ "TRASH_FILE_TITLE": "حذف الملف؟",
+ "DELETE_FILES_TITLE": "حذف فورا؟",
+ "DELETE_FILES_MESSAGE": "سيتم حذف الملفات المحددة نهائيا من حساب Ente الخاص بك.",
+ "DELETE": "حذف",
+ "DELETE_OPTION": "حذف (DEL)",
+ "FAVORITE_OPTION": "مفضلة (L)",
+ "UNFAVORITE_OPTION": "غير مفضلة (L)",
+ "MULTI_FOLDER_UPLOAD": "تم اكتشاف مجلدات متعددة",
+ "UPLOAD_STRATEGY_CHOICE": "هل ترغب في تحميلهم إلى",
+ "UPLOAD_STRATEGY_SINGLE_COLLECTION": "ألبوم واحد",
+ "OR": "أو",
+ "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "ألبومات منفصلة",
+ "SESSION_EXPIRED_MESSAGE": "لقد انتهت صلاحية جلستك، يرجى تسجيل الدخول مرة أخرى للمتابعة",
+ "SESSION_EXPIRED": "انتهت صلاحية الجلسة",
+ "PASSWORD_GENERATION_FAILED": "لم يتمكن متصفحك من إنشاء مفتاح قوي يفي بمعايير تشفير Ente، يرجى المحاولة باستخدام تطبيق الهاتف المحمول أو متصفح آخر",
+ "CHANGE_PASSWORD": "تغيير كلمة المرور",
+ "password_changed_elsewhere": "تم تغيير كلمة المرور في مكان آخر",
+ "password_changed_elsewhere_message": "يرجى تسجيل الدخول مرة أخرى على هذا الجهاز لاستخدام كلمة المرور الجديدة للمصادقة.",
+ "GO_BACK": "رجوع",
+ "RECOVERY_KEY": "مفتاح الاستعادة",
+ "SAVE_LATER": "قم بهذا لاحقا",
+ "SAVE": "حفظ المفتاح",
"RECOVERY_KEY_DESCRIPTION": "",
"RECOVER_KEY_GENERATION_FAILED": "",
"KEY_NOT_STORED_DISCLAIMER": "",
- "FORGOT_PASSWORD": "",
- "RECOVER_ACCOUNT": "",
- "RECOVERY_KEY_HINT": "",
- "RECOVER": "",
- "NO_RECOVERY_KEY": "",
- "INCORRECT_RECOVERY_KEY": "",
- "SORRY": "",
+ "FORGOT_PASSWORD": "نسيت كلمة المرور",
+ "RECOVER_ACCOUNT": "إستعادة الحساب",
+ "RECOVERY_KEY_HINT": "مفتاح الاستعادة",
+ "RECOVER": "استعادة",
+ "NO_RECOVERY_KEY": "ما من مفتاح استعادة؟",
+ "INCORRECT_RECOVERY_KEY": "مفتاح استعادة غير صحيح",
+ "SORRY": "عذرا",
"NO_RECOVERY_KEY_MESSAGE": "",
"NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "",
- "CONTACT_SUPPORT": "",
- "REQUEST_FEATURE": "",
- "SUPPORT": "",
- "CONFIRM": "",
- "cancel": "",
- "LOGOUT": "",
- "delete_account": "",
+ "CONTACT_SUPPORT": "الاتصال بالدعم",
+ "REQUEST_FEATURE": "طلب ميزة",
+ "SUPPORT": "الدعم",
+ "CONFIRM": "تأكيد",
+ "cancel": "إلغاء",
+ "LOGOUT": "تسجيل الخروج",
+ "delete_account": "حذف الحساب",
"delete_account_manually_message": "",
- "LOGOUT_MESSAGE": "",
- "CHANGE_EMAIL": "",
- "OK": "",
- "SUCCESS": "",
- "ERROR": "",
- "MESSAGE": "",
+ "LOGOUT_MESSAGE": "هل أنت متأكد من أنك تريد تسجيل الخروج؟",
+ "CHANGE_EMAIL": "تغيير البريد الإلكتروني",
+ "OK": "حسنا",
+ "SUCCESS": "تم بنجاح",
+ "ERROR": "خطأ",
+ "MESSAGE": "رسالة",
"OFFLINE_MSG": "",
"INSTALL_MOBILE_APP": "",
"DOWNLOAD_APP_MESSAGE": "",
- "DOWNLOAD_APP": "",
- "EXPORT": "",
- "SUBSCRIPTION": "",
- "SUBSCRIBE": "",
- "MANAGEMENT_PORTAL": "",
- "MANAGE_FAMILY_PORTAL": "",
- "LEAVE_FAMILY_PLAN": "",
- "LEAVE": "",
- "LEAVE_FAMILY_CONFIRM": "",
- "CHOOSE_PLAN": "",
- "MANAGE_PLAN": "",
- "CURRENT_USAGE": "",
- "TWO_MONTHS_FREE": "",
- "POPULAR": "",
- "free_plan_option": "",
- "free_plan_description": "",
- "active": "",
- "subscription_info_free": "",
+ "DOWNLOAD_APP": "تنزيل تطبيق سطح المكتب",
+ "EXPORT": "تصدير البيانات",
+ "SUBSCRIPTION": "اشتراك",
+ "SUBSCRIBE": "اشترك",
+ "MANAGEMENT_PORTAL": "إدارة طريقة الدفع",
+ "MANAGE_FAMILY_PORTAL": "إدارة العائلة",
+ "LEAVE_FAMILY_PLAN": "مغادرة خطة العائلة",
+ "LEAVE": "مغادرة",
+ "LEAVE_FAMILY_CONFIRM": "هل أنت متأكد من أنك تريد مغادرة الخطة العائلية؟",
+ "CHOOSE_PLAN": "اختر خطتك",
+ "MANAGE_PLAN": "إدارة اشتراكك",
+ "CURRENT_USAGE": "الاستخدام الحالي هو {{usage}}",
+ "TWO_MONTHS_FREE": "احصل على شهرين مجانا في الخطط السنوية",
+ "POPULAR": "رائج",
+ "free_plan_option": "المتابعة مع الخطة المجانية",
+ "free_plan_description": "{{storage}} مجاني للأبد",
+ "active": "نشط",
+ "subscription_info_free": "أنت في الخطة المجانية",
"subscription_info_family": "",
"subscription_info_expired": "",
"subscription_info_renewal_cancelled": "",
"subscription_info_storage_quota_exceeded": "",
"subscription_status_renewal_active": "",
- "subscription_status_renewal_cancelled": "",
+ "subscription_status_renewal_cancelled": "ينتهي في {{date, date}}",
"add_on_valid_till": "",
- "subscription_expired": "",
- "storage_quota_exceeded": "",
- "SUBSCRIPTION_PURCHASE_SUCCESS": "",
+ "subscription_expired": "إنتهت صلاحية الاشتراك",
+ "storage_quota_exceeded": "تم تجاوز حد التخزين",
+ "SUBSCRIPTION_PURCHASE_SUCCESS": "لقد تلقينا دفعتك
اشتراكك صالح حتى {{date, date}}
",
"SUBSCRIPTION_PURCHASE_CANCELLED": "",
"SUBSCRIPTION_PURCHASE_FAILED": "",
"SUBSCRIPTION_UPDATE_FAILED": "",
"UPDATE_PAYMENT_METHOD_MESSAGE": "",
"STRIPE_AUTHENTICATION_FAILED": "",
"UPDATE_PAYMENT_METHOD": "",
- "MONTHLY": "",
- "YEARLY": "",
- "MONTH_SHORT": "",
- "YEAR": "",
- "update_subscription_title": "",
- "UPDATE_SUBSCRIPTION_MESSAGE": "",
- "UPDATE_SUBSCRIPTION": "",
- "CANCEL_SUBSCRIPTION": "",
+ "MONTHLY": "شهريا",
+ "YEARLY": "سنويا",
+ "MONTH_SHORT": "شهر",
+ "YEAR": "سنة",
+ "update_subscription_title": "تأكيد تغيير الخطة",
+ "UPDATE_SUBSCRIPTION_MESSAGE": "هل أنت متأكد من أنك تريد تغيير خطتك؟",
+ "UPDATE_SUBSCRIPTION": "تغيير الخطة",
+ "CANCEL_SUBSCRIPTION": "إلغاء الاشتراك",
"CANCEL_SUBSCRIPTION_MESSAGE": "",
"CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "",
- "SUBSCRIPTION_CANCEL_FAILED": "",
- "SUBSCRIPTION_CANCEL_SUCCESS": "",
- "REACTIVATE_SUBSCRIPTION": "",
+ "SUBSCRIPTION_CANCEL_FAILED": "فشل في إلغاء الاشتراك",
+ "SUBSCRIPTION_CANCEL_SUCCESS": "تم إلغاء الاشتراك بنجاح",
+ "REACTIVATE_SUBSCRIPTION": "إعادة تنشيط الاشتراك",
"REACTIVATE_SUBSCRIPTION_MESSAGE": "",
"SUBSCRIPTION_ACTIVATE_SUCCESS": "",
"SUBSCRIPTION_ACTIVATE_FAILED": "",
- "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "",
+ "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "شكرا لك",
"CANCEL_SUBSCRIPTION_ON_MOBILE": "",
"CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "",
"MAIL_TO_MANAGE_SUBSCRIPTION": "",
- "RENAME": "",
- "RENAME_FILE": "",
- "RENAME_COLLECTION": "",
- "DELETE_COLLECTION_TITLE": "",
- "DELETE_COLLECTION": "",
+ "RENAME": "اعادة تسمية",
+ "RENAME_FILE": "إعادة تسمية ملف",
+ "RENAME_COLLECTION": "إعادة تسمية ألبوم",
+ "DELETE_COLLECTION_TITLE": "حذف ألبوم؟",
+ "DELETE_COLLECTION": "حذف ألبوم",
"DELETE_COLLECTION_MESSAGE": "",
- "DELETE_PHOTOS": "",
- "KEEP_PHOTOS": "",
- "SHARE_COLLECTION": "",
- "SHARE_WITH_SELF": "",
+ "DELETE_PHOTOS": "حذف الصور",
+ "KEEP_PHOTOS": "الاحتفاظ بالصور",
+ "SHARE_COLLECTION": "مشاركة الألبوم",
+ "SHARE_WITH_SELF": "عفوا، لا يمكنك المشاركة مع نفسك",
"ALREADY_SHARED": "",
- "SHARING_BAD_REQUEST_ERROR": "",
- "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "",
- "DOWNLOAD_COLLECTION": "",
+ "SHARING_BAD_REQUEST_ERROR": "لا يسمح بمشاركة الألبوم",
+ "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "المشاركة معطلة للحسابات المجانية",
+ "DOWNLOAD_COLLECTION": "تنزيل الألبوم",
"CREATE_ALBUM_FAILED": "",
- "SEARCH": "",
- "SEARCH_RESULTS": "",
- "NO_RESULTS": "",
- "SEARCH_HINT": "",
+ "SEARCH": "بحث",
+ "SEARCH_RESULTS": "نتائج البحث",
+ "NO_RESULTS": "لا توجد نتائج",
+ "SEARCH_HINT": "البحث عن الألبومات، التواريخ، والأوصاف...",
"SEARCH_TYPE": {
- "COLLECTION": "",
- "LOCATION": "",
- "CITY": "",
- "DATE": "",
- "FILE_NAME": "",
- "THING": "",
- "FILE_CAPTION": "",
- "FILE_TYPE": "",
- "CLIP": ""
+ "COLLECTION": "ألبوم",
+ "LOCATION": "الموقع",
+ "CITY": "الموقع",
+ "DATE": "تاريخ",
+ "FILE_NAME": "إسم الملف",
+ "THING": "المحتوى",
+ "FILE_CAPTION": "وصف",
+ "FILE_TYPE": "نوع الملف",
+ "CLIP": "سحر"
},
- "photos_count_zero": "",
- "photos_count_one": "",
- "photos_count": "",
- "TERMS_AND_CONDITIONS": "",
- "ADD_TO_COLLECTION": "",
- "SELECTED": "",
+ "photos_count_zero": "لا توجد ذكريات",
+ "photos_count_one": "ذكرى واحدة",
+ "photos_count": "{{count, number}} ذكريات",
+ "TERMS_AND_CONDITIONS": "أوافق على شروط الخدمة وسياسة الخصوصية",
+ "ADD_TO_COLLECTION": "إضافة إلى الألبوم",
+ "SELECTED": "محدد",
"PEOPLE": "",
- "indexing_scheduled": "",
+ "indexing_scheduled": "الفهرسة مجدولة...",
"indexing_photos": "",
"indexing_fetching": "",
"indexing_people": "",
"indexing_done": "",
- "UNIDENTIFIED_FACES": "",
+ "UNIDENTIFIED_FACES": "وجوه غير محددة",
"OBJECTS": "",
- "TEXT": "",
- "INFO": "",
- "INFO_OPTION": "",
- "FILE_NAME": "",
- "CAPTION_PLACEHOLDER": "",
- "LOCATION": "",
- "SHOW_ON_MAP": "",
- "MAP": "",
- "MAP_SETTINGS": "",
- "ENABLE_MAPS": "",
- "ENABLE_MAP": "",
- "DISABLE_MAPS": "",
+ "TEXT": "نص",
+ "INFO": "معلومات ",
+ "INFO_OPTION": "معلومات (I)",
+ "FILE_NAME": "إسم الملف",
+ "CAPTION_PLACEHOLDER": "إضافة وصف",
+ "LOCATION": "الموقع",
+ "SHOW_ON_MAP": "عرض على OpenStreetMap",
+ "MAP": "خريطة",
+ "MAP_SETTINGS": "إعدادات الخريطة",
+ "ENABLE_MAPS": "تمكين الخرائط ؟",
+ "ENABLE_MAP": "تمكين الخريطة",
+ "DISABLE_MAPS": "تعطيل الخرائط؟",
"ENABLE_MAP_DESCRIPTION": "",
"DISABLE_MAP_DESCRIPTION": "",
- "DISABLE_MAP": "",
- "DETAILS": "",
- "view_exif": "",
- "no_exif": "",
- "exif": "",
- "ISO": "",
+ "DISABLE_MAP": "تعطيل الخريطة",
+ "DETAILS": "تفاصيل",
+ "view_exif": "عرض جميع بيانات Exif",
+ "no_exif": "لا توجد بيانات Exif",
+ "exif": "Exif",
+ "ISO": "ISO",
"TWO_FACTOR": "",
- "TWO_FACTOR_AUTHENTICATION": "",
+ "TWO_FACTOR_AUTHENTICATION": "المصادقة الثنائية",
"TWO_FACTOR_QR_INSTRUCTION": "",
- "ENTER_CODE_MANUALLY": "",
+ "ENTER_CODE_MANUALLY": "أدخل الرمز يدويا",
"TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "",
- "SCAN_QR_CODE": "",
+ "SCAN_QR_CODE": "مسح رمز QR بدلاً من ذلك",
"ENABLE_TWO_FACTOR": "",
- "enable": "",
- "enabled": "",
+ "enable": "تفعيل",
+ "enabled": "مفعل",
"LOST_DEVICE": "",
- "INCORRECT_CODE": "",
+ "INCORRECT_CODE": "رمز غير صحيح",
"TWO_FACTOR_INFO": "",
"DISABLE_TWO_FACTOR_LABEL": "",
"UPDATE_TWO_FACTOR_LABEL": "",
- "disable": "",
- "reconfigure": "",
+ "disable": "تعطيل",
+ "reconfigure": "إعادة التهيئة",
"UPDATE_TWO_FACTOR": "",
"UPDATE_TWO_FACTOR_MESSAGE": "",
- "UPDATE": "",
+ "UPDATE": "تحديث",
"DISABLE_TWO_FACTOR": "",
"DISABLE_TWO_FACTOR_MESSAGE": "",
"TWO_FACTOR_DISABLE_FAILED": "",
- "EXPORT_DATA": "",
+ "EXPORT_DATA": "تصدير البيانات",
"select_folder": "",
"select_zips": "",
- "faq": "",
+ "faq": "الأسئلة الشائعة",
"takeout_hint": "",
- "DESTINATION": "",
- "START": "",
- "LAST_EXPORT_TIME": "",
+ "DESTINATION": "الوجهة",
+ "START": "بدء",
+ "LAST_EXPORT_TIME": "آخر وقت تصدير",
"EXPORT_AGAIN": "",
- "LOCAL_STORAGE_NOT_ACCESSIBLE": "",
+ "LOCAL_STORAGE_NOT_ACCESSIBLE": "التخزين المحلي غير قابل للوصول",
"LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "",
"SEND_OTT": "",
- "EMAIl_ALREADY_OWNED": "",
+ "EMAIl_ALREADY_OWNED": "البريد الإلكتروني مأخوذ بالفعل",
"ETAGS_BLOCKED": "",
"LIVE_PHOTOS_DETECTED": "",
"RETRY_FAILED": "",
@@ -321,22 +322,22 @@
"MOVE_TO_COLLECTION": "",
"UNARCHIVE": "",
"UNARCHIVE_COLLECTION": "",
- "HIDE_COLLECTION": "",
- "UNHIDE_COLLECTION": "",
- "MOVE": "",
- "ADD": "",
- "REMOVE": "",
- "YES_REMOVE": "",
+ "HIDE_COLLECTION": "إخفاء الألبوم",
+ "UNHIDE_COLLECTION": "إلغاء إخفاء الألبوم",
+ "MOVE": "نقل",
+ "ADD": "إضافة",
+ "REMOVE": "ازالة",
+ "YES_REMOVE": "نعم، إزالة",
"REMOVE_FROM_COLLECTION": "",
- "TRASH": "",
- "MOVE_TO_TRASH": "",
+ "TRASH": "سلة المهملات",
+ "MOVE_TO_TRASH": "نقل إلى سلة المهملات",
"TRASH_FILES_MESSAGE": "",
"TRASH_FILE_MESSAGE": "",
- "DELETE_PERMANENTLY": "",
- "RESTORE": "",
+ "DELETE_PERMANENTLY": "حذف بشكل دائم",
+ "RESTORE": "استعادة",
"RESTORE_TO_COLLECTION": "",
- "EMPTY_TRASH": "",
- "EMPTY_TRASH_TITLE": "",
+ "EMPTY_TRASH": "إفراغ سلة المهملات",
+ "EMPTY_TRASH_TITLE": "إفراغ سلة المهملات؟",
"EMPTY_TRASH_MESSAGE": "",
"LEAVE_SHARED_ALBUM": "",
"LEAVE_ALBUM": "",
diff --git a/web/packages/base/locales/bg-BG/translation.json b/web/packages/base/locales/bg-BG/translation.json
index 7df973eb83..787567c446 100644
--- a/web/packages/base/locales/bg-BG/translation.json
+++ b/web/packages/base/locales/bg-BG/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/ca-ES/translation.json b/web/packages/base/locales/ca-ES/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/ca-ES/translation.json
+++ b/web/packages/base/locales/ca-ES/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/de-DE/translation.json b/web/packages/base/locales/de-DE/translation.json
index 6c83a4dd72..fdad284a60 100644
--- a/web/packages/base/locales/de-DE/translation.json
+++ b/web/packages/base/locales/de-DE/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Wie hast du von Ente erfahren? (optional)",
"REFERRAL_INFO": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!",
"PASSPHRASE_MATCH_ERROR": "Die Passwörter stimmen nicht überein",
+ "create_albums": "",
"CREATE_COLLECTION": "Neues Album",
"ENTER_ALBUM_NAME": "Albumname",
"CLOSE_OPTION": "Schließen (Esc)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Das Vorschaubild konnte nicht erzeugt werden",
"UNSUPPORTED_FILES": "Nicht unterstützte Dateien",
"SUCCESSFUL_UPLOADS": "Erfolgreiche Uploads",
- "SKIPPED_INFO": "Diese wurden übersprungen, da es Dateien mit gleichen Namen im selben Album gibt",
+ "SKIPPED_INFO": "",
"UNSUPPORTED_INFO": "Ente unterstützt diese Dateiformate noch nicht",
"BLOCKED_UPLOADS": "Blockierte Uploads",
"INPROGRESS_METADATA_EXTRACTION": "In Bearbeitung",
diff --git a/web/packages/base/locales/el-GR/translation.json b/web/packages/base/locales/el-GR/translation.json
index 5f93899450..9ecc3b137f 100644
--- a/web/packages/base/locales/el-GR/translation.json
+++ b/web/packages/base/locales/el-GR/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Πώς ακούσατε για το Ente; (προαιρετικό)",
"REFERRAL_INFO": "Δεν παρακολουθούμε τις εγκαταστάσεις εφαρμογών. Θα μας βοηθούσε αν μας λέγατε που μας βρήκατε!",
"PASSPHRASE_MATCH_ERROR": "Οι κωδικοί πρόσβασης δεν ταιριάζουν",
+ "create_albums": "",
"CREATE_COLLECTION": "Νέο άλμπουμ",
"ENTER_ALBUM_NAME": "Όνομα άλμπουμ",
"CLOSE_OPTION": "Κλείσιμο (Esc)",
diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json
index 3adb4f3673..2e984af93b 100644
--- a/web/packages/base/locales/en-US/translation.json
+++ b/web/packages/base/locales/en-US/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "How did you hear about Ente? (optional)",
"REFERRAL_INFO": "We don't track app installs, It'd help us if you told us where you found us!",
"PASSPHRASE_MATCH_ERROR": "Passwords don't match",
+ "create_albums": "Create albums",
"CREATE_COLLECTION": "New album",
"ENTER_ALBUM_NAME": "Album name",
"CLOSE_OPTION": "Close (Esc)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generation failed",
"UNSUPPORTED_FILES": "Unsupported files",
"SUCCESSFUL_UPLOADS": "Successful uploads",
- "SKIPPED_INFO": "Skipped these as there are files with matching names in the same album",
+ "SKIPPED_INFO": "Skipped these as there are files with matching name and content in the same album",
"UNSUPPORTED_INFO": "Ente does not support these file formats yet",
"BLOCKED_UPLOADS": "Blocked uploads",
"INPROGRESS_METADATA_EXTRACTION": "In progress",
diff --git a/web/packages/base/locales/es-ES/translation.json b/web/packages/base/locales/es-ES/translation.json
index 6fee153eb7..3254dc154c 100644
--- a/web/packages/base/locales/es-ES/translation.json
+++ b/web/packages/base/locales/es-ES/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "¿Cómo escuchaste acerca de Ente? (opcional)",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "Las contraseñas no coinciden",
+ "create_albums": "",
"CREATE_COLLECTION": "Nuevo álbum",
"ENTER_ALBUM_NAME": "Nombre del álbum",
"CLOSE_OPTION": "Cerrar (Esc)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generación de miniaturas fallida",
"UNSUPPORTED_FILES": "Archivos no soportados",
"SUCCESSFUL_UPLOADS": "Subidas exitosas",
- "SKIPPED_INFO": "Se han omitido ya que hay archivos con nombres coincidentes en el mismo álbum",
+ "SKIPPED_INFO": "",
"UNSUPPORTED_INFO": "ente no soporta estos formatos de archivo aún",
"BLOCKED_UPLOADS": "Subidas bloqueadas",
"INPROGRESS_METADATA_EXTRACTION": "En proceso",
diff --git a/web/packages/base/locales/et-EE/translation.json b/web/packages/base/locales/et-EE/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/et-EE/translation.json
+++ b/web/packages/base/locales/et-EE/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/fa-IR/translation.json b/web/packages/base/locales/fa-IR/translation.json
index c3d6c8159a..b6d481c36a 100644
--- a/web/packages/base/locales/fa-IR/translation.json
+++ b/web/packages/base/locales/fa-IR/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/fi-FI/translation.json b/web/packages/base/locales/fi-FI/translation.json
index d0f899abef..8794455ddc 100644
--- a/web/packages/base/locales/fi-FI/translation.json
+++ b/web/packages/base/locales/fi-FI/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Miten kuulit Entestä? (valinnainen)",
"REFERRAL_INFO": "Emme seuraa sovelluksen asennuksia. Se auttaisi meitä, jos kertoisit mistä löysit meidät!",
"PASSPHRASE_MATCH_ERROR": "Salasanat eivät täsmää",
+ "create_albums": "",
"CREATE_COLLECTION": "Uusi albumi",
"ENTER_ALBUM_NAME": "Albumin nimi",
"CLOSE_OPTION": "Sulje (Esc)",
diff --git a/web/packages/base/locales/fr-FR/translation.json b/web/packages/base/locales/fr-FR/translation.json
index b315c99929..639b8a29b6 100644
--- a/web/packages/base/locales/fr-FR/translation.json
+++ b/web/packages/base/locales/fr-FR/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Comment avez-vous entendu parler de Ente? (facultatif)",
"REFERRAL_INFO": "Nous ne suivons pas les installations d'applications. Il serait utile que vous nous disiez comment vous nous avez trouvés !",
"PASSPHRASE_MATCH_ERROR": "Les mots de passe ne correspondent pas",
+ "create_albums": "",
"CREATE_COLLECTION": "Nouvel album",
"ENTER_ALBUM_NAME": "Nom de l'album",
"CLOSE_OPTION": "Fermer (Échap)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Échec de création d'une miniature",
"UNSUPPORTED_FILES": "Fichiers non supportés",
"SUCCESSFUL_UPLOADS": "Chargements réussis",
- "SKIPPED_INFO": "Ignorés car il y a des fichiers avec des noms identiques dans le même album",
+ "SKIPPED_INFO": "",
"UNSUPPORTED_INFO": "Ente ne supporte pas encore ces formats de fichiers",
"BLOCKED_UPLOADS": "Chargements bloqués",
"INPROGRESS_METADATA_EXTRACTION": "En cours",
diff --git a/web/packages/base/locales/gu-IN/translation.json b/web/packages/base/locales/gu-IN/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/gu-IN/translation.json
+++ b/web/packages/base/locales/gu-IN/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/hi-IN/translation.json b/web/packages/base/locales/hi-IN/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/hi-IN/translation.json
+++ b/web/packages/base/locales/hi-IN/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/id-ID/translation.json b/web/packages/base/locales/id-ID/translation.json
index 9bf887d7be..3f0ae89793 100644
--- a/web/packages/base/locales/id-ID/translation.json
+++ b/web/packages/base/locales/id-ID/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Dari mana Anda menemukan Ente? (opsional)",
"REFERRAL_INFO": "Kami tidak melacak pemasangan aplikasi, Ini akan membantu kami jika Anda memberi tahu kami di mana Anda menemukan kami!",
"PASSPHRASE_MATCH_ERROR": "Kata sandi tidak cocok",
+ "create_albums": "",
"CREATE_COLLECTION": "Album baru",
"ENTER_ALBUM_NAME": "Nama album",
"CLOSE_OPTION": "Tutup (Esc)",
diff --git a/web/packages/base/locales/is-IS/translation.json b/web/packages/base/locales/is-IS/translation.json
index 609dff0ef7..326ed688ec 100644
--- a/web/packages/base/locales/is-IS/translation.json
+++ b/web/packages/base/locales/is-IS/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/it-IT/translation.json b/web/packages/base/locales/it-IT/translation.json
index e32e814fda..63d1d3fee1 100644
--- a/web/packages/base/locales/it-IT/translation.json
+++ b/web/packages/base/locales/it-IT/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Come hai conosciuto Ente? (opzionale)",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "Le password non corrispondono",
+ "create_albums": "",
"CREATE_COLLECTION": "Nuovo album",
"ENTER_ALBUM_NAME": "Nome album",
"CLOSE_OPTION": "Chiudi (Esc)",
diff --git a/web/packages/base/locales/ja-JP/translation.json b/web/packages/base/locales/ja-JP/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/ja-JP/translation.json
+++ b/web/packages/base/locales/ja-JP/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/km-KH/translation.json b/web/packages/base/locales/km-KH/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/km-KH/translation.json
+++ b/web/packages/base/locales/km-KH/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/ko-KR/translation.json b/web/packages/base/locales/ko-KR/translation.json
index 0afc0224fd..0a973ce7ae 100644
--- a/web/packages/base/locales/ko-KR/translation.json
+++ b/web/packages/base/locales/ko-KR/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "어떻게 Ente에 대해 들으셨나요? (선택사항)",
"REFERRAL_INFO": "우리는 앱 설치를 추적하지 않습니다. 우리를 알게 된 곳을 남겨주시면 우리에게 도움이 될꺼에요!",
"PASSPHRASE_MATCH_ERROR": "비밀번호가 일치하지 않습니다",
+ "create_albums": "",
"CREATE_COLLECTION": "새 앨범",
"ENTER_ALBUM_NAME": "앨범 이름",
"CLOSE_OPTION": "닫기 (Esc)",
diff --git a/web/packages/base/locales/nl-NL/translation.json b/web/packages/base/locales/nl-NL/translation.json
index 1dd8934a3c..d8cba2d986 100644
--- a/web/packages/base/locales/nl-NL/translation.json
+++ b/web/packages/base/locales/nl-NL/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Hoe hoorde je over Ente? (optioneel)",
"REFERRAL_INFO": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!",
"PASSPHRASE_MATCH_ERROR": "Wachtwoorden komen niet overeen",
+ "create_albums": "",
"CREATE_COLLECTION": "Nieuw album",
"ENTER_ALBUM_NAME": "Albumnaam",
"CLOSE_OPTION": "Sluiten (Esc)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generatie mislukt",
"UNSUPPORTED_FILES": "Niet-ondersteunde bestanden",
"SUCCESSFUL_UPLOADS": "Succesvolle uploads",
- "SKIPPED_INFO": "Deze zijn overgeslagen omdat er bestanden zijn met overeenkomende namen in hetzelfde album",
+ "SKIPPED_INFO": "",
"UNSUPPORTED_INFO": "Ente ondersteunt deze bestandsformaten nog niet",
"BLOCKED_UPLOADS": "Geblokkeerde uploads",
"INPROGRESS_METADATA_EXTRACTION": "In behandeling",
diff --git a/web/packages/base/locales/pl-PL/translation.json b/web/packages/base/locales/pl-PL/translation.json
index 2bf6775c9a..61e464e0af 100644
--- a/web/packages/base/locales/pl-PL/translation.json
+++ b/web/packages/base/locales/pl-PL/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Jak usłyszałeś/aś o Ente? (opcjonalnie)",
"REFERRAL_INFO": "Nie śledzimy instalacji aplikacji. Pomogłyby nam, gdybyś powiedział/a nam, gdzie nas znalazłeś/aś!",
"PASSPHRASE_MATCH_ERROR": "Hasła nie pasują do siebie",
+ "create_albums": "Utwórz albumy",
"CREATE_COLLECTION": "Nowy album",
"ENTER_ALBUM_NAME": "Nazwa albumu",
"CLOSE_OPTION": "Zamknij (Esc)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generowanie miniatur nie powiodło się",
"UNSUPPORTED_FILES": "Nieobsługiwane pliki",
"SUCCESSFUL_UPLOADS": "Pomyślne przesłania",
- "SKIPPED_INFO": "Pominięto te pliki, ponieważ są pliki z pasującymi nazwami w tym samym albumie",
+ "SKIPPED_INFO": "Pominięto te pliki, ponieważ są pliki z pasującymi nazwami i zawartością w tym samym albumie",
"UNSUPPORTED_INFO": "Ente nie obsługuje jeszcze tych formatów plików",
"BLOCKED_UPLOADS": "Zablokowane przesłania",
"INPROGRESS_METADATA_EXTRACTION": "W toku",
diff --git a/web/packages/base/locales/pt-BR/translation.json b/web/packages/base/locales/pt-BR/translation.json
index 7cb8e2e3ca..6e640539b1 100644
--- a/web/packages/base/locales/pt-BR/translation.json
+++ b/web/packages/base/locales/pt-BR/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Como você ouviu sobre o Ente? (opcional)",
"REFERRAL_INFO": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!",
"PASSPHRASE_MATCH_ERROR": "As senhas não coincidem",
+ "create_albums": "Criar álbuns",
"CREATE_COLLECTION": "Novo álbum",
"ENTER_ALBUM_NAME": "Nome do álbum",
"CLOSE_OPTION": "Fechar (Esc)",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Falha ao gerar miniaturas",
"UNSUPPORTED_FILES": "Arquivos não suportados",
"SUCCESSFUL_UPLOADS": "Envios bem sucedidos",
- "SKIPPED_INFO": "Ignorar estes como existem arquivos com nomes correspondentes no mesmo álbum",
+ "SKIPPED_INFO": "Estes foram pulados, pois há arquivos com nome e conteúdo correspondentes no mesmo álbum",
"UNSUPPORTED_INFO": "ente ainda não suporta estes formatos de arquivo",
"BLOCKED_UPLOADS": "Envios bloqueados",
"INPROGRESS_METADATA_EXTRACTION": "Em andamento",
diff --git a/web/packages/base/locales/pt-PT/translation.json b/web/packages/base/locales/pt-PT/translation.json
index b1d4f2f26e..e5f318a3be 100644
--- a/web/packages/base/locales/pt-PT/translation.json
+++ b/web/packages/base/locales/pt-PT/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "Novo álbum",
"ENTER_ALBUM_NAME": "Nome do álbum",
"CLOSE_OPTION": "Fechar (Esc)",
diff --git a/web/packages/base/locales/ru-RU/translation.json b/web/packages/base/locales/ru-RU/translation.json
index f15cc4a8f0..26a8fb055c 100644
--- a/web/packages/base/locales/ru-RU/translation.json
+++ b/web/packages/base/locales/ru-RU/translation.json
@@ -1,11 +1,11 @@
{
- "HERO_SLIDE_1_TITLE": "Личные резервные копии
для твоих воспоминаний
",
+ "HERO_SLIDE_1_TITLE": "Приватные резервные копии
для ваших воспоминаний
",
"HERO_SLIDE_1": "Сквозное шифрование по умолчанию",
"HERO_SLIDE_2_TITLE": "Надежно хранится
в убежище от радиоактивных осадков
",
"HERO_SLIDE_2": "Созданный для того, чтобы пережить",
"HERO_SLIDE_3_TITLE": "Доступно
везде
",
"HERO_SLIDE_3": "Android, iOS, Веб, ПК",
- "LOGIN": "Авторизоваться",
+ "LOGIN": "Войти",
"SIGN_UP": "Регистрация",
"NEW_USER": "Новенький в Ente",
"EXISTING_USER": "Существующий пользователь",
@@ -19,7 +19,7 @@
"ENTER_OTT": "Проверочный код",
"RESEND_MAIL": "Отправить код еще раз",
"VERIFY": "Подтвердить",
- "UNKNOWN_ERROR": "Что-то пошло не так, Попробуйте еще раз",
+ "UNKNOWN_ERROR": "Что-то пошло не так, попробуйте еще раз",
"INVALID_CODE": "Неверный код подтверждения",
"EXPIRED_CODE": "Срок действия вашего проверочного кода истек",
"SENDING": "Отправка...",
@@ -28,7 +28,7 @@
"link_password_description": "Введите пароль, чтобы разблокировать альбом",
"unlock": "Разблокировать",
"SET_PASSPHRASE": "Установить пароль",
- "VERIFY_PASSPHRASE": "Войти",
+ "VERIFY_PASSPHRASE": "Зарегистрироваться",
"INCORRECT_PASSPHRASE": "Неверный пароль",
"ENTER_ENC_PASSPHRASE": "Пожалуйста, введите пароль, который мы можем использовать для шифрования ваших данных",
"PASSPHRASE_DISCLAIMER": "Мы не храним ваш пароль, поэтому, если вы его забудете,\nмы ничем не сможем вам помочь\nвосстановите ваши данные без ключа восстановления.",
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Как вы узнали о Ente? (необязательно)",
"REFERRAL_INFO": "Будет полезно, если вы укажете, где нашли нас, так как мы не отслеживаем установки приложения!",
"PASSPHRASE_MATCH_ERROR": "Пароли не совпадают",
+ "create_albums": "Создать альбомы",
"CREATE_COLLECTION": "Новый альбом",
"ENTER_ALBUM_NAME": "Название альбома",
"CLOSE_OPTION": "Закрыть (Esc)",
@@ -175,7 +176,7 @@
"UPDATE_PAYMENT_METHOD": "Обновить платёжную информацию",
"MONTHLY": "Ежемесячно",
"YEARLY": "Ежегодно",
- "MONTH_SHORT": "мо",
+ "MONTH_SHORT": "мес",
"YEAR": "год",
"update_subscription_title": "Подтвердить изменение плана",
"UPDATE_SUBSCRIPTION_MESSAGE": "Хотите сменить текущий план?",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Не удалось создать миниатюру",
"UNSUPPORTED_FILES": "Неподдерживаемые файлы",
"SUCCESSFUL_UPLOADS": "Успешные загрузки",
- "SKIPPED_INFO": "Пропустил их, так как в одном альбоме есть файлы с одинаковыми названиями",
+ "SKIPPED_INFO": "Пропущено, так как в альбоме есть файлы с совпадающими именем и содержимым",
"UNSUPPORTED_INFO": "Ente пока не поддерживает эти форматы файлов",
"BLOCKED_UPLOADS": "Заблокированные загрузки",
"INPROGRESS_METADATA_EXTRACTION": "В процессе",
@@ -335,8 +336,8 @@
"DELETE_PERMANENTLY": "Удалить навсегда",
"RESTORE": "Восстанавливать",
"RESTORE_TO_COLLECTION": "Восстановить в альбом",
- "EMPTY_TRASH": "Пустой мусор",
- "EMPTY_TRASH_TITLE": "Пустой мусор?",
+ "EMPTY_TRASH": "Очистить корзину",
+ "EMPTY_TRASH_TITLE": "Очистить корзину?",
"EMPTY_TRASH_MESSAGE": "Эти файлы будут безвозвратно удалены из вашей учетной записи Ente.",
"LEAVE_SHARED_ALBUM": "Да, уходи",
"LEAVE_ALBUM": "Оставить альбом",
@@ -485,15 +486,15 @@
"indexing": "Индексирование",
"processed": "Обработано",
"indexing_status_running": "Выполняется",
- "indexing_status_fetching": "",
+ "indexing_status_fetching": "Получение",
"indexing_status_scheduled": "Запланировано",
"indexing_status_done": "Готово",
"ml_search_disable": "Отключить машинное обучение",
"ml_search_disable_confirm": "Вы хотите отключить машинное обучение на всех ваших устройствах?",
- "ml_consent": "",
- "ml_consent_title": "",
- "ml_consent_description": "",
- "ml_consent_confirmation": "",
+ "ml_consent": "Включить машинное обучение",
+ "ml_consent_title": "Включить машинное обучение?",
+ "ml_consent_description": "Если вы включите машинное обучение, Ente будет извлекать информацию из файлов (например, геометрию лица), включая те, которыми с вами поделились.
Это будет происходить на вашем устройстве, и любая сгенерированная биометрическая информация будет зашифрована с использованием сквозного (End-to-End) шифрования
Пожалуйста нажмите здесь для получения дополнительной информации об этой функции в нашей политике конфиденциальности
",
+ "ml_consent_confirmation": "Я понимаю, и хочу разрешить машинное обучение",
"labs": "Лаборатории",
"YOURS": "твой",
"passphrase_strength_weak": "Надежность пароля: слабая",
@@ -532,8 +533,8 @@
"EXPORT_PROGRESS": "{{progress.success, number}} / {{progress.total, number}} синхронизированные элементы",
"MIGRATING_EXPORT": "Подготовка...",
"RENAMING_COLLECTION_FOLDERS": "Переименование папок альбомов...",
- "TRASHING_DELETED_FILES": "Удаление удаленных файлов...",
- "TRASHING_DELETED_COLLECTIONS": "Удаление удаленных альбомов...",
+ "TRASHING_DELETED_FILES": "Очистка удаленных файлов...",
+ "TRASHING_DELETED_COLLECTIONS": "Очистка удаленных альбомов...",
"CONTINUOUS_EXPORT": "Непрерывная синхронизация",
"PENDING_ITEMS": "Отложенные пункты",
"EXPORT_STARTING": "Запуск экспорта...",
diff --git a/web/packages/base/locales/sv-SE/translation.json b/web/packages/base/locales/sv-SE/translation.json
index 42cdf3e79a..48bea6d012 100644
--- a/web/packages/base/locales/sv-SE/translation.json
+++ b/web/packages/base/locales/sv-SE/translation.json
@@ -7,7 +7,7 @@
"HERO_SLIDE_3": "Android, iOS, webb, skrivbord",
"LOGIN": "Logga in",
"SIGN_UP": "Registrera",
- "NEW_USER": "",
+ "NEW_USER": "Ny hos Ente",
"EXISTING_USER": "Befintlig användare",
"ENTER_NAME": "Ange namn",
"PUBLIC_UPLOADER_NAME_MESSAGE": "Lägg till ett namn så att dina vänner vet vem de ska tacka för dessa fantastiska bilder!",
@@ -26,7 +26,7 @@
"SENT": "Skickat!",
"password": "Lösenord",
"link_password_description": "Ange lösenord för att låsa upp albumet",
- "unlock": "",
+ "unlock": "Lås upp",
"SET_PASSPHRASE": "Välj lösenord",
"VERIFY_PASSPHRASE": "Logga in",
"INCORRECT_PASSPHRASE": "Felaktigt lösenord",
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "Hur hörde du talas om Ente? (valfritt)",
"REFERRAL_INFO": "Vi spårar inte appinstallationer, Det skulle hjälpa oss om du berättade var du hittade oss!",
"PASSPHRASE_MATCH_ERROR": "Lösenorden matchar inte",
+ "create_albums": "Skapa album",
"CREATE_COLLECTION": "Nytt album",
"ENTER_ALBUM_NAME": "Albumnamn",
"CLOSE_OPTION": "Stäng (Esc)",
@@ -60,11 +61,11 @@
"0": "Förbereder att ladda upp",
"1": "Läser Google metadatafiler",
"2": "Metadata för {{uploadCounter.finished, number}} / {{uploadCounter.total, number}} filer extraherat",
- "3": "",
- "4": "",
- "5": ""
+ "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} filer behandlade",
+ "4": "Avbryter återstående uppladdningar",
+ "5": "Säkerhetskopiering slutförd"
},
- "FILE_NOT_UPLOADED_LIST": "",
+ "FILE_NOT_UPLOADED_LIST": "Följande filer laddades ej upp",
"INITIAL_LOAD_DELAY_WARNING": "",
"USER_DOES_NOT_EXIST": "",
"NO_ACCOUNT": "",
@@ -650,7 +651,7 @@
"redirect_close_instructions": "",
"redirect_again": "",
"autogenerated_first_album_name": "",
- "autogenerated_default_album_name": "",
+ "autogenerated_default_album_name": "Nytt album",
"developer_settings": "Utvecklarinställningar",
"server_endpoint": "",
"more_information": "",
diff --git a/web/packages/base/locales/ta-IN/translation.json b/web/packages/base/locales/ta-IN/translation.json
new file mode 100644
index 0000000000..f90267b710
--- /dev/null
+++ b/web/packages/base/locales/ta-IN/translation.json
@@ -0,0 +1,659 @@
+{
+ "HERO_SLIDE_1_TITLE": "",
+ "HERO_SLIDE_1": "",
+ "HERO_SLIDE_2_TITLE": "",
+ "HERO_SLIDE_2": "",
+ "HERO_SLIDE_3_TITLE": "",
+ "HERO_SLIDE_3": "",
+ "LOGIN": "",
+ "SIGN_UP": "",
+ "NEW_USER": "",
+ "EXISTING_USER": "",
+ "ENTER_NAME": "",
+ "PUBLIC_UPLOADER_NAME_MESSAGE": "",
+ "ENTER_EMAIL": "",
+ "EMAIL_ERROR": "",
+ "REQUIRED": "",
+ "EMAIL_SENT": "",
+ "CHECK_INBOX": "",
+ "ENTER_OTT": "",
+ "RESEND_MAIL": "",
+ "VERIFY": "",
+ "UNKNOWN_ERROR": "",
+ "INVALID_CODE": "",
+ "EXPIRED_CODE": "",
+ "SENDING": "",
+ "SENT": "",
+ "password": "",
+ "link_password_description": "",
+ "unlock": "",
+ "SET_PASSPHRASE": "",
+ "VERIFY_PASSPHRASE": "",
+ "INCORRECT_PASSPHRASE": "",
+ "ENTER_ENC_PASSPHRASE": "",
+ "PASSPHRASE_DISCLAIMER": "",
+ "WELCOME_TO_ENTE_HEADING": "",
+ "WELCOME_TO_ENTE_SUBHEADING": "",
+ "WHERE_YOUR_BEST_PHOTOS_LIVE": "",
+ "KEY_GENERATION_IN_PROGRESS_MESSAGE": "",
+ "PASSPHRASE_HINT": "",
+ "CONFIRM_PASSPHRASE": "",
+ "REFERRAL_CODE_HINT": "",
+ "REFERRAL_INFO": "",
+ "PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
+ "CREATE_COLLECTION": "",
+ "ENTER_ALBUM_NAME": "",
+ "CLOSE_OPTION": "",
+ "ENTER_FILE_NAME": "",
+ "CLOSE": "",
+ "NO": "",
+ "NOTHING_HERE": "",
+ "upload": "",
+ "import": "",
+ "ADD_PHOTOS": "",
+ "ADD_MORE_PHOTOS": "",
+ "add_photos_count_one": "",
+ "add_photos_count": "",
+ "select_photos": "",
+ "FILE_UPLOAD": "",
+ "UPLOAD_STAGE_MESSAGE": {
+ "0": "",
+ "1": "",
+ "2": "",
+ "3": "",
+ "4": "",
+ "5": ""
+ },
+ "FILE_NOT_UPLOADED_LIST": "",
+ "INITIAL_LOAD_DELAY_WARNING": "",
+ "USER_DOES_NOT_EXIST": "",
+ "NO_ACCOUNT": "",
+ "ACCOUNT_EXISTS": "",
+ "CREATE": "",
+ "DOWNLOAD": "",
+ "DOWNLOAD_OPTION": "",
+ "DOWNLOAD_FAVORITES": "",
+ "DOWNLOAD_UNCATEGORIZED": "",
+ "DOWNLOAD_HIDDEN_ITEMS": "",
+ "COPY_OPTION": "",
+ "TOGGLE_FULLSCREEN": "",
+ "ZOOM_IN_OUT": "",
+ "PREVIOUS": "",
+ "NEXT": "",
+ "title_photos": "",
+ "title_auth": "",
+ "title_accounts": "",
+ "UPLOAD_FIRST_PHOTO": "",
+ "IMPORT_YOUR_FOLDERS": "",
+ "UPLOAD_DROPZONE_MESSAGE": "",
+ "WATCH_FOLDER_DROPZONE_MESSAGE": "",
+ "TRASH_FILES_TITLE": "",
+ "TRASH_FILE_TITLE": "",
+ "DELETE_FILES_TITLE": "",
+ "DELETE_FILES_MESSAGE": "",
+ "DELETE": "",
+ "DELETE_OPTION": "",
+ "FAVORITE_OPTION": "",
+ "UNFAVORITE_OPTION": "",
+ "MULTI_FOLDER_UPLOAD": "",
+ "UPLOAD_STRATEGY_CHOICE": "",
+ "UPLOAD_STRATEGY_SINGLE_COLLECTION": "",
+ "OR": "",
+ "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "",
+ "SESSION_EXPIRED_MESSAGE": "",
+ "SESSION_EXPIRED": "",
+ "PASSWORD_GENERATION_FAILED": "",
+ "CHANGE_PASSWORD": "",
+ "password_changed_elsewhere": "",
+ "password_changed_elsewhere_message": "",
+ "GO_BACK": "",
+ "RECOVERY_KEY": "",
+ "SAVE_LATER": "",
+ "SAVE": "",
+ "RECOVERY_KEY_DESCRIPTION": "",
+ "RECOVER_KEY_GENERATION_FAILED": "",
+ "KEY_NOT_STORED_DISCLAIMER": "",
+ "FORGOT_PASSWORD": "",
+ "RECOVER_ACCOUNT": "",
+ "RECOVERY_KEY_HINT": "",
+ "RECOVER": "",
+ "NO_RECOVERY_KEY": "",
+ "INCORRECT_RECOVERY_KEY": "",
+ "SORRY": "",
+ "NO_RECOVERY_KEY_MESSAGE": "",
+ "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "",
+ "CONTACT_SUPPORT": "",
+ "REQUEST_FEATURE": "",
+ "SUPPORT": "",
+ "CONFIRM": "",
+ "cancel": "",
+ "LOGOUT": "",
+ "delete_account": "",
+ "delete_account_manually_message": "",
+ "LOGOUT_MESSAGE": "",
+ "CHANGE_EMAIL": "",
+ "OK": "",
+ "SUCCESS": "",
+ "ERROR": "",
+ "MESSAGE": "",
+ "OFFLINE_MSG": "",
+ "INSTALL_MOBILE_APP": "",
+ "DOWNLOAD_APP_MESSAGE": "",
+ "DOWNLOAD_APP": "",
+ "EXPORT": "",
+ "SUBSCRIPTION": "",
+ "SUBSCRIBE": "",
+ "MANAGEMENT_PORTAL": "",
+ "MANAGE_FAMILY_PORTAL": "",
+ "LEAVE_FAMILY_PLAN": "",
+ "LEAVE": "",
+ "LEAVE_FAMILY_CONFIRM": "",
+ "CHOOSE_PLAN": "",
+ "MANAGE_PLAN": "",
+ "CURRENT_USAGE": "",
+ "TWO_MONTHS_FREE": "",
+ "POPULAR": "",
+ "free_plan_option": "",
+ "free_plan_description": "",
+ "active": "",
+ "subscription_info_free": "",
+ "subscription_info_family": "",
+ "subscription_info_expired": "",
+ "subscription_info_renewal_cancelled": "",
+ "subscription_info_storage_quota_exceeded": "",
+ "subscription_status_renewal_active": "",
+ "subscription_status_renewal_cancelled": "",
+ "add_on_valid_till": "",
+ "subscription_expired": "",
+ "storage_quota_exceeded": "",
+ "SUBSCRIPTION_PURCHASE_SUCCESS": "",
+ "SUBSCRIPTION_PURCHASE_CANCELLED": "",
+ "SUBSCRIPTION_PURCHASE_FAILED": "",
+ "SUBSCRIPTION_UPDATE_FAILED": "",
+ "UPDATE_PAYMENT_METHOD_MESSAGE": "",
+ "STRIPE_AUTHENTICATION_FAILED": "",
+ "UPDATE_PAYMENT_METHOD": "",
+ "MONTHLY": "",
+ "YEARLY": "",
+ "MONTH_SHORT": "",
+ "YEAR": "",
+ "update_subscription_title": "",
+ "UPDATE_SUBSCRIPTION_MESSAGE": "",
+ "UPDATE_SUBSCRIPTION": "",
+ "CANCEL_SUBSCRIPTION": "",
+ "CANCEL_SUBSCRIPTION_MESSAGE": "",
+ "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "",
+ "SUBSCRIPTION_CANCEL_FAILED": "",
+ "SUBSCRIPTION_CANCEL_SUCCESS": "",
+ "REACTIVATE_SUBSCRIPTION": "",
+ "REACTIVATE_SUBSCRIPTION_MESSAGE": "",
+ "SUBSCRIPTION_ACTIVATE_SUCCESS": "",
+ "SUBSCRIPTION_ACTIVATE_FAILED": "",
+ "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "",
+ "CANCEL_SUBSCRIPTION_ON_MOBILE": "",
+ "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "",
+ "MAIL_TO_MANAGE_SUBSCRIPTION": "",
+ "RENAME": "",
+ "RENAME_FILE": "",
+ "RENAME_COLLECTION": "",
+ "DELETE_COLLECTION_TITLE": "",
+ "DELETE_COLLECTION": "",
+ "DELETE_COLLECTION_MESSAGE": "",
+ "DELETE_PHOTOS": "",
+ "KEEP_PHOTOS": "",
+ "SHARE_COLLECTION": "",
+ "SHARE_WITH_SELF": "",
+ "ALREADY_SHARED": "",
+ "SHARING_BAD_REQUEST_ERROR": "",
+ "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "",
+ "DOWNLOAD_COLLECTION": "",
+ "CREATE_ALBUM_FAILED": "",
+ "SEARCH": "",
+ "SEARCH_RESULTS": "",
+ "NO_RESULTS": "",
+ "SEARCH_HINT": "",
+ "SEARCH_TYPE": {
+ "COLLECTION": "",
+ "LOCATION": "",
+ "CITY": "",
+ "DATE": "",
+ "FILE_NAME": "",
+ "THING": "",
+ "FILE_CAPTION": "",
+ "FILE_TYPE": "",
+ "CLIP": ""
+ },
+ "photos_count_zero": "",
+ "photos_count_one": "",
+ "photos_count": "",
+ "TERMS_AND_CONDITIONS": "",
+ "ADD_TO_COLLECTION": "",
+ "SELECTED": "",
+ "PEOPLE": "",
+ "indexing_scheduled": "",
+ "indexing_photos": "",
+ "indexing_fetching": "",
+ "indexing_people": "",
+ "indexing_done": "",
+ "UNIDENTIFIED_FACES": "",
+ "OBJECTS": "",
+ "TEXT": "",
+ "INFO": "",
+ "INFO_OPTION": "",
+ "FILE_NAME": "",
+ "CAPTION_PLACEHOLDER": "",
+ "LOCATION": "",
+ "SHOW_ON_MAP": "",
+ "MAP": "",
+ "MAP_SETTINGS": "",
+ "ENABLE_MAPS": "",
+ "ENABLE_MAP": "",
+ "DISABLE_MAPS": "",
+ "ENABLE_MAP_DESCRIPTION": "",
+ "DISABLE_MAP_DESCRIPTION": "",
+ "DISABLE_MAP": "",
+ "DETAILS": "",
+ "view_exif": "",
+ "no_exif": "",
+ "exif": "",
+ "ISO": "",
+ "TWO_FACTOR": "",
+ "TWO_FACTOR_AUTHENTICATION": "",
+ "TWO_FACTOR_QR_INSTRUCTION": "",
+ "ENTER_CODE_MANUALLY": "",
+ "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "",
+ "SCAN_QR_CODE": "",
+ "ENABLE_TWO_FACTOR": "",
+ "enable": "",
+ "enabled": "",
+ "LOST_DEVICE": "",
+ "INCORRECT_CODE": "",
+ "TWO_FACTOR_INFO": "",
+ "DISABLE_TWO_FACTOR_LABEL": "",
+ "UPDATE_TWO_FACTOR_LABEL": "",
+ "disable": "",
+ "reconfigure": "",
+ "UPDATE_TWO_FACTOR": "",
+ "UPDATE_TWO_FACTOR_MESSAGE": "",
+ "UPDATE": "",
+ "DISABLE_TWO_FACTOR": "",
+ "DISABLE_TWO_FACTOR_MESSAGE": "",
+ "TWO_FACTOR_DISABLE_FAILED": "",
+ "EXPORT_DATA": "",
+ "select_folder": "",
+ "select_zips": "",
+ "faq": "",
+ "takeout_hint": "",
+ "DESTINATION": "",
+ "START": "",
+ "LAST_EXPORT_TIME": "",
+ "EXPORT_AGAIN": "",
+ "LOCAL_STORAGE_NOT_ACCESSIBLE": "",
+ "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "",
+ "SEND_OTT": "",
+ "EMAIl_ALREADY_OWNED": "",
+ "ETAGS_BLOCKED": "",
+ "LIVE_PHOTOS_DETECTED": "",
+ "RETRY_FAILED": "",
+ "FAILED_UPLOADS": "",
+ "failed_uploads_hint": "",
+ "SKIPPED_FILES": "",
+ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "",
+ "UNSUPPORTED_FILES": "",
+ "SUCCESSFUL_UPLOADS": "",
+ "SKIPPED_INFO": "",
+ "UNSUPPORTED_INFO": "",
+ "BLOCKED_UPLOADS": "",
+ "INPROGRESS_METADATA_EXTRACTION": "",
+ "INPROGRESS_UPLOADS": "",
+ "TOO_LARGE_UPLOADS": "",
+ "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "",
+ "LARGER_THAN_AVAILABLE_STORAGE_INFO": "",
+ "TOO_LARGE_INFO": "",
+ "THUMBNAIL_GENERATION_FAILED_INFO": "",
+ "UPLOAD_TO_COLLECTION": "",
+ "UNCATEGORIZED": "",
+ "ARCHIVE": "",
+ "FAVORITES": "",
+ "ARCHIVE_COLLECTION": "",
+ "ARCHIVE_SECTION_NAME": "",
+ "ALL_SECTION_NAME": "",
+ "MOVE_TO_COLLECTION": "",
+ "UNARCHIVE": "",
+ "UNARCHIVE_COLLECTION": "",
+ "HIDE_COLLECTION": "",
+ "UNHIDE_COLLECTION": "",
+ "MOVE": "",
+ "ADD": "",
+ "REMOVE": "",
+ "YES_REMOVE": "",
+ "REMOVE_FROM_COLLECTION": "",
+ "TRASH": "",
+ "MOVE_TO_TRASH": "",
+ "TRASH_FILES_MESSAGE": "",
+ "TRASH_FILE_MESSAGE": "",
+ "DELETE_PERMANENTLY": "",
+ "RESTORE": "",
+ "RESTORE_TO_COLLECTION": "",
+ "EMPTY_TRASH": "",
+ "EMPTY_TRASH_TITLE": "",
+ "EMPTY_TRASH_MESSAGE": "",
+ "LEAVE_SHARED_ALBUM": "",
+ "LEAVE_ALBUM": "",
+ "LEAVE_SHARED_ALBUM_TITLE": "",
+ "LEAVE_SHARED_ALBUM_MESSAGE": "",
+ "NOT_FILE_OWNER": "",
+ "CONFIRM_SELF_REMOVE_MESSAGE": "",
+ "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "",
+ "SORT_BY_CREATION_TIME_ASCENDING": "",
+ "SORT_BY_UPDATION_TIME_DESCENDING": "",
+ "SORT_BY_NAME": "",
+ "FIX_CREATION_TIME": "",
+ "FIX_CREATION_TIME_IN_PROGRESS": "",
+ "CREATION_TIME_UPDATED": "",
+ "UPDATE_CREATION_TIME_NOT_STARTED": "",
+ "UPDATE_CREATION_TIME_COMPLETED": "",
+ "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "",
+ "CAPTION_CHARACTER_LIMIT": "",
+ "DATE_TIME_ORIGINAL": "",
+ "DATE_TIME_DIGITIZED": "",
+ "METADATA_DATE": "",
+ "CUSTOM_TIME": "",
+ "REOPEN_PLAN_SELECTOR_MODAL": "",
+ "OPEN_PLAN_SELECTOR_MODAL_FAILED": "",
+ "INSTALL": "",
+ "SHARING_DETAILS": "",
+ "MODIFY_SHARING": "",
+ "ADD_COLLABORATORS": "",
+ "ADD_NEW_EMAIL": "",
+ "shared_with_people_count_zero": "",
+ "shared_with_people_count_one": "",
+ "shared_with_people_count": "",
+ "participants_count_zero": "",
+ "participants_count_one": "",
+ "participants_count": "",
+ "ADD_VIEWERS": "",
+ "CHANGE_PERMISSIONS_TO_VIEWER": "",
+ "CHANGE_PERMISSIONS_TO_COLLABORATOR": "",
+ "CONVERT_TO_VIEWER": "",
+ "CONVERT_TO_COLLABORATOR": "",
+ "CHANGE_PERMISSION": "",
+ "REMOVE_PARTICIPANT": "",
+ "CONFIRM_REMOVE": "",
+ "MANAGE": "",
+ "ADDED_AS": "",
+ "COLLABORATOR_RIGHTS": "",
+ "REMOVE_PARTICIPANT_HEAD": "",
+ "OWNER": "",
+ "COLLABORATORS": "",
+ "ADD_MORE": "",
+ "VIEWERS": "",
+ "OR_ADD_EXISTING": "",
+ "REMOVE_PARTICIPANT_MESSAGE": "",
+ "NOT_FOUND": "",
+ "LINK_EXPIRED": "",
+ "LINK_EXPIRED_MESSAGE": "",
+ "MANAGE_LINK": "",
+ "LINK_TOO_MANY_REQUESTS": "",
+ "FILE_DOWNLOAD": "",
+ "link_password_lock": "",
+ "PUBLIC_COLLECT": "",
+ "LINK_DEVICE_LIMIT": "",
+ "NO_DEVICE_LIMIT": "",
+ "LINK_EXPIRY": "",
+ "NEVER": "",
+ "DISABLE_FILE_DOWNLOAD": "",
+ "DISABLE_FILE_DOWNLOAD_MESSAGE": "",
+ "SHARED_USING": "",
+ "SHARING_REFERRAL_CODE": "",
+ "LIVE": "",
+ "DISABLE_PASSWORD": "",
+ "DISABLE_PASSWORD_MESSAGE": "",
+ "PASSWORD_LOCK": "",
+ "LOCK": "",
+ "DOWNLOAD_UPLOAD_LOGS": "",
+ "file": "",
+ "folder": "",
+ "google_takeout": "",
+ "DEDUPLICATE_FILES": "",
+ "NO_DUPLICATES_FOUND": "",
+ "FILES": "",
+ "EACH": "",
+ "DEDUPLICATE_BASED_ON_SIZE": "",
+ "STOP_ALL_UPLOADS_MESSAGE": "",
+ "STOP_UPLOADS_HEADER": "",
+ "YES_STOP_UPLOADS": "",
+ "STOP_DOWNLOADS_HEADER": "",
+ "YES_STOP_DOWNLOADS": "",
+ "STOP_ALL_DOWNLOADS_MESSAGE": "",
+ "albums_count_one": "",
+ "albums_count": "",
+ "ALL_ALBUMS": "",
+ "ALBUMS": "",
+ "ALL_HIDDEN_ALBUMS": "",
+ "HIDDEN_ALBUMS": "",
+ "HIDDEN_ITEMS": "",
+ "ENTER_TWO_FACTOR_OTP": "",
+ "CREATE_ACCOUNT": "",
+ "COPIED": "",
+ "WATCH_FOLDERS": "",
+ "upgrade_now": "",
+ "renew_now": "",
+ "STORAGE": "",
+ "USED": "",
+ "YOU": "",
+ "FAMILY": "",
+ "FREE": "",
+ "OF": "",
+ "WATCHED_FOLDERS": "",
+ "NO_FOLDERS_ADDED": "",
+ "FOLDERS_AUTOMATICALLY_MONITORED": "",
+ "UPLOAD_NEW_FILES_TO_ENTE": "",
+ "REMOVE_DELETED_FILES_FROM_ENTE": "",
+ "ADD_FOLDER": "",
+ "STOP_WATCHING": "",
+ "STOP_WATCHING_FOLDER": "",
+ "STOP_WATCHING_DIALOG_MESSAGE": "",
+ "YES_STOP": "",
+ "CHANGE_FOLDER": "",
+ "FAMILY_PLAN": "",
+ "DOWNLOAD_LOGS": "",
+ "DOWNLOAD_LOGS_MESSAGE": "",
+ "WEAK_DEVICE": "",
+ "drag_and_drop_hint": "",
+ "AUTHENTICATE": "",
+ "UPLOADED_TO_SINGLE_COLLECTION": "",
+ "UPLOADED_TO_SEPARATE_COLLECTIONS": "",
+ "NEVERMIND": "",
+ "UPDATE_AVAILABLE": "",
+ "UPDATE_INSTALLABLE_MESSAGE": "",
+ "INSTALL_NOW": "",
+ "INSTALL_ON_NEXT_LAUNCH": "",
+ "UPDATE_AVAILABLE_MESSAGE": "",
+ "DOWNLOAD_AND_INSTALL": "",
+ "IGNORE_THIS_VERSION": "",
+ "TODAY": "",
+ "YESTERDAY": "",
+ "NAME_PLACEHOLDER": "",
+ "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "",
+ "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "",
+ "CHOSE_THEME": "",
+ "more_details": "",
+ "ml_search": "",
+ "ml_search_description": "",
+ "ml_search_footnote": "",
+ "indexing": "",
+ "processed": "",
+ "indexing_status_running": "",
+ "indexing_status_fetching": "",
+ "indexing_status_scheduled": "",
+ "indexing_status_done": "",
+ "ml_search_disable": "",
+ "ml_search_disable_confirm": "",
+ "ml_consent": "",
+ "ml_consent_title": "",
+ "ml_consent_description": "",
+ "ml_consent_confirmation": "",
+ "labs": "",
+ "YOURS": "",
+ "passphrase_strength_weak": "",
+ "passphrase_strength_moderate": "",
+ "passphrase_strength_strong": "",
+ "preferences": "",
+ "language": "",
+ "advanced": "",
+ "EXPORT_DIRECTORY_DOES_NOT_EXIST": "",
+ "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "",
+ "SUBSCRIPTION_VERIFICATION_ERROR": "",
+ "storage_unit": {
+ "b": "",
+ "kb": "",
+ "mb": "",
+ "gb": "",
+ "tb": ""
+ },
+ "AFTER_TIME": {
+ "HOUR": "",
+ "DAY": "",
+ "WEEK": "",
+ "MONTH": "",
+ "YEAR": ""
+ },
+ "COPY_LINK": "",
+ "DONE": "",
+ "LINK_SHARE_TITLE": "",
+ "REMOVE_LINK": "",
+ "CREATE_PUBLIC_SHARING": "",
+ "PUBLIC_LINK_CREATED": "",
+ "PUBLIC_LINK_ENABLED": "",
+ "COLLECT_PHOTOS": "",
+ "PUBLIC_COLLECT_SUBTEXT": "",
+ "STOP_EXPORT": "",
+ "EXPORT_PROGRESS": "",
+ "MIGRATING_EXPORT": "",
+ "RENAMING_COLLECTION_FOLDERS": "",
+ "TRASHING_DELETED_FILES": "",
+ "TRASHING_DELETED_COLLECTIONS": "",
+ "CONTINUOUS_EXPORT": "",
+ "PENDING_ITEMS": "",
+ "EXPORT_STARTING": "",
+ "delete_account_reason_label": "",
+ "delete_account_reason_placeholder": "",
+ "delete_reason": {
+ "missing_feature": "",
+ "behaviour": "",
+ "found_another_service": "",
+ "not_listed": ""
+ },
+ "delete_account_feedback_label": "",
+ "delete_account_feedback_placeholder": "",
+ "delete_account_confirm_checkbox_label": "",
+ "delete_account_confirm": "",
+ "delete_account_confirm_message": "",
+ "feedback_required": "",
+ "feedback_required_found_another_service": "",
+ "RECOVER_TWO_FACTOR": "",
+ "at": "",
+ "AUTH_NEXT": "",
+ "AUTH_DOWNLOAD_MOBILE_APP": "",
+ "HIDDEN": "",
+ "HIDE": "",
+ "UNHIDE": "",
+ "UNHIDE_TO_COLLECTION": "",
+ "SORT_BY": "",
+ "NEWEST_FIRST": "",
+ "OLDEST_FIRST": "",
+ "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "",
+ "SELECT_COLLECTION": "",
+ "PIN_ALBUM": "",
+ "UNPIN_ALBUM": "",
+ "DOWNLOAD_COMPLETE": "",
+ "DOWNLOADING_COLLECTION": "",
+ "DOWNLOAD_FAILED": "",
+ "DOWNLOAD_PROGRESS": "",
+ "CHRISTMAS": "",
+ "CHRISTMAS_EVE": "",
+ "NEW_YEAR": "",
+ "NEW_YEAR_EVE": "",
+ "IMAGE": "",
+ "VIDEO": "",
+ "LIVE_PHOTO": "",
+ "editor": {
+ "crop": ""
+ },
+ "CONVERT": "",
+ "CONFIRM_EDITOR_CLOSE_MESSAGE": "",
+ "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "",
+ "BRIGHTNESS": "",
+ "CONTRAST": "",
+ "SATURATION": "",
+ "BLUR": "",
+ "INVERT_COLORS": "",
+ "ASPECT_RATIO": "",
+ "SQUARE": "",
+ "ROTATE_LEFT": "",
+ "ROTATE_RIGHT": "",
+ "FLIP_VERTICALLY": "",
+ "FLIP_HORIZONTALLY": "",
+ "DOWNLOAD_EDITED": "",
+ "SAVE_A_COPY_TO_ENTE": "",
+ "RESTORE_ORIGINAL": "",
+ "TRANSFORM": "",
+ "COLORS": "",
+ "FLIP": "",
+ "ROTATION": "",
+ "RESET": "",
+ "PHOTO_EDITOR": "",
+ "FASTER_UPLOAD": "",
+ "FASTER_UPLOAD_DESCRIPTION": "",
+ "CAST_ALBUM_TO_TV": "",
+ "ENTER_CAST_PIN_CODE": "",
+ "PAIR_DEVICE_TO_TV": "",
+ "TV_NOT_FOUND": "",
+ "AUTO_CAST_PAIR": "",
+ "AUTO_CAST_PAIR_DESC": "",
+ "PAIR_WITH_PIN": "",
+ "CHOOSE_DEVICE_FROM_BROWSER": "",
+ "PAIR_WITH_PIN_DESC": "",
+ "VISIT_CAST_ENTE_IO": "",
+ "CAST_AUTO_PAIR_FAILED": "",
+ "FREEHAND": "",
+ "APPLY_CROP": "",
+ "PHOTO_EDIT_REQUIRED_TO_SAVE": "",
+ "passkeys": "",
+ "passkey_fetch_failed": "",
+ "manage_passkey": "",
+ "delete_passkey": "",
+ "delete_passkey_confirmation": "",
+ "rename_passkey": "",
+ "add_passkey": "",
+ "enter_passkey_name": "",
+ "passkeys_description": "",
+ "CREATED_AT": "",
+ "passkey_add_failed": "",
+ "passkey_login_failed": "",
+ "passkey_login_invalid_url": "",
+ "passkey_login_already_claimed_session": "",
+ "passkey_login_generic_error": "",
+ "passkey_login_credential_hint": "",
+ "passkeys_not_supported": "",
+ "try_again": "",
+ "check_status": "",
+ "passkey_login_instructions": "",
+ "passkey_login": "",
+ "passkey": "",
+ "passkey_verify_description": "",
+ "waiting_for_verification": "",
+ "verification_still_pending": "",
+ "passkey_verified": "",
+ "redirecting_back_to_app": "",
+ "redirect_close_instructions": "",
+ "redirect_again": "",
+ "autogenerated_first_album_name": "",
+ "autogenerated_default_album_name": "",
+ "developer_settings": "",
+ "server_endpoint": "",
+ "more_information": "",
+ "save": ""
+}
diff --git a/web/packages/base/locales/te-IN/translation.json b/web/packages/base/locales/te-IN/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/te-IN/translation.json
+++ b/web/packages/base/locales/te-IN/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/th-TH/translation.json b/web/packages/base/locales/th-TH/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/th-TH/translation.json
+++ b/web/packages/base/locales/th-TH/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/ti-ER/translation.json b/web/packages/base/locales/ti-ER/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/ti-ER/translation.json
+++ b/web/packages/base/locales/ti-ER/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/tr-TR/translation.json b/web/packages/base/locales/tr-TR/translation.json
index 2f7b02d9ee..f90267b710 100644
--- a/web/packages/base/locales/tr-TR/translation.json
+++ b/web/packages/base/locales/tr-TR/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "",
"REFERRAL_INFO": "",
"PASSPHRASE_MATCH_ERROR": "",
+ "create_albums": "",
"CREATE_COLLECTION": "",
"ENTER_ALBUM_NAME": "",
"CLOSE_OPTION": "",
diff --git a/web/packages/base/locales/zh-CN/translation.json b/web/packages/base/locales/zh-CN/translation.json
index 6064de6295..66fc6fa6f9 100644
--- a/web/packages/base/locales/zh-CN/translation.json
+++ b/web/packages/base/locales/zh-CN/translation.json
@@ -41,6 +41,7 @@
"REFERRAL_CODE_HINT": "您是如何知道Ente的? (可选的)",
"REFERRAL_INFO": "我们不跟踪应用程序安装情况,如果您告诉我们您是在哪里找到我们的,将会有所帮助!",
"PASSPHRASE_MATCH_ERROR": "两次输入的密码不一致",
+ "create_albums": "创建相册",
"CREATE_COLLECTION": "新建相册",
"ENTER_ALBUM_NAME": "相册名称",
"CLOSE_OPTION": "关闭 (或按Esc键)",
@@ -98,7 +99,7 @@
"MULTI_FOLDER_UPLOAD": "检测到多个文件夹",
"UPLOAD_STRATEGY_CHOICE": "你想要上传他们到",
"UPLOAD_STRATEGY_SINGLE_COLLECTION": "单个相册",
- "OR": "或者",
+ "OR": "还是",
"UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "独立相册",
"SESSION_EXPIRED_MESSAGE": "您的会话已过期,请重新登录以继续",
"SESSION_EXPIRED": "会话已过期",
@@ -301,7 +302,7 @@
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "缩略图生成失败",
"UNSUPPORTED_FILES": "不支持的文件",
"SUCCESSFUL_UPLOADS": "上传成功",
- "SKIPPED_INFO": "跳过这些,因为在同一相册中有具有匹配名称的文件",
+ "SKIPPED_INFO": "跳过这些文件,因为同一相册中有名称和内容相匹配的文件",
"UNSUPPORTED_INFO": "Ente 尚不支持这些文件格式",
"BLOCKED_UPLOADS": "已阻止上传",
"INPROGRESS_METADATA_EXTRACTION": "进行中",
diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx
index eeff4d1be8..dde90b5368 100644
--- a/web/packages/new/photos/components/MLSettings.tsx
+++ b/web/packages/new/photos/components/MLSettings.tsx
@@ -8,7 +8,6 @@ import {
enableML,
mlStatusSnapshot,
mlStatusSubscribe,
- wipCluster,
wipClusterEnable,
type MLStatus,
} from "@/new/photos/services/ml";
@@ -341,8 +340,7 @@ const ManageML: React.FC = ({
// TODO-Cluster
const router = useRouter();
- const wipClusterNow = () => wipCluster();
- const wipClusterShowNow = () => router.push("/cluster-debug");
+ const wipClusterDebug = () => router.push("/cluster-debug");
return (
@@ -391,28 +389,15 @@ const ManageML: React.FC = ({
-
- )}
- {showClusterOpt && (
-
-
-
-
-
diff --git a/web/packages/new/photos/services/ml/cluster-hdb.ts b/web/packages/new/photos/services/ml/cluster-hdb.ts
new file mode 100644
index 0000000000..3ecda4b5bc
--- /dev/null
+++ b/web/packages/new/photos/services/ml/cluster-hdb.ts
@@ -0,0 +1,35 @@
+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(),
+ };
+};
diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts
deleted file mode 100644
index 9e07b2812c..0000000000
--- a/web/packages/new/photos/services/ml/cluster-new.ts
+++ /dev/null
@@ -1,452 +0,0 @@
-import { newNonSecureID } from "@/base/id-worker";
-import log from "@/base/log";
-import { ensure } from "@/utils/ensure";
-import { clusterFacesHdbscan } from "./cluster";
-import { clusterGroups, faceClusters } from "./db";
-import type { Face, FaceIndex } from "./face";
-import { dotProduct } from "./math";
-
-/**
- * A face cluster is an set of faces.
- *
- * Each cluster has an id so that a {@link CGroup} can refer to it.
- *
- * The cluster is not directly synced to remote. Only clusters that the user
- * interacts with get synced to remote, as part of a {@link CGroup}.
- */
-export interface FaceCluster {
- /**
- * A nanoid for this cluster.
- */
- id: string;
- /**
- * An unordered set of ids of the faces that belong to this cluster.
- *
- * For ergonomics of transportation and persistence this is an array, but it
- * should conceptually be thought of as a set.
- */
- faceIDs: string[];
-}
-
-/**
- * A cgroup ("cluster group") is a group of clusters (possibly containing a
- * single cluster) that the user has interacted with.
- *
- * Interactions include hiding, merging and giving a name and/or a cover photo.
- *
- * The most frequent interaction is naming a {@link FaceCluster}, which promotes
- * it to a become a {@link CGroup}. The promotion comes with the ability to be
- * synced with remote (as a "cgroup" user entity).
- *
- * There after, the user may attach more clusters to the same {@link CGroup}.
- *
- * > A named cluster group can be thought of as a "person", though this is not
- * > necessarily an accurate characterization. e.g. there can be a named cluster
- * > group that contains face clusters of pets.
- *
- * The other form of interaction is hiding. The user may hide a single (unnamed)
- * cluster, or they may hide an named {@link CGroup}. In both cases, we promote
- * the cluster to a CGroup if needed so that their request to hide gets synced.
- *
- * While in our local representation we separately maintain clusters and link to
- * them from within CGroups by their clusterID, in the remote representation
- * clusters themselves don't get synced. Instead, the "cgroup" entities synced
- * with remote contain the clusters within themselves. So a group that gets
- * synced with remote looks something like:
- *
- * { id, name, clusters: [{ clusterID, faceIDs }] }
- *
- */
-export interface CGroup {
- /**
- * A nanoid for this cluster group.
- *
- * This is the ID of the "cgroup" user entity (the envelope), and it is not
- * contained as part of the group entity payload itself.
- */
- id: string;
- /**
- * A name assigned by the user to this cluster group.
- *
- * The client should handle both empty strings and undefined as indicating a
- * cgroup without a name. When the client needs to set this to an "empty"
- * value, which happens when hiding an unnamed cluster, it should it to an
- * empty string. That is, expect `"" | undefined`, but set `""`.
- */
- name: string | undefined;
- /**
- * An unordered set of ids of the clusters that belong to this group.
- *
- * For ergonomics of transportation and persistence this is an array, but it
- * should conceptually be thought of as a set.
- */
- clusterIDs: string[];
- /**
- * True if this cluster group should be hidden.
- *
- * The user can hide both named cluster groups and single unnamed clusters.
- * If the user hides a single cluster that was offered as a suggestion to
- * them on a client, the client will create a new unnamed cgroup containing
- * it, and set its hidden flag to sync it with remote (so that other clients
- * can also stop showing this cluster).
- */
- isHidden: boolean;
- /**
- * The ID of the face that should be used as the cover photo for this
- * cluster group (if the user has set one).
- *
- * This is similar to the [@link displayFaceID}, the difference being:
- *
- * - {@link avatarFaceID} is the face selected by the user.
- *
- * - {@link displayFaceID} is the automatic placeholder, and only comes
- * into effect if the user has not explicitly selected a face.
- */
- avatarFaceID: string | undefined;
- /**
- * 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;
-}
-
-// TODO-Cluster
-export interface FaceNeighbours {
- face: Face;
- neighbours: FaceNeighbour[];
-}
-
-interface FaceNeighbour {
- face: Face;
- cosineSimilarity: number;
-}
-
-/**
- * Cluster faces into groups.
- *
- * [Note: Face clustering algorithm]
- *
- * A cgroup (cluster group) consists of clusters, each of which itself is a set
- * of faces.
- *
- * cgroup << cluster << face
- *
- * The clusters are generated locally by clients using the following algorithm:
- *
- * 1. clusters = [] initially, or fetched from remote.
- *
- * 2. For each face, find its nearest neighbour in the embedding space.
- *
- * 3. If no such neighbour is found within our threshold, create a new cluster.
- *
- * 4. Otherwise assign this face to the same cluster as its nearest neighbour.
- *
- * This user can then tweak the output of the algorithm by performing the
- * following actions to the list of clusters that they can see:
- *
- * - They can provide a name for a cluster ("name a person"). This upgrades a
- * cluster into a "cgroup", which is an entity that gets synced via remote
- * to the user's other clients.
- *
- * - They can attach more clusters to a cgroup ("merge clusters")
- *
- * - They can remove a cluster from a cgroup ("break clusters").
- *
- * After clustering, we also do some routine cleanup. Faces belonging to files
- * that have been deleted (including those in Trash) should be pruned off.
- *
- * We should not make strict assumptions about the clusters we get from remote.
- * In particular, the same face ID can be in different clusters. In such cases
- * we should assign it arbitrarily assign it to the last cluster we find it in.
- * Such leeway is intentionally provided to allow clients some slack in how they
- * implement the sync without needing to make an blocking API request for every
- * user interaction.
- */
-export const clusterFaces = async (faceIndexes: FaceIndex[]) => {
- const t = Date.now();
-
- // A flattened array of faces.
- // TODO-Cluster note the 2k slice
- const faces = [...enumerateFaces(faceIndexes)].slice(0, 2000);
-
- // Start with the clusters we already have (either from a previous indexing,
- // or fetched from remote).
- const clusters = await faceClusters();
-
- // For fast reverse lookup - map from cluster ids to their index in the
- // clusters array.
- const clusterIndexForClusterID = new Map(clusters.map((c, i) => [c.id, i]));
-
- // For fast reverse lookup - map from face ids to the id of the cluster to
- // which they belong.
- const clusterIDForFaceID = new Map(
- clusters.flatMap((c) => c.faceIDs.map((id) => [id, c.id] as const)),
- );
-
- // A function to generate new cluster IDs.
- const newClusterID = () => newNonSecureID("cluster_");
-
- const faceAndNeigbours: FaceNeighbours[] = [];
-
- // For each face,
- for (const [i, fi] of faces.entries()) {
- // If the face is already part of a cluster, then skip it.
- if (clusterIDForFaceID.get(fi.faceID)) continue;
-
- // Find the nearest neighbour from among all the other faces.
- let nn: Face | undefined;
- let nnCosineSimilarity = 0;
- let neighbours: FaceNeighbour[] = [];
- for (let j = 0; j < faces.length; j++) {
- // ! This is an O(n^2) loop, be careful when adding more code here.
-
- // TODO-Cluster Commenting this here and moving it downward
- // // Skip ourselves.
- // if (i == j) continue;
-
- // Can't find a way of avoiding the null assertion here.
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const fj = faces[j]!;
-
- // The vectors are already normalized, so we can directly use their
- // dot product as their cosine similarity.
- const csim = dotProduct(fi.embedding, fj.embedding);
-
- // TODO-Cluster Delete me and uncomment the check above
- // Skip ourselves.
- if (i == j) {
- neighbours.push({ face: fj, cosineSimilarity: csim });
- continue;
- }
-
- const threshold = fi.blur < 100 || fj.blur < 100 ? 0.7 : 0.6;
- if (csim > threshold && csim > nnCosineSimilarity) {
- nn = fj;
- nnCosineSimilarity = csim;
- }
-
- neighbours.push({ face: fj, cosineSimilarity: csim });
- }
-
- neighbours = neighbours.sort(
- (a, b) => b.cosineSimilarity - a.cosineSimilarity,
- );
- faceAndNeigbours.push({ face: fi, neighbours });
-
- const { faceID } = fi;
-
- if (nn) {
- // Found a neighbour near enough.
- const nnFaceID = nn.faceID;
-
- // Find the cluster the nearest neighbour belongs to, if any.
- const nnClusterID = clusterIDForFaceID.get(nn.faceID);
-
- if (nnClusterID) {
- // If the neighbour is already part of a cluster, also add
- // ourselves to that cluster.
-
- const nnClusterIndex = ensure(
- clusterIndexForClusterID.get(nnClusterID),
- );
- clusters[nnClusterIndex]?.faceIDs.push(faceID);
- clusterIDForFaceID.set(faceID, nnClusterID);
- } else {
- // Otherwise create a new cluster with us and our nearest
- // neighbour.
-
- const cluster = {
- id: newClusterID(),
- faceIDs: [faceID, nnFaceID],
- };
- clusterIndexForClusterID.set(cluster.id, clusters.length);
- clusterIDForFaceID.set(faceID, cluster.id);
- clusterIDForFaceID.set(nnFaceID, cluster.id);
- clusters.push(cluster);
- }
- } else {
- // We didn't find a neighbour within the threshold. Create a new
- // cluster with only this face.
-
- const cluster = { id: newClusterID(), faceIDs: [faceID] };
- clusterIndexForClusterID.set(cluster.id, clusters.length);
- clusterIDForFaceID.set(faceID, cluster.id);
- clusters.push(cluster);
- }
- }
-
- // Prune too small clusters.
- const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1);
-
- let cgroups = await clusterGroups();
-
- // 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.
- cgroups = cgroups.concat(
- validClusters.map((c) => ({
- id: c.id,
- name: undefined,
- clusterIDs: [c.id],
- isHidden: false,
- avatarFaceID: undefined,
- displayFaceID: undefined,
- })),
- );
-
- // For each cluster group, use the highest scoring face in any of its
- // clusters as its display face.
- const faceForFaceID = new Map(faces.map((f) => [f.faceID, f]));
- for (const cgroup of cgroups) {
- cgroup.displayFaceID = cgroup.clusterIDs
- .map((clusterID) => clusterIndexForClusterID.get(clusterID))
- .filter((i) => i !== undefined) /* 0 is a valid index */
- .flatMap((i) => clusters[i]?.faceIDs ?? [])
- .map((faceID) => faceForFaceID.get(faceID))
- .filter((face) => !!face)
- .reduce((max, face) =>
- max.score > face.score ? max : face,
- ).faceID;
- }
-
- log.info("ml/cluster", {
- faces,
- validClusters,
- clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID),
- clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID),
- cgroups,
- });
- log.info(
- `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`,
- );
-
- return { faces, clusters: validClusters, cgroups, faceAndNeigbours };
-};
-
-/**
- * A generator function that returns a stream of {faceID, embedding} values,
- * flattening all the the faces present in the given {@link faceIndices}.
- */
-function* enumerateFaces(faceIndices: FaceIndex[]) {
- for (const fi of faceIndices) {
- for (const f of fi.faces) {
- yield f;
- }
- }
-}
-
-export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => {
- const t = Date.now();
-
- // A flattened array of faces.
- // TODO-Cluster note the 2k slice
- const faces = [...enumerateFaces(faceIndexes)].slice(0, 2000);
-
- const faceEmbeddings = faces.map(({ embedding }) => embedding);
-
- const {
- clusters: clusterIndices,
- noise,
- debugInfo,
- } = clusterFacesHdbscan(faceEmbeddings);
-
- log.info({ method: "hdbscan", clusterIndices, noise, debugInfo });
- log.info(
- `Clustered ${faces.length} faces into ${clusterIndices.length} clusters (${Date.now() - t} ms)`,
- );
-
- // For fast reverse lookup - map from cluster ids to their index in the
- // clusters array.
- const clusterIndexForClusterID = new Map();
-
- // For fast reverse lookup - map from face ids to the id of the cluster to
- // which they belong.
- const clusterIDForFaceID = new Map();
-
- // A function to generate new cluster IDs.
- const newClusterID = () => newNonSecureID("cluster_");
-
- // Convert the numerical face indices into the result.
- const clusters: FaceCluster[] = [];
- for (const [ci, faceIndices] of clusterIndices.entries()) {
- const clusterID = newClusterID();
- const faceIDs: string[] = [];
- clusterIndexForClusterID.set(clusterID, ci);
- for (const fi of faceIndices) {
- // Can't find a way of avoiding the null assertion here.
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const face = faces[fi]!;
- clusterIDForFaceID.set(face.faceID, clusterID);
- faceIDs.push(face.faceID);
- }
- clusters.push({ id: clusterID, faceIDs });
- }
-
- // Convert into the data structure we're using to debug/visualize.
- const faceAndNeigbours: FaceNeighbours[] = [];
- for (const fi of faces) {
- let neighbours: FaceNeighbour[] = [];
- for (const fj of faces) {
- // The vectors are already normalized, so we can directly use their
- // dot product as their cosine similarity.
- const csim = dotProduct(fi.embedding, fj.embedding);
- neighbours.push({ face: fj, cosineSimilarity: csim });
- }
-
- neighbours = neighbours.sort(
- (a, b) => b.cosineSimilarity - a.cosineSimilarity,
- );
-
- faceAndNeigbours.push({ face: fi, neighbours });
- }
-
- // Prune too small clusters.
- const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1);
-
- let cgroups = await clusterGroups();
-
- // 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.
- cgroups = cgroups.concat(
- validClusters.map((c) => ({
- id: c.id,
- name: undefined,
- clusterIDs: [c.id],
- isHidden: false,
- avatarFaceID: undefined,
- displayFaceID: undefined,
- })),
- );
-
- // For each cluster group, use the highest scoring face in any of its
- // clusters as its display face.
- const faceForFaceID = new Map(faces.map((f) => [f.faceID, f]));
- for (const cgroup of cgroups) {
- cgroup.displayFaceID = cgroup.clusterIDs
- .map((clusterID) => clusterIndexForClusterID.get(clusterID))
- .filter((i) => i !== undefined) /* 0 is a valid index */
- .flatMap((i) => clusters[i]?.faceIDs ?? [])
- .map((faceID) => faceForFaceID.get(faceID))
- .filter((face) => !!face)
- .reduce((max, face) =>
- max.score > face.score ? max : face,
- ).faceID;
- }
-
- log.info("ml/cluster", {
- faces,
- validClusters,
- clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID),
- clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID),
- cgroups,
- });
- log.info(
- `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`,
- );
-
- return { faces, clusters: validClusters, cgroups, faceAndNeigbours };
-};
diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts
index ff62f466a9..254988dea9 100644
--- a/web/packages/new/photos/services/ml/cluster.ts
+++ b/web/packages/new/photos/services/ml/cluster.ts
@@ -1,35 +1,551 @@
-import { Hdbscan, type DebugInfo } from "hdbscan";
+import { newNonSecureID } from "@/base/id-worker";
+import log from "@/base/log";
+import { ensure } from "@/utils/ensure";
+import { type EmbeddingCluster, clusterHdbscan } from "./cluster-hdb";
+import type { Face, FaceIndex } from "./face";
+import { dotProduct } from "./math";
-export type Cluster = number[];
-
-export interface ClusterFacesResult {
- clusters: Cluster[];
- noise: Cluster;
- debugInfo?: DebugInfo;
+/**
+ * A face cluster is an set of faces.
+ *
+ * Each cluster has an id so that a {@link CGroup} can refer to it.
+ *
+ * The cluster is not directly synced to remote. Only clusters that the user
+ * interacts with get synced to remote, as part of a {@link CGroup}.
+ */
+export interface FaceCluster {
+ /**
+ * A nanoid for this cluster.
+ */
+ id: string;
+ /**
+ * An unordered set of ids of the faces that belong to this cluster.
+ *
+ * For ergonomics of transportation and persistence this is an array, but it
+ * should conceptually be thought of as a set.
+ */
+ faceIDs: string[];
}
/**
- * Cluster the given {@link faceEmbeddings}.
+ * A cgroup ("cluster group") is a group of clusters (possibly containing a
+ * single cluster) that the user has interacted with.
+ *
+ * Interactions include hiding, merging and giving a name and/or a cover photo.
+ *
+ * The most frequent interaction is naming a {@link FaceCluster}, which promotes
+ * it to a become a {@link CGroup}. The promotion comes with the ability to be
+ * synced with remote (as a "cgroup" user entity).
+ *
+ * There after, the user may attach more clusters to the same {@link CGroup}.
+ *
+ * > A named cluster group can be thought of as a "person", though this is not
+ * > necessarily an accurate characterization. e.g. there can be a named cluster
+ * > group that contains face clusters of pets.
+ *
+ * The other form of interaction is hiding. The user may hide a single (unnamed)
+ * cluster, or they may hide an named {@link CGroup}. In both cases, we promote
+ * the cluster to a CGroup if needed so that their request to hide gets synced.
+ *
+ * While in our local representation we separately maintain clusters and link to
+ * them from within CGroups by their clusterID, in the remote representation
+ * clusters themselves don't get synced. Instead, the "cgroup" entities synced
+ * with remote contain the clusters within themselves. So a group that gets
+ * synced with remote looks something like:
+ *
+ * { id, name, clusters: [{ clusterID, faceIDs }] }
*
- * @param faceEmbeddings An array of embeddings produced by our face indexing
- * pipeline. Each embedding is for a face detected in an image (a single image
- * may have multiple faces detected within it).
*/
-export const clusterFacesHdbscan = (
- faceEmbeddings: number[][],
-): ClusterFacesResult => {
- const hdbscan = new Hdbscan({
- input: faceEmbeddings,
- minClusterSize: 3,
- minSamples: 5,
- clusterSelectionEpsilon: 0.6,
- clusterSelectionMethod: "leaf",
- debug: true,
+export interface CGroup {
+ /**
+ * A nanoid for this cluster group.
+ *
+ * This is the ID of the "cgroup" user entity (the envelope), and it is not
+ * contained as part of the group entity payload itself.
+ */
+ id: string;
+ /**
+ * A name assigned by the user to this cluster group.
+ *
+ * The client should handle both empty strings and undefined as indicating a
+ * cgroup without a name. When the client needs to set this to an "empty"
+ * value, which happens when hiding an unnamed cluster, it should it to an
+ * empty string. That is, expect `"" | undefined`, but set `""`.
+ */
+ name: string | undefined;
+ /**
+ * An unordered set of ids of the clusters that belong to this group.
+ *
+ * For ergonomics of transportation and persistence this is an array, but it
+ * should conceptually be thought of as a set.
+ */
+ clusterIDs: string[];
+ /**
+ * True if this cluster group should be hidden.
+ *
+ * The user can hide both named cluster groups and single unnamed clusters.
+ * If the user hides a single cluster that was offered as a suggestion to
+ * them on a client, the client will create a new unnamed cgroup containing
+ * it, and set its hidden flag to sync it with remote (so that other clients
+ * can also stop showing this cluster).
+ */
+ isHidden: boolean;
+ /**
+ * The ID of the face that should be used as the cover photo for this
+ * cluster group (if the user has set one).
+ *
+ * This is similar to the [@link displayFaceID}, the difference being:
+ *
+ * - {@link avatarFaceID} is the face selected by the user.
+ *
+ * - {@link displayFaceID} is the automatic placeholder, and only comes
+ * into effect if the user has not explicitly selected a face.
+ */
+ avatarFaceID: string | undefined;
+ /**
+ * 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;
+}
+
+export interface ClusteringOpts {
+ method: "linear" | "hdbscan";
+ minBlur: number;
+ minScore: number;
+ minClusterSize: number;
+ batchSize: number;
+ joinThreshold: number;
+}
+
+export interface ClusterPreview {
+ clusterSize: number;
+ faces: ClusterPreviewFace[];
+}
+
+export interface ClusterPreviewFace {
+ face: Face;
+ cosineSimilarity: number;
+ wasMerged: boolean;
+}
+
+/**
+ * Cluster faces into groups.
+ *
+ * [Note: Face clustering algorithm]
+ *
+ * A cgroup (cluster group) consists of clusters, each of which itself is a set
+ * of faces.
+ *
+ * cgroup << cluster << face
+ *
+ * The clusters are generated locally by clients using the following algorithm:
+ *
+ * 1. clusters = [] initially, or fetched from remote.
+ *
+ * 2. For each face, find its nearest neighbour in the embedding space.
+ *
+ * 3. If no such neighbour is found within our threshold, create a new cluster.
+ *
+ * 4. Otherwise assign this face to the same cluster as its nearest neighbour.
+ *
+ * This user can then tweak the output of the algorithm by performing the
+ * following actions to the list of clusters that they can see:
+ *
+ * - They can provide a name for a cluster ("name a person"). This upgrades a
+ * cluster into a "cgroup", which is an entity that gets synced via remote
+ * to the user's other clients.
+ *
+ * - They can attach more clusters to a cgroup ("merge clusters")
+ *
+ * - They can remove a cluster from a cgroup ("break clusters").
+ *
+ * After clustering, we also do some routine cleanup. Faces belonging to files
+ * that have been deleted (including those in Trash) should be pruned off.
+ *
+ * We should not make strict assumptions about the clusters we get from remote.
+ * In particular, the same face ID can be in different clusters. In such cases
+ * we should assign it arbitrarily assign it to the last cluster we find it in.
+ * Such leeway is intentionally provided to allow clients some slack in how they
+ * implement the sync without needing to make an blocking API request for every
+ * user interaction.
+ */
+export const clusterFaces = (
+ faceIndexes: FaceIndex[],
+ opts: ClusteringOpts,
+) => {
+ const {
+ method,
+ batchSize,
+ minBlur,
+ minScore,
+ minClusterSize,
+ joinThreshold,
+ } = opts;
+ const t = Date.now();
+
+ // A flattened array of faces.
+ const allFaces = [...enumerateFaces(faceIndexes)];
+ const faces = allFaces
+ .filter((f) => f.blur > minBlur)
+ .filter((f) => f.score > minScore);
+ // .slice(0, 2000);
+
+ // For fast reverse lookup - map from face ids to the face.
+ const faceForFaceID = new Map(faces.map((f) => [f.faceID, f]));
+
+ const faceEmbeddings = faces.map(({ embedding }) => embedding);
+
+ // For fast reverse lookup - map from cluster ids to their index in the
+ // clusters array.
+ const clusterIndexForClusterID = new Map();
+
+ // For fast reverse lookup - map from the id of a face to the id of the
+ // cluster to which it belongs.
+ const clusterIDForFaceID = new Map();
+
+ // Keeps track of which faces were found by the OG clustering algorithm, and
+ // which were sublimated in from a later match.
+ const wasMergedFaceIDs = new Set();
+
+ // A function to chain two reverse lookup.
+ const firstFaceOfCluster = (cluster: FaceCluster) =>
+ ensure(faceForFaceID.get(ensure(cluster.faceIDs[0])));
+
+ // A function to generate new cluster IDs.
+ const newClusterID = () => newNonSecureID("cluster_");
+
+ // The resultant clusters.
+ // TODO-Cluster Later on, instead of starting from a blank slate, this will
+ // be list of existing clusters we fetch from remote.
+ const clusters: FaceCluster[] = [];
+
+ // Process the faces in batches.
+ for (let i = 0; i < faceEmbeddings.length; i += batchSize) {
+ const it = Date.now();
+
+ const embeddingBatch = faceEmbeddings.slice(i, i + batchSize);
+ let embeddingClusters: EmbeddingCluster[];
+ if (method == "hdbscan") {
+ ({ clusters: embeddingClusters } = clusterHdbscan(embeddingBatch));
+ } else {
+ ({ clusters: embeddingClusters } = clusterLinear(
+ embeddingBatch,
+ joinThreshold,
+ ));
+ }
+
+ log.info(
+ `${method} produced ${embeddingClusters.length} clusters from ${embeddingBatch.length} faces (${Date.now() - it} ms)`,
+ );
+
+ // Merge the new clusters we got from this batch into the existing
+ // clusters if they are "near" enough (using some heuristic).
+ //
+ // We need to ensure we don't change any of the existing cluster IDs,
+ // since these might be existing clusters we got from remote.
+
+ // Create a copy so that we don't modify existing clusters as we're
+ // iterating.
+ const existingClusters = [...clusters];
+
+ for (const newCluster of embeddingClusters) {
+ // Find the existing cluster whose (arbitrarily chosen) first face
+ // is the nearest neighbour of the (arbitrarily chosen) first face
+ // of the cluster produced in this batch.
+
+ const newFace = ensure(faces[i + ensure(newCluster[0])]);
+
+ let nnCluster: FaceCluster | undefined;
+ let nnCosineSimilarity = 0;
+ for (const existingCluster of existingClusters) {
+ const existingFace = firstFaceOfCluster(existingCluster);
+
+ // The vectors are already normalized, so we can directly use their
+ // dot product as their cosine similarity.
+ const csim = dotProduct(
+ existingFace.embedding,
+ newFace.embedding,
+ );
+
+ // Use a higher cosine similarity threshold if either of the two
+ // faces are blurry.
+ const threshold =
+ existingFace.blur < 200 || newFace.blur < 200
+ ? 0.9
+ : joinThreshold;
+ if (csim > threshold && csim > nnCosineSimilarity) {
+ nnCluster = existingCluster;
+ nnCosineSimilarity = csim;
+ }
+ }
+
+ // If we found an existing cluster that is near enough, merge the
+ // new cluster into the existing cluster.
+ if (nnCluster) {
+ for (const j of newCluster) {
+ const { faceID } = ensure(faces[i + j]);
+ wasMergedFaceIDs.add(faceID);
+ nnCluster.faceIDs.push(faceID);
+ clusterIDForFaceID.set(faceID, nnCluster.id);
+ }
+ } else {
+ // Otherwise retain the new cluster.
+ const clusterID = newClusterID();
+ const faceIDs: string[] = [];
+ for (const j of newCluster) {
+ const { faceID } = ensure(faces[i + j]);
+ faceIDs.push(faceID);
+ clusterIDForFaceID.set(faceID, clusterID);
+ }
+ clusterIndexForClusterID.set(clusterID, clusters.length);
+ clusters.push({ id: clusterID, faceIDs });
+ }
+ }
+ }
+
+ // Prune clusters that are smaller than the threshold.
+ const validClusters = clusters.filter(
+ (cs) => cs.faceIDs.length > minClusterSize,
+ );
+
+ const sortedClusters = validClusters.sort(
+ (a, b) => b.faceIDs.length - a.faceIDs.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.faceIDs.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);
+ const wasMerged = wasMergedFaceIDs.has(face.faceID);
+ return { face, cosineSimilarity: csim, wasMerged };
+ });
+ return {
+ clusterSize: cluster.faceIDs.length,
+ faces: previewFaces
+ .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity)
+ .slice(0, 50),
+ };
});
+ // 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: CGroup[] = [];
+ for (const cluster of sortedClusters) {
+ const faces = cluster.faceIDs.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,
+ clusterIDs: [cluster.id],
+ isHidden: false,
+ avatarFaceID: undefined,
+ displayFaceID: topFace.faceID,
+ });
+ }
+
+ const totalFaceCount = allFaces.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)`,
+ );
+
return {
- clusters: hdbscan.getClusters(),
- noise: hdbscan.getNoise(),
- debugInfo: hdbscan.getDebugInfo(),
+ totalFaceCount,
+ filteredFaceCount,
+ clusteredFaceCount,
+ unclusteredFaceCount,
+ clusterPreviews,
+ clusters: sortedClusters,
+ cgroups,
+ unclusteredFaces: unclusteredFaces,
+ timeTakenMs,
};
};
+
+/**
+ * A generator function that returns a stream of {faceID, embedding} values,
+ * flattening all the the faces present in the given {@link faceIndices}.
+ */
+function* enumerateFaces(faceIndices: FaceIndex[]) {
+ for (const fi of faceIndices) {
+ for (const f of fi.faces) {
+ yield f;
+ }
+ }
+}
+
+interface ClusterLinearResult {
+ clusters: EmbeddingCluster[];
+}
+
+// TODO-Cluster remove me
+export const clusterLinear_Direct = (
+ embeddings: number[][],
+ threshold: number,
+): ClusterLinearResult => {
+ const clusters: EmbeddingCluster[] = [];
+ const clusterIndexForEmbeddingIndex = new Map();
+ // For each embedding
+ for (const [i, ei] of embeddings.entries()) {
+ // If the embedding is already part of a cluster, then skip it.
+ if (clusterIndexForEmbeddingIndex.get(i)) continue;
+
+ // Find the nearest neighbour from among all the other embeddings.
+ let nnIndex: number | undefined;
+ let nnCosineSimilarity = 0;
+ for (const [j, ej] of embeddings.entries()) {
+ // ! This is an O(n^2) loop, be careful when adding more code here.
+
+ // Skip ourselves.
+ if (i == j) continue;
+
+ // The vectors are already normalized, so we can directly use their
+ // dot product as their cosine similarity.
+ const csim = dotProduct(ei, ej);
+ if (csim > threshold && csim > nnCosineSimilarity) {
+ nnIndex = j;
+ nnCosineSimilarity = csim;
+ }
+ }
+
+ if (nnIndex) {
+ // Find the cluster the nearest neighbour belongs to, if any.
+ const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex);
+
+ if (nnClusterIndex) {
+ // If the neighbour is already part of a cluster, also add
+ // ourselves to that cluster.
+
+ ensure(clusters[nnClusterIndex]).push(i);
+ clusterIndexForEmbeddingIndex.set(i, nnClusterIndex);
+ } else {
+ // Otherwise create a new cluster with us and our nearest
+ // neighbour.
+
+ clusterIndexForEmbeddingIndex.set(i, clusters.length);
+ clusterIndexForEmbeddingIndex.set(nnIndex, clusters.length);
+ clusters.push([i, nnIndex]);
+ }
+ } else {
+ // We didn't find a neighbour within the threshold. Create a new
+ // cluster with only this embedding.
+
+ clusterIndexForEmbeddingIndex.set(i, clusters.length);
+ clusters.push([i]);
+ }
+ }
+
+ // Prune singleton clusters.
+ const validClusters = clusters.filter((cs) => cs.length > 1);
+
+ return { clusters: validClusters };
+};
+
+const clusterLinear = (
+ embeddings: number[][],
+ threshold: number,
+): ClusterLinearResult => {
+ const clusters: EmbeddingCluster[] = [];
+ const clusterIndexForEmbeddingIndex = new Map();
+ // For each embedding
+ for (const [i, ei] of embeddings.entries()) {
+ // If the embedding is already part of a cluster, then skip it.
+ if (clusterIndexForEmbeddingIndex.get(i)) continue;
+
+ // Find the nearest neighbour from among all the other embeddings.
+ let nnIndex: number | undefined;
+ let nnCosineSimilarity = 0;
+ // Find the nearest cluster from among all the existing clusters.
+ let nClusterIndex: number | undefined;
+ let nClusterCosineSimilarity = 0;
+ for (const [j, ej] of embeddings.entries()) {
+ // ! This is an O(n^2) loop, be careful when adding more code here.
+
+ // Skip ourselves.
+ if (i == j) continue;
+
+ // The vectors are already normalized, so we can directly use their
+ // dot product as their cosine similarity.
+ const csim = dotProduct(ei, ej);
+ if (csim > threshold) {
+ if (csim > nnCosineSimilarity) {
+ nnIndex = j;
+ nnCosineSimilarity = csim;
+ }
+ if (csim > nClusterCosineSimilarity) {
+ const jClusterIndex = clusterIndexForEmbeddingIndex.get(j);
+ if (jClusterIndex) {
+ nClusterIndex = jClusterIndex;
+ nClusterCosineSimilarity = csim;
+ }
+ }
+ }
+ }
+
+ if (nClusterIndex) {
+ // Found a neighbouring cluster close enough, add ourselves to that.
+
+ ensure(clusters[nClusterIndex]).push(i);
+ clusterIndexForEmbeddingIndex.set(i, nClusterIndex);
+ } else if (nnIndex) {
+ // Find the cluster the nearest neighbour belongs to, if any.
+ const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex);
+
+ if (nnClusterIndex) {
+ // TODO-Cluster remove this case.
+ // If the neighbour is already part of a cluster, also add
+ // ourselves to that cluster.
+
+ // ensure(clusters[nnClusterIndex]).push(i);
+ // clusterIndexForEmbeddingIndex.set(i, nnClusterIndex);
+ throw new Error("We shouldn't have reached here");
+ } else {
+ // Otherwise create a new cluster with us and our nearest
+ // neighbour.
+
+ clusterIndexForEmbeddingIndex.set(i, clusters.length);
+ clusterIndexForEmbeddingIndex.set(nnIndex, clusters.length);
+ clusters.push([i, nnIndex]);
+ }
+ } else {
+ // We didn't find a neighbour within the threshold. Create a new
+ // cluster with only this embedding.
+
+ clusterIndexForEmbeddingIndex.set(i, clusters.length);
+ clusters.push([i]);
+ }
+ }
+
+ // Prune singleton clusters.
+ const validClusters = clusters.filter((cs) => cs.length > 1);
+
+ return { clusters: validClusters };
+};
diff --git a/web/packages/new/photos/services/ml/db.ts b/web/packages/new/photos/services/ml/db.ts
index f6d2043752..5f57ea30e1 100644
--- a/web/packages/new/photos/services/ml/db.ts
+++ b/web/packages/new/photos/services/ml/db.ts
@@ -3,7 +3,7 @@ import log from "@/base/log";
import localForage from "@ente/shared/storage/localForage";
import { deleteDB, openDB, type DBSchema } from "idb";
import type { LocalCLIPIndex } from "./clip";
-import type { CGroup, FaceCluster } from "./cluster-new";
+import type { CGroup, FaceCluster } from "./cluster";
import type { LocalFaceIndex } from "./face";
/**
diff --git a/web/packages/new/photos/services/ml/face.ts b/web/packages/new/photos/services/ml/face.ts
index 891b605db2..d8616b7426 100644
--- a/web/packages/new/photos/services/ml/face.ts
+++ b/web/packages/new/photos/services/ml/face.ts
@@ -714,7 +714,7 @@ const detectBlur = (
type FaceDirection = "left" | "right" | "straight";
-const faceDirection = ({ landmarks }: FaceDetection): FaceDirection => {
+export const faceDirection = ({ landmarks }: FaceDetection): FaceDirection => {
const leftEye = landmarks[0]!;
const rightEye = landmarks[1]!;
const nose = landmarks[2]!;
diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts
index 43d90578b8..3bb6bb3eaf 100644
--- a/web/packages/new/photos/services/ml/index.ts
+++ b/web/packages/new/photos/services/ml/index.ts
@@ -20,14 +20,13 @@ import { getAllLocalFiles } from "../files";
import { getRemoteFlag, updateRemoteFlag } from "../remote-store";
import type { SearchPerson } from "../search/types";
import type { UploadItem } from "../upload/types";
-import { clusterFacesHdb, type CGroup, type FaceCluster } from "./cluster-new";
-import { regenerateFaceCrops } from "./crop";
import {
- clearMLDB,
- faceIndex,
- faceIndexes,
- indexableAndIndexedCounts,
-} from "./db";
+ type ClusteringOpts,
+ type ClusterPreviewFace,
+ type FaceCluster,
+} from "./cluster";
+import { regenerateFaceCrops } from "./crop";
+import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db";
import type { Face } from "./face";
import { MLWorker } from "./worker";
import type { CLIPMatches } from "./worker-types";
@@ -349,118 +348,99 @@ export const wipSearchPersons = async () => {
return _wip_searchPersons ?? [];
};
-export interface FaceFileNeighbours {
- face: Face;
- neighbours: FaceFileNeighbour[];
+export interface ClusterPreviewWithFile {
+ clusterSize: number;
+ faces: ClusterPreviewFaceWithFile[];
}
-export interface FaceFileNeighbour {
- face: Face;
+export type ClusterPreviewFaceWithFile = ClusterPreviewFace & {
enteFile: EnteFile;
- cosineSimilarity: number;
-}
+};
export interface ClusterDebugPageContents {
- faceFNs: FaceFileNeighbours[];
+ totalFaceCount: number;
+ filteredFaceCount: number;
+ clusteredFaceCount: number;
+ unclusteredFaceCount: number;
+ timeTakenMs: number;
clusters: FaceCluster[];
- clusterIDForFaceID: Map;
+ clusterPreviewsWithFile: ClusterPreviewWithFile[];
+ unclusteredFacesWithFile: {
+ face: Face;
+ enteFile: EnteFile;
+ }[];
}
-export const wipClusterDebugPageContents = async (): Promise<
- ClusterDebugPageContents | undefined
-> => {
- if (!(await wipClusterEnable())) return undefined;
+export const wipClusterDebugPageContents = async (
+ opts: ClusteringOpts,
+): Promise => {
+ if (!(await wipClusterEnable())) throw new Error("Not implemented");
- log.info("clustering");
+ log.info("clustering", opts);
_wip_isClustering = true;
_wip_searchPersons = undefined;
triggerStatusUpdate();
- // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces(
- const { faceAndNeigbours, clusters, cgroups } = await clusterFacesHdb(
- await faceIndexes(),
- );
- const searchPersons = await convertToSearchPersons(clusters, cgroups);
+ const { clusterPreviews, clusters, cgroups, unclusteredFaces, ...rest } =
+ await worker().then((w) => w.clusterFaces(opts));
const localFiles = await getAllLocalFiles();
const localFileByID = new Map(localFiles.map((f) => [f.id, f]));
const fileForFace = ({ faceID }: Face) =>
ensure(localFileByID.get(ensure(fileIDFromFaceID(faceID))));
- const faceFNs = faceAndNeigbours
- .map(({ face, neighbours }) => ({
- face,
- neighbours: neighbours.map(({ face, cosineSimilarity }) => ({
+ const clusterPreviewsWithFile = clusterPreviews.map(
+ ({ clusterSize, faces }) => ({
+ clusterSize,
+ faces: faces.map(({ face, ...rest }) => ({
face,
enteFile: fileForFace(face),
- cosineSimilarity,
+ ...rest,
})),
- }))
- .sort((a, b) => b.face.score - a.face.score);
-
- const clusterIDForFaceID = new Map(
- clusters.flatMap((cluster) =>
- cluster.faceIDs.map((id) => [id, cluster.id]),
- ),
+ }),
);
+ const unclusteredFacesWithFile = unclusteredFaces.map((face) => ({
+ face,
+ enteFile: fileForFace(face),
+ }));
+
+ const clusterByID = new Map(clusters.map((c) => [c.id, c]));
+
+ const searchPersons = cgroups
+ .map((cgroup) => {
+ const faceID = ensure(cgroup.displayFaceID);
+ const fileID = ensure(fileIDFromFaceID(faceID));
+ const file = ensure(localFileByID.get(fileID));
+
+ const faceIDs = cgroup.clusterIDs
+ .map((id) => ensure(clusterByID.get(id)))
+ .flatMap((cluster) => cluster.faceIDs);
+ const fileIDs = faceIDs
+ .map((faceID) => fileIDFromFaceID(faceID))
+ .filter((fileID) => fileID !== undefined);
+
+ return {
+ id: cgroup.id,
+ name: cgroup.name,
+ faceIDs,
+ files: [...new Set(fileIDs)],
+ displayFaceID: faceID,
+ displayFaceFile: file,
+ };
+ })
+ .sort((a, b) => b.faceIDs.length - a.faceIDs.length);
+
_wip_isClustering = false;
_wip_searchPersons = searchPersons;
triggerStatusUpdate();
- const prunedFaceFNs = faceFNs.slice(0, 30).map(({ face, neighbours }) => ({
- face,
- neighbours: neighbours.slice(0, 30),
- }));
-
- return { faceFNs: prunedFaceFNs, clusters, clusterIDForFaceID };
-};
-
-export const wipCluster = () => void wipClusterDebugPageContents();
-
-const convertToSearchPersons = async (
- clusters: FaceCluster[],
- cgroups: CGroup[],
-) => {
- const clusterByID = new Map(clusters.map((c) => [c.id, c]));
-
- const localFiles = await getAllLocalFiles();
- const localFileByID = new Map(localFiles.map((f) => [f.id, f]));
-
- const result: SearchPerson[] = [];
- for (const cgroup of cgroups) {
- const displayFaceID = cgroup.displayFaceID;
- if (!displayFaceID) {
- // TODO-Cluster
- assertionFailed(`cgroup ${cgroup.id} without displayFaceID`);
- continue;
- }
-
- const displayFaceFileID = fileIDFromFaceID(displayFaceID);
- if (!displayFaceFileID) continue;
-
- const displayFaceFile = localFileByID.get(displayFaceFileID);
- if (!displayFaceFile) {
- assertionFailed(`Face ID ${displayFaceFileID} without local file`);
- continue;
- }
-
- const fileIDs = cgroup.clusterIDs
- .map((id) => clusterByID.get(id))
- .flatMap((cluster) => cluster?.faceIDs ?? [])
- .map((faceID) => fileIDFromFaceID(faceID))
- .filter((fileID) => fileID !== undefined);
-
- result.push({
- id: cgroup.id,
- name: cgroup.name,
- files: [...new Set(fileIDs)],
- displayFaceID,
- displayFaceFile,
- });
- }
-
- return result.sort((a, b) => b.files.length - a.files.length);
+ return {
+ clusters,
+ clusterPreviewsWithFile,
+ unclusteredFacesWithFile,
+ ...rest,
+ };
};
export type MLStatus =
diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts
index f21f58d85a..c663abc2c9 100644
--- a/web/packages/new/photos/services/ml/worker.ts
+++ b/web/packages/new/photos/services/ml/worker.ts
@@ -24,8 +24,10 @@ import {
indexCLIP,
type CLIPIndex,
} from "./clip";
+import { clusterFaces, type ClusteringOpts } from "./cluster";
import { saveFaceCrops } from "./crop";
import {
+ faceIndexes,
indexableFileIDs,
markIndexingFailed,
saveIndexes,
@@ -272,6 +274,11 @@ export class MLWorker {
remoteMLData: mlDataByID.get(id),
}));
}
+
+ // TODO-Cluster
+ async clusterFaces(opts: ClusteringOpts) {
+ return clusterFaces(await faceIndexes(), opts);
+ }
}
expose(MLWorker);
diff --git a/web/packages/new/photos/services/user-entity.ts b/web/packages/new/photos/services/user-entity.ts
index 7e26726dd5..121171d214 100644
--- a/web/packages/new/photos/services/user-entity.ts
+++ b/web/packages/new/photos/services/user-entity.ts
@@ -12,7 +12,7 @@ import { ensure } from "@/utils/ensure";
import { nullToUndefined } from "@/utils/transform";
import { z } from "zod";
import { gunzip } from "./gzip";
-import type { CGroup } from "./ml/cluster-new";
+import type { CGroup } from "./ml/cluster";
import { applyCGroupDiff } from "./ml/db";
/**