浏览代码

Implemented EXIF store and display (#19)

* Added EXIF extracting in the backend
* Added EXIF displaying on `image_viewer_page.dart`
* Added Icon for backup option not enable
Alex 3 年之前
父节点
当前提交
de1dbcea9c
共有 35 个文件被更改,包括 1089 次插入844 次删除
  1. 45 0
      mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart
  2. 0 0
      mobile/lib/modules/asset_viewer/models/store_model_here.txt
  3. 21 0
      mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart
  4. 0 0
      mobile/lib/modules/asset_viewer/services/store_services_here.txt
  5. 118 0
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  6. 57 0
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  7. 85 0
      mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
  8. 18 0
      mobile/lib/modules/home/services/asset.service.dart
  9. 82 69
      mobile/lib/modules/home/ui/immich_sliver_appbar.dart
  10. 1 0
      mobile/lib/modules/home/ui/thumbnail_image.dart
  11. 1 1
      mobile/lib/modules/login/ui/login_form.dart
  12. 0 2
      mobile/lib/modules/login/views/login_page.dart
  13. 2 1
      mobile/lib/routing/router.dart
  14. 11 5
      mobile/lib/routing/router.gr.dart
  15. 187 0
      mobile/lib/shared/models/exif.model.dart
  16. 133 0
      mobile/lib/shared/models/immich_asset_with_exif.model.dart
  17. 0 64
      mobile/lib/shared/views/image_viewer_page.dart
  18. 1 1
      mobile/makefile
  19. 15 8
      mobile/pubspec.lock
  20. 2 0
      mobile/pubspec.yaml
  21. 13 23
      server/Dockerfile
  22. 28 664
      server/package-lock.json
  23. 1 0
      server/package.json
  24. 10 2
      server/src/api-v1/asset/asset.controller.ts
  25. 12 1
      server/src/api-v1/asset/asset.module.ts
  26. 10 0
      server/src/api-v1/asset/asset.service.ts
  27. 48 0
      server/src/api-v1/asset/dto/create-exif.dto.ts
  28. 4 0
      server/src/api-v1/asset/dto/update-exif.dto.ts
  29. 5 1
      server/src/api-v1/asset/entities/asset.entity.ts
  30. 67 0
      server/src/api-v1/asset/entities/exif.entity.ts
  31. 3 1
      server/src/app.module.ts
  32. 24 0
      server/src/modules/background-task/background-task.module.ts
  33. 59 0
      server/src/modules/background-task/background-task.processor.ts
  34. 25 0
      server/src/modules/background-task/background-task.service.ts
  35. 1 1
      server/src/modules/image-optimize/image-optimize.service.ts

+ 45 - 0
mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart

@@ -0,0 +1,45 @@
+import 'dart:convert';
+
+class ImageViewerPageState {
+  final bool isBottomSheetEnable;
+  ImageViewerPageState({
+    required this.isBottomSheetEnable,
+  });
+
+  ImageViewerPageState copyWith({
+    bool? isBottomSheetEnable,
+  }) {
+    return ImageViewerPageState(
+      isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'isBottomSheetEnable': isBottomSheetEnable,
+    };
+  }
+
+  factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
+    return ImageViewerPageState(
+      isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
+
+  @override
+  String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
+  }
+
+  @override
+  int get hashCode => isBottomSheetEnable.hashCode;
+}

+ 0 - 0
mobile/lib/modules/asset_viewer/models/store_model_here.txt


+ 21 - 0
mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart

@@ -0,0 +1,21 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
+import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+
+class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
+  ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
+
+  void toggleBottomSheet() {
+    bool isBottomSheetEnable = state.isBottomSheetEnable;
+
+    if (isBottomSheetEnable) {
+      state.copyWith(isBottomSheetEnable: false);
+    } else {
+      state.copyWith(isBottomSheetEnable: true);
+    }
+  }
+}
+
+final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
+    ((ref) => ImageViewerPageStateNotifier()));

+ 0 - 0
mobile/lib/modules/asset_viewer/services/store_services_here.txt


+ 118 - 0
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -0,0 +1,118 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
+import 'package:intl/intl.dart';
+import 'package:path/path.dart' as p;
+
+class ExifBottomSheet extends ConsumerWidget {
+  final ImmichAssetWithExif assetDetail;
+
+  const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
+      child: ListView(
+        children: [
+          assetDetail.exifInfo?.dateTimeOriginal != null
+              ? Text(
+                  DateFormat('E, LLL d, y • h:mm a').format(
+                    DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
+                  ),
+                  style: TextStyle(
+                    color: Colors.grey[400],
+                    fontWeight: FontWeight.bold,
+                    fontSize: 14,
+                  ),
+                )
+              : Container(),
+          Padding(
+            padding: const EdgeInsets.only(top: 16.0),
+            child: Text(
+              "Add Description...",
+              style: TextStyle(
+                color: Colors.grey[500],
+                fontSize: 11,
+              ),
+            ),
+          ),
+
+          // Location
+          assetDetail.exifInfo?.latitude != null
+              ? Padding(
+                  padding: const EdgeInsets.only(top: 32.0),
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Divider(
+                        thickness: 1,
+                        color: Colors.grey[600],
+                      ),
+                      Text(
+                        "LOCATION",
+                        style: TextStyle(fontSize: 11, color: Colors.grey[400]),
+                      ),
+                      Text(
+                        "${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
+                        style: TextStyle(fontSize: 11, color: Colors.grey[400]),
+                      )
+                    ],
+                  ),
+                )
+              : Container(),
+          // Detail
+          assetDetail.exifInfo != null
+              ? Padding(
+                  padding: const EdgeInsets.only(top: 32.0),
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Divider(
+                        thickness: 1,
+                        color: Colors.grey[600],
+                      ),
+                      Padding(
+                        padding: const EdgeInsets.only(bottom: 8.0),
+                        child: Text(
+                          "DETAILS",
+                          style: TextStyle(fontSize: 11, color: Colors.grey[400]),
+                        ),
+                      ),
+                      ListTile(
+                        contentPadding: const EdgeInsets.all(0),
+                        dense: true,
+                        textColor: Colors.grey[300],
+                        iconColor: Colors.grey[300],
+                        leading: const Icon(Icons.image),
+                        title: Text(
+                          "${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
+                          style: const TextStyle(fontWeight: FontWeight.bold),
+                        ),
+                        subtitle: Text(
+                            "${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!}  ${assetDetail.exifInfo?.fileSizeInByte!}B "),
+                      ),
+                      assetDetail.exifInfo?.make != null
+                          ? ListTile(
+                              contentPadding: const EdgeInsets.all(0),
+                              dense: true,
+                              textColor: Colors.grey[300],
+                              iconColor: Colors.grey[300],
+                              leading: const Icon(Icons.camera),
+                              title: Text(
+                                "${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
+                                style: const TextStyle(fontWeight: FontWeight.bold),
+                              ),
+                              subtitle: Text(
+                                  "ƒ/${assetDetail.exifInfo?.fNumber}   1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)}   ${assetDetail.exifInfo?.focalLength}mm   ISO${assetDetail.exifInfo?.iso} "),
+                            )
+                          : Container()
+                    ],
+                  ),
+                )
+              : Container()
+        ],
+      ),
+    );
+  }
+}

+ 57 - 0
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -0,0 +1,57 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+
+class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
+  const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
+
+  final ImmichAsset asset;
+  final Function onMoreInfoPressed;
+  @override
+  Widget build(BuildContext context) {
+    double iconSize = 18.0;
+
+    return AppBar(
+      foregroundColor: Colors.grey[100],
+      toolbarHeight: 60,
+      backgroundColor: Colors.black,
+      leading: IconButton(
+        onPressed: () {
+          AutoRouter.of(context).pop();
+        },
+        icon: const Icon(
+          Icons.arrow_back_ios_new_rounded,
+          size: 20.0,
+        ),
+      ),
+      actions: [
+        IconButton(
+          iconSize: iconSize,
+          splashRadius: iconSize,
+          onPressed: () {
+            print("backup");
+          },
+          icon: const Icon(Icons.backup_outlined),
+        ),
+        IconButton(
+          iconSize: iconSize,
+          splashRadius: iconSize,
+          onPressed: () {
+            print("favorite");
+          },
+          icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
+        ),
+        IconButton(
+            iconSize: iconSize,
+            splashRadius: iconSize,
+            onPressed: () {
+              onMoreInfoPressed();
+            },
+            icon: const Icon(Icons.more_horiz_rounded))
+      ],
+    );
+  }
+
+  @override
+  Size get preferredSize => const Size.fromHeight(kToolbarHeight);
+}

