Compare commits

...

5 Commits

Author SHA1 Message Date
shenlong-tanwen 22c5add816 fix: do not log out on automatic url switch 2026-07-08 01:26:39 +05:30
Adam Gastineau 8cbf0db3b3 fix(mobile): disable broken Xcode queue debugging (#29657) 2026-07-07 12:26:19 -05:00
Adam Gastineau a225c962de fix(mobile): properly handle live photos locally (#29462)
* fix(mobile): properly handle live photos locally

* Added basic motion photo data tests

* Cover mismatched platforms and motion photos

* Revert unnecessary storage.repository changes

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 12:25:43 -05:00
Mees Frensel daabab8c1c fix(docs): update developer setup (#29661) 2026-07-07 17:07:35 +00:00
Brandon Wees a4f28dafb2 fix(mobile): missing crop options and rotation scaling (#29645)
* fix: missing aspect ratio options in mobile editor

* fix: smooth rotation

* chore: comments

* chore: better preset management and tooling

* lint

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-07 12:05:50 -05:00
15 changed files with 248 additions and 81 deletions
+3 -3
View File
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
- Redis
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it
All the services are packaged to run as with single Docker Compose command.
All the services are packaged to run with a single Docker Compose command.
:::tip mise
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
@@ -41,7 +41,7 @@ All the services are packaged to run as with single Docker Compose command.
1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`.
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
4. Install dependencies - `pnpm i`
4. Install dependencies - `mise x -- pnpm i`
5. From the root directory, run:
```bash title="Start development server"
@@ -52,7 +52,7 @@ mise dev
All the services will be started with hot-reloading enabled for a quick feedback loop.
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000/api`
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000`
**Notes:**
@@ -70,7 +70,8 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
allowLocationSimulation = "YES"
queueDebuggingEnabled = "NO">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
+60 -14
View File
@@ -1,19 +1,65 @@
import 'package:flutter/material.dart';
enum AspectRatioPreset {
free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded),
ratio16x9(ratio: 16 / 9, label: '16:9', icon: Icons.crop_16_9_rounded),
ratio3x2(ratio: 3 / 2, label: '3:2', icon: Icons.crop_3_2_rounded),
ratio7x5(ratio: 7 / 5, label: '7:5', icon: Icons.crop_7_5_rounded),
ratio9x16(ratio: 9 / 16, label: '9:16', icon: Icons.crop_16_9_rounded, iconRotated: true),
ratio2x3(ratio: 2 / 3, label: '2:3', icon: Icons.crop_3_2_rounded, iconRotated: true),
ratio5x7(ratio: 5 / 7, label: '5:7', icon: Icons.crop_7_5_rounded, iconRotated: true);
class CropAspectRatio {
final int? numerator;
final int? denominator;
final double? ratio;
final String label;
final IconData icon;
final bool iconRotated;
final String? customLabel;
final IconData? icon;
const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
String get label {
return customLabel ?? (numerator != null && denominator != null ? '$numerator:$denominator' : 'Free');
}
bool get hasFlippedVariant => numerator != denominator;
double? get ratio => (numerator != null && denominator != null) ? numerator! / denominator! : null;
CropAspectRatio get flipped {
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is CropAspectRatio &&
other.numerator == numerator &&
other.denominator == denominator &&
other.customLabel == customLabel &&
other.icon == icon;
}
@override
int get hashCode {
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
}
}
const aspectRatioFree = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
const aspectRatioOriginal = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
final aspectRatioPresets = [
CropAspectRatio.free,
CropAspectRatio.original,
const CropAspectRatio(numerator: 1, denominator: 1),
// lanscape
const CropAspectRatio(numerator: 16, denominator: 9),
const CropAspectRatio(numerator: 3, denominator: 2),
const CropAspectRatio(numerator: 7, denominator: 5),
const CropAspectRatio(numerator: 4, denominator: 3),
// portrait
const CropAspectRatio(numerator: 16, denominator: 9).flipped,
const CropAspectRatio(numerator: 3, denominator: 2).flipped,
const CropAspectRatio(numerator: 7, denominator: 5).flipped,
const CropAspectRatio(numerator: 4, denominator: 3).flipped,
];
@@ -27,7 +27,6 @@ sealed class BaseAsset {
final int? height;
final int? durationMs;
final bool isFavorite;
final String? livePhotoVideoId;
final bool isEdited;
const BaseAsset({
@@ -40,32 +39,15 @@ sealed class BaseAsset {
this.height,
this.durationMs,
this.isFavorite = false,
this.livePhotoVideoId,
required this.isEdited,
});
bool get isImage => type == AssetType.image;
bool get isVideo => type == AssetType.video;
bool get isMotionPhoto => livePhotoVideoId != null;
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto;
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
if (isMotionPhoto) {
return AssetPlaybackStyle.livePhoto;
}
if (isImage && durationMs != null && durationMs! > 0) {
return AssetPlaybackStyle.imageAnimated;
}
if (isImage) {
return AssetPlaybackStyle.image;
}
return AssetPlaybackStyle.unknown;
}
Duration get duration {
final durationMs = this.durationMs;
if (durationMs != null) {
@@ -86,6 +68,7 @@ sealed class BaseAsset {
String? get localId;
String? get remoteId;
String get heroTag;
AssetPlaybackStyle get playbackStyle;
@override
String toString() {
@@ -25,7 +25,6 @@ class LocalAsset extends BaseAsset {
super.height,
super.durationMs,
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
required this.playbackStyle,
this.adjustmentTime,
@@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility;
final String ownerId;
final String? stackId;
final String? livePhotoVideoId;
final DateTime? uploadedAt;
final DateTime? deletedAt;
@@ -29,7 +30,7 @@ class RemoteAsset extends BaseAsset {
super.isFavorite = false,
this.thumbHash,
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.livePhotoVideoId,
this.stackId,
required super.isEdited,
this.deletedAt,
@@ -38,6 +39,23 @@ class RemoteAsset extends BaseAsset {
@override
String? get localId => localAssetId;
@override
AssetPlaybackStyle get playbackStyle {
if (isVideo) {
return AssetPlaybackStyle.video;
}
if (livePhotoVideoId != null) {
return AssetPlaybackStyle.livePhoto;
}
if (isImage && durationMs != null && durationMs! > 0) {
return AssetPlaybackStyle.imageAnimated;
}
if (isImage) {
return AssetPlaybackStyle.image;
}
return AssetPlaybackStyle.unknown;
}
@override
String? get remoteId => id;
@@ -90,6 +108,7 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
stackId == other.stackId &&
livePhotoVideoId == other.livePhotoVideoId &&
uploadedAt == other.uploadedAt &&
deletedAt == other.deletedAt;
}
@@ -103,6 +122,7 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
stackId.hashCode ^
livePhotoVideoId.hashCode ^
uploadedAt.hashCode ^
deletedAt.hashCode;
@@ -26,6 +26,10 @@ class AssetService {
return _localRepository.getByChecksum(checksum);
}
Future<LocalAsset?> getLocalAsset(String id) {
return _localRepository.get(id);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum);
}
@@ -33,6 +33,7 @@ class StorageRepository {
return file;
}
// TODO(agg23): Unify these methods
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
@@ -114,7 +114,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Height', value: asset.height?.toString()),
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
]);
}
@@ -151,6 +150,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
_PropertyItem(label: 'Stack ID', value: asset.stackId),
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
];
properties.insertAll(4, additionalProps);
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
}
class _AspectRatioButton extends StatelessWidget {
final AspectRatioPreset ratio;
final CropAspectRatio ratio;
final bool isSelected;
final VoidCallback onPressed;
@@ -162,15 +162,16 @@ class _AspectRatioButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
return Column(
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
iconSize: 36,
icon: Transform.rotate(
angle: ratio.iconRotated ? pi / 2 : 0,
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
),
icon: ratio.ratio != null
? _AspectRatioRect(ratio: ratio.ratio!, color: color)
: Icon(ratio.icon, color: color),
onPressed: onPressed,
),
Text(ratio.label, style: context.textTheme.displayMedium),
@@ -179,6 +180,32 @@ class _AspectRatioButton extends StatelessWidget {
}
}
class _AspectRatioRect extends StatelessWidget {
final double ratio;
final Color? color;
const _AspectRatioRect({required this.ratio, required this.color});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Center(
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: color ?? Colors.transparent, width: 3),
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
),
),
),
);
}
}
class _AspectRatioSelector extends ConsumerWidget {
const _AspectRatioSelector();
@@ -187,22 +214,16 @@ class _AspectRatioSelector extends ConsumerWidget {
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
// the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees
double? selectedAspectRatio = editorState.aspectRatio;
if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) {
selectedAspectRatio = 1 / selectedAspectRatio;
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: AspectRatioPreset.values.map((entry) {
children: aspectRatioPresets.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
ratio: entry,
isSelected: selectedAspectRatio == entry.ratio,
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
isSelected: editorState.aspectRatio == entry,
onPressed: () => editorNotifier.setAspectRatio(entry),
),
);
}).toList(),
@@ -357,8 +378,22 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
final editorState = ref.watch(editorStateProvider);
final editorNotifier = ref.read(editorStateProvider.notifier);
ref.listen(editorStateProvider, (_, current) {
cropController.aspectRatio = current.aspectRatio;
ref.listen(editorStateProvider, (previous, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
if (previous?.aspectRatio != current.aspectRatio) {
double? ratio;
ratio = switch (current.aspectRatio) {
CropAspectRatio.original => current.originalWidth / current.originalHeight,
_ => current.aspectRatio.ratio,
};
if (current.rotationAngle % 180 != 0) {
ratio = ratio != null ? 1 / ratio : null;
}
cropController.aspectRatio = ratio;
}
if (cropController.crop != current.crop) {
cropController.crop = current.crop;
@@ -386,7 +421,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
1.0,
1.0,
),
child: Container(
child: AnimatedContainer(
duration: editorState.animationDuration,
curve: Curves.easeInOut,
padding: const EdgeInsets.all(10),
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
@@ -1,5 +1,6 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/aspect_ratios.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
@@ -60,13 +61,8 @@ class EditorProvider extends Notifier<EditorState> {
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
}
void setAspectRatio(double? aspectRatio) {
if (aspectRatio != null && state.rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
aspectRatio = 1 / aspectRatio;
}
state = state.copyWith(aspectRatio: aspectRatio);
void setAspectRatio(CropAspectRatio preset) {
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true);
}
void resetEdits() {
@@ -76,19 +72,19 @@ class EditorProvider extends Notifier<EditorState> {
flipHorizontal: false,
flipVertical: false,
crop: const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio: null,
aspectRatio: CropAspectRatio.free,
hasUnsavedEdits: true,
);
}
void rotateCCW() {
_animateRotation(state.rotationAngle - 90);
state = state.copyWith(hasUnsavedEdits: true);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
}
void rotateCW() {
_animateRotation(state.rotationAngle + 90);
state = state.copyWith(hasUnsavedEdits: true);
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
}
void flipHorizontally() {
@@ -117,7 +113,7 @@ class EditorState {
final bool flipHorizontal;
final bool flipVertical;
final Rect crop;
final double? aspectRatio;
final CropAspectRatio aspectRatio;
final int originalWidth;
final int originalHeight;
@@ -132,7 +128,7 @@ class EditorState {
bool? flipHorizontal,
bool? flipVertical,
Rect? crop,
this.aspectRatio,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
@@ -145,6 +141,7 @@ class EditorState {
originalWidth = originalWidth ?? 0,
originalHeight = originalHeight ?? 0,
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
aspectRatio = aspectRatio ?? CropAspectRatio.free,
hasUnsavedEdits = hasUnsavedEdits ?? false;
EditorState copyWith({
@@ -152,7 +149,7 @@ class EditorState {
int? rotationAngle,
bool? flipHorizontal,
bool? flipVertical,
double? aspectRatio = double.infinity,
CropAspectRatio? aspectRatio,
int? originalWidth,
int? originalHeight,
Duration? animationDuration,
@@ -164,7 +161,7 @@ class EditorState {
rotationAngle: rotationAngle ?? this.rotationAngle,
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
flipVertical: flipVertical ?? this.flipVertical,
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
aspectRatio: aspectRatio ?? this.aspectRatio,
animationDuration: animationDuration ?? this.animationDuration,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
@@ -122,9 +122,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await StorageRepository().getFileForAsset(id);
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
final localAsset = await _localPlaybackAsset(videoAsset);
if (localAsset != null) {
final file = localAsset.isMotionPhoto
? await StorageRepository().getMotionFileForAsset(localAsset)
: await StorageRepository().getFileForAsset(localAsset.id);
if (!mounted) {
return null;
}
@@ -141,14 +146,13 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
final remoteId = (videoAsset as RemoteAsset).id;
final remoteAsset = videoAsset as RemoteAsset;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
@@ -157,6 +161,43 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
}
Future<LocalAsset?> _localPlaybackAsset(BaseAsset baseAsset) async {
if (!baseAsset.hasLocal) {
return null;
}
LocalAsset? localAsset;
if (baseAsset is LocalAsset) {
localAsset = baseAsset;
} else {
final localId = (baseAsset as RemoteAsset).localId;
localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null;
}
if (localAsset == null) {
_log.severe(
'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved',
);
return null;
}
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
// If this scenario occurs, fall back to using the remote asset
if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) {
// Platform mismatch for motion photo, use remote instead
_log.warning(
'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}',
);
return null;
}
return localAsset;
}
void _onPlaybackReady() async {
if (!mounted || !widget.isCurrent) {
return;
@@ -297,8 +297,7 @@ class _AssetTypeIcons extends StatelessWidget {
@override
Widget build(BuildContext context) {
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
final isLivePhoto = asset.isMotionPhoto;
return Column(
mainAxisSize: MainAxisSize.min,
+8 -1
View File
@@ -85,6 +85,7 @@ class ApiService {
// Save in local database for next startup
await Store.put(StoreKey.serverEndpoint, endpoint);
await updateHeaders();
return endpoint;
}
@@ -173,8 +174,14 @@ class ApiService {
static List<String> getServerUrls() {
final urls = <String>[];
// Include the original server URL so the native side can use it
// to sync the auth token to other external hosts
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint);
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
if (serverEndpoint != null && serverEndpoint.isNotEmpty && serverEndpoint != serverUrl) {
urls.add(serverEndpoint);
}
final network = SettingsRepository.instance.appConfig.network;
@@ -70,4 +70,36 @@ void main() {
expect((assets.first as RemoteAsset).id, asset.id);
});
});
group('live photos', () {
test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async {
final user = await ctx.newUser();
final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1');
final assets = await sut.main([user.id], .day).assetSource(0, 10);
expect(assets, hasLength(1));
final remote = assets.single as RemoteAsset;
expect(remote.id, asset.id);
expect(remote.livePhotoVideoId, 'motion-photo-1');
expect(remote.isMotionPhoto, isTrue);
expect(remote.localId, isNull);
});
test('merged live photo resolves localId and is marked as a motion photo', () async {
final user = await ctx.newUser();
const checksum = 'shared-live-photo-checksum';
final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2');
final local = await ctx.newLocalAsset(checksum: checksum);
final assets = await sut.main([user.id], .day).assetSource(0, 10);
expect(assets, hasLength(1));
final remote = assets.single as RemoteAsset;
expect(remote.id, asset.id);
expect(remote.livePhotoVideoId, 'motion-photo-2');
expect(remote.isMotionPhoto, isTrue);
expect(remote.localId, local.id);
});
});
}