Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MediaToolkit.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Pod::Spec.new do |s|

s.platforms = { :ios => '15.0' }
s.source = { :git => "https://github.com/thangdevalone/react-native-media-toolkit.git", :tag => "#{s.version}" }
s.frameworks = 'AVFoundation', 'Photos', 'UIKit'

# Swift implementation files
s.source_files = [
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,19 @@ interface ThumbnailResult {

---

## PhotoKit & Photos Library (`ph://`) Support (iOS)

`react-native-media-toolkit` directly supports `ph://<localIdentifier>` URIs on iOS across all video and image operations (`trimVideo`, `cropVideo`, `compressVideo`, `concatVideos`, `getThumbnail`, `getMediaMetadata`, `compressImage`, `splitImage`, etc.).

### iOS Sandbox & DCIM Limitation Notice
On iOS, raw filesystem paths inside `/var/mobile/Media/DCIM/...` (which some picker libraries like `expo-media-library` expose as `localUri`, sometimes tagged with base64 fragments `#...`) **cannot** be directly decoded by a fresh `AVURLAsset` in an application process due to Apple's system security sandbox (`tracksError = The file couldn't be opened because you don't have permission to view it`).

To work with videos and photos from the device library:
1. **Use `ph://<localIdentifier>`:** Pass the asset identifier directly (e.g. `ph://ED7AC36B-A150-4C38-BB8C-B6D696F69B2C/L0/001` or `ph://<id>`). The toolkit resolves the underlying asset via PhotoKit (`PHImageManager.requestAVAsset` / `requestImageDataAndOrientation`). Ensure your `Info.plist` includes `NSPhotoLibraryUsageDescription`.
2. **Or copy into the app sandbox:** If your integration only provides raw DCIM file paths, copy the file into your app's sandbox directory (e.g. `cacheDirectory` / `documentDirectory`) first before processing. (Libraries like `expo-image-picker` perform this copy automatically).

---

## Custom UI

This library is **headless** — it provides native processing logic only, without any built-in UI.
Expand Down
13 changes: 13 additions & 0 deletions README.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,19 @@ interface ThumbnailResult {

---

## Hỗ trợ PhotoKit & Thư viện ảnh (`ph://`) (iOS)

`react-native-media-toolkit` hỗ trợ trực tiếp các URI dạng `ph://<localIdentifier>` trên iOS cho toàn bộ các hàm xử lý video và ảnh (`trimVideo`, `cropVideo`, `compressVideo`, `concatVideos`, `getThumbnail`, `getMediaMetadata`, `compressImage`, `splitImage`, v.v.).

### Lưu ý về Sandbox và đường dẫn DCIM trên iOS
Trên iOS, các đường dẫn file gốc trong `/var/mobile/Media/DCIM/...` (thường được các thư viện như `expo-media-library` trả về dưới dạng `localUri`, đôi khi gắn kèm fragment base64 `#...`) **không thể** được giải mã trực tiếp bởi `AVURLAsset` trong process ứng dụng do cơ chế bảo mật sandbox của Apple (`tracksError = The file couldn't be opened because you don't have permission to view it`).

Để làm việc với video và ảnh từ thư viện thiết bị:
1. **Sử dụng `ph://<localIdentifier>`:** Truyền trực tiếp identifier của asset (ví dụ `ph://ED7AC36B-A150-4C38-BB8C-B6D696F69B2C/L0/001` hoặc `ph://<id>`). Thư viện sẽ tự động giải quyết asset thông qua PhotoKit (`PHImageManager.requestAVAsset` / `requestImageDataAndOrientation`). Hãy đảm bảo `Info.plist` đã khai báo quyền `NSPhotoLibraryUsageDescription`.
2. **Hoặc sao chép vào sandbox ứng dụng:** Nếu bạn chỉ có đường dẫn file DCIM gốc, hãy sao chép file vào thư mục sandbox của app (ví dụ `cacheDirectory` / `documentDirectory`) trước khi truyền vào hàm xử lý. (Các thư viện như `expo-image-picker` đã thực hiện việc copy này tự động).

---

## UI tuỳ chỉnh

Thư viện này là **headless** — chỉ cung cấp logic xử lý native, không có UI đi kèm.
Expand Down
161 changes: 130 additions & 31 deletions ios/HybridMediaToolkit.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AVFoundation
import Foundation
import NitroModules
import Photos
import UIKit

/// Nitro HybridObject implementation for MediaToolkit.
Expand Down Expand Up @@ -374,32 +375,128 @@ class HybridMediaToolkit: HybridMediaToolkitSpec {
}

private func fetchMetadata(uri: String) throws -> MediaMetadata {
let isVideo = uri.lowercased().hasSuffix(".mp4") || uri.lowercased().hasSuffix(".mov") || uri.lowercased().hasSuffix(".m4a")
let fileUrl = URL(string: uri) ?? URL(fileURLWithPath: uri)

if uri.hasPrefix("ph://") {
guard let phAsset = MediaUtils.fetchPHAsset(from: uri) else {
throw MediaToolkitError.invalidInput("PHAsset not found for identifier: \(uri)")
}

let resources = PHAssetResource.assetResources(for: phAsset)
let size = Double(resources.first?.value(forKey: "fileSize") as? Int64 ?? 0)
var location: LocationData? = nil
if let loc = phAsset.location {
location = LocationData(latitude: loc.coordinate.latitude, longitude: loc.coordinate.longitude)
}
let datetime = phAsset.creationDate.map { ISO8601DateFormatter().string(from: $0) }

if phAsset.mediaType == .video {
guard let asset = MediaUtils.loadAVAsset(from: uri) else {
throw MediaToolkitError.invalidInput("Cannot load video asset from PhotoKit: \(uri)")
}
let duration = asset.duration.seconds * 1000
var width: Double = Double(phAsset.pixelWidth)
var height: Double = Double(phAsset.pixelHeight)
if let track = asset.tracks(withMediaType: .video).first {
let naturalSize = track.naturalSize.applying(track.preferredTransform)
width = Double(abs(naturalSize.width))
height = Double(abs(naturalSize.height))
}
return MediaMetadata(
type: "video", width: width, height: height, size: size,
duration: duration, mime: "video/mp4", make: "Apple", model: nil,
datetime: datetime, location: location,
aperture: nil, exposureTime: nil, iso: nil, focalLength: nil
)
} else {
guard let data = MediaUtils.loadImageData(from: uri),
let source = CGImageSourceCreateWithData(data as CFData, nil),
let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] else {
throw MediaToolkitError.invalidInput("Cannot read image properties from PhotoKit: \(uri)")
}
var width = props[kCGImagePropertyPixelWidth as String] as? Double ?? Double(phAsset.pixelWidth)
var height = props[kCGImagePropertyPixelHeight as String] as? Double ?? Double(phAsset.pixelHeight)
let orientation = props[kCGImagePropertyOrientation as String] as? Int ?? 1
if orientation > 4 {
let tmp = width
width = height
height = tmp
}
let tiff = props[kCGImagePropertyTIFFDictionary as String] as? [String: Any]
let make = tiff?[kCGImagePropertyTIFFMake as String] as? String ?? "Apple"
let model = tiff?[kCGImagePropertyTIFFModel as String] as? String
let exif = props[kCGImagePropertyExifDictionary as String] as? [String: Any]
let aperture = (exif?[kCGImagePropertyExifApertureValue as String] as? NSNumber)?.doubleValue
let exposure = (exif?[kCGImagePropertyExifExposureTime as String] as? NSNumber)?.doubleValue
let iso = ((exif?[kCGImagePropertyExifISOSpeedRatings as String] as? [NSNumber])?.first)?.doubleValue
let focalLength = (exif?[kCGImagePropertyExifFocalLength as String] as? NSNumber)?.doubleValue

return MediaMetadata(
type: "image", width: width, height: height, size: size,
duration: 0, mime: "image/jpeg", make: make, model: model,
datetime: datetime, location: location,
aperture: aperture, exposureTime: exposure, iso: iso, focalLength: focalLength
)
}
}

let fileUrl = MediaUtils.resolveURL(from: uri)
let filePath = MediaUtils.resolveFilePath(from: uri)

var size: Double = 0
if let attrs = try? FileManager.default.attributesOfItem(atPath: fileUrl.path),
if let attrs = try? FileManager.default.attributesOfItem(atPath: filePath),
let fSize = attrs[.size] as? NSNumber {
size = fSize.doubleValue
}


let ext = fileUrl.pathExtension.lowercased()
let videoExtensions: Set<String> = ["mp4", "mov", "m4v", "m4a", "3gp", "avi", "mkv", "webm", "ts"]
let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "heic", "heif", "webp", "gif", "bmp", "tiff", "tif"]

let asset = AVURLAsset(url: fileUrl)
let hasVideoOrAudioTracks = !asset.tracks(withMediaType: .video).isEmpty || !asset.tracks(withMediaType: .audio).isEmpty
var tracksError: NSError?
let tracksStatus = asset.statusOfValue(forKey: "tracks", error: &tracksError)

let isVideo: Bool
if videoExtensions.contains(ext) {
isVideo = true
} else if imageExtensions.contains(ext) {
isVideo = false
} else if hasVideoOrAudioTracks {
isVideo = true
} else {
// Check if it's an image via CGImageSource
let imgSource = CGImageSourceCreateWithURL(fileUrl as CFURL, nil)
let props = imgSource != nil ? CGImageSourceCopyPropertiesAtIndex(imgSource!, 0, nil) as? [String: Any] : nil
if props != nil {
isVideo = false
} else if !asset.tracks.isEmpty {
isVideo = true
} else {
let fileExists = FileManager.default.fileExists(atPath: filePath)
let readable = FileManager.default.isReadableFile(atPath: filePath)
let tracksErrDesc = tracksError?.localizedDescription ?? "none"
throw MediaToolkitError.invalidInput(
"Cannot read media at: \(uri) (fileExists=\(fileExists), readable=\(readable), size=\(Int64(size)), tracksStatus=\(tracksStatus.rawValue), tracksError=\(tracksErrDesc))"
)
}
}

if isVideo {
let asset = AVAsset(url: fileUrl)
let duration = asset.duration.seconds * 1000

var width: Double = 0
var height: Double = 0
if let track = asset.tracks(withMediaType: .video).first {
let size = track.naturalSize.applying(track.preferredTransform)
width = Double(abs(size.width))
height = Double(abs(size.height))
let naturalSize = track.naturalSize.applying(track.preferredTransform)
width = Double(abs(naturalSize.width))
height = Double(abs(naturalSize.height))
}

var make: String? = nil
var model: String? = nil
var datetime: String? = nil
var location: LocationData? = nil

let metadata = asset.commonMetadata
for item in metadata {
if item.commonKey == .commonKeyMake {
Expand All @@ -423,20 +520,24 @@ class HybridMediaToolkit: HybridMediaToolkitSpec {
}
}
}


let mime = ext == "mov" ? "video/quicktime" : "video/mp4"

return MediaMetadata(
type: "video", width: width, height: height, size: size,
duration: duration, mime: "video/mp4", make: make, model: model,
duration: duration, mime: mime, make: make, model: model,
datetime: datetime, location: location,
aperture: nil, exposureTime: nil, iso: nil, focalLength: nil
)
} else {
// Image
guard let source = CGImageSourceCreateWithURL(fileUrl as CFURL, nil) else {
throw MediaToolkitError.invalidInput("Cannot read image")
throw MediaToolkitError.invalidInput("Cannot read image at: \(uri)")
}
guard let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] else {
throw MediaToolkitError.invalidInput("Cannot read image properties")
let fileExists = FileManager.default.fileExists(atPath: filePath)
let readable = FileManager.default.isReadableFile(atPath: filePath)
throw MediaToolkitError.invalidInput("Cannot read image properties at: \(uri) (fileExists=\(fileExists), readable=\(readable), size=\(Int64(size)))")
}

var width = props[kCGImagePropertyPixelWidth as String] as? Double ?? 0
Expand Down Expand Up @@ -564,13 +665,19 @@ class HybridMediaToolkit: HybridMediaToolkitSpec {
try data.write(to: outURL)

// Source video file size (NOT the thumbnail JPEG size)
let srcPath = uri.hasPrefix("file://") ? String(uri.dropFirst(7)) : uri
let srcFileSize: Double
if let attrs = try? FileManager.default.attributesOfItem(atPath: srcPath),
let sz = attrs[.size] as? Int {
srcFileSize = Double(sz)
if uri.hasPrefix("ph://"), let phAsset = MediaUtils.fetchPHAsset(from: uri) {
let resources = PHAssetResource.assetResources(for: phAsset)
let sz = resources.first?.value(forKey: "fileSize") as? Int64 ?? 0
srcFileSize = Double(sz > 0 ? sz : Int64(data.count))
} else {
srcFileSize = Double(data.count) // fallback to thumbnail size
let srcPath = MediaUtils.resolveFilePath(from: uri)
if let attrs = try? FileManager.default.attributesOfItem(atPath: srcPath),
let sz = attrs[.size] as? Int {
srcFileSize = Double(sz)
} else {
srcFileSize = Double(data.count) // fallback to thumbnail size
}
}

// Source video duration in milliseconds (actual file duration)
Expand Down Expand Up @@ -602,14 +709,6 @@ private func makeMediaResult(_ raw: [String: Any]) -> MediaResult {
}

private func loadAsset(_ uri: String) -> AVAsset? {
let path = uri.hasPrefix("file://") ? String(uri.dropFirst(7)) : uri
let url: URL
if path.hasPrefix("/") {
url = URL(fileURLWithPath: path)
} else if let u = URL(string: uri) {
url = u
} else {
return nil
}
return AVAsset(url: url)
return MediaUtils.loadAVAsset(from: uri)
}

31 changes: 19 additions & 12 deletions ios/ImageProcessor.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Foundation
import UIKit
import CoreGraphics
import Foundation
import ImageIO
import MobileCoreServices
import Photos
import UIKit
import UniformTypeIdentifiers

/// Handles image crop and compress on iOS using CGImage + UIKit.
Expand Down Expand Up @@ -255,10 +256,7 @@ class ImageProcessor: NSObject {
cornerRadius: Double,
outputPath: String?
) throws -> [String: Any] {
let path = uri.hasPrefix("file://") ? String(uri.dropFirst(7)) : uri
let url = URL(fileURLWithPath: path)

guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
guard let source = createImageSource(from: uri) else {
throw MediaToolkitError.invalidInput("Cannot load image source at: \(uri)")
}

Expand Down Expand Up @@ -325,9 +323,7 @@ class ImageProcessor: NSObject {
throw MediaToolkitError.invalidInput("rows and columns must be greater than 0")
}

let path = uri.hasPrefix("file://") ? String(uri.dropFirst(7)) : uri
let url = URL(fileURLWithPath: path)
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
guard let source = createImageSource(from: uri) else {
throw MediaToolkitError.invalidInput("Cannot load image source at: \(uri)")
}
guard let image = loadImage(from: uri) else {
Expand Down Expand Up @@ -374,10 +370,21 @@ class ImageProcessor: NSObject {

// ─── Helpers ─────────────────────────────────────────────────────────────

private static func createImageSource(from uri: String) -> CGImageSource? {
if uri.hasPrefix("ph://") {
guard let data = MediaUtils.loadImageData(from: uri) else { return nil }
return CGImageSourceCreateWithData(data as CFData, nil)
}
let url = MediaUtils.resolveURL(from: uri)
return CGImageSourceCreateWithURL(url as CFURL, nil)
}

private static func loadImage(from uri: String) -> UIImage? {
let path = uri.hasPrefix("file://")
? String(uri.dropFirst(7))
: uri
if uri.hasPrefix("ph://") {
guard let data = MediaUtils.loadImageData(from: uri) else { return nil }
return UIImage(data: data)
}
let path = MediaUtils.resolveFilePath(from: uri)
return UIImage(contentsOfFile: path)
}

Expand Down
Loading
Loading