diff --git a/ios/HybridMediaToolkit.swift b/ios/HybridMediaToolkit.swift index 50f0aac..07cc8b8 100644 --- a/ios/HybridMediaToolkit.swift +++ b/ios/HybridMediaToolkit.swift @@ -374,32 +374,65 @@ 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) - + 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) + var tracksError: NSError? + let tracksStatus = asset.statusOfValue(forKey: "tracks", error: &tracksError) + let hasVideoOrAudioTracks = !asset.tracks(withMediaType: .video).isEmpty || !asset.tracks(withMediaType: .audio).isEmpty + + 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 +456,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,7 +601,7 @@ 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 srcPath = MediaUtils.resolveFilePath(from: uri) let srcFileSize: Double if let attrs = try? FileManager.default.attributesOfItem(atPath: srcPath), let sz = attrs[.size] as? Int { @@ -602,14 +639,7 @@ 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 - } + let url = MediaUtils.resolveURL(from: uri) return AVAsset(url: url) } + diff --git a/ios/ImageProcessor.swift b/ios/ImageProcessor.swift index ba54ec1..afb54ca 100644 --- a/ios/ImageProcessor.swift +++ b/ios/ImageProcessor.swift @@ -255,8 +255,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) + let url = MediaUtils.resolveURL(from: uri) guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { throw MediaToolkitError.invalidInput("Cannot load image source at: \(uri)") @@ -325,8 +324,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) + let url = MediaUtils.resolveURL(from: uri) guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { throw MediaToolkitError.invalidInput("Cannot load image source at: \(uri)") } @@ -375,9 +373,7 @@ class ImageProcessor: NSObject { // ─── Helpers ───────────────────────────────────────────────────────────── private static func loadImage(from uri: String) -> UIImage? { - let path = uri.hasPrefix("file://") - ? String(uri.dropFirst(7)) - : uri + let path = MediaUtils.resolveFilePath(from: uri) return UIImage(contentsOfFile: path) } diff --git a/ios/MediaToolkitErrors.swift b/ios/MediaToolkitErrors.swift index 93b1777..fb7047e 100644 --- a/ios/MediaToolkitErrors.swift +++ b/ios/MediaToolkitErrors.swift @@ -14,3 +14,43 @@ enum MediaToolkitError: Error, LocalizedError { } } } + +/// Utility helpers for safely resolving URIs, Foundation URLs, 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[.. 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 } - audioInput.append(sampleBuffer) + if !audioInput.append(sampleBuffer) { + reader.cancelReading() + finishAudio() + return + } + } + + 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,13 @@ 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) { + 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 } - let asset = AVURLAsset(url: URL(fileURLWithPath: normalized)) + let asset = AVURLAsset(url: fileUrl) guard let videoAssetTrack = asset.tracks(withMediaType: .video).first else { completion(0, MediaToolkitError.processingFailed("concatVideos: no video track in: \(path)")) @@ -1330,15 +1409,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 url = MediaUtils.resolveURL(from: uri) let asset = AVAsset(url: url) return asset }