Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be218c7
Improve progressive face thumbnails and remove redundant quality decode
laurenspriem Mar 3, 2026
56fef7e
Lower progressive face upgrade cluster threshold to 5
laurenspriem Mar 3, 2026
18c1718
Skip progressive full-upgrade for video face crops
laurenspriem Mar 3, 2026
62e6ae5
[mob][photos] Apply cached full face crop in upgrade path
laurenspriem Mar 3, 2026
8049abf
Gate progressive face thumbnail generation by flag
laurenspriem Mar 5, 2026
5cc811f
Minor improvement
laurenspriem Mar 17, 2026
bf3aa5c
Speed up progressive people face thumbnails
laurenspriem Mar 17, 2026
ba3af2e
Prioritize visible face thumbnail work
laurenspriem Mar 17, 2026
0284306
Keep first-paint face thumbnails ahead of upgrades
laurenspriem Mar 17, 2026
87fbcf8
Fix hidden-face source reuse and shared queue cancellation
laurenspriem Mar 17, 2026
a1db87e
Fix progressive face crop cache and queue cleanup
laurenspriem Mar 18, 2026
dccea73
Fall back when preview thumbnail fetch fails
laurenspriem Mar 18, 2026
67fa3b0
Refresh person avatar before trusting seeded face IDs
laurenspriem Mar 18, 2026
ff7be85
Revalidate face-source fast paths and clear task claims
laurenspriem Mar 18, 2026
2dcdac3
Revalidate cached face sources after people changes
laurenspriem Mar 18, 2026
ccec3e9
Fix progressive face upgrade and prefetch edge cases
laurenspriem Mar 18, 2026
ff238b5
Keep visible face upgrades alive under queue pressure
laurenspriem Mar 18, 2026
edc828c
Reload seeded face sources without overwriting covers
laurenspriem Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions mobile/apps/photos/lib/models/ml/face/person_face_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:photos/models/file/file.dart';
import 'package:photos/models/ml/face/face.dart';

class PersonFaceSource {
final EnteFile file;
final Face face;
final int resolvedFileId;
final String? personName;

const PersonFaceSource({
required this.file,
required this.face,
required this.resolvedFileId,
this.personName,
});
}
2 changes: 2 additions & 0 deletions mobile/apps/photos/lib/models/search/search_constants.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const kPersonParamID = 'person_id';
const kPersonWidgetKey = 'person_widget_key';
const kPersonPinned = 'person_pinned';
const kPersonAvatarFaceID = 'person_avatar_face_id';
const kPersonFaceSource = 'person_face_source';
const kClusterParamId = 'cluster_id';
const kFileID = 'file_id';
const kContactEmail = 'contact_email';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ import "package:photos/utils/image_ml_util.dart";
import "package:photos/utils/isolate/isolate_operations.dart";
import "package:photos/utils/isolate/super_isolate.dart";

class FaceThumbnailGenerationResult {
final List<Uint8List> thumbnails;
final int sourceWidth;
final int sourceHeight;

const FaceThumbnailGenerationResult({
required this.thumbnails,
required this.sourceWidth,
required this.sourceHeight,
});
}

@pragma('vm:entry-point')
class FaceThumbnailGenerator extends SuperIsolate {
@override
Expand Down Expand Up @@ -64,6 +76,66 @@ class FaceThumbnailGenerator extends SuperIsolate {
return compressedFaces;
} catch (e, s) {
_logger.severe("Failed to generate face thumbnails", e, s);
rethrow;
}
}

