diff --git a/apps/native-component-list/src/screens/MediaLibrary@Next/AssetScreen.tsx b/apps/native-component-list/src/screens/MediaLibrary@Next/AssetScreen.tsx index 38fb9e56c71348..31839c420c02dc 100644 --- a/apps/native-component-list/src/screens/MediaLibrary@Next/AssetScreen.tsx +++ b/apps/native-component-list/src/screens/MediaLibrary@Next/AssetScreen.tsx @@ -9,6 +9,7 @@ import { Query, requestPermissionsAsync, MediaSubtype, + AssetUriVersion, } from 'expo-media-library'; import { useVideoPlayer, VideoView } from 'expo-video'; import { useEffect, useState } from 'react'; @@ -38,6 +39,8 @@ const AssetScreen = () => { const [orientation, setOrientation] = useState(undefined); const [isNetworkAsset, setIsNetworkAsset] = useState(undefined); const [pairedVideoUri, setPairedVideoUri] = useState(undefined); + const [uriVersions, setUriVersions] = useState | null>(null); + const [hasExplainedUriVersions, setHasExplainedUriVersions] = useState(false); const [testState, setTestState] = useState(TestState.START); const isVideo = assetInfo?.mediaType === MediaType.VIDEO; @@ -152,6 +155,32 @@ const AssetScreen = () => { } }; + // The Asset is re-instantiated because it caches its PHAsset, so an instance created before the + // edit would resolve a stale snapshot. + const handleCompareUriVersions = async () => { + if (!asset) { + return; + } + if (!hasExplainedUriVersions) { + setHasExplainedUriVersions(true); + Alert.alert( + 'Edit the asset first', + 'Open the Photos app, edit this asset and save over the original, then tap this button again. The two URIs should then point to different files.' + ); + return; + } + try { + const freshAsset = new Asset(asset.id); + const current = await freshAsset.getUri({ version: AssetUriVersion.CURRENT }); + const original = await freshAsset.getUri({ version: AssetUriVersion.ORIGINAL }); + + setUriVersions({ [AssetUriVersion.CURRENT]: current, [AssetUriVersion.ORIGINAL]: original }); + } catch (e) { + console.error('Error comparing uri versions:', e); + Alert.alert('Error', 'Unable to resolve both uri versions.'); + } + }; + const downloadFile = async (type: 'image' | 'video'): Promise => { try { const dir = new Directory(Paths.cache, screenName); @@ -238,6 +267,17 @@ const AssetScreen = () => { {pairedVideoUri !== undefined ? (pairedVideoUri ?? 'N/A') : 'N/A'} )} + {uriVersions && ( + <> + + Current URI: {uriVersions[AssetUriVersion.CURRENT]} + + + Original URI:{' '} + {uriVersions[AssetUriVersion.ORIGINAL]} + + + )} ); @@ -283,6 +323,11 @@ const AssetScreen = () => { {assetInfo?.isFavorite ? 'Unmark Favorite' : 'Mark Favorite'} + {Platform.OS === 'ios' && ( + + Compare URI Versions + + )} {renderAssetInfo()} @@ -308,7 +353,7 @@ const styles = StyleSheet.create({ buttonContainer: { marginVertical: 20, gap: 20, - flexDirection: 'row', + flexDirection: 'column', justifyContent: 'space-evenly', }, statusText: { diff --git a/apps/test-suite/tests/MediaLibraryNext.ts b/apps/test-suite/tests/MediaLibraryNext.ts index 5df4d928a8e1ac..85b2ab2bafb8d1 100644 --- a/apps/test-suite/tests/MediaLibraryNext.ts +++ b/apps/test-suite/tests/MediaLibraryNext.ts @@ -6,6 +6,7 @@ import { Query, MediaType, AssetField, + AssetUriVersion, addListener, removeAllListeners, } from 'expo-media-library'; @@ -494,6 +495,16 @@ export async function test(t: any) { t.expect(uri.toLowerCase()).toMatch(/\.png/); }); + // the fixture has no edits, so both renditions exist. Their paths are not compared: + // PhotoKit may back identical content with different files. + t.it('resolves both uri versions', async () => { + const asset = await createImageAsset(pngFileLocalUri); + const current = await asset.getUri({ version: AssetUriVersion.CURRENT }); + const original = await asset.getUri({ version: AssetUriVersion.ORIGINAL }); + t.expect(current.toLowerCase()).toMatch(/\.png/); + t.expect(original.toLowerCase()).toMatch(/\.png/); + }); + t.it('returns positive width', async () => { const asset = await createImageAsset(pngFileLocalUri); const width = await asset.getWidth(); @@ -590,6 +601,15 @@ export async function test(t: any) { t.expect(uri.toLowerCase()).toMatch(/\.mp4/); }); + // an unedited video resolves to the same file either way. Whether an edited one resolves + // to its edited render can only be checked by hand, since a test cannot edit in Photos. + t.it('resolves both uri versions', async () => { + const current = await videoAsset.getUri({ version: AssetUriVersion.CURRENT }); + const original = await videoAsset.getUri({ version: AssetUriVersion.ORIGINAL }); + t.expect(current.toLowerCase()).toMatch(/\.mp4/); + t.expect(original.toLowerCase()).toMatch(/\.mp4/); + }); + t.it('returns positive width', async () => { const width = await videoAsset.getWidth(); t.expect(width).toBeGreaterThan(0); diff --git a/packages/expo-media-library/CHANGELOG.md b/packages/expo-media-library/CHANGELOG.md index dd53f74f3337c4..a8fb1cd64daeeb 100644 --- a/packages/expo-media-library/CHANGELOG.md +++ b/packages/expo-media-library/CHANGELOG.md @@ -4,8 +4,11 @@ ### 🛠 Breaking changes +- [iOS] `Asset.getUri()` and `AssetInfo.uri` now resolve a video to the version currently shown in the Photos app instead of the originally captured file. Pass `version: AssetUriVersion.ORIGINAL` to `getUri()` to keep the previous behavior. Note that only `getUri()` accepts the option; `AssetInfo.uri` from `getInfo()` always resolves the current version. ([#48640](https://github.com/expo/expo/pull/48640) by [@barthap](https://github.com/barthap)) + ### 🎉 New features +- [iOS] Add a `version` option to `Asset.getUri()` for choosing between the current and the original rendition of an asset. ([#48640](https://github.com/expo/expo/pull/48640) by [@barthap](https://github.com/barthap)) - [Android] Add `PhotographicSensitivity` to returned EXIF metadata. ([#47222](https://github.com/expo/expo/pull/47222) by [@Wenszel](https://github.com/Wenszel)) ### 🐛 Bug fixes diff --git a/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/MediaLibraryNextModule.kt b/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/MediaLibraryNextModule.kt index 509244081d46ae..ebb4dcc9a4f950 100644 --- a/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/MediaLibraryNextModule.kt +++ b/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/MediaLibraryNextModule.kt @@ -31,6 +31,7 @@ import expo.modules.medialibrary.next.permissions.MediaStorePermissionsDelegate import expo.modules.medialibrary.next.permissions.SystemPermissionsDelegate import expo.modules.medialibrary.next.permissions.enums.GranularPermission import expo.modules.medialibrary.next.records.AssetField +import expo.modules.medialibrary.next.records.AssetUriOptions import expo.modules.medialibrary.next.observers.MediaStoreObserverManager import expo.modules.medialibrary.next.records.SortDescriptor @@ -168,7 +169,7 @@ class MediaLibraryNextModule : Module() { self.getShape() } - AsyncFunction("getUri") Coroutine { self: Asset -> + AsyncFunction("getUri") Coroutine { self: Asset, _: AssetUriOptions? -> self.getUri() } diff --git a/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/records/AssetUriOptions.kt b/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/records/AssetUriOptions.kt new file mode 100644 index 00000000000000..017e43913c3fdf --- /dev/null +++ b/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/next/records/AssetUriOptions.kt @@ -0,0 +1,8 @@ +package expo.modules.medialibrary.next.records + +import expo.modules.kotlin.records.Record +import expo.modules.kotlin.types.OptimizedRecord + +// This record is iOS only +@OptimizedRecord +class AssetUriOptions : Record diff --git a/packages/expo-media-library/ios/next/MediaLibraryNextModule.swift b/packages/expo-media-library/ios/next/MediaLibraryNextModule.swift index fee575642a822c..ab45c26a0d6497 100644 --- a/packages/expo-media-library/ios/next/MediaLibraryNextModule.swift +++ b/packages/expo-media-library/ios/next/MediaLibraryNextModule.swift @@ -96,8 +96,8 @@ public final class MediaLibraryNextModule: Module { try await this.getShape() } - AsyncFunction("getUri") { (this: Asset) in - try await this.getUri() + AsyncFunction("getUri") { (this: Asset, options: AssetUriOptions?) in + try await this.getUri(options: options ?? AssetUriOptions()) } AsyncFunction("getWidth") { (this: Asset) in diff --git a/packages/expo-media-library/ios/next/objects/asset/Asset.swift b/packages/expo-media-library/ios/next/objects/asset/Asset.swift index 638635c921e979..b2f3852e9b4489 100644 --- a/packages/expo-media-library/ios/next/objects/asset/Asset.swift +++ b/packages/expo-media-library/ios/next/objects/asset/Asset.swift @@ -127,16 +127,16 @@ class Asset: SharedObject { guard try await getMediaType() == MediaTypeNext.IMAGE else { return [:] } - let uri = try await UriExtractor.extract(from: phAsset) + let uri = try await UriExtractor.extract(from: phAsset, version: .CURRENT) guard let ciImage = CIImage(contentsOf: uri) else { return [:] } return ciImage.properties } - func getUri() async throws -> String { + func getUri(options: AssetUriOptions) async throws -> String { let phAsset = try await requirePHAsset() - return try await assetMapper.mapUri(phAsset) + return try await assetMapper.mapUri(phAsset, version: options.version) } func getInfo() async throws -> AssetInfo { diff --git a/packages/expo-media-library/ios/next/objects/asset/AssetMapper.swift b/packages/expo-media-library/ios/next/objects/asset/AssetMapper.swift index d6d2a48adb9049..b02cb3767c488d 100644 --- a/packages/expo-media-library/ios/next/objects/asset/AssetMapper.swift +++ b/packages/expo-media-library/ios/next/objects/asset/AssetMapper.swift @@ -7,7 +7,7 @@ class AssetMapper { id: "ph://\(phAsset.localIdentifier)", creationTime: mapCreationTime(phAsset.creationDate), duration: mapDuration(phAsset.duration), - uri: try await mapUri(phAsset), + uri: try await mapUri(phAsset, version: .CURRENT), filename: try mapFilename(phAsset), height: phAsset.pixelHeight, width: phAsset.pixelWidth, @@ -39,8 +39,8 @@ class AssetMapper { return duration > 0 ? Int(duration * 1000) : nil } - func mapUri(_ phAsset: PHAsset) async throws -> String { - return try await UriExtractor.extract(from: phAsset).absoluteString + func mapUri(_ phAsset: PHAsset, version: AssetUriVersion) async throws -> String { + return try await UriExtractor.extract(from: phAsset, version: version).absoluteString } func mapFilename(_ phAsset: PHAsset) throws -> String { diff --git a/packages/expo-media-library/ios/next/objects/asset/AssetUriOptions.swift b/packages/expo-media-library/ios/next/objects/asset/AssetUriOptions.swift new file mode 100644 index 00000000000000..3c8bf1a4106466 --- /dev/null +++ b/packages/expo-media-library/ios/next/objects/asset/AssetUriOptions.swift @@ -0,0 +1,5 @@ +import ExpoModulesCore + +struct AssetUriOptions: Record { + @Field var version: AssetUriVersion = .CURRENT +} diff --git a/packages/expo-media-library/ios/next/objects/asset/AssetUriVersion.swift b/packages/expo-media-library/ios/next/objects/asset/AssetUriVersion.swift new file mode 100644 index 00000000000000..5e2bdaef1afda6 --- /dev/null +++ b/packages/expo-media-library/ios/next/objects/asset/AssetUriVersion.swift @@ -0,0 +1,14 @@ +import Photos +import ExpoModulesCore + +enum AssetUriVersion: String, Enumerable { + case CURRENT = "current" + case ORIGINAL = "original" + + func toPHVideoRequestOptionsVersion() -> PHVideoRequestOptionsVersion { + switch self { + case .CURRENT: return .current + case .ORIGINAL: return .original + } + } +} diff --git a/packages/expo-media-library/ios/next/objects/asset/UriExtractor.swift b/packages/expo-media-library/ios/next/objects/asset/UriExtractor.swift index 4dc576b0567943..e300be347dedbe 100644 --- a/packages/expo-media-library/ios/next/objects/asset/UriExtractor.swift +++ b/packages/expo-media-library/ios/next/objects/asset/UriExtractor.swift @@ -2,21 +2,19 @@ import Photos import AVFoundation class UriExtractor { - static func extract(from phAsset: PHAsset) async throws -> URL { + static func extract(from phAsset: PHAsset, version: AssetUriVersion) async throws -> URL { switch phAsset.mediaType { case .image: - return try await extract(fromImage: phAsset) + return try await extract(fromImage: phAsset, version: version) case .video: - return try await extract(fromVideo: phAsset) + return try await extract(fromVideo: phAsset, version: version) default: throw FailedToExtractUri("Unsupported media type") } } - private static func extract(fromImage phAsset: PHAsset) async throws -> URL { - let options = PHContentEditingInputRequestOptions() - options.isNetworkAccessAllowed = true - let result = try await phAsset.requestContentEditingInput(options: options) + private static func extract(fromImage phAsset: PHAsset, version: AssetUriVersion) async throws -> URL { + let result = try await phAsset.requestContentEditingInput(options: imageRequestOptions(for: version)) guard let contentEditingInput = result.input else { throw FailedToExtractUri("Missing content editing input for image") } @@ -26,12 +24,29 @@ class UriExtractor { return url } - private static func extract(fromVideo phAsset: PHAsset) async throws -> URL { + // Photos renders the edits into the image it returns, unless we say we can handle the + // adjustment data ourselves. Then it returns the image those edits were applied to. + static func imageRequestOptions(for version: AssetUriVersion) -> PHContentEditingInputRequestOptions { + let options = PHContentEditingInputRequestOptions() + options.isNetworkAccessAllowed = true + if version == .ORIGINAL { + options.canHandleAdjustmentData = { _ in true } + } + return options + } + + static func videoRequestOptions(for version: AssetUriVersion) -> PHVideoRequestOptions { let options = PHVideoRequestOptions() - options.version = .original + options.version = version.toPHVideoRequestOptionsVersion() options.isNetworkAccessAllowed = true + // without this an asset stored in iCloud can come back downscaled + options.deliveryMode = .highQualityFormat + return options + } + + private static func extract(fromVideo phAsset: PHAsset, version: AssetUriVersion) async throws -> URL { let result = try await PHImageManager.default() - .requestAVAsset(forVideo: phAsset, options: options) + .requestAVAsset(forVideo: phAsset, options: videoRequestOptions(for: version)) guard let avAsset = result.asset else { throw FailedToExtractUri("Missing AVAsset for video") } diff --git a/packages/expo-media-library/src/next/index.ts b/packages/expo-media-library/src/next/index.ts index 916f8a9671b981..fd4673d4778543 100644 --- a/packages/expo-media-library/src/next/index.ts +++ b/packages/expo-media-library/src/next/index.ts @@ -12,12 +12,14 @@ export { export { AssetField, + AssetUriVersion, MediaSubtype, MediaType, type Shape, type Location, type AssetFieldValueMap, type AssetInfo, + type AssetUriOptions, type GranularPermission, type MediaLibraryAssetsChangeEvent, type MediaTypeFilter, diff --git a/packages/expo-media-library/src/next/js/AssetAlbum.ts b/packages/expo-media-library/src/next/js/AssetAlbum.ts index 160f8c95c5c246..a8ee57f668a2cc 100644 --- a/packages/expo-media-library/src/next/js/AssetAlbum.ts +++ b/packages/expo-media-library/src/next/js/AssetAlbum.ts @@ -2,7 +2,14 @@ import { UnavailabilityError } from 'expo'; import { Platform } from 'react-native'; import { NativeAsset, NativeAlbum } from '../native'; -import type { AssetInfo, Location, MediaSubtype, MediaType, Shape } from '../types'; +import type { + AssetInfo, + AssetUriOptions, + Location, + MediaSubtype, + MediaType, + Shape, +} from '../types'; // Asset and Album construct each other, so their implementations live together to avoid Metro require-cycle warnings @@ -84,10 +91,16 @@ export class Asset { /** * Gets the asset URI. + * + * On iOS this resolves to the asset as it currently appears in the Photos app. Pass + * `version: AssetUriVersion.ORIGINAL` to get the file the edits were applied to instead. + * The option is ignored on Android. Note that resolving the current version of a slow-motion + * or trimmed video exports a file, so it takes longer than resolving the original. + * @param options - Selects which version of the asset to resolve. * @returns A promise resolving to the asset URI. */ - getUri(): Promise { - return this.nativeAsset.getUri(); + getUri(options?: AssetUriOptions): Promise { + return this.nativeAsset.getUri(options); } /** diff --git a/packages/expo-media-library/src/next/native/NativeMediaLibraryModule.web.ts b/packages/expo-media-library/src/next/native/NativeMediaLibraryModule.web.ts index 3f121b61d1b301..d6dfd8ebaa0a74 100644 --- a/packages/expo-media-library/src/next/native/NativeMediaLibraryModule.web.ts +++ b/packages/expo-media-library/src/next/native/NativeMediaLibraryModule.web.ts @@ -9,6 +9,7 @@ import type { AssetField, AssetFieldValueMap, AssetMetadata, + AssetUriOptions, GranularPermission, MediaTypeFilter, MediaLibraryAssetsChangeEvent, @@ -70,7 +71,7 @@ class NativeAssetWeb implements NativeAssetClass { getShape() { return unavailable('Asset.getShape'); } - getUri() { + getUri(_options?: AssetUriOptions) { return unavailable('Asset.getUri'); } getWidth() { diff --git a/packages/expo-media-library/src/next/native/types/NativeAssetClass.types.ts b/packages/expo-media-library/src/next/native/types/NativeAssetClass.types.ts index 5c3609469f9216..f9b103a5d2a5aa 100644 --- a/packages/expo-media-library/src/next/native/types/NativeAssetClass.types.ts +++ b/packages/expo-media-library/src/next/native/types/NativeAssetClass.types.ts @@ -1,4 +1,11 @@ -import type { AssetInfo, Location, Shape, MediaSubtype, MediaType } from '../../types'; +import type { + AssetInfo, + AssetUriOptions, + Location, + Shape, + MediaSubtype, + MediaType, +} from '../../types'; import type { NativeAlbumClass } from './NativeAlbumClass.types'; export declare class NativeAssetClass { @@ -15,7 +22,7 @@ export declare class NativeAssetClass { getOrientation(): Promise; getModificationTime(): Promise; getShape(): Promise; - getUri(): Promise; + getUri(options?: AssetUriOptions): Promise; getWidth(): Promise; getInfo(): Promise; getAlbums(): Promise; diff --git a/packages/expo-media-library/src/next/types/Asset.types.ts b/packages/expo-media-library/src/next/types/Asset.types.ts index 0b3e13d787004e..6c9c5d43be3753 100644 --- a/packages/expo-media-library/src/next/types/Asset.types.ts +++ b/packages/expo-media-library/src/next/types/Asset.types.ts @@ -22,6 +22,33 @@ export enum MediaSubtype { VIDEO_CINEMATIC = 'videoCinematic', } +/** + * Selects which version of an asset to resolve. + * @platform ios + */ +export enum AssetUriVersion { + /** + * The asset as it currently appears in the Photos app, including any edits applied to it. + */ + CURRENT = 'current', + /** + * The asset the edits were applied to. For an asset that was never edited this is the same + * file as `CURRENT`. + */ + ORIGINAL = 'original', +} + +/** + * @platform ios + */ +export type AssetUriOptions = { + /** + * Which version of the asset to resolve. + * @default AssetUriVersion.CURRENT + */ + version?: AssetUriVersion; +}; + export type Location = { latitude: number; longitude: number;