+ 85 - 0
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -0,0 +1,85 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hive/hive.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
+import 'package:immich_mobile/modules/home/services/asset.service.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
+import 'package:photo_view/photo_view.dart';
+
+// ignore: must_be_immutable
+class ImageViewerPage extends HookConsumerWidget {
+  final String imageUrl;
+  final String heroTag;
+  final String thumbnailUrl;
+  final ImmichAsset asset;
+  final AssetService _assetService = AssetService();
+  ImmichAssetWithExif? assetDetail;
+
+  ImageViewerPage(
+      {Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
+      : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    var box = Hive.box(userInfoBox);
+
+    getAssetExif() async {
+      assetDetail = await _assetService.getAssetById(asset.id);
+    }
+
+    useEffect(() {
+      getAssetExif();
+    }, []);
+
+    return Scaffold(
+      backgroundColor: Colors.black,
+      appBar: TopControlAppBar(
+        asset: asset,
+        onMoreInfoPressed: () {
+          showModalBottomSheet(
+              backgroundColor: Colors.black,
+              barrierColor: Colors.transparent,
+              isScrollControlled: false,
+              context: context,
+              builder: (context) {
+                return ExifBottomSheet(assetDetail: assetDetail!);
+              });
+        },
+      ),
+      body: Center(
+        child: Hero(
+          tag: heroTag,
+          child: CachedNetworkImage(
+            fit: BoxFit.cover,
+            imageUrl: imageUrl,
+            httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+            fadeInDuration: const Duration(milliseconds: 250),
+            errorWidget: (context, url, error) => const Icon(Icons.error),
+            imageBuilder: (context, imageProvider) {
+              return PhotoView(imageProvider: imageProvider);
+            },
+            placeholder: (context, url) {
+              return CachedNetworkImage(
+                fit: BoxFit.cover,
+                imageUrl: thumbnailUrl,
+                httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                placeholderFadeInDuration: const Duration(milliseconds: 0),
+                progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
+                  scale: 0.2,
+                  child: CircularProgressIndicator(value: downloadProgress.progress),
+                ),
+                errorWidget: (context, url, error) => const Icon(Icons.error),
+              );
+            },
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 18 - 0
mobile/lib/modules/home/services/asset.service.dart

@@ -3,6 +3,7 @@ import 'dart:convert';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
 import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 import 'package:immich_mobile/shared/services/network.service.dart';
 
 
 class AssetService {
 class AssetService {
@@ -58,4 +59,21 @@ class AssetService {
       return [];
       return [];
     }
     }
   }
   }
+
+  Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
+    try {
+      var res = await _networkService.getRequest(
+        url: "asset/assetById/$assetId",
+      );
+
+      Map<String, dynamic> decodedData = jsonDecode(res.toString());
+
+      ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
+      print("result $result");
+      return result;
+    } catch (e) {
+      debugPrint("Error getAllAsset  ${e.toString()}");
+      return null;
+    }
+  }
 }
 }

+ 82 - 69
mobile/lib/modules/home/ui/immich_sliver_appbar.dart

@@ -1,7 +1,10 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:auto_route/auto_route.dart';
+import 'package:badges/badges.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:google_fonts/google_fonts.dart';
 import 'package:google_fonts/google_fonts.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 
 
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/backup_state.model.dart';
 import 'package:immich_mobile/shared/models/backup_state.model.dart';
@@ -20,79 +23,89 @@ class ImmichSliverAppBar extends ConsumerWidget {
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
     final BackUpState _backupState = ref.watch(backupProvider);
     final BackUpState _backupState = ref.watch(backupProvider);
-
-    return SliverPadding(
-      padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
-      sliver: SliverAppBar(
-        centerTitle: true,
-        floating: true,
-        pinned: false,
-        snap: false,
-        backgroundColor: Colors.grey[200],
-        shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
-        leading: Builder(
-          builder: (BuildContext context) {
-            return IconButton(
-              icon: const Icon(Icons.account_circle_rounded),
-              onPressed: () {
-                Scaffold.of(context).openDrawer();
-              },
-              tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
-            );
-          },
-        ),
-        title: Text(
-          'IMMICH',
-          style: GoogleFonts.snowburstOne(
-            textStyle: TextStyle(
-              fontWeight: FontWeight.bold,
-              fontSize: 18,
-              color: Theme.of(context).primaryColor,
-            ),
+    bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
+    return SliverAppBar(
+      centerTitle: true,
+      floating: true,
+      pinned: false,
+      snap: false,
+      backgroundColor: Colors.grey[200],
+      shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
+      leading: Builder(
+        builder: (BuildContext context) {
+          return IconButton(
+            icon: const Icon(Icons.account_circle_rounded),
+            onPressed: () {
+              Scaffold.of(context).openDrawer();
+            },
+            tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
+          );
+        },
+      ),
+      title: Text(
+        'IMMICH',
+        style: GoogleFonts.snowburstOne(
+          textStyle: TextStyle(
+            fontWeight: FontWeight.bold,
+            fontSize: 18,
+            color: Theme.of(context).primaryColor,
           ),
           ),
         ),
         ),
-        actions: [
-          Stack(
-            alignment: AlignmentDirectional.center,
-            children: [
-              _backupState.backupProgress == BackUpProgressEnum.inProgress
-                  ? Positioned(
-                      top: 10,
-                      right: 12,
-                      child: SizedBox(
-                        height: 8,
-                        width: 8,
-                        child: CircularProgressIndicator(
-                          strokeWidth: 1,
-                          valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
-                        ),
+      ),
+      actions: [
+        Stack(
+          alignment: AlignmentDirectional.center,
+          children: [
+            _backupState.backupProgress == BackUpProgressEnum.inProgress
+                ? Positioned(
+                    top: 10,
+                    right: 12,
+                    child: SizedBox(
+                      height: 8,
+                      width: 8,
+                      child: CircularProgressIndicator(
+                        strokeWidth: 1,
+                        valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
                       ),
                       ),
-                    )
-                  : Container(),
-              IconButton(
-                icon: const Icon(Icons.backup_rounded),
-                tooltip: 'Backup Controller',
-                onPressed: () async {
-                  var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
-
-                  if (onPop == true) {
-                    onPopBack!();
-                  }
-                },
-              ),
-              _backupState.backupProgress == BackUpProgressEnum.inProgress
-                  ? Positioned(
-                      bottom: 5,
-                      child: Text(
-                        _backupState.backingUpAssetCount.toString(),
-                        style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
+                    ),
+                  )
+                : Container(),
+            IconButton(
+              splashRadius: 25,
+              iconSize: 30,
+              icon: _isEnableAutoBackup
+                  ? const Icon(Icons.backup_rounded)
+                  : Badge(
+                      padding: const EdgeInsets.all(4),
+                      elevation: 1,
+                      position: BadgePosition.bottomEnd(bottom: -4, end: -4),
+                      badgeColor: Colors.white,
+                      badgeContent: const Icon(
+                        Icons.cloud_off_rounded,
+                        size: 8,
                       ),
                       ),
-                    )
-                  : Container()
-            ],
-          ),
-        ],
-      ),
+                      child: const Icon(Icons.backup_rounded)),
+              tooltip: 'Backup Controller',
+              onPressed: () async {
+                var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
+
+                if (onPop == true) {
+                  onPopBack!();
+                }
+              },
+            ),
+            _backupState.backupProgress == BackUpProgressEnum.inProgress
+                ? Positioned(
+                    bottom: 5,
+                    child: Text(
+                      _backupState.backingUpAssetCount.toString(),
+                      style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
+                    ),
+                  )
+                : Container()
+          ],
+        ),
+      ],
     );
     );
   }
   }
 }
 }

+ 1 - 0
mobile/lib/modules/home/ui/thumbnail_image.dart

@@ -56,6 +56,7 @@ class ThumbnailImage extends HookConsumerWidget {
                     '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
                     '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
                 heroTag: asset.id,
                 heroTag: asset.id,
                 thumbnailUrl: thumbnailRequestUrl,
                 thumbnailUrl: thumbnailRequestUrl,
+                asset: asset,
               ),
               ),
             );
             );
           } else {
           } else {

+ 1 - 1
mobile/lib/modules/login/ui/login_form.dart

@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
     final usernameController = useTextEditingController(text: 'testuser@email.com');
     final usernameController = useTextEditingController(text: 'testuser@email.com');
     final passwordController = useTextEditingController(text: 'password');
     final passwordController = useTextEditingController(text: 'password');
-    final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
+    final serverEndpointController = useTextEditingController(text: 'http://192.168.1.204:2283');
 
 
     return Center(
     return Center(
       child: ConstrainedBox(
       child: ConstrainedBox(

+ 0 - 2
mobile/lib/modules/login/views/login_page.dart

@@ -1,7 +1,5 @@
-import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/ui/login_form.dart';
 import 'package:immich_mobile/modules/login/ui/login_form.dart';
 
 
 class LoginPage extends HookConsumerWidget {
 class LoginPage extends HookConsumerWidget {

+ 2 - 1
mobile/lib/routing/router.dart

@@ -3,8 +3,9 @@ import 'package:flutter/widgets.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/views/backup_controller_page.dart';
 import 'package:immich_mobile/shared/views/backup_controller_page.dart';
-import 'package:immich_mobile/shared/views/image_viewer_page.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/shared/views/video_viewer_page.dart';
 import 'package:immich_mobile/shared/views/video_viewer_page.dart';
 
 
 part 'router.gr.dart';
 part 'router.gr.dart';

+ 11 - 5
mobile/lib/routing/router.gr.dart

@@ -41,7 +41,8 @@ class _$AppRouter extends RootStackRouter {
               key: args.key,
               key: args.key,
               imageUrl: args.imageUrl,
               imageUrl: args.imageUrl,
               heroTag: args.heroTag,
               heroTag: args.heroTag,
-              thumbnailUrl: args.thumbnailUrl));
+              thumbnailUrl: args.thumbnailUrl,
+              asset: args.asset));
     },
     },
     VideoViewerRoute.name: (routeData) {
     VideoViewerRoute.name: (routeData) {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
       final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -96,14 +97,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
       {Key? key,
       {Key? key,
       required String imageUrl,
       required String imageUrl,
       required String heroTag,
       required String heroTag,
-      required String thumbnailUrl})
+      required String thumbnailUrl,
+      required ImmichAsset asset})
       : super(ImageViewerRoute.name,
       : super(ImageViewerRoute.name,
             path: '/image-viewer-page',
             path: '/image-viewer-page',
             args: ImageViewerRouteArgs(
             args: ImageViewerRouteArgs(
                 key: key,
                 key: key,
                 imageUrl: imageUrl,
                 imageUrl: imageUrl,
                 heroTag: heroTag,
                 heroTag: heroTag,
-                thumbnailUrl: thumbnailUrl));
+                thumbnailUrl: thumbnailUrl,
+                asset: asset));
 
 
   static const String name = 'ImageViewerRoute';
   static const String name = 'ImageViewerRoute';
 }
 }
@@ -113,7 +116,8 @@ class ImageViewerRouteArgs {
       {this.key,
       {this.key,
       required this.imageUrl,
       required this.imageUrl,
       required this.heroTag,
       required this.heroTag,
-      required this.thumbnailUrl});
+      required this.thumbnailUrl,
+      required this.asset});
 
 
   final Key? key;
   final Key? key;
 
 
@@ -123,9 +127,11 @@ class ImageViewerRouteArgs {
 
 
   final String thumbnailUrl;
   final String thumbnailUrl;
 
 
+  final ImmichAsset asset;
+
   @override
   @override
   String toString() {
   String toString() {
-    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}';
+    return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
   }
   }
 }
 }
 
 

+ 187 - 0
mobile/lib/shared/models/exif.model.dart