Future<FaceThumbnailGenerationResult>
generateFaceThumbnailsWithSourceDimensions(
String imagePath,
List<FaceBox> faceBoxes,
) async {
if (!flagService.progressivePersonFaceThumbnailsEnabled) {
final thumbnails = await generateFaceThumbnails(imagePath, faceBoxes);
return FaceThumbnailGenerationResult(
thumbnails: thumbnails,
sourceWidth: 0,
sourceHeight: 0,
);
}

try {
final useRustForFaceThumbnails = flagService.useRustForFaceThumbnails;
_logger.info(
"Generating face thumbnails for ${faceBoxes.length} face boxes in $imagePath",
);
final List<Map<String, dynamic>> faceBoxesJson =
faceBoxes.map((box) => box.toJson()).toList();
final Map<String, dynamic> rawResult = await runInIsolate(
IsolateOperation.generateFaceThumbnailsWithSourceDimensions,
{
'imagePath': imagePath,
'faceBoxesList': faceBoxesJson,
'useRustForFaceThumbnails': useRustForFaceThumbnails,
},
).then((value) => Map<String, dynamic>.from(value));
final List<Uint8List> faces =
(rawResult['thumbnails'] as List<dynamic>).cast<Uint8List>();
final int sourceWidth = rawResult['sourceWidth'] as int? ?? 0;
final int sourceHeight = rawResult['sourceHeight'] as int? ?? 0;
_logger.info(
"Generated face thumbnails with source dimensions ${sourceWidth}x$sourceHeight",
);
if (useRustForFaceThumbnails) {
// Rust path already emits compressed JPEG bytes.
return FaceThumbnailGenerationResult(
thumbnails: faces,
sourceWidth: sourceWidth,
sourceHeight: sourceHeight,
);
}
final compressedFaces =
await compressFaceThumbnails({'listPngBytes': faces});
_logger.fine(
"Compressed face thumbnails from sizes ${faces.map((e) => e.length / 1024).toList()} to ${compressedFaces.map((e) => e.length / 1024).toList()} kilobytes",
);
return FaceThumbnailGenerationResult(
thumbnails: compressedFaces,
sourceWidth: sourceWidth,
sourceHeight: sourceHeight,
);
} catch (e, s) {
_logger.severe("Failed to generate face thumbnails", e, s);

rethrow;
}
Expand Down
127 changes: 127 additions & 0 deletions mobile/apps/photos/lib/services/search_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import "package:photos/models/memories/memories_cache.dart";
import "package:photos/models/memories/memory.dart";
import "package:photos/models/memories/smart_memory.dart";
import "package:photos/models/ml/face/person.dart";
import "package:photos/models/ml/face/person_face_source.dart";
import 'package:photos/models/search/album_search_result.dart';
import 'package:photos/models/search/device_album_search_result.dart';
import 'package:photos/models/search/generic_search_result.dart';
Expand Down Expand Up @@ -66,11 +67,13 @@ import "package:photos/utils/file_util.dart";
import "package:photos/utils/people_sort_util.dart";

class SearchService {
static const int _initialFaceSourcePrefetchCount = 24;
Future<List<EnteFile>>? _cachedFilesFuture;
Future<List<EnteFile>>? _cachedFilesForSearch;
Future<List<EnteFile>>? _cachedFilesForHierarchicalSearch;
Future<List<EnteFile>>? _cachedFilesForGenericGallery;
Future<List<EnteFile>>? _cachedHiddenFilesFuture;
final Set<String> _faceSourcePrefetchInFlight = {};
final _logger = Logger((SearchService).toString());
final _collectionService = CollectionsService.instance;
static const _maximumResultsLimit = 20;
Expand Down Expand Up @@ -197,6 +200,127 @@ class SearchService {
unawaited(memoriesCacheService.clearMemoriesCache());
}

Future<void> _enrichInitialFaceSources(
List<GenericSearchResult> results,
) async {
await prefetchFaceSourcesInWindow(
results,
startIndex: 0,
count: _initialFaceSourcePrefetchCount,
);
}

Future<void> prefetchFaceSourcesInWindow(
List<GenericSearchResult> results, {
required int startIndex,
int count = _initialFaceSourcePrefetchCount,
int leadingBuffer = 0,
}) async {
if (results.isEmpty || count <= 0) {
return;
}
final safeStart = max(0, startIndex - leadingBuffer);
final safeEnd = min(results.length, safeStart + count);
if (safeStart >= safeEnd) {
return;
}
await Future.wait(
results.sublist(safeStart, safeEnd).map(_prefetchFaceSourceIfNeeded),
);
}

Future<void> _prefetchFaceSourceIfNeeded(
GenericSearchResult result,
) async {
if (result.params[kPersonFaceSource] != null) {
return;
}
final prefetchKey = _faceSourcePrefetchKey(result);
if (prefetchKey == null || !_faceSourcePrefetchInFlight.add(prefetchKey)) {
return;
}
try {
final source = await _tryBuildInitialPersonFaceSource(result);
if (source != null) {
result.params[kPersonFaceSource] = source;
}
} catch (e, s) {
_logger.fine(
"Failed to prefetch initial face source for ${result.params[kPersonParamID] ?? result.params[kClusterParamId]}",
e,
s,
);
} finally {
_faceSourcePrefetchInFlight.remove(prefetchKey);
}
}

String? _faceSourcePrefetchKey(GenericSearchResult result) {
final personID = result.params[kPersonParamID] as String?;
if (personID != null && personID.isNotEmpty) {
return "person:$personID";
}
final clusterID = result.params[kClusterParamId] as String?;
if (clusterID != null && clusterID.isNotEmpty) {
return "cluster:$clusterID";
}
return null;
}

Future<PersonFaceSource?> _tryBuildInitialPersonFaceSource(
GenericSearchResult result,
) async {
final personID = result.params[kPersonParamID] as String?;
final clusterID = result.params[kClusterParamId] as String?;
final avatarFaceID = result.params[kPersonAvatarFaceID] as String?;
final previewFile = result.previewThumbnail();
final previewFileID = previewFile?.uploadedFileID;
if (previewFile == null || previewFileID == null) {
return null;
}

final hasPersonSeed = personID != null &&
personID.isNotEmpty &&
avatarFaceID != null &&
avatarFaceID.isNotEmpty;
final hasClusterSeed = clusterID != null && clusterID.isNotEmpty;
if (!hasPersonSeed && !hasClusterSeed) {
return null;
}

final face = await mlDataDB.getCoverFaceForPerson(
recentFileID: previewFileID,
personID: hasPersonSeed ? personID : null,
avatarFaceId: hasPersonSeed ? avatarFaceID : null,
clusterID: hasClusterSeed ? clusterID : null,
);
if (face == null) {
return null;
}

EnteFile sourceFile = previewFile;
if (face.fileID != previewFileID) {
EnteFile? faceFile;
for (final file in result.resultFiles()) {
if (file.uploadedFileID == face.fileID) {
faceFile = file;
break;
}
}
if (faceFile == null) {
return null;
}
sourceFile = faceFile;
}

return PersonFaceSource(
file: sourceFile,
face: face,
resolvedFileId: face.fileID,
personName: result.name(),
);
}

// getFilteredCollectionsWithThumbnail removes deleted or archived or
// collections which don't have a file from search result
Future<List<AlbumSearchResult>> getCollectionSearchResults(
Expand Down Expand Up @@ -1083,6 +1207,7 @@ class SearchService {
params: {
kPersonWidgetKey: p.data.avatarFaceID ?? p.hashCode.toString(),
kPersonParamID: personID,
kPersonAvatarFaceID: p.data.avatarFaceID,
kFileID: files.first.uploadedFileID,
kPersonPinned: p.data.isPinned,
},
Expand All @@ -1100,6 +1225,7 @@ class SearchService {
kPersonWidgetKey:
p.data.avatarFaceID ?? p.hashCode.toString(),
kPersonParamID: personID,
kPersonAvatarFaceID: p.data.avatarFaceID,
kPersonPinned: p.data.isPinned,
kFileID: files.first.uploadedFileID,
},
Expand Down Expand Up @@ -1206,6 +1332,7 @@ class SearchService {
photosSortAscending: localSettings.peoplePhotosSortAscending,
),
);
await _enrichInitialFaceSources(facesResult);
if (limit != null) {
return facesResult.sublist(0, min(limit, facesResult.length));
} else {
Expand Down
Loading