diff --git a/MediaToolkit.podspec b/MediaToolkit.podspec index 64d129f..3cd16f9 100644 --- a/MediaToolkit.podspec +++ b/MediaToolkit.podspec @@ -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 = [ diff --git a/README.md b/README.md index 758525a..6701d4a 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,19 @@ interface ThumbnailResult { --- +## PhotoKit & Photos Library (`ph://`) Support (iOS) + +`react-native-media-toolkit` directly supports `ph://` 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://`:** Pass the asset identifier directly (e.g. `ph://ED7AC36B-A150-4C38-BB8C-B6D696F69B2C/L0/001` or `ph://`). 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. diff --git a/README.vi.md b/README.vi.md index b00d198..087faff 100644 --- a/README.vi.md +++ b/README.vi.md @@ -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://` 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://`:** Truyền trực tiếp identifier của asset (ví dụ `ph://ED7AC36B-A150-4C38-BB8C-B6D696F69B2C/L0/001` hoặc `ph://`). 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. diff --git a/ios/HybridMediaToolkit.swift b/ios/HybridMediaToolkit.swift index 50f0aac..4c67c20 100644 --- a/ios/HybridMediaToolkit.swift +++ b/ios/HybridMediaToolkit.swift @@ -1,6 +1,7 @@ import AVFoundation import Foundation import NitroModules +import Photos import UIKit /// Nitro HybridObject implementation for MediaToolkit. @@ -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 = ["mp4", "mov", "m4v", "m4a", "3gp", "avi", "mkv", "webm", "ts"] + let imageExtensions: Set = ["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 { @@ -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 @@ -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) @@ -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) } + diff --git a/ios/ImageProcessor.swift b/ios/ImageProcessor.swift index ba54ec1..4943763 100644 --- a/ios/ImageProcessor.swift +++ b/ios/ImageProcessor.swift @@ -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. @@ -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)") } @@ -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 { @@ -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) } diff --git a/ios/MediaToolkitErrors.swift b/ios/MediaToolkitErrors.swift index 93b1777..347fb5e 100644 --- a/ios/MediaToolkitErrors.swift +++ b/ios/MediaToolkitErrors.swift @@ -1,4 +1,6 @@ +import AVFoundation import Foundation +import Photos /// Typed errors thrown by the media processors enum MediaToolkitError: Error, LocalizedError { @@ -14,3 +16,142 @@ enum MediaToolkitError: Error, LocalizedError { } } } + +/// Utility helpers for safely resolving URIs, Foundation URLs, PhotoKit assets, and filesystem paths. +enum MediaUtils { + /// Resolves any input URI (file:// with fragments/query, plain paths, or custom schemes) + /// into a proper Foundation URL. + static func resolveURL(from uri: String) -> URL { + if uri.hasPrefix("file://") { + if let parsed = URL(string: uri) { + return parsed + } + let stripped = String(uri.dropFirst(7)) + return URL(fileURLWithPath: stripped) + } + if uri.hasPrefix("/") { + return URL(fileURLWithPath: uri) + } + if let parsed = URL(string: uri) { + return parsed + } + return URL(fileURLWithPath: uri) + } + + /// Extracts the clean filesystem path from a URI or URL, stripping fragments/query and decoding percent escapes. + static func resolveFilePath(from uri: String) -> String { + let url = resolveURL(from: uri) + if url.isFileURL { + return url.path + } + if uri.hasPrefix("file://") { + let stripped = String(uri.dropFirst(7)) + if let hashIdx = stripped.firstIndex(of: "#") { + let base = String(stripped[.. PHAsset? { + guard uri.hasPrefix("ph://") else { return nil } + let raw = String(uri.dropFirst(5)) + let clean = (raw.removingPercentEncoding ?? raw).trimmingCharacters(in: .whitespacesAndNewlines) + let baseId = clean.components(separatedBy: "#")[0].components(separatedBy: "?")[0] + + let candidates = [baseId, "\(baseId)/L0/001", clean, raw] + for id in candidates { + guard !id.isEmpty else { continue } + let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil) + if let first = result.firstObject { + return first + } + } + return nil + } + + /// Synchronously loads an AVAsset (waiting on semaphore for ph:// if on background thread). + static func loadAVAsset(from uri: String) -> AVAsset? { + if uri.hasPrefix("ph://") { + guard let phAsset = fetchPHAsset(from: uri) else { return nil } + let options = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + var loadedAsset: AVAsset? + let sema = DispatchSemaphore(value: 0) + PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { asset, _, _ in + loadedAsset = asset + sema.signal() + } + _ = sema.wait(timeout: .now() + 120.0) + return loadedAsset + } else { + let url = resolveURL(from: uri) + return AVAsset(url: url) + } + } + + /// Asynchronously loads an AVAsset from a file URI or PhotoKit ph:// URI. + static func loadAVAsset(from uri: String, completion: @escaping (AVAsset?, Error?) -> Void) { + if uri.hasPrefix("ph://") { + guard let phAsset = fetchPHAsset(from: uri) else { + completion(nil, MediaToolkitError.invalidInput("PHAsset not found for identifier: \(uri)")) + return + } + let options = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { asset, audioMix, info in + if let asset = asset { + completion(asset, nil) + } else { + let err = info?[PHImageErrorKey] as? Error + completion(nil, err ?? MediaToolkitError.invalidInput("Could not load AVAsset from PhotoKit for: \(uri)")) + } + } + } else { + let url = resolveURL(from: uri) + let asset = AVAsset(url: url) + completion(asset, nil) + } + } + + /// Async/await wrapper for loadAVAsset. + static func loadAVAssetAsync(from uri: String) async throws -> AVAsset { + return try await withCheckedThrowingContinuation { continuation in + loadAVAsset(from: uri) { asset, error in + if let error = error { + continuation.resume(throwing: error) + } else if let asset = asset { + continuation.resume(returning: asset) + } else { + continuation.resume(throwing: MediaToolkitError.invalidInput("Cannot load video: \(uri)")) + } + } + } + } + + /// Loads image data from a URI, resolving ph:// from PhotoKit if needed. + static func loadImageData(from uri: String) -> Data? { + if uri.hasPrefix("ph://") { + guard let phAsset = fetchPHAsset(from: uri) else { return nil } + let options = PHImageRequestOptions() + options.isSynchronous = true + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + var imgData: Data? + PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in + imgData = data + } + return imgData + } else { + let path = resolveFilePath(from: uri) + return try? Data(contentsOf: URL(fileURLWithPath: path)) + } + } +} diff --git a/ios/VideoProcessor.swift b/ios/VideoProcessor.swift index 228762f..07fe785 100644 --- a/ios/VideoProcessor.swift +++ b/ios/VideoProcessor.swift @@ -1082,7 +1082,7 @@ class VideoProcessor: NSObject { assetWriterInput: videoWriterInput, sourcePixelBufferAttributes: adaptorAttrs ) - ciContext = CIContext() + ciContext = CIContext(options: [.cacheIntermediates: false]) } // Audio writer (passthrough — nil settings = copy compressed stream) @@ -1112,25 +1112,45 @@ class VideoProcessor: NSObject { let videoQueue = DispatchQueue(label: "com.mediatoolkit.compress.video") let audioQueue = DispatchQueue(label: "com.mediatoolkit.compress.audio") + var lastReportedProgress: Float = -1.0 + var videoFinished = false + let finishVideo: () -> Void = { + guard !videoFinished else { return } + videoFinished = true + videoWriterInput.markAsFinished() + group.leave() + } + // ── Video samples ───────────────────────────────────────────────────── group.enter() videoWriterInput.requestMediaDataWhenReady(on: videoQueue) { while videoWriterInput.isReadyForMoreMediaData { + if writer.status != .writing { + reader.cancelReading() + finishVideo() + return + } guard reader.status == .reading else { - videoWriterInput.markAsFinished() - group.leave() + finishVideo() return } guard let sampleBuffer = videoReaderOutput.copyNextSampleBuffer() else { - videoWriterInput.markAsFinished() - group.leave() + if reader.status == .failed { + reader.cancelReading() + } + finishVideo() return } let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) let progress = Float(pts.seconds / totalDuration) - DispatchQueue.main.async { onProgress(min(progress, 0.95)) } + if progress - lastReportedProgress >= 0.01 || lastReportedProgress < 0 { + lastReportedProgress = progress + let clamped = min(progress, 0.95) + DispatchQueue.main.async { onProgress(clamped) } + } + let appendSuccess: Bool if needsResize, let adaptor = pixelBufferAdaptor, let ctx = ciContext, @@ -1146,37 +1166,93 @@ class VideoProcessor: NSObject { CVPixelBufferPoolCreatePixelBuffer(nil, pool, &destBuffer) guard let dest = destBuffer else { continue } ctx.render(scaled, to: dest) - adaptor.append(dest, withPresentationTime: pts) + appendSuccess = adaptor.append(dest, withPresentationTime: pts) } else { - videoWriterInput.append(sampleBuffer) + appendSuccess = videoWriterInput.append(sampleBuffer) } + + if !appendSuccess { + reader.cancelReading() + finishVideo() + return + } + } + + if writer.status != .writing { + reader.cancelReading() + finishVideo() + return } } // ── Audio samples (passthrough) ─────────────────────────────────────── if let audioInput = audioWriterInput, let audioOutput = audioReaderOutput { + var audioFinished = false + let finishAudio: () -> Void = { + guard !audioFinished else { return } + audioFinished = true + audioInput.markAsFinished() + group.leave() + } + group.enter() audioInput.requestMediaDataWhenReady(on: audioQueue) { while audioInput.isReadyForMoreMediaData { + if writer.status != .writing { + reader.cancelReading() + finishAudio() + return + } guard reader.status == .reading else { - audioInput.markAsFinished() - group.leave() + finishAudio() return } guard let sampleBuffer = audioOutput.copyNextSampleBuffer() else { - audioInput.markAsFinished() - group.leave() + if reader.status == .failed { + reader.cancelReading() + } + finishAudio() + return + } + if !audioInput.append(sampleBuffer) { + reader.cancelReading() + finishAudio() return } - audioInput.append(sampleBuffer) + } + + if writer.status != .writing { + reader.cancelReading() + finishAudio() + return } } } // ── Finalize ────────────────────────────────────────────────────────── group.notify(queue: .global(qos: .userInitiated)) { + if reader.status == .failed { + writer.cancelWriting() + try? FileManager.default.removeItem(at: outputURL) + completion(nil, reader.error ?? MediaToolkitError.processingFailed("Reader failed during compression")) + return + } + + if writer.status == .failed { + try? FileManager.default.removeItem(at: outputURL) + completion(nil, writer.error ?? MediaToolkitError.processingFailed("Writer failed during compression")) + return + } + + if writer.status == .cancelled || reader.status == .cancelled { + try? FileManager.default.removeItem(at: outputURL) + let err = writer.error ?? reader.error ?? MediaToolkitError.processingFailed("Compression cancelled") + completion(nil, err) + return + } + writer.finishWriting { - if writer.status == .completed { + if writer.status == .completed && reader.status != .failed { DispatchQueue.main.async { onProgress(1.0) } let outPath = outputURL.path @@ -1199,7 +1275,9 @@ class VideoProcessor: NSObject { let durationMs = asset.duration.seconds * 1000 completion(videoResult(path: outPath, asset: asset, trimmed: durationMs), nil) } else { - completion(nil, writer.error ?? MediaToolkitError.processingFailed("Export failed")) + try? FileManager.default.removeItem(at: outputURL) + let err = writer.error ?? reader.error ?? MediaToolkitError.processingFailed("Export failed") + completion(nil, err) } } } @@ -1238,12 +1316,22 @@ class VideoProcessor: NSObject { var firstVideoTransform: CGAffineTransform? for path in clipPaths { - let normalized = path.hasPrefix("file://") ? String(path.dropFirst(7)) : path - if !FileManager.default.fileExists(atPath: normalized) { - completion(0, MediaToolkitError.invalidInput("concatVideos: file not found: \(path)")) - return + let asset: AVAsset + if path.hasPrefix("ph://") { + guard let loaded = MediaUtils.loadAVAsset(from: path) else { + completion(0, MediaToolkitError.invalidInput("concatVideos: cannot load PhotoKit asset: \(path)")) + return + } + asset = loaded + } else { + let fileUrl = MediaUtils.resolveURL(from: path) + let diskPath = MediaUtils.resolveFilePath(from: path) + if !FileManager.default.fileExists(atPath: diskPath) { + completion(0, MediaToolkitError.invalidInput("concatVideos: file not found: \(path)")) + return + } + asset = AVURLAsset(url: fileUrl) } - let asset = AVURLAsset(url: URL(fileURLWithPath: normalized)) guard let videoAssetTrack = asset.tracks(withMediaType: .video).first else { completion(0, MediaToolkitError.processingFailed("concatVideos: no video track in: \(path)")) @@ -1330,17 +1418,7 @@ class VideoProcessor: NSObject { // ─── Helpers ───────────────────────────────────────────────────────────── private static 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 - } - let asset = AVAsset(url: url) - return asset + return MediaUtils.loadAVAsset(from: uri) } private static func pollProgress(session: AVAssetExportSession, onProgress: @escaping ProgressHandler) {