@@ -0,0 +1,187 @@
+import 'dart:convert';
+
+class ImmichExif {
+  final int? id;
+  final String? assetId;
+  final String? make;
+  final String? model;
+  final String? imageName;
+  final int? exifImageWidth;
+  final int? exifImageHeight;
+  final int? fileSizeInByte;
+  final String? orientation;
+  final String? dateTimeOriginal;
+  final String? modifyDate;
+  final String? lensModel;
+  final double? fNumber;
+  final double? focalLength;
+  final int? iso;
+  final double? exposureTime;
+  final double? latitude;
+  final double? longitude;
+
+  ImmichExif({
+    this.id,
+    this.assetId,
+    this.make,
+    this.model,
+    this.imageName,
+    this.exifImageWidth,
+    this.exifImageHeight,
+    this.fileSizeInByte,
+    this.orientation,
+    this.dateTimeOriginal,
+    this.modifyDate,
+    this.lensModel,
+    this.fNumber,
+    this.focalLength,
+    this.iso,
+    this.exposureTime,
+    this.latitude,
+    this.longitude,
+  });
+
+  ImmichExif copyWith({
+    int? id,
+    String? assetId,
+    String? make,
+    String? model,
+    String? imageName,
+    int? exifImageWidth,
+    int? exifImageHeight,
+    int? fileSizeInByte,
+    String? orientation,
+    String? dateTimeOriginal,
+    String? modifyDate,
+    String? lensModel,
+    double? fNumber,
+    double? focalLength,
+    int? iso,
+    double? exposureTime,
+    double? latitude,
+    double? longitude,
+  }) {
+    return ImmichExif(
+      id: id ?? this.id,
+      assetId: assetId ?? this.assetId,
+      make: make ?? this.make,
+      model: model ?? this.model,
+      imageName: imageName ?? this.imageName,
+      exifImageWidth: exifImageWidth ?? this.exifImageWidth,
+      exifImageHeight: exifImageHeight ?? this.exifImageHeight,
+      fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
+      orientation: orientation ?? this.orientation,
+      dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
+      modifyDate: modifyDate ?? this.modifyDate,
+      lensModel: lensModel ?? this.lensModel,
+      fNumber: fNumber ?? this.fNumber,
+      focalLength: focalLength ?? this.focalLength,
+      iso: iso ?? this.iso,
+      exposureTime: exposureTime ?? this.exposureTime,
+      latitude: latitude ?? this.latitude,
+      longitude: longitude ?? this.longitude,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'assetId': assetId,
+      'make': make,
+      'model': model,
+      'imageName': imageName,
+      'exifImageWidth': exifImageWidth,
+      'exifImageHeight': exifImageHeight,
+      'fileSizeInByte': fileSizeInByte,
+      'orientation': orientation,
+      'dateTimeOriginal': dateTimeOriginal,
+      'modifyDate': modifyDate,
+      'lensModel': lensModel,
+      'fNumber': fNumber,
+      'focalLength': focalLength,
+      'iso': iso,
+      'exposureTime': exposureTime,
+      'latitude': latitude,
+      'longitude': longitude,
+    };
+  }
+
+  factory ImmichExif.fromMap(Map<String, dynamic> map) {
+    return ImmichExif(
+      id: map['id']?.toInt(),
+      assetId: map['assetId'],
+      make: map['make'],
+      model: map['model'],
+      imageName: map['imageName'],
+      exifImageWidth: map['exifImageWidth']?.toInt(),
+      exifImageHeight: map['exifImageHeight']?.toInt(),
+      fileSizeInByte: map['fileSizeInByte']?.toInt(),
+      orientation: map['orientation'],
+      dateTimeOriginal: map['dateTimeOriginal'],
+      modifyDate: map['modifyDate'],
+      lensModel: map['lensModel'],
+      fNumber: map['fNumber']?.toDouble(),
+      focalLength: map['focalLength']?.toDouble(),
+      iso: map['iso']?.toInt(),
+      exposureTime: map['exposureTime']?.toDouble(),
+      latitude: map['latitude']?.toDouble(),
+      longitude: map['longitude']?.toDouble(),
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ImmichExif &&
+        other.id == id &&
+        other.assetId == assetId &&
+        other.make == make &&
+        other.model == model &&
+        other.imageName == imageName &&
+        other.exifImageWidth == exifImageWidth &&
+        other.exifImageHeight == exifImageHeight &&
+        other.fileSizeInByte == fileSizeInByte &&
+        other.orientation == orientation &&
+        other.dateTimeOriginal == dateTimeOriginal &&
+        other.modifyDate == modifyDate &&
+        other.lensModel == lensModel &&
+        other.fNumber == fNumber &&
+        other.focalLength == focalLength &&
+        other.iso == iso &&
+        other.exposureTime == exposureTime &&
+        other.latitude == latitude &&
+        other.longitude == longitude;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^
+        assetId.hashCode ^
+        make.hashCode ^
+        model.hashCode ^
+        imageName.hashCode ^
+        exifImageWidth.hashCode ^
+        exifImageHeight.hashCode ^
+        fileSizeInByte.hashCode ^
+        orientation.hashCode ^
+        dateTimeOriginal.hashCode ^
+        modifyDate.hashCode ^
+        lensModel.hashCode ^
+        fNumber.hashCode ^
+        focalLength.hashCode ^
+        iso.hashCode ^
+        exposureTime.hashCode ^
+        latitude.hashCode ^
+        longitude.hashCode;
+  }
+}

+ 133 - 0
mobile/lib/shared/models/immich_asset_with_exif.model.dart

@@ -0,0 +1,133 @@
+import 'dart:convert';
+
+import 'package:immich_mobile/shared/models/exif.model.dart';
+
+class ImmichAssetWithExif {
+  final String id;
+  final String deviceAssetId;
+  final String userId;
+  final String deviceId;
+  final String type;
+  final String createdAt;
+  final String modifiedAt;
+  final String originalPath;
+  final bool isFavorite;
+  final String? duration;
+  final ImmichExif? exifInfo;
+
+  ImmichAssetWithExif({
+    required this.id,
+    required this.deviceAssetId,
+    required this.userId,
+    required this.deviceId,
+    required this.type,
+    required this.createdAt,
+    required this.modifiedAt,
+    required this.originalPath,
+    required this.isFavorite,
+    this.duration,
+    this.exifInfo,
+  });
+
+  ImmichAssetWithExif copyWith({
+    String? id,
+    String? deviceAssetId,
+    String? userId,
+    String? deviceId,
+    String? type,
+    String? createdAt,
+    String? modifiedAt,
+    String? originalPath,
+    bool? isFavorite,
+    String? duration,
+    ImmichExif? exifInfo,
+  }) {
+    return ImmichAssetWithExif(
+      id: id ?? this.id,
+      deviceAssetId: deviceAssetId ?? this.deviceAssetId,
+      userId: userId ?? this.userId,
+      deviceId: deviceId ?? this.deviceId,
+      type: type ?? this.type,
+      createdAt: createdAt ?? this.createdAt,
+      modifiedAt: modifiedAt ?? this.modifiedAt,
+      originalPath: originalPath ?? this.originalPath,
+      isFavorite: isFavorite ?? this.isFavorite,
+      duration: duration ?? this.duration,
+      exifInfo: exifInfo ?? this.exifInfo,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return {
+      'id': id,
+      'deviceAssetId': deviceAssetId,
+      'userId': userId,
+      'deviceId': deviceId,
+      'type': type,
+      'createdAt': createdAt,
+      'modifiedAt': modifiedAt,
+      'originalPath': originalPath,
+      'isFavorite': isFavorite,
+      'duration': duration,
+      'exifInfo': exifInfo?.toMap(),
+    };
+  }
+
+  factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
+    return ImmichAssetWithExif(
+      id: map['id'] ?? '',
+      deviceAssetId: map['deviceAssetId'] ?? '',
+      userId: map['userId'] ?? '',
+      deviceId: map['deviceId'] ?? '',
+      type: map['type'] ?? '',
+      createdAt: map['createdAt'] ?? '',
+      modifiedAt: map['modifiedAt'] ?? '',
+      originalPath: map['originalPath'] ?? '',
+      isFavorite: map['isFavorite'] ?? false,
+      duration: map['duration'],
+      exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
+    );
+  }
+
+  String toJson() => json.encode(toMap());
+
+  factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
+
+  @override
+  String toString() {
+    return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, originalPath: $originalPath, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is ImmichAssetWithExif &&
+        other.id == id &&
+        other.deviceAssetId == deviceAssetId &&
+        other.userId == userId &&
+        other.deviceId == deviceId &&
+        other.type == type &&
+        other.createdAt == createdAt &&
+        other.modifiedAt == modifiedAt &&
+        other.originalPath == originalPath &&
+        other.isFavorite == isFavorite &&
+        other.duration == duration &&
+        other.exifInfo == exifInfo;
+  }
+
+  @override
+  int get hashCode {
+    return id.hashCode ^
+        deviceAssetId.hashCode ^
+        userId.hashCode ^
+        deviceId.hashCode ^
+        type.hashCode ^
+        createdAt.hashCode ^
+        modifiedAt.hashCode ^
+        originalPath.hashCode ^
+        isFavorite.hashCode ^
+        duration.hashCode ^
+        exifInfo.hashCode;
+  }
+}

+ 0 - 64
mobile/lib/shared/views/image_viewer_page.dart

@@ -1,64 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:flutter/material.dart';
-import 'package:hive/hive.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
-
-class ImageViewerPage extends StatelessWidget {
-  final String imageUrl;
-  final String heroTag;
-  final String thumbnailUrl;
-
-  const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
-      : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    var box = Hive.box(userInfoBox);
-
-    return Scaffold(
-      backgroundColor: Colors.black,
-      appBar: AppBar(
-        toolbarHeight: 60,
-        backgroundColor: Colors.black,
-        leading: IconButton(
-            onPressed: () {
-              AutoRouter.of(context).pop();
-            },
-            icon: const Icon(Icons.arrow_back_ios)),
-      ),
-      body: Dismissible(
-        direction: DismissDirection.vertical,
-        onDismissed: (_) {
-          AutoRouter.of(context).pop();
-        },
-        key: Key(heroTag),
-        child: Center(
-          child: Hero(
-            tag: heroTag,
-            child: CachedNetworkImage(
-              fit: BoxFit.cover,
-              imageUrl: imageUrl,
-              httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-              fadeInDuration: const Duration(milliseconds: 250),
-              errorWidget: (context, url, error) => const Icon(Icons.error),
-              placeholder: (context, url) {
-                return CachedNetworkImage(
-                  fit: BoxFit.cover,
-                  imageUrl: thumbnailUrl,
-                  httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-                  placeholderFadeInDuration: const Duration(milliseconds: 0),
-                  progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
-                    scale: 0.2,
-                    child: CircularProgressIndicator(value: downloadProgress.progress),
-                  ),
-                  errorWidget: (context, url, error) => const Icon(Icons.error),
-                );
-              },
-            ),
-          ),
-        ),
-      ),
-    );
-  }
-}

+ 1 - 1
mobile/makefile

@@ -2,7 +2,7 @@ build:
 	flutter packages pub run build_runner build
 	flutter packages pub run build_runner build
 
 
 watch:
 watch:
-	flutter packages pub run build_runner watch
+	flutter packages pub run build_runner watch --delete-conflicting-outputs
 
 
 create_app_icon:
 create_app_icon:
 	flutter pub run flutter_launcher_icons:main
 	flutter pub run flutter_launcher_icons:main

+ 15 - 8
mobile/pubspec.lock

@@ -50,6 +50,13 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "3.2.1"
     version: "3.2.1"
+  badges:
+    dependency: "direct main"
+    description:
+      name: badges
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.2"
   boolean_selector:
   boolean_selector:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -513,13 +520,6 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "0.12.11"
     version: "0.12.11"
-  material_color_utilities:
-    dependency: transitive
-    description:
-      name: material_color_utilities
-      url: "https://pub.dartlang.org"
-    source: hosted
-    version: "0.1.3"
   meta:
   meta:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -639,6 +639,13 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "1.3.10"
     version: "1.3.10"
+  photo_view:
+    dependency: "direct main"
+    description:
+      name: photo_view
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.13.0"
   platform:
   platform:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -825,7 +832,7 @@ packages:
       name: test_api
       name: test_api
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "0.4.8"
+    version: "0.4.3"
   timing:
   timing:
     dependency: transitive
     dependency: transitive
     description:
     description:

+ 2 - 0
mobile/pubspec.yaml

@@ -31,6 +31,8 @@ dependencies:
   video_player: ^2.2.18
   video_player: ^2.2.18
   chewie: ^1.2.2
   chewie: ^1.2.2
   sliver_tools: ^0.2.5
   sliver_tools: ^0.2.5
+  badges: ^2.0.2
+  photo_view: ^0.13.0
 
 
 dev_dependencies:
 dev_dependencies:
   flutter_test:
   flutter_test:

+ 13 - 23
server/Dockerfile

@@ -17,35 +17,25 @@ COPY . .
 
 
 RUN npm run build
 RUN npm run build
 
 
-##################################
+#################################
 # PRODUCTION
 # PRODUCTION
-##################################
-# FROM node:16-bullseye-slim as production
-# ARG DEBIAN_FRONTEND=noninteractive
-# ARG NODE_ENV=production
-# ENV NODE_ENV=${NODE_ENV}
-
-# WORKDIR /usr/src/app
-
-# COPY package.json yarn.lock ./
-
-# RUN apt-get update
-# RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
+#################################
+FROM node:16-alpine3.14 AS production
 
 
-# RUN npm i -g yarn --force
+ARG DEBIAN_FRONTEND=noninteractive
+ARG NODE_ENV=production
+ENV NODE_ENV=${NODE_ENV}
 
 
-# RUN yarn install --only=production
+WORKDIR /usr/src/app
 
 
-# COPY . .
+COPY package.json package-lock.json ./
 
 
-# COPY --from=development /usr/src/app/dist ./dist
+RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
 
 
-# # Clean up commands
-# RUN apt-get autoremove -y && apt-get clean && \
-#   rm -rf /usr/local/src/*
+RUN npm install --only=production
 
 
-# RUN apt-get clean && \
-#   rm -rf /var/lib/apt/lists/*
+COPY . .
 
 
+COPY --from=development /usr/src/app/dist ./dist
 
 
-# CMD ["node", "dist/main"]
+CMD ["node", "dist/main"]

+ 28 - 664
server/package-lock.json

@@ -24,8 +24,8 @@
         "class-transformer": "^0.5.1",
         "class-transformer": "^0.5.1",
         "class-validator": "^0.13.2",
         "class-validator": "^0.13.2",
         "dotenv": "^14.2.0",
         "dotenv": "^14.2.0",
+        "exifr": "^7.1.3",
         "fluent-ffmpeg": "^2.1.2",
         "fluent-ffmpeg": "^2.1.2",
-        "heic-convert": "^1.2.4",
         "joi": "^17.5.0",
         "joi": "^17.5.0",
         "lodash": "^4.17.21",
         "lodash": "^4.17.21",
         "passport": "^0.5.2",
         "passport": "^0.5.2",
@@ -1775,177 +1775,6 @@
       "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
       "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
       "integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
       "integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
     },
     },
-    "node_modules/@tensorflow-models/coco-ssd": {
-      "version": "2.2.2",
-      "resolved": "https://registry.npmjs.org/@tensorflow-models/coco-ssd/-/coco-ssd-2.2.2.tgz",
-      "integrity": "sha512-Jey2JscmKEValcFZH2ZLz14s8KPRmVtfJ0d0M3dPhvBp9dJiGNanVXr/pJAY5OS7emKj9uSciGhdkHWXY9Hovw==",
-      "peerDependencies": {
-        "@tensorflow/tfjs-converter": "^3.3.0",
-        "@tensorflow/tfjs-core": "^3.3.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-3.13.0.tgz",
-      "integrity": "sha512-B5HvNH+6hHhQQkn+AG+u4j5sxZBMYdsq4IWXlBZzioJcVygtZhBWXkxp01boSwngjqUBgi8S2DopBE7McAUKqQ==",
-      "dependencies": {
-        "@tensorflow/tfjs-backend-cpu": "3.13.0",
-        "@tensorflow/tfjs-backend-webgl": "3.13.0",
-        "@tensorflow/tfjs-converter": "3.13.0",
-        "@tensorflow/tfjs-core": "3.13.0",
-        "@tensorflow/tfjs-data": "3.13.0",
-        "@tensorflow/tfjs-layers": "3.13.0",
-        "argparse": "^1.0.10",
-        "chalk": "^4.1.0",
-        "core-js": "3",
-        "regenerator-runtime": "^0.13.5",
-        "yargs": "^16.0.3"
-      },
-      "bin": {
-        "tfjs-custom-module": "dist/tools/custom_module/cli.js"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-backend-cpu": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-3.13.0.tgz",
-      "integrity": "sha512-POmzUoAP8HooYYTZ72O1ZYkpVZB0f+8PeAkbTxIG0oahcJccj6a0Vovp1A6xWKfljUoPlJb3jWVC++S603ZL8w==",
-      "dependencies": {
-        "@types/seedrandom": "2.4.27",
-        "seedrandom": "2.4.3"
-      },
-      "engines": {
-        "yarn": ">= 1.3.2"
-      },
-      "peerDependencies": {
-        "@tensorflow/tfjs-core": "3.13.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-backend-webgl": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-3.13.0.tgz",
-      "integrity": "sha512-ZuJS11tCoZx2F1Eq7wqiqu8euJpPW/JV0qOKBehlRpV2qQrR+wHMpBT1hhDl4qU4LdgFTtSggKIRg/L8b0ScUQ==",
-      "dependencies": {
-        "@tensorflow/tfjs-backend-cpu": "3.13.0",
-        "@types/offscreencanvas": "~2019.3.0",
-        "@types/seedrandom": "2.4.27",
-        "@types/webgl-ext": "0.0.30",
-        "@types/webgl2": "0.0.6",
-        "seedrandom": "2.4.3"
-      },
-      "engines": {
-        "yarn": ">= 1.3.2"
-      },
-      "peerDependencies": {
-        "@tensorflow/tfjs-core": "3.13.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-converter": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-3.13.0.tgz",
-      "integrity": "sha512-H2VpDTv9Ve0HBt7ttzz46DmnsPaiT0B+yJjVH3NebGZbgY9C8boBgJIsdyqfiqEWBS3WxF8h4rh58Hv5XXMgaQ==",
-      "peerDependencies": {
-        "@tensorflow/tfjs-core": "3.13.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-core": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-3.13.0.tgz",
-      "integrity": "sha512-18qBEVIB/4u2OUK9nA5P1XT3e3LyarElD1UKNSNDpnMLxhLTUVZaCR71eHJcpl9wP2Q0cciaTJCTpJdPv1tNDQ==",
-      "dependencies": {
-        "@types/long": "^4.0.1",
-        "@types/offscreencanvas": "~2019.3.0",
-        "@types/seedrandom": "2.4.27",
-        "@types/webgl-ext": "0.0.30",
-        "long": "4.0.0",
-        "node-fetch": "~2.6.1",
-        "seedrandom": "2.4.3"
-      },
-      "engines": {
-        "yarn": ">= 1.3.2"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-data": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-3.13.0.tgz",
-      "integrity": "sha512-n50+lxPK0CU72nlFt4dzMCCNV44CQsQU3sSP9zdR2bYHeoFqjjy1ISp+UV5N5DNLj7bsEMs73kGS1EuJ7YcdqQ==",
-      "dependencies": {
-        "@types/node-fetch": "^2.1.2",
-        "node-fetch": "~2.6.1"
-      },
-      "peerDependencies": {
-        "@tensorflow/tfjs-core": "3.13.0",
-        "seedrandom": "~2.4.3"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-layers": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-3.13.0.tgz",
-      "integrity": "sha512-kTWJ/+9fbNCMDA9iQjDMYHmWivsiWz8CKNSOZdeCW7tiBwF1EiREBVQXMk1JI11ngQa8f+rYSLs7rkhp3SYl5Q==",
-      "peerDependencies": {
-        "@tensorflow/tfjs-core": "3.13.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-node": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-node/-/tfjs-node-3.13.0.tgz",
-      "integrity": "sha512-LYM3ck/TyipxMFD23moX9qC3F23UBC3zbiw85HTxZ9FPlE1QNLP1UNlfFGeUTnPvY6CUcvPyQsrG9fBTvtwB1A==",
-      "hasInstallScript": true,
-      "dependencies": {
-        "@mapbox/node-pre-gyp": "1.0.4",
-        "@tensorflow/tfjs": "3.13.0",
-        "adm-zip": "^0.5.2",
-        "google-protobuf": "^3.9.2",
-        "https-proxy-agent": "^2.2.1",
-        "progress": "^2.0.0",
-        "rimraf": "^2.6.2",
-        "tar": "^4.4.6"
-      },
-      "engines": {
-        "node": ">=8.11.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-node/node_modules/agent-base": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
-      "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==",
-      "dependencies": {
-        "es6-promisify": "^5.0.0"
-      },
-      "engines": {
-        "node": ">= 4.0.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-node/node_modules/debug": {
-      "version": "3.2.7",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
-      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
-      "dependencies": {
-        "ms": "^2.1.1"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-node/node_modules/https-proxy-agent": {
-      "version": "2.2.4",
-      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz",
-      "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==",
-      "dependencies": {
-        "agent-base": "^4.3.0",
-        "debug": "^3.1.0"
-      },
-      "engines": {
-        "node": ">= 4.5.0"
-      }
-    },
-    "node_modules/@tensorflow/tfjs-node/node_modules/rimraf": {
-      "version": "2.7.1",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
-      "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
-      "dependencies": {
-        "glob": "^7.1.3"
-      },
-      "bin": {
-        "rimraf": "bin.js"
-      }
-    },
     "node_modules/@tootallnate/once": {
     "node_modules/@tootallnate/once": {
       "version": "1.1.2",
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -2210,11 +2039,6 @@
       "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==",
       "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==",
       "dev": true
       "dev": true
     },
     },
-    "node_modules/@types/long": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
-      "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
-    },
     "node_modules/@types/mime": {
     "node_modules/@types/mime": {
       "version": "1.3.2",
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -2235,20 +2059,6 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
       "integrity": "sha512-Pf8M1XD9i1ksZEcCP8vuSNwooJ/bZapNmIzpmsMaL+jMI+8mEYU3PKvs+xDNuQcJWF/x24WzY4qxLtB0zNow9A=="
       "integrity": "sha512-Pf8M1XD9i1ksZEcCP8vuSNwooJ/bZapNmIzpmsMaL+jMI+8mEYU3PKvs+xDNuQcJWF/x24WzY4qxLtB0zNow9A=="
     },
     },
-    "node_modules/@types/node-fetch": {
-      "version": "2.5.12",
-      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz",
-      "integrity": "sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==",
-      "dependencies": {
-        "@types/node": "*",
-        "form-data": "^3.0.0"
-      }
-    },
-    "node_modules/@types/offscreencanvas": {
-      "version": "2019.3.0",
-      "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
-      "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="
-    },
     "node_modules/@types/parse-json": {
     "node_modules/@types/parse-json": {
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -2312,11 +2122,6 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
-    "node_modules/@types/seedrandom": {
-      "version": "2.4.27",
-      "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
-      "integrity": "sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE="
-    },
     "node_modules/@types/serve-static": {
     "node_modules/@types/serve-static": {
       "version": "1.13.10",
       "version": "1.13.10",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
@@ -2361,16 +2166,6 @@
         "@types/superagent": "*"
         "@types/superagent": "*"
       }
       }
     },
     },
-    "node_modules/@types/webgl-ext": {
-      "version": "0.0.30",
-      "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
-      "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
-    },
-    "node_modules/@types/webgl2": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.6.tgz",
-      "integrity": "sha512-50GQhDVTq/herLMiqSQkdtRu+d5q/cWHn4VvKJtrj4DJAjo1MNkWYa2MA41BaBO1q1HgsUjuQvEOk0QHvlnAaQ=="
-    },
     "node_modules/@types/yargs": {
     "node_modules/@types/yargs": {
       "version": "16.0.4",
       "version": "16.0.4",
       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
@@ -2822,14 +2617,6 @@
         "node": ">=0.4.0"
         "node": ">=0.4.0"
       }
       }
     },
     },
-    "node_modules/adm-zip": {
-      "version": "0.5.9",
-      "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.9.tgz",
-      "integrity": "sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg==",
-      "engines": {
-        "node": ">=6.0"
-      }
-    },
     "node_modules/agent-base": {
     "node_modules/agent-base": {
       "version": "6.0.2",
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -3041,6 +2828,7 @@
       "version": "1.0.10",
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
       "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
       "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
       "dependencies": {
       "dependencies": {
         "sprintf-js": "~1.0.2"
         "sprintf-js": "~1.0.2"
       }
       }
@@ -3073,7 +2861,8 @@
     "node_modules/asynckit": {
     "node_modules/asynckit": {
       "version": "0.4.0",
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "dev": true
     },
     },
     "node_modules/at-least-node": {
     "node_modules/at-least-node": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -3779,6 +3568,7 @@
       "version": "1.0.8",
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
       "dependencies": {
       "dependencies": {
         "delayed-stream": "~1.0.0"
         "delayed-stream": "~1.0.0"
       },
       },
@@ -3915,16 +3705,6 @@
       "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
       "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
       "dev": true
       "dev": true
     },
     },
-    "node_modules/core-js": {
-      "version": "3.21.0",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.0.tgz",
-      "integrity": "sha512-YUdI3fFu4TF/2WykQ2xzSiTQdldLB4KVuL9WeAy5XONZYt5Cun/fpQvctoKbCgvPhmzADeesTk/j2Rdx77AcKQ==",
-      "hasInstallScript": true,
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/core-js"
-      }
-    },
     "node_modules/core-util-is": {
     "node_modules/core-util-is": {
       "version": "1.0.3",
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -4129,6 +3909,7 @@
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
       "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "dev": true,
       "engines": {
       "engines": {
         "node": ">=0.4.0"
         "node": ">=0.4.0"
       }
       }
@@ -4388,19 +4169,6 @@
       "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
       "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
       "dev": true
       "dev": true
     },
     },
-    "node_modules/es6-promise": {
-      "version": "4.2.8",
-      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
-      "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
-    },
-    "node_modules/es6-promisify": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
-      "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
-      "dependencies": {
-        "es6-promise": "^4.0.3"
-      }
-    },
     "node_modules/escalade": {
     "node_modules/escalade": {
       "version": "3.1.1",
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -4822,6 +4590,11 @@
         "url": "https://github.com/sindresorhus/execa?sponsor=1"
         "url": "https://github.com/sindresorhus/execa?sponsor=1"
       }
       }
     },
     },
+    "node_modules/exifr": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+      "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
+    },
     "node_modules/exit": {
     "node_modules/exit": {
       "version": "0.1.2",
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
       "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -5276,6 +5049,7 @@
       "version": "3.0.1",
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
       "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
       "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+      "dev": true,
       "dependencies": {
       "dependencies": {
         "asynckit": "^0.4.0",
         "asynckit": "^0.4.0",
         "combined-stream": "^1.0.8",
         "combined-stream": "^1.0.8",
@@ -5608,11 +5382,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
         "url": "https://github.com/sponsors/sindresorhus"
       }
       }
     },
     },
-    "node_modules/google-protobuf": {
-      "version": "3.19.4",
-      "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.19.4.tgz",
-      "integrity": "sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg=="
-    },
     "node_modules/graceful-fs": {
     "node_modules/graceful-fs": {
       "version": "4.2.9",
       "version": "4.2.9",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
@@ -5656,30 +5425,6 @@
       "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
       "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
       "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
       "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
     },
     },
-    "node_modules/heic-convert": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-1.2.4.tgz",
-      "integrity": "sha512-klJHyv+BqbgKiCQvCqI9IKIvweCcohDuDl0Jphearj8+16+v8eff2piVevHqq4dW9TK0r1onTR6PKHP1I4hdbA==",
-      "dependencies": {
-        "heic-decode": "^1.1.2",
-        "jpeg-js": "^0.4.1",
-        "pngjs": "^3.4.0"
-      },
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/heic-decode": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-1.1.2.tgz",
-      "integrity": "sha512-UF8teegxvzQPdSTcx5frIUhitNDliz/9Pui0JFdIqVRE00spVE33DcCYtZqaLNyd4y5RP/QQWZFIc1YWVKKm2A==",
-      "dependencies": {
-        "libheif-js": "^1.10.0"
-      },
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
     "node_modules/hexoid": {
     "node_modules/hexoid": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@@ -6894,11 +6639,6 @@
         "@sideway/pinpoint": "^2.0.0"
         "@sideway/pinpoint": "^2.0.0"
       }
       }
     },
     },
-    "node_modules/jpeg-js": {
-      "version": "0.4.3",
-      "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz",
-      "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q=="
-    },
     "node_modules/js-tokens": {
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7111,14 +6851,6 @@
         "node": ">= 0.8.0"
         "node": ">= 0.8.0"
       }
       }
     },
     },
-    "node_modules/libheif-js": {
-      "version": "1.12.0",
-      "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.12.0.tgz",
-      "integrity": "sha512-hDs6xQ7028VOwAFwEtM0Q+B2x2NW69Jb2MhQFUbk3rUrHzz4qo5mqS8VrqNgYnSc8TiUGnR691LnO4uIfEE23w==",
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
     "node_modules/libphonenumber-js": {
     "node_modules/libphonenumber-js": {
       "version": "1.9.48",
       "version": "1.9.48",
       "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.48.tgz",
       "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.48.tgz",
@@ -7260,11 +6992,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
         "url": "https://github.com/sponsors/sindresorhus"
       }
       }
     },
     },
-    "node_modules/long": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
-      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
-    },
     "node_modules/lru-cache": {
     "node_modules/lru-cache": {
       "version": "6.0.0",
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -8244,14 +7971,6 @@
         "node": ">=4"
         "node": ">=4"
       }
       }
     },
     },
-    "node_modules/pngjs": {
-      "version": "3.4.0",
-      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
-      "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==",
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
     "node_modules/postgres-array": {
     "node_modules/postgres-array": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -8382,14 +8101,6 @@
       "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz",
       "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q=="
       "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q=="
     },
     },
-    "node_modules/progress": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
-      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
     "node_modules/prompts": {
     "node_modules/prompts": {
       "version": "2.4.2",
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -8598,11 +8309,6 @@
       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
     },
     },
-    "node_modules/regenerator-runtime": {
-      "version": "0.13.9",
-      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
-      "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
-    },
     "node_modules/regexpp": {
     "node_modules/regexpp": {
       "version": "3.2.0",
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -8848,11 +8554,6 @@
       "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.4.0.tgz",
       "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.4.0.tgz",
       "integrity": "sha512-Q5Z/97nbON5t/L/sH6mY2EacfjVGwrCcSi5D3btRO2GZ8pf1K1UN7Z9H5J57hjVU2Qzxr1xO+FmBhOvEkzCMmg=="
       "integrity": "sha512-Q5Z/97nbON5t/L/sH6mY2EacfjVGwrCcSi5D3btRO2GZ8pf1K1UN7Z9H5J57hjVU2Qzxr1xO+FmBhOvEkzCMmg=="
     },
     },
-    "node_modules/seedrandom": {
-      "version": "2.4.3",
-      "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
-      "integrity": "sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw="
-    },
     "node_modules/semver": {
     "node_modules/semver": {
       "version": "7.3.5",
       "version": "7.3.5",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
@@ -9154,7 +8855,8 @@
     "node_modules/sprintf-js": {
     "node_modules/sprintf-js": {
       "version": "1.0.3",
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
     },
     },
     "node_modules/stack-utils": {
     "node_modules/stack-utils": {
       "version": "2.0.5",
       "version": "2.0.5",
@@ -9427,23 +9129,6 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
-    "node_modules/tar": {
-      "version": "4.4.19",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz",
-      "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==",
-      "dependencies": {
-        "chownr": "^1.1.4",
-        "fs-minipass": "^1.2.7",
-        "minipass": "^2.9.0",
-        "minizlib": "^1.3.3",
-        "mkdirp": "^0.5.5",
-        "safe-buffer": "^5.2.1",
-        "yallist": "^3.1.1"
-      },
-      "engines": {
-        "node": ">=4.5"
-      }
-    },
     "node_modules/tar-fs": {
     "node_modules/tar-fs": {
       "version": "2.1.1",
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
       "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
@@ -9475,41 +9160,6 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
-    "node_modules/tar/node_modules/chownr": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
-    },
-    "node_modules/tar/node_modules/fs-minipass": {
-      "version": "1.2.7",
-      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
-      "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
-      "dependencies": {
-        "minipass": "^2.6.0"
-      }
-    },
-    "node_modules/tar/node_modules/minipass": {
-      "version": "2.9.0",
-      "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
-      "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
-      "dependencies": {
-        "safe-buffer": "^5.1.2",
-        "yallist": "^3.0.0"
-      }
-    },
-    "node_modules/tar/node_modules/minizlib": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
-      "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
-      "dependencies": {
-        "minipass": "^2.9.0"
-      }
-    },
-    "node_modules/tar/node_modules/yallist": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
-      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
-    },
     "node_modules/terminal-link": {
     "node_modules/terminal-link": {
       "version": "2.1.1",
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
       "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -11930,137 +11580,6 @@
       "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
       "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz",
       "integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
       "integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg=="
     },
     },
-    "@tensorflow-models/coco-ssd": {
-      "version": "2.2.2",
-      "resolved": "https://registry.npmjs.org/@tensorflow-models/coco-ssd/-/coco-ssd-2.2.2.tgz",
-      "integrity": "sha512-Jey2JscmKEValcFZH2ZLz14s8KPRmVtfJ0d0M3dPhvBp9dJiGNanVXr/pJAY5OS7emKj9uSciGhdkHWXY9Hovw==",
-      "requires": {}
-    },
-    "@tensorflow/tfjs": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs/-/tfjs-3.13.0.tgz",
-      "integrity": "sha512-B5HvNH+6hHhQQkn+AG+u4j5sxZBMYdsq4IWXlBZzioJcVygtZhBWXkxp01boSwngjqUBgi8S2DopBE7McAUKqQ==",
-      "requires": {
-        "@tensorflow/tfjs-backend-cpu": "3.13.0",
-        "@tensorflow/tfjs-backend-webgl": "3.13.0",
-        "@tensorflow/tfjs-converter": "3.13.0",
-        "@tensorflow/tfjs-core": "3.13.0",
-        "@tensorflow/tfjs-data": "3.13.0",
-        "@tensorflow/tfjs-layers": "3.13.0",
-        "argparse": "^1.0.10",
-        "chalk": "^4.1.0",
-        "core-js": "3",
-        "regenerator-runtime": "^0.13.5",
-        "yargs": "^16.0.3"
-      }
-    },
-    "@tensorflow/tfjs-backend-cpu": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-3.13.0.tgz",
-      "integrity": "sha512-POmzUoAP8HooYYTZ72O1ZYkpVZB0f+8PeAkbTxIG0oahcJccj6a0Vovp1A6xWKfljUoPlJb3jWVC++S603ZL8w==",
-      "requires": {
-        "@types/seedrandom": "2.4.27",
-        "seedrandom": "2.4.3"
-      }
-    },
-    "@tensorflow/tfjs-backend-webgl": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-3.13.0.tgz",
-      "integrity": "sha512-ZuJS11tCoZx2F1Eq7wqiqu8euJpPW/JV0qOKBehlRpV2qQrR+wHMpBT1hhDl4qU4LdgFTtSggKIRg/L8b0ScUQ==",
-      "requires": {
-        "@tensorflow/tfjs-backend-cpu": "3.13.0",
-        "@types/offscreencanvas": "~2019.3.0",
-        "@types/seedrandom": "2.4.27",
-        "@types/webgl-ext": "0.0.30",
-        "@types/webgl2": "0.0.6",
-        "seedrandom": "2.4.3"
-      }
-    },
-    "@tensorflow/tfjs-converter": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-3.13.0.tgz",
-      "integrity": "sha512-H2VpDTv9Ve0HBt7ttzz46DmnsPaiT0B+yJjVH3NebGZbgY9C8boBgJIsdyqfiqEWBS3WxF8h4rh58Hv5XXMgaQ==",
-      "requires": {}
-    },
-    "@tensorflow/tfjs-core": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-3.13.0.tgz",
-      "integrity": "sha512-18qBEVIB/4u2OUK9nA5P1XT3e3LyarElD1UKNSNDpnMLxhLTUVZaCR71eHJcpl9wP2Q0cciaTJCTpJdPv1tNDQ==",
-      "requires": {
-        "@types/long": "^4.0.1",
-        "@types/offscreencanvas": "~2019.3.0",
-        "@types/seedrandom": "2.4.27",
-        "@types/webgl-ext": "0.0.30",
-        "long": "4.0.0",
-        "node-fetch": "~2.6.1",
-        "seedrandom": "2.4.3"
-      }
-    },
-    "@tensorflow/tfjs-data": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-data/-/tfjs-data-3.13.0.tgz",
-      "integrity": "sha512-n50+lxPK0CU72nlFt4dzMCCNV44CQsQU3sSP9zdR2bYHeoFqjjy1ISp+UV5N5DNLj7bsEMs73kGS1EuJ7YcdqQ==",
-      "requires": {
-        "@types/node-fetch": "^2.1.2",
-        "node-fetch": "~2.6.1"
-      }
-    },
-    "@tensorflow/tfjs-layers": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-layers/-/tfjs-layers-3.13.0.tgz",
-      "integrity": "sha512-kTWJ/+9fbNCMDA9iQjDMYHmWivsiWz8CKNSOZdeCW7tiBwF1EiREBVQXMk1JI11ngQa8f+rYSLs7rkhp3SYl5Q==",
-      "requires": {}
-    },
-    "@tensorflow/tfjs-node": {
-      "version": "3.13.0",
-      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-node/-/tfjs-node-3.13.0.tgz",
-      "integrity": "sha512-LYM3ck/TyipxMFD23moX9qC3F23UBC3zbiw85HTxZ9FPlE1QNLP1UNlfFGeUTnPvY6CUcvPyQsrG9fBTvtwB1A==",
-      "requires": {
-        "@mapbox/node-pre-gyp": "1.0.4",
-        "@tensorflow/tfjs": "3.13.0",
-        "adm-zip": "^0.5.2",
-        "google-protobuf": "^3.9.2",
-        "https-proxy-agent": "^2.2.1",
-        "progress": "^2.0.0",
-        "rimraf": "^2.6.2",
-        "tar": "^4.4.6"
-      },
-      "dependencies": {
-        "agent-base": {
-          "version": "4.3.0",
-          "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
-          "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==",
-          "requires": {
-            "es6-promisify": "^5.0.0"
-          }
-        },
-        "debug": {
-          "version": "3.2.7",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
-          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
-        },
-        "https-proxy-agent": {
-          "version": "2.2.4",
-          "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz",
-          "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==",
-          "requires": {
-            "agent-base": "^4.3.0",
-            "debug": "^3.1.0"
-          }
-        },
-        "rimraf": {
-          "version": "2.7.1",
-          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
-          "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
-          "requires": {
-            "glob": "^7.1.3"
-          }
-        }
-      }
-    },
     "@tootallnate/once": {
     "@tootallnate/once": {
       "version": "1.1.2",
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -12322,11 +11841,6 @@
       "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==",
       "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==",
       "dev": true
       "dev": true
     },
     },
-    "@types/long": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
-      "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w=="
-    },
     "@types/mime": {
     "@types/mime": {
       "version": "1.3.2",
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@@ -12347,20 +11861,6 @@
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
       "integrity": "sha512-Pf8M1XD9i1ksZEcCP8vuSNwooJ/bZapNmIzpmsMaL+jMI+8mEYU3PKvs+xDNuQcJWF/x24WzY4qxLtB0zNow9A=="
       "integrity": "sha512-Pf8M1XD9i1ksZEcCP8vuSNwooJ/bZapNmIzpmsMaL+jMI+8mEYU3PKvs+xDNuQcJWF/x24WzY4qxLtB0zNow9A=="
     },
     },
-    "@types/node-fetch": {
-      "version": "2.5.12",
-      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz",
-      "integrity": "sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==",
-      "requires": {
-        "@types/node": "*",
-        "form-data": "^3.0.0"
-      }
-    },
-    "@types/offscreencanvas": {
-      "version": "2019.3.0",
-      "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
-      "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="
-    },
     "@types/parse-json": {
     "@types/parse-json": {
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -12424,11 +11924,6 @@
         "@types/node": "*"
         "@types/node": "*"
       }
       }
     },
     },
-    "@types/seedrandom": {
-      "version": "2.4.27",
-      "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
-      "integrity": "sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE="
-    },
     "@types/serve-static": {
     "@types/serve-static": {
       "version": "1.13.10",
       "version": "1.13.10",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz",
@@ -12473,16 +11968,6 @@
         "@types/superagent": "*"
         "@types/superagent": "*"
       }
       }
     },
     },
-    "@types/webgl-ext": {
-      "version": "0.0.30",
-      "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
-      "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
-    },
-    "@types/webgl2": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.6.tgz",
-      "integrity": "sha512-50GQhDVTq/herLMiqSQkdtRu+d5q/cWHn4VvKJtrj4DJAjo1MNkWYa2MA41BaBO1q1HgsUjuQvEOk0QHvlnAaQ=="
-    },
     "@types/yargs": {
     "@types/yargs": {
       "version": "16.0.4",
       "version": "16.0.4",
       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
@@ -12825,11 +12310,6 @@
       "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
       "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
       "dev": true
       "dev": true
     },
     },
-    "adm-zip": {
-      "version": "0.5.9",
-      "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.9.tgz",
-      "integrity": "sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg=="
-    },
     "agent-base": {
     "agent-base": {
       "version": "6.0.2",
       "version": "6.0.2",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -13002,6 +12482,7 @@
       "version": "1.0.10",
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
       "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
       "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
       "requires": {
       "requires": {
         "sprintf-js": "~1.0.2"
         "sprintf-js": "~1.0.2"
       }
       }
@@ -13031,7 +12512,8 @@
     "asynckit": {
     "asynckit": {
       "version": "0.4.0",
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "dev": true
     },
     },
     "at-least-node": {
     "at-least-node": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -13576,6 +13058,7 @@
       "version": "1.0.8",
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
       "requires": {
       "requires": {
         "delayed-stream": "~1.0.0"
         "delayed-stream": "~1.0.0"
       }
       }
@@ -13698,11 +13181,6 @@
       "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
       "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==",
       "dev": true
       "dev": true
     },
     },
-    "core-js": {
-      "version": "3.21.0",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.0.tgz",
-      "integrity": "sha512-YUdI3fFu4TF/2WykQ2xzSiTQdldLB4KVuL9WeAy5XONZYt5Cun/fpQvctoKbCgvPhmzADeesTk/j2Rdx77AcKQ=="
-    },
     "core-util-is": {
     "core-util-is": {
       "version": "1.0.3",
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -13868,7 +13346,8 @@
     "delayed-stream": {
     "delayed-stream": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "dev": true
     },
     },
     "delegates": {
     "delegates": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -14079,19 +13558,6 @@
       "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
       "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==",
       "dev": true
       "dev": true
     },
     },
-    "es6-promise": {
-      "version": "4.2.8",
-      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
-      "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
-    },
-    "es6-promisify": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
-      "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
-      "requires": {
-        "es6-promise": "^4.0.3"
-      }
-    },
     "escalade": {
     "escalade": {
       "version": "3.1.1",
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -14391,6 +13857,11 @@
         "strip-final-newline": "^2.0.0"
         "strip-final-newline": "^2.0.0"
       }
       }
     },
     },
+    "exifr": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+      "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="
+    },
     "exit": {
     "exit": {
       "version": "0.1.2",
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
       "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -14765,6 +14236,7 @@
       "version": "3.0.1",
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
       "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
       "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+      "dev": true,
       "requires": {
       "requires": {
         "asynckit": "^0.4.0",
         "asynckit": "^0.4.0",
         "combined-stream": "^1.0.8",
         "combined-stream": "^1.0.8",
@@ -15009,11 +14481,6 @@
         "slash": "^3.0.0"
         "slash": "^3.0.0"
       }
       }
     },
     },
-    "google-protobuf": {
-      "version": "3.19.4",
-      "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.19.4.tgz",
-      "integrity": "sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg=="
-    },
     "graceful-fs": {
     "graceful-fs": {
       "version": "4.2.9",
       "version": "4.2.9",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
@@ -15045,24 +14512,6 @@
       "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
       "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
       "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
       "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
     },
     },
-    "heic-convert": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-1.2.4.tgz",
-      "integrity": "sha512-klJHyv+BqbgKiCQvCqI9IKIvweCcohDuDl0Jphearj8+16+v8eff2piVevHqq4dW9TK0r1onTR6PKHP1I4hdbA==",
-      "requires": {
-        "heic-decode": "^1.1.2",
-        "jpeg-js": "^0.4.1",
-        "pngjs": "^3.4.0"
-      }
-    },
-    "heic-decode": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-1.1.2.tgz",
-      "integrity": "sha512-UF8teegxvzQPdSTcx5frIUhitNDliz/9Pui0JFdIqVRE00spVE33DcCYtZqaLNyd4y5RP/QQWZFIc1YWVKKm2A==",
-      "requires": {
-        "libheif-js": "^1.10.0"
-      }
-    },
     "hexoid": {
     "hexoid": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@@ -15984,11 +15433,6 @@
         "@sideway/pinpoint": "^2.0.0"
         "@sideway/pinpoint": "^2.0.0"
       }
       }
     },
     },
-    "jpeg-js": {
-      "version": "0.4.3",
-      "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz",
-      "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q=="
-    },
     "js-tokens": {
     "js-tokens": {
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -16164,11 +15608,6 @@
         "type-check": "~0.4.0"
         "type-check": "~0.4.0"
       }
       }
     },
     },
-    "libheif-js": {
-      "version": "1.12.0",
-      "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.12.0.tgz",
-      "integrity": "sha512-hDs6xQ7028VOwAFwEtM0Q+B2x2NW69Jb2MhQFUbk3rUrHzz4qo5mqS8VrqNgYnSc8TiUGnR691LnO4uIfEE23w=="
-    },
     "libphonenumber-js": {
     "libphonenumber-js": {
       "version": "1.9.48",
       "version": "1.9.48",
       "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.48.tgz",
       "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.48.tgz",
@@ -16296,11 +15735,6 @@
         "is-unicode-supported": "^0.1.0"
         "is-unicode-supported": "^0.1.0"
       }
       }
     },
     },
-    "long": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
-      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
-    },
     "lru-cache": {
     "lru-cache": {
       "version": "6.0.0",
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -17047,11 +16481,6 @@
       "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
       "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
       "dev": true
       "dev": true
     },
     },
-    "pngjs": {
-      "version": "3.4.0",
-      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
-      "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="
-    },
     "postgres-array": {
     "postgres-array": {
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -17145,11 +16574,6 @@
       "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz",
       "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q=="
       "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q=="
     },
     },
-    "progress": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
-      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="
-    },
     "prompts": {
     "prompts": {
       "version": "2.4.2",
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
       "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -17304,11 +16728,6 @@
       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
     },
     },
-    "regenerator-runtime": {
-      "version": "0.13.9",
-      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
-      "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
-    },
     "regexpp": {
     "regexpp": {
       "version": "3.2.0",
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -17469,11 +16888,6 @@
       "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.4.0.tgz",
       "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.4.0.tgz",
       "integrity": "sha512-Q5Z/97nbON5t/L/sH6mY2EacfjVGwrCcSi5D3btRO2GZ8pf1K1UN7Z9H5J57hjVU2Qzxr1xO+FmBhOvEkzCMmg=="
       "integrity": "sha512-Q5Z/97nbON5t/L/sH6mY2EacfjVGwrCcSi5D3btRO2GZ8pf1K1UN7Z9H5J57hjVU2Qzxr1xO+FmBhOvEkzCMmg=="
     },
     },
-    "seedrandom": {
-      "version": "2.4.3",
-      "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
-      "integrity": "sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw="
-    },
     "semver": {
     "semver": {
       "version": "7.3.5",
       "version": "7.3.5",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
@@ -17712,7 +17126,8 @@
     "sprintf-js": {
     "sprintf-js": {
       "version": "1.0.3",
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
     },
     },
     "stack-utils": {
     "stack-utils": {
       "version": "2.0.5",
       "version": "2.0.5",
@@ -17903,57 +17318,6 @@
       "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
       "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==",
       "dev": true
       "dev": true
     },
     },
-    "tar": {
-      "version": "4.4.19",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz",
-      "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==",
-      "requires": {
-        "chownr": "^1.1.4",
-        "fs-minipass": "^1.2.7",
-        "minipass": "^2.9.0",
-        "minizlib": "^1.3.3",
-        "mkdirp": "^0.5.5",
-        "safe-buffer": "^5.2.1",
-        "yallist": "^3.1.1"
-      },
-      "dependencies": {
-        "chownr": {
-          "version": "1.1.4",
-          "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-          "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
-        },
-        "fs-minipass": {
-          "version": "1.2.7",
-          "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
-          "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
-          "requires": {
-            "minipass": "^2.6.0"
-          }
-        },
-        "minipass": {
-          "version": "2.9.0",
-          "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
-          "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
-          "requires": {
-            "safe-buffer": "^5.1.2",
-            "yallist": "^3.0.0"
-          }
-        },
-        "minizlib": {
-          "version": "1.3.3",
-          "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
-          "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
-          "requires": {
-            "minipass": "^2.9.0"
-          }
-        },
-        "yallist": {
-          "version": "3.1.1",
-          "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
-          "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
-        }
-      }
-    },
     "tar-fs": {
     "tar-fs": {
       "version": "2.1.1",
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
       "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",

+ 1 - 0
server/package.json

@@ -36,6 +36,7 @@
     "class-transformer": "^0.5.1",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.13.2",
     "class-validator": "^0.13.2",
     "dotenv": "^14.2.0",
     "dotenv": "^14.2.0",
+    "exifr": "^7.1.3",
     "fluent-ffmpeg": "^2.1.2",
     "fluent-ffmpeg": "^2.1.2",
     "joi": "^17.5.0",
     "joi": "^17.5.0",
     "lodash": "^4.17.21",
     "lodash": "^4.17.21",

+ 10 - 2
server/src/api-v1/asset/asset.controller.ts

@@ -30,6 +30,7 @@ import { promisify } from 'util';
 import { stat } from 'fs';
 import { stat } from 'fs';
 import { pipeline } from 'stream';
 import { pipeline } from 'stream';
 import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
 import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
+import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 
 
 const fileInfo = promisify(stat);
 const fileInfo = promisify(stat);
 
 
@@ -37,8 +38,9 @@ const fileInfo = promisify(stat);
 @Controller('asset')
 @Controller('asset')
 export class AssetController {
 export class AssetController {
   constructor(
   constructor(
-    private readonly assetService: AssetService,
-    private readonly assetOptimizeService: AssetOptimizeService,
+    private assetService: AssetService,
+    private assetOptimizeService: AssetOptimizeService,
+    private backgroundTaskService: BackgroundTaskService,
   ) {}
   ) {}
 
 
   @Post('upload')
   @Post('upload')
@@ -53,6 +55,7 @@ export class AssetController {
 
 
       if (savedAsset && savedAsset.type == AssetType.IMAGE) {
       if (savedAsset && savedAsset.type == AssetType.IMAGE) {
         await this.assetOptimizeService.resizeImage(savedAsset);
         await this.assetOptimizeService.resizeImage(savedAsset);
+        await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
       }
       }
 
 
       if (savedAsset && savedAsset.type == AssetType.VIDEO) {
       if (savedAsset && savedAsset.type == AssetType.VIDEO) {
@@ -155,4 +158,9 @@ export class AssetController {
   async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
   async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
     return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
     return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
   }
   }
+
+  @Get('/assetById/:assetId')
+  async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
+    return this.assetService.getAssetById(authUser, assetId);
+  }
 }
 }

+ 12 - 1
server/src/api-v1/asset/asset.module.ts

@@ -6,6 +6,8 @@ import { AssetEntity } from './entities/asset.entity';
 import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
 import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
 import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
 import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
 import { BullModule } from '@nestjs/bull';
 import { BullModule } from '@nestjs/bull';
+import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
+import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 
 
 @Module({
 @Module({
   imports: [
   imports: [
@@ -17,11 +19,20 @@ import { BullModule } from '@nestjs/bull';
         removeOnFail: false,
         removeOnFail: false,
       },
       },
     }),
     }),
+    BullModule.registerQueue({
+      name: 'background-task',
+      defaultJobOptions: {
+        attempts: 3,
+        removeOnComplete: true,
+        removeOnFail: false,
+      },
+    }),
     TypeOrmModule.forFeature([AssetEntity]),
     TypeOrmModule.forFeature([AssetEntity]),
     ImageOptimizeModule,
     ImageOptimizeModule,
+    BackgroundTaskModule,
   ],
   ],
   controllers: [AssetController],
   controllers: [AssetController],
-  providers: [AssetService, AssetOptimizeService],
+  providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
   exports: [],
   exports: [],
 })
 })
 export class AssetModule {}
 export class AssetModule {}

+ 10 - 0
server/src/api-v1/asset/asset.service.ts

@@ -112,4 +112,14 @@ export class AssetService {
       },
       },
     });
     });
   }
   }
+
+  public async getAssetById(authUser: AuthUserDto, assetId: string) {
+    return await this.assetRepository.findOne({
+      where: {
+        userId: authUser.id,
+        id: assetId,
+      },
+      relations: ['exifInfo'],
+    });
+  }
 }
 }

+ 48 - 0
server/src/api-v1/asset/dto/create-exif.dto.ts

@@ -0,0 +1,48 @@
+import { IsNotEmpty, IsOptional } from 'class-validator';
+
+export class CreateExifDto {
+  @IsNotEmpty()
+  assetId: string;
+
+  @IsOptional()
+  make: string;
+
+  @IsOptional()
+  model: string;
+
+  @IsOptional()
+  imageName: string;
+
+  @IsOptional()
+  exifImageWidth: number;
+
+  @IsOptional()
+  exifImageHeight: number;
+
+  @IsOptional()
+  fileSizeInByte: number;
+
+  @IsOptional()
+  orientation: string;
+
+  @IsOptional()
+  dateTimeOriginal: Date;
+
+  @IsOptional()
+  modifiedDate: Date;
+
+  @IsOptional()
+  lensModel: string;
+
+  @IsOptional()
+  fNumber: number;
+
+  @IsOptional()
+  focalLenght: number;
+
+  @IsOptional()
+  iso: number;
+
+  @IsOptional()
+  exposureTime: number;
+}

+ 4 - 0
server/src/api-v1/asset/dto/update-exif.dto.ts

@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/mapped-types';
+import { CreateExifDto } from './create-exif.dto';
+
+export class UpdateExifDto extends PartialType(CreateExifDto) {}

+ 5 - 1
server/src/api-v1/asset/entities/asset.entity.ts

@@ -1,4 +1,5 @@
-import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { ExifEntity } from './exif.entity';
 
 
 @Entity('assets')
 @Entity('assets')
 @Unique(['deviceAssetId', 'userId', 'deviceId'])
 @Unique(['deviceAssetId', 'userId', 'deviceId'])
@@ -38,6 +39,9 @@ export class AssetEntity {
 
 
   @Column({ nullable: true })
   @Column({ nullable: true })
   duration: string;
   duration: string;
+
+  @OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
+  exifInfo: ExifEntity;
 }
 }
 
 
 export enum AssetType {
 export enum AssetType {

+ 67 - 0
server/src/api-v1/asset/entities/exif.entity.ts

@@ -0,0 +1,67 @@
+import { Index, JoinColumn, OneToOne } from 'typeorm';
+import { Column } from 'typeorm/decorator/columns/Column';
+import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
+import { Entity } from 'typeorm/decorator/entity/Entity';
+import { AssetEntity } from './asset.entity';
+
+@Entity('exif')
+export class ExifEntity {
+  @PrimaryGeneratedColumn()
+  id: string;
+
+  @Index({ unique: true })
+  @Column({ type: 'uuid' })
+  assetId: string;
+
+  @Column({ nullable: true })
+  make: string;
+
+  @Column({ nullable: true })
+  model: string;
+
+  @Column({ nullable: true })
+  imageName: string;
+
+  @Column({ nullable: true })
+  exifImageWidth: number;
+
+  @Column({ nullable: true })
+  exifImageHeight: number;
+
+  @Column({ nullable: true })
+  fileSizeInByte: number;
+
+  @Column({ nullable: true })
+  orientation: string;
+
+  @Column({ type: 'timestamptz', nullable: true })
+  dateTimeOriginal: Date;
+
+  @Column({ type: 'timestamptz', nullable: true })
+  modifyDate: Date;
+
+  @Column({ nullable: true })
+  lensModel: string;
+
+  @Column({ type: 'float8', nullable: true })
+  fNumber: number;
+
+  @Column({ type: 'float8', nullable: true })
+  focalLength: number;
+
+  @Column({ nullable: true })
+  iso: number;
+
+  @Column({ type: 'float', nullable: true })
+  exposureTime: number;
+
+  @Column({ type: 'float', nullable: true })
+  latitude: number;
+
+  @Column({ type: 'float', nullable: true })
+  longitude: number;
+
+  @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
+  @JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
+  asset: ExifEntity;
+}

+ 3 - 1
server/src/app.module.ts

@@ -13,6 +13,7 @@ import { immichAppConfig } from './config/app.config';
 import { BullModule } from '@nestjs/bull';
 import { BullModule } from '@nestjs/bull';
 import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
 import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
 import { ServerInfoModule } from './api-v1/server-info/server-info.module';
 import { ServerInfoModule } from './api-v1/server-info/server-info.module';
+import { BackgroundTaskModule } from './modules/background-task/background-task.module';
 
 
 @Module({
 @Module({
   imports: [
   imports: [
@@ -29,7 +30,6 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
         redis: {
         redis: {
           host: 'immich_redis',
           host: 'immich_redis',
           port: 6379,
           port: 6379,
-          // password: configService.get('REDIS_PASSWORD'),
         },
         },
       }),
       }),
       inject: [ConfigService],
       inject: [ConfigService],
@@ -38,6 +38,8 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
     ImageOptimizeModule,
     ImageOptimizeModule,
 
 
     ServerInfoModule,
     ServerInfoModule,
+
+    BackgroundTaskModule,
   ],
   ],
   controllers: [],
   controllers: [],
   providers: [],
   providers: [],

+ 24 - 0
server/src/modules/background-task/background-task.module.ts

@@ -0,0 +1,24 @@
+import { BullModule } from '@nestjs/bull';
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
+import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
+import { BackgroundTaskProcessor } from './background-task.processor';
+import { BackgroundTaskService } from './background-task.service';
+
+@Module({
+  imports: [
+    BullModule.registerQueue({
+      name: 'background-task',
+      defaultJobOptions: {
+        attempts: 3,
+        removeOnComplete: true,
+        removeOnFail: false,
+      },
+    }),
+    TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
+  ],
+  providers: [BackgroundTaskService, BackgroundTaskProcessor],
+  exports: [BackgroundTaskService],
+})
+export class BackgroundTaskModule {}

+ 59 - 0
server/src/modules/background-task/background-task.processor.ts

@@ -0,0 +1,59 @@
+import { InjectQueue, Process, Processor } from '@nestjs/bull';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Job, Queue } from 'bull';
+import { Repository } from 'typeorm';
+import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
+import { ConfigService } from '@nestjs/config';
+import exifr from 'exifr';
+import { readFile } from 'fs/promises';
+import { Logger } from '@nestjs/common';
+import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
+
+@Processor('background-task')
+export class BackgroundTaskProcessor {
+  constructor(
+    @InjectRepository(AssetEntity)
+    private assetRepository: Repository<AssetEntity>,
+
+    @InjectRepository(ExifEntity)
+    private exifRepository: Repository<ExifEntity>,
+
+    private configService: ConfigService,
+  ) {}
+
+  @Process('extract-exif')
+  async extractExif(job: Job) {
+    const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
+      job.data;
+
+    const fileBuffer = await readFile(savedAsset.originalPath);
+
+    const exifData = await exifr.parse(fileBuffer);
+
+    const newExif = new ExifEntity();
+    newExif.assetId = savedAsset.id;
+    newExif.make = exifData['Make'] || null;
+    newExif.model = exifData['Model'] || null;
+    newExif.imageName = fileName || null;
+    newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
+    newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
+    newExif.fileSizeInByte = fileSize || null;
+    newExif.orientation = exifData['Orientation'] || null;
+    newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
+    newExif.modifyDate = exifData['ModifyDate'] || null;
+    newExif.lensModel = exifData['LensModel'] || null;
+    newExif.fNumber = exifData['FNumber'] || null;
+    newExif.focalLength = exifData['FocalLength'] || null;
+    newExif.iso = exifData['ISO'] || null;
+    newExif.exposureTime = exifData['ExposureTime'] || null;
+    newExif.latitude = exifData['latitude'] || null;
+    newExif.longitude = exifData['longitude'] || null;
+
+    await this.exifRepository.save(newExif);
+
+    try {
+    } catch (e) {
+      Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
+    }
+  }
+}

+ 25 - 0
server/src/modules/background-task/background-task.service.ts

@@ -0,0 +1,25 @@
+import { InjectQueue } from '@nestjs/bull/dist/decorators';
+import { Injectable } from '@nestjs/common';
+import { Queue } from 'bull';
+import { randomUUID } from 'node:crypto';
+import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
+
+@Injectable()
+export class BackgroundTaskService {
+  constructor(
+    @InjectQueue('background-task')
+    private backgroundTaskQueue: Queue,
+  ) {}
+
+  async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
+    const job = await this.backgroundTaskQueue.add(
+      'extract-exif',
+      {
+        savedAsset,
+        fileName,
+        fileSize,
+      },
+      { jobId: randomUUID() },
+    );
+  }
+}

+ 1 - 1
server/src/modules/image-optimize/image-optimize.service.ts

@@ -22,7 +22,7 @@ export class AssetOptimizeService {
     };
     };
   }
   }
 
 
-  public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) {
+  public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
     const job = await this.optimizeQueue.add(
     const job = await this.optimizeQueue.add(
       'get-video-thumbnail',
       'get-video-thumbnail',
       {
       {