diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift b/Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift new file mode 100644 index 000000000000..6ff79a43a571 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift @@ -0,0 +1,43 @@ +import Foundation + +enum MaterializerError: LocalizedError { + case securityScopedAccessDenied + case fileNotFound + case durationCapExceeded + case disallowedContentType + case heicConversionFailed + case videoExportFailed(underlyingError: Error) + case unknownContentType + case remoteDownloadFailed(underlyingError: Error) + + var errorDescription: String? { + switch self { + case .securityScopedAccessDenied: return Strings.uploadErrorSecurityScopedAccess + case .fileNotFound: return Strings.uploadErrorFileNotFound + case .durationCapExceeded: return Strings.uploadErrorDurationCap + case .disallowedContentType: return Strings.uploadErrorDisallowedType + case .heicConversionFailed: return Strings.uploadErrorHEICConversion + case .videoExportFailed(let underlyingError): + return String.localizedStringWithFormat( + Strings.uploadErrorVideoExport, + underlyingError.localizedDescription + ) + case .unknownContentType: return Strings.uploadErrorUnknownContentType + case .remoteDownloadFailed(let underlyingError): + return String.localizedStringWithFormat( + Strings.materializerErrorRemoteDownloadFailed, + underlyingError.localizedDescription + ) + } + } +} + +enum VideoExportFailureReason: LocalizedError { + case noExporterForPreset + + var errorDescription: String? { + switch self { + case .noExporterForPreset: return Strings.uploadErrorVideoExportNoExporter + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift new file mode 100644 index 000000000000..08b71bd5a0b8 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Single-use downloader for `.remoteURL` materialization. The materializer +/// constructs one per download and discards it after. +/// +/// Built on the async `URLSession.download(from:delegate:)`, so the runtime +/// owns the continuation, cancellation, temp-file delivery, and error +/// propagation. The only thing it can't give us is byte-level progress +/// (the async convenience methods suppress `didWriteData`), so we observe +/// the task's own `Progress` via KVO from `didCreateTask`, the workaround +/// Apple's URL Loading System team recommends for this case: +/// https://developer.apple.com/forums/thread/723015 +final class RemoteDownloader { + + /// Downloads `url` into `parentDir` and returns the local file URL. Reports + /// byte-level progress on `progress` (mapped to its existing 0-100 + /// `totalUnitCount`). Cooperative task cancellation cancels the request and + /// surfaces as `CancellationError`. + func download(from url: URL, into parentDir: URL, progress: Progress) async throws -> URL { + let delegate = ProgressForwardingDelegate(progress: progress) + let location: URL + let response: URLResponse + do { + (location, response) = try await URLSession.shared.download(from: url, delegate: delegate) + } catch is CancellationError { + throw CancellationError() + } catch let urlError as URLError where urlError.code == .cancelled { + throw CancellationError() + } catch { + throw MaterializerError.remoteDownloadFailed(underlyingError: error) + } + + // HTTP status check: a 404/500 response body looks like a successful + // download to URLSession (it still hands back a valid temp file). For + // Stock Photos the image-byte validator catches HTML error bodies + // later, but Tenor GIF is a raw passthrough, so without this we'd + // happily upload an HTML 404 response as 'image/gif'. + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + let statusError = NSError( + domain: "RemoteDownloader", + code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode) response"] + ) + throw MaterializerError.remoteDownloadFailed(underlyingError: statusError) + } + + // Move out of the system temp location (reaped after this call) into our + // owned parentDir, reusing the system-generated unique name. + let dest = parentDir.appendingPathComponent(location.lastPathComponent) + do { + try FileManager.default.moveItem(at: location, to: dest) + } catch { + throw MaterializerError.remoteDownloadFailed(underlyingError: error) + } + return dest + } +} + +/// Forwards the download task's `Progress` to the caller-supplied `progress`. +/// `didCreateTask` fires for the async download API (unlike `didWriteData`), +/// so it's the hook for installing the KVO observation. +private final class ProgressForwardingDelegate: NSObject, URLSessionTaskDelegate { + private let progress: Progress + private var observation: NSKeyValueObservation? + + init(progress: Progress) { + self.progress = progress + super.init() + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + observation = task.progress.observe(\.fractionCompleted) { [progress] taskProgress, _ in + // `fractionCompleted` stays 0 when the server sends no Content-Length + // (totalUnitCount is -1), leaving the row indeterminate until the + // upload phase drives it to completion. + progress.completedUnitCount = Int64((taskProgress.fractionCompleted * 100).rounded()) + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift new file mode 100644 index 000000000000..45651b7c5545 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift @@ -0,0 +1,65 @@ +import Foundation +import os + +/// Generates the temp-file basenames used for media uploads, and with them the +/// filenames WordPress ends up storing. +/// +/// The uploaded media's filename is the basename of the file sent to the +/// server: `MediaCreateParams` carries no explicit filename, so the upload +/// layer (wordpress-rs) falls back to the last path component of the file it +/// uploads. Naming each temp file `.` from the source's own name is +/// therefore the whole of filename preservation (and, since the title is left +/// unset, drives the server-derived attachment title too). +/// +/// Holds the basenames already handed out so repeated names within one uploader +/// session get ` (2)`, ` (3)` suffixes instead of overwriting each other. +final class UploadFilenameAllocator: Sendable { + private let usedBasenames = OSAllocatedUnfairLock>(initialState: []) + + /// The sanitized `preferred` name, or `-` when + /// the source has no usable name. Carries no extension and is not yet + /// deduplicated; pass the result to `basename`. + func stem(preferred: String?, fallbackPrefix: String, date: Date) -> String { + preferred.flatMap(sanitize) ?? "\(fallbackPrefix)-\(timestampedName(date: date))" + } + + /// A unique `.` basename. Names already handed out for the + /// lifetime of this allocator are tracked, so a batch with two same-named + /// files becomes `name.jpg`, `name (2).jpg`, and so on. + func basename(stem: String, ext: String) -> String { + usedBasenames.withLock { used in + let first = "\(stem).\(ext)" + if used.insert(first).inserted { + return first + } + var n = 2 + while true { + let candidate = "\(stem) (\(n)).\(ext)" + if used.insert(candidate).inserted { + return candidate + } + n += 1 + } + } + } + + /// Filesystem-safe `yyyy-MM-dd HH-mm-ss` timestamp (fixed POSIX locale) + /// used to build fallback stems when a source has no name of its own. + private func timestampedName(date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH-mm-ss" + return formatter.string(from: date) + } + + /// Strips path separators and NUL, caps length, and treats an empty result + /// as "no usable name" (nil) so the caller falls back to a generated stem. + private func sanitize(_ name: String) -> String? { + let cleaned = + name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\u{0}", with: "") + let trimmed = String(cleaned.prefix(256)) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift new file mode 100644 index 000000000000..65474d3b43a6 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift @@ -0,0 +1,766 @@ +import AVFoundation +import Foundation +import ImageIO +import UIKit +import UniformTypeIdentifiers +import WordPressAPI + +struct MaterializedUpload: Sendable { + let tempFileURL: URL + let params: MediaCreateParams + let kind: MediaKind + let displayName: String +} + +/// Test seam over `UploadSourceMaterializer.materialize`. The actor talks +/// to materialization via this protocol so tests can substitute a mock. +protocol MediaSourceMaterializing: Sendable { + func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload +} + +final class UploadSourceMaterializer: MediaSourceMaterializing, Sendable { + private let policy: MediaUploadPolicy + private let temporaryRoot: URL + private let filenames = UploadFilenameAllocator() + + init( + policy: MediaUploadPolicy, + temporaryRoot: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("WordPressMediaLibrary-Uploads", isDirectory: true) + ) { + self.policy = policy + self.temporaryRoot = temporaryRoot + } + + func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + let parentDir = temporaryRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + + do { + let result: MaterializedUpload + switch source { + case .photoLibrary(let itemProvider, let suggestedName, let hint): + result = try await materializePhotoLibrary( + parentDir: parentDir, + itemProvider: itemProvider, + suggestedName: suggestedName, + hint: hint, + stageProgress: stageProgress + ) + case .cameraImage(let image, let capturedAt): + result = try materializeCameraImage( + parentDir: parentDir, + image: image, + capturedAt: capturedAt + ) + case .cameraVideo(let url, let capturedAt): + result = try await materializeCameraVideo( + parentDir: parentDir, + sourceURL: url, + capturedAt: capturedAt, + stageProgress: stageProgress + ) + case .file(let url): + result = try await materializeFile( + parentDir: parentDir, + sourceURL: url, + stageProgress: stageProgress + ) + case .remoteURL(let remote): + result = try await materializeRemoteURL( + parentDir: parentDir, + remote: remote, + stageProgress: stageProgress + ) + case .imagePlayground(let url, let suggestedName): + result = try materializeImagePlayground( + parentDir: parentDir, + sourceURL: url, + suggestedName: suggestedName + ) + } + // Ensure the stage child reaches its total even when sub-methods + // didn't report fine-grained progress (image sub-methods snap + // 0→100 here; video sub-methods set it themselves after polling). + stageProgress.completedUnitCount = stageProgress.totalUnitCount + return result + } catch { + try? FileManager.default.removeItem(at: parentDir) + throw error + } + } + + // MARK: - Photo Library + + private func materializePhotoLibrary( + parentDir: URL, + itemProvider: NSItemProvider, + suggestedName: String?, + hint: UTType, + stageProgress: Progress + ) async throws -> MaterializedUpload { + if hint.conforms(to: .movie) { + return try await materializePhotoLibraryVideo( + parentDir: parentDir, + itemProvider: itemProvider, + suggestedName: suggestedName, + hint: hint, + stageProgress: stageProgress + ) + } + + if hint == .gif { + return try await materializePhotoLibraryGIF( + parentDir: parentDir, + itemProvider: itemProvider, + suggestedName: suggestedName + ) + } + + let data = try await loadDataRepresentation(itemProvider: itemProvider, hint: hint) + let (outputData, effectiveType) = try convertHEICIfNeeded(data, type: hint) + return try finalizeImage( + data: outputData, + effectiveType: effectiveType, + ext: effectiveType.preferredFilenameExtension ?? hint.preferredFilenameExtension ?? "bin", + stem: filenames.stem(preferred: suggestedName, fallbackPrefix: "Photo", date: Date()), + parentDir: parentDir + ) + } + + /// PHPicker video path. `loadFileRepresentation` hands us a + /// provider-owned URL valid ONLY during the callback. Copy bytes into + /// our parentDir inside the callback, then run the export from the + /// owned copy. + private func materializePhotoLibraryVideo( + parentDir: URL, + itemProvider: NSItemProvider, + suggestedName: String?, + hint: UTType, + stageProgress: Progress + ) async throws -> MaterializedUpload { + let copiedSource: URL = try await withCheckedThrowingContinuation { cont in + itemProvider.loadFileRepresentation( + forTypeIdentifier: hint.identifier + ) { providerURL, err in + if let err { + cont.resume(throwing: err) + return + } + guard let providerURL else { + cont.resume(throwing: MaterializerError.fileNotFound); return + } + let sourceExt = + providerURL.pathExtension.isEmpty + ? (hint.preferredFilenameExtension ?? "mov") + : providerURL.pathExtension + let dest = parentDir.appendingPathComponent("source.\(sourceExt)") + do { + try FileManager.default.copyItem(at: providerURL, to: dest) + cont.resume(returning: dest) + } catch { + cont.resume(throwing: error) + } + } + } + + defer { try? FileManager.default.removeItem(at: copiedSource) } + + return try await finalizeVideo( + asset: AVURLAsset(url: copiedSource), + stem: filenames.stem(preferred: suggestedName, fallbackPrefix: "Video", date: Date()), + parentDir: parentDir, + stageProgress: stageProgress + ) + } + + /// PHPicker GIF path. ImageIO's thumbnail/encode round-trip used by + /// `resizeIfNeeded` and `stripGPS` would flatten animation to a single + /// frame, so GIFs raw-copy bytes through the provider — matching V1's + /// `ItemProviderMediaExporter.processGIF` and V2's `.file` GIF branch. + private func materializePhotoLibraryGIF( + parentDir: URL, + itemProvider: NSItemProvider, + suggestedName: String? + ) async throws -> MaterializedUpload { + guard policy.isAllowedForUpload(.gif, "gif") else { + throw MaterializerError.disallowedContentType + } + + let stem = filenames.stem(preferred: suggestedName, fallbackPrefix: "Photo", date: Date()) + let basename = filenames.basename(stem: stem, ext: "gif") + let destURL = parentDir.appendingPathComponent(basename) + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + itemProvider.loadFileRepresentation( + forTypeIdentifier: UTType.gif.identifier + ) { providerURL, err in + if let err { + cont.resume(throwing: err) + return + } + guard let providerURL else { + cont.resume(throwing: MaterializerError.fileNotFound); return + } + do { + try FileManager.default.copyItem(at: providerURL, to: destURL) + cont.resume() + } catch { + cont.resume(throwing: error) + } + } + } + + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(filePath: destURL.path), + kind: .image, + displayName: basename + ) + } + + // MARK: - Camera + + func materializeCameraImage( + parentDir: URL, + image: UIImage, + capturedAt: Date + ) throws -> MaterializedUpload { + guard let jpegData = image.jpegData(compressionQuality: CGFloat(policy.imageJpegQuality)) + else { + throw MaterializerError.heicConversionFailed + } + return try finalizeImage( + data: jpegData, + effectiveType: .jpeg, + ext: "jpg", + stem: filenames.stem(preferred: nil, fallbackPrefix: "IMG", date: capturedAt), + parentDir: parentDir + ) + } + + private func materializeCameraVideo( + parentDir: URL, + sourceURL: URL, + capturedAt: Date, + stageProgress: Progress + ) async throws -> MaterializedUpload { + try await finalizeVideo( + asset: AVURLAsset(url: sourceURL), + stem: filenames.stem(preferred: nil, fallbackPrefix: "IMG", date: capturedAt), + parentDir: parentDir, + stageProgress: stageProgress + ) + } + + /// Re-exports `asset` to `destURL`, driving `stageProgress` from a sibling + /// poll of `session.progress`. + /// + /// `export(to:as:isolation:)` is `@backDeployed` to iOS 13, so the export + /// runs structured even on the iOS 17 floor: it sets the output URL / file + /// type itself, observes `Task` cancellation natively, and throws on + /// failure instead of reporting through a callback. Progress still uses the + /// legacy `session.progress` property because the modern + /// `states(updateInterval:)` AsyncSequence is iOS 18+ and not back-deployed. + private func exportVideo( + asset: AVURLAsset, + to destURL: URL, + outputType: AVFileType, + stageProgress: Progress + ) async throws { + guard + let exportSession = AVAssetExportSession( + asset: asset, + presetName: policy.videoExportPreset + ) + else { + throw MaterializerError.videoExportFailed( + underlyingError: VideoExportFailureReason.noExporterForPreset + ) + } + exportSession.shouldOptimizeForNetworkUse = true + + // `AVAssetExportSession` isn't `Sendable`, but the poll task only reads + // `.progress`, which is safe to sample off the originating actor. + nonisolated(unsafe) let session = exportSession + let pollTask = Task { [stageProgress] in + while !Task.isCancelled { + stageProgress.completedUnitCount = Int64( + (Double(stageProgress.totalUnitCount) * Double(session.progress)).rounded() + ) + try? await Task.sleep(for: .milliseconds(100)) + } + } + defer { pollTask.cancel() } + + do { + try await exportSession.export(to: destURL, as: outputType) + } catch { + // Let cancellation propagate untouched so the uploader treats it as + // a cancel rather than a failure; wrap everything else. + if error is CancellationError { throw error } + throw MaterializerError.videoExportFailed(underlyingError: error) + } + + // Snap stageProgress to full — the final poll may have been just shy + // of 1.0 when export returned. + stageProgress.completedUnitCount = stageProgress.totalUnitCount + } + + // MARK: - File + + private func materializeFile( + parentDir: URL, + sourceURL: URL, + stageProgress: Progress + ) async throws -> MaterializedUpload { + guard sourceURL.startAccessingSecurityScopedResource() else { + throw MaterializerError.securityScopedAccessDenied + } + defer { sourceURL.stopAccessingSecurityScopedResource() } + + let contentType = try resolveContentType(of: sourceURL) + let stem = filenames.stem( + preferred: sourceURL.deletingPathExtension().lastPathComponent, + fallbackPrefix: "File", + date: Date() + ) + + if contentType.conforms(to: .image) && contentType != .gif { + return try materializeFileImage( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: contentType, + stem: stem + ) + } + if contentType.conforms(to: .movie) { + return try await materializeFileVideo( + parentDir: parentDir, + sourceURL: sourceURL, + stem: stem, + stageProgress: stageProgress + ) + } + return try materializeFileRawCopy( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: contentType, + stem: stem + ) + } + + func materializeFileImage( + parentDir: URL, + sourceURL: URL, + contentType: UTType, + stem: String, + caption: String? = nil + ) throws -> MaterializedUpload { + let data = try Data(contentsOf: sourceURL) + let (converted, effectiveType) = try convertHEICIfNeeded(data, type: contentType) + return try finalizeImage( + data: converted, + effectiveType: effectiveType, + ext: effectiveType.preferredFilenameExtension ?? sourceURL.pathExtension, + stem: stem, + caption: caption, + parentDir: parentDir + ) + } + + /// Image Playground returns a local file URL inside our own sandbox — no + /// security-scoped access required. Resolves the URL's UTType with a + /// defensive `.heic` fallback (matches V1 `MediaPickerMenu+ImagePlayground` + /// behavior) and dispatches to the existing image policy path. + private func materializeImagePlayground( + parentDir: URL, + sourceURL: URL, + suggestedName: String + ) throws -> MaterializedUpload { + let resolvedType: UTType = { + if let type = try? sourceURL.resourceValues(forKeys: [.contentTypeKey]).contentType { + return type + } + return .heic // V1 fallback (MediaPickerMenu+ImagePlayground.swift:46-55) + }() + + return try materializeFileImage( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: resolvedType, + stem: suggestedName + ) + } + + private func materializeFileVideo( + parentDir: URL, + sourceURL: URL, + stem: String, + stageProgress: Progress + ) async throws -> MaterializedUpload { + try await finalizeVideo( + asset: AVURLAsset(url: sourceURL), + stem: stem, + parentDir: parentDir, + stageProgress: stageProgress + ) + } + + private func materializeFileRawCopy( + parentDir: URL, + sourceURL: URL, + contentType: UTType, + stem: String + ) throws -> MaterializedUpload { + let ext = sourceURL.pathExtension.isEmpty ? "bin" : sourceURL.pathExtension + guard policy.isAllowedForUpload(contentType, ext) else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try FileManager.default.copyItem(at: sourceURL, to: destURL) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(filePath: destURL.path), + kind: MediaKind(estimating: contentType), + displayName: basename + ) + } + + // MARK: - Remote URL (post-download dispatch) + + /// `.remoteURL` = download phase (RemoteDownloader writes bytes into + /// `parentDir`) then dispatch phase (`dispatchRemoteDownload`). `parentDir` + /// is created and cleaned up by `materialize`, so neither phase manages it. + private func materializeRemoteURL( + parentDir: URL, + remote: UploadSource.RemoteURL, + stageProgress: Progress + ) async throws -> MaterializedUpload { + // TODO: failed `.remoteURL` materialization is currently + // Dismiss-only (FailedUpload.isRetryable: materialized != nil). + // Retaining the original UploadSource on failed entries would + // let Retry re-run materialization for this case (and every + // other materialization-failing source). + let downloader = RemoteDownloader() + let localFile = try await downloader.download( + from: remote.url, + into: parentDir, + progress: stageProgress + ) + return try await dispatchRemoteDownload( + localFile: localFile, + contentType: remote.contentType, + suggestedName: remote.suggestedName, + caption: remote.caption, + parentDir: parentDir + ) + } + + /// Post-download dispatch for `.remoteURL`. Takes a local file (already + /// downloaded into `parentDir`), the declared content type, the sanitized + /// suggested-name stem, and an optional caption. Assumes `parentDir` + /// exists — `materialize` owns its creation and cleanup. Branches: GIF → + /// write to `.gif` raw; image (non-GIF) → byte-validate then + /// materializeFileImage; anything else → MaterializerError.disallowedContentType. + /// + /// Kept module-internal as a test seam so the post-download dispatch can be + /// exercised without a live network download. + func dispatchRemoteDownload( + localFile: URL, + contentType: UTType, + suggestedName: String, + caption: String?, + parentDir: URL + ) async throws -> MaterializedUpload { + if contentType == .gif { + return try materializeRemoteGIF( + localFile: localFile, + stem: suggestedName, + caption: caption, + parentDir: parentDir + ) + } else if contentType.conforms(to: .image) { + return try materializeRemoteImage( + localFile: localFile, + contentType: contentType, + stem: suggestedName, + caption: caption, + parentDir: parentDir + ) + } else { + throw MaterializerError.disallowedContentType + } + } + + /// GIF passthrough: writes downloaded bytes to `.gif` in our owned + /// parentDir, ignoring whatever extension URLSession's temp file had. + /// Mirrors V1's MediaExternalExporter.swift:63-74. + private func materializeRemoteGIF( + localFile: URL, + stem: String, + caption: String?, + parentDir: URL + ) throws -> MaterializedUpload { + guard policy.isAllowedForUpload(.gif, "gif") else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: "gif") + let destURL = parentDir.appendingPathComponent(basename) + try FileManager.default.moveItem(at: localFile, to: destURL) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(caption: caption, filePath: destURL.path), + kind: .image, + displayName: basename + ) + } + + /// Image branch: decode-validate bytes (UIImage(data:)-equivalent via + /// CGImageSource) BEFORE materializeFileImage; without this, the resize + /// and GPS-strip helpers return original bytes on decode failure, so a + /// Pexels HTML error response would upload as 'allowed JPEG'. The caption + /// is threaded through `materializeFileImage` so V1 Stock Photos attribution + /// lands in `MediaCreateParams.caption`. + private func materializeRemoteImage( + localFile: URL, + contentType: UTType, + stem: String, + caption: String?, + parentDir: URL + ) throws -> MaterializedUpload { + let data = try Data(contentsOf: localFile) + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceCreateImageAtIndex(source, 0, nil) != nil + else { + throw MaterializerError.remoteDownloadFailed( + underlyingError: NSError( + domain: "RemoteImageValidation", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Downloaded bytes are not a valid image."] + ) + ) + } + // Reuse the bytes just read for validation instead of letting + // materializeFileImage re-read the file from disk. Mirrors the + // in-memory tail of materializePhotoLibrary. + let (converted, effectiveType) = try convertHEICIfNeeded(data, type: contentType) + let ext = effectiveType.preferredFilenameExtension ?? localFile.pathExtension + return try finalizeImage( + data: converted, + effectiveType: effectiveType, + ext: ext, + stem: stem, + caption: caption, + parentDir: parentDir + ) + } + + // MARK: - Shared finalize + + /// Shared image tail: resize, optional GPS strip, allow-check, then write + /// into `parentDir`. Callers supply the already-decided effective type, + /// file extension, name stem, and optional caption — everything that + /// differs per source. `parentDir` is created and cleaned up by the + /// `materialize` entry point. + private func finalizeImage( + data: Data, + effectiveType: UTType, + ext: String, + stem: String, + caption: String? = nil, + parentDir: URL + ) throws -> MaterializedUpload { + let resized = try resizeIfNeeded(data: data, contentType: effectiveType) + let stripped = + policy.stripImageLocation + ? try stripGPS(data: resized, contentType: effectiveType) + : resized + + guard policy.isAllowedForUpload(effectiveType, ext) else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try stripped.write(to: destURL) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(caption: caption, filePath: destURL.path), + kind: .image, + displayName: basename + ) + } + + /// Shared video tail: duration cap, allow-check, then re-export `asset` + /// into `parentDir`. The output container and extension come from the + /// policy; callers supply only the asset and the name stem. + private func finalizeVideo( + asset: AVURLAsset, + stem: String, + parentDir: URL, + stageProgress: Progress + ) async throws -> MaterializedUpload { + let duration = try await asset.load(.duration).seconds + if let cap = policy.videoMaxDurationSeconds, duration > cap { + throw MaterializerError.durationCapExceeded + } + let outputType = AVFileType(rawValue: policy.videoOutputContentType.identifier) + let ext = policy.videoOutputContentType.preferredFilenameExtension ?? "mp4" + guard policy.isAllowedForUpload(policy.videoOutputContentType, ext) else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try await exportVideo( + asset: asset, + to: destURL, + outputType: outputType, + stageProgress: stageProgress + ) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(filePath: destURL.path), + kind: .video, + displayName: basename + ) + } + + // MARK: - Helpers + + private func loadDataRepresentation( + itemProvider: NSItemProvider, + hint: UTType + ) async throws -> Data { + try await withCheckedThrowingContinuation { cont in + itemProvider.loadDataRepresentation(forTypeIdentifier: hint.identifier) { data, err in + if let err { + cont.resume(throwing: err) + return + } + guard let data else { + cont.resume(throwing: MaterializerError.fileNotFound); return + } + cont.resume(returning: data) + } + } + } + + /// Converts HEIC bytes to JPEG when the policy asks for it, returning the + /// effective content type alongside. Non-HEIC input (or HEIC under a policy + /// that keeps it) passes through untouched. + private func convertHEICIfNeeded(_ data: Data, type contentType: UTType) throws -> (Data, UTType) { + guard contentType == .heic, policy.convertHEICToJPEG else { + return (data, contentType) + } + return (try heicToJPEG(data: data), .jpeg) + } + + private func heicToJPEG(data: Data) throws -> Data { + guard + let src = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(src, 0, nil) + else { + throw MaterializerError.heicConversionFailed + } + let mutable = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData( + mutable, + UTType.jpeg.identifier as CFString, + 1, + nil + ) + else { + throw MaterializerError.heicConversionFailed + } + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: policy.imageJpegQuality + ] + CGImageDestinationAddImage(dst, cgImage, options as CFDictionary) + guard CGImageDestinationFinalize(dst) else { + throw MaterializerError.heicConversionFailed + } + return mutable as Data + } + + private func resizeIfNeeded(data: Data, contentType: UTType) throws -> Data { + guard + let max = policy.imageMaxDimension, max > 0, + let src = CGImageSourceCreateWithData(data as CFData, nil) + else { + return data + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: max + ] + guard + let thumb = CGImageSourceCreateThumbnailAtIndex(src, 0, options as CFDictionary) + else { + return data + } + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData( + out, + contentType.identifier as CFString, + 1, + nil + ) + else { + return data + } + let writeOptions: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: policy.imageJpegQuality + ] + CGImageDestinationAddImage(dst, thumb, writeOptions as CFDictionary) + guard CGImageDestinationFinalize(dst) else { return data } + return out as Data + } + + private func stripGPS(data: Data, contentType: UTType) throws -> Data { + guard + let src = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(src, 0, nil) + else { return data } + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData( + out, + contentType.identifier as CFString, + 1, + nil + ) + else { return data } + // Build a clean properties dict without the GPS entry. We decode the + // source pixel data (cgImage) and re-encode it with the stripped props + // rather than copying from source, which would carry over GPS metadata + // even if we set the key to nil in the options dict. + var props = + (CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any]) ?? [:] + props.removeValue(forKey: kCGImagePropertyGPSDictionary) + props[kCGImageDestinationLossyCompressionQuality] = policy.imageJpegQuality + CGImageDestinationAddImage(dst, cgImage, props as CFDictionary) + guard CGImageDestinationFinalize(dst) else { return data } + return out as Data + } + + private func resolveContentType(of url: URL) throws -> UTType { + if let type = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + return type + } + if let type = UTType(filenameExtension: url.pathExtension) { + return type + } + throw MaterializerError.unknownContentType + } +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift b/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift new file mode 100644 index 000000000000..fbbf82de294a --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift @@ -0,0 +1,241 @@ +import Foundation +import Testing +import UniformTypeIdentifiers +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressMediaLibrary + +// MARK: - Recording tracker + +@MainActor +final class RecordingMediaTracker: MediaTracker { + var events: [MediaTrackerEvent] = [] + func track(_ event: MediaTrackerEvent) { events.append(event) } +} + +// MARK: - Fake upload transports + +actor FakeUploadTransport: MediaUploadTransport { + var uploadCount = 0 + var responses: [Result] = [] + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + uploadCount += 1 + progress.completedUnitCount = progress.totalUnitCount + if responses.isEmpty { + return MediaWithEditContext.fixture() + } + return try responses.removeFirst().get() + } + + func setResponses(_ responses: [Result]) { + self.responses = responses + } +} + +/// A transport that blocks until signalled, used to test cancel mid-flight. +actor BlockingFakeUploadTransport: MediaUploadTransport { + private var continuation: CheckedContinuation? + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + await withCheckedContinuation { cont in + self.continuation = cont + } + try Task.checkCancellation() + return MediaWithEditContext.fixture() + } + + func unblock() { + continuation?.resume() + continuation = nil + } +} + +/// Fails the first call then blocks the second — used to build a mixed +/// (1 failed + 1 pending) banner state through a single uploader. +actor BlockingAndThenFailFakeUploadTransport: MediaUploadTransport { + private var callIndex = 0 + private var firstCallError: Error? + private var continuation: CheckedContinuation? + + func configureFirstCallAsFailure(_ error: Error) { + self.firstCallError = error + } + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + callIndex += 1 + if callIndex == 1, let firstCallError { + throw firstCallError + } + await withCheckedContinuation { cont in + self.continuation = cont + } + try Task.checkCancellation() + return MediaWithEditContext.fixture() + } + + func unblock() { + continuation?.resume() + continuation = nil + } +} + +// MARK: - MediaWithEditContext fixture + +extension MediaWithEditContext { + static func fixture(id: Int64 = 9999) -> MediaWithEditContext { + MediaWithEditContext( + id: id, + date: "", + dateGmt: Date(timeIntervalSince1970: 0), + guid: PostGuidWithEditContext(raw: nil, rendered: ""), + link: "", + modified: "", + modifiedGmt: Date(timeIntervalSince1970: 0), + slug: "", + status: .inherit, + postType: "", + password: nil, + permalinkTemplate: "", + generatedSlug: "", + title: PostTitleWithEditContext(raw: nil, rendered: ""), + author: 0, + commentStatus: .closed, + pingStatus: .closed, + template: "", + altText: "", + caption: MediaCaptionWithEditContext(raw: "", rendered: ""), + description: MediaDescriptionWithEditContext(raw: "", rendered: ""), + mediaType: .file, + mimeType: "", + mediaDetails: MediaDetails(noHandle: .init()), + postId: nil, + sourceUrl: "", + missingImageSizes: [] + ) + } +} + +// MARK: - MediaUploadPolicy helper + +func makeAllowEverythingPolicy() -> MediaUploadPolicy { + MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { _, _ in true }, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: "AVAssetExportPresetMediumQuality", + videoOutputContentType: .mpeg4Movie, + stripImageLocation: false + ) +} + +// MARK: - Mock materializer + +/// Test seam that lets a test drive materialization timing and outcome. +/// - Suspends on a "start" continuation when `materialize` is called. +/// - When unblocked by the test, either throws the configured error or +/// returns the configured `MaterializedUpload`. +actor MockMaterializer: MediaSourceMaterializing { + enum Outcome { + case success(MaterializedUpload) + case failure(Error) + } + + private var startedContinuations: [CheckedContinuation] = [] + private var completionContinuations: [CheckedContinuation] = [] + private(set) var lastStageProgress: Progress? + private(set) var lastSource: UploadSource? + private(set) var sawCancellation = false + + nonisolated func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + try await materializeAsync(source: source, into: stageProgress) + } + + private func materializeAsync( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + self.lastSource = source + lastStageProgress = stageProgress + await withCheckedContinuation { cont in + startedContinuations.append(cont) + } + let outcome = await withCheckedContinuation { cont in + completionContinuations.append(cont) + } + // Mirror the real materializer's contract: if cancellation is + // observed before we hand back the payload, clean up the temp dir + // so the caller is not responsible for it. + if Task.isCancelled { + if case .success(let m) = outcome { + try? FileManager.default.removeItem(at: m.tempFileURL.deletingLastPathComponent()) + } + throw CancellationError() + } + switch outcome { + case .success(let m): return m + case .failure(let e): throw e + } + } + + /// Signals that `materialize` has been entered. The test typically + /// awaits this before driving stageProgress or calling cancel. + func waitForStart() async { + // Spin until at least one start continuation has been captured. + while startedContinuations.isEmpty { + await Task.yield() + } + let cont = startedContinuations.removeFirst() + cont.resume() + } + + /// Resolve the in-flight `materialize` call. If the work Task hasn't + /// reached the completion suspension point yet, spin-wait briefly so + /// callers don't need to insert sleeps. + func complete(with outcome: Outcome) async { + while completionContinuations.isEmpty { + await Task.yield() + } + let cont = completionContinuations.removeFirst() + cont.resume(returning: outcome) + } +} + +// MARK: - Temp file helpers + +func writeTempFile(name: String, content: Data) throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("MediaLibraryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(name) + try content.write(to: url) + return url +} + +func writeTempPDF(name: String = "doc.pdf") throws -> URL { + try writeTempFile(name: name, content: Data("%PDF-1.4\n%EOF\n".utf8)) +} + +func writeTempJPEG(name: String = "IMG_1234.jpg") throws -> URL { + let jpegHeader: [UInt8] = [0xFF, 0xD8, 0xFF, 0xE0] + return try writeTempFile(name: name, content: Data(jpegHeader)) +} + +func writeTempMOV(name: String = "IMG_1234.mov") throws -> URL { + try writeTempFile(name: name, content: Data("fake-mov".utf8)) +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift new file mode 100644 index 000000000000..f7e744393be4 --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +@testable import WordPressMediaLibrary + +@Suite("UploadFilenameAllocator") +struct UploadFilenameAllocatorTests { + + // MARK: - basename + + @Test("first use of a name returns . unchanged") + func freshNameIsVerbatim() { + let allocator = UploadFilenameAllocator() + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo.jpg") + } + + @Test("repeated names get sequential numeric suffixes") + func repeatsAreSuffixed() { + let allocator = UploadFilenameAllocator() + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo.jpg") + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo (2).jpg") + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo (3).jpg") + } + + @Test("the same stem with a different extension does not collide") + func differentExtensionDoesNotCollide() { + let allocator = UploadFilenameAllocator() + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo.jpg") + #expect(allocator.basename(stem: "photo", ext: "png") == "photo.png") + } + + @Test("different stems do not collide") + func differentStemsDoNotCollide() { + let allocator = UploadFilenameAllocator() + #expect(allocator.basename(stem: "a", ext: "jpg") == "a.jpg") + #expect(allocator.basename(stem: "b", ext: "jpg") == "b.jpg") + } + + @Test("deduplication state is scoped to a single allocator instance") + func dedupIsPerInstance() { + #expect(UploadFilenameAllocator().basename(stem: "photo", ext: "jpg") == "photo.jpg") + // A separate allocator starts with a clean slate, so no suffix. + #expect(UploadFilenameAllocator().basename(stem: "photo", ext: "jpg") == "photo.jpg") + } + + // MARK: - stem derivation + + @Test("a usable preferred name is kept as the stem") + func preferredNameIsKept() { + let allocator = UploadFilenameAllocator() + #expect( + allocator.stem(preferred: "My Vacation", fallbackPrefix: "Photo", date: Date()) == "My Vacation" + ) + } + + @Test("path separators in the preferred name become underscores") + func slashesAreReplaced() { + let allocator = UploadFilenameAllocator() + #expect(allocator.stem(preferred: "a/b/c", fallbackPrefix: "Photo", date: Date()) == "a_b_c") + } + + @Test("a name made only of separators sanitizes to underscores, not a fallback") + func onlySeparatorsBecomeUnderscores() { + let allocator = UploadFilenameAllocator() + #expect(allocator.stem(preferred: "///", fallbackPrefix: "Photo", date: Date()) == "___") + } + + @Test("NUL characters are stripped from the preferred name") + func nulIsStripped() { + let allocator = UploadFilenameAllocator() + #expect(allocator.stem(preferred: "a\u{0}b", fallbackPrefix: "Photo", date: Date()) == "ab") + } + + @Test("an over-long preferred name is capped at 256 characters") + func longNameIsCapped() { + let allocator = UploadFilenameAllocator() + let stem = allocator.stem( + preferred: String(repeating: "a", count: 300), + fallbackPrefix: "Photo", + date: Date() + ) + #expect(stem == String(repeating: "a", count: 256)) + } + + @Test("a nil preferred name falls back to a filesystem-safe -") + func nilFallsBackToPrefixTimestamp() { + let allocator = UploadFilenameAllocator() + let stem = allocator.stem(preferred: nil, fallbackPrefix: "Photo", date: Date()) + let pattern = #"^Photo-\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}$"# + #expect(stem.range(of: pattern, options: .regularExpression) != nil) + // The generated timestamp must be filesystem-safe. + #expect(!stem.contains(":")) + #expect(!stem.contains("/")) + } + + @Test("an empty preferred name falls back to the prefix") + func emptyFallsBackToPrefix() { + let allocator = UploadFilenameAllocator() + let stem = allocator.stem(preferred: "", fallbackPrefix: "Video", date: Date()) + #expect(stem.hasPrefix("Video-")) + } + + // MARK: - end-to-end + + @Test("a source name flows through to a complete upload basename") + func sourceNameBecomesBasename() { + let allocator = UploadFilenameAllocator() + let stem = allocator.stem(preferred: "Quarterly Report", fallbackPrefix: "File", date: Date()) + #expect(allocator.basename(stem: stem, ext: "pdf") == "Quarterly Report.pdf") + } +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift new file mode 100644 index 000000000000..46c992f9b92a --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift @@ -0,0 +1,784 @@ +import AVFoundation +import Foundation +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import WordPressMediaLibrary + +/// Fresh stage progress for materializer tests that don't care about +/// the value — matches what the actor would allocate in production. +private func stage() -> Progress { Progress(totalUnitCount: 100) } + +@Suite("UploadSourceMaterializer") +struct UploadSourceMaterializerTests { + private func policy( + allow: @escaping @Sendable (UTType, String) -> Bool = { _, _ in true } + ) -> MediaUploadPolicy { + MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: allow, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: false + ) + } + + @Test("file source: validator rejection surfaces disallowedContentType") + func validatorRejectsDocument() async throws { + let tempURL = try createTempPDF() + defer { try? FileManager.default.removeItem(at: tempURL) } + + let materializer = UploadSourceMaterializer(policy: policy(allow: { _, _ in false })) + await #expect { + _ = try await materializer.materialize(source: .file(tempURL), into: stage()) + } throws: { error in + guard case MaterializerError.disallowedContentType = error else { return false } + return true + } + } + + @Test("file source preserves original basename verbatim") + func fileSourcePreservesBasename() async throws { + let tempURL = try createTempPDF(name: "Quarterly Report.pdf") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let m = UploadSourceMaterializer(policy: policy()) + let result = try await m.materialize(source: .file(tempURL), into: stage()) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + #expect(result.displayName == "Quarterly Report.pdf") + } + + @Test("materialization failures remove their parent temp directory") + func failureRemovesTempDir() async throws { + let tempURL = try createTempPDF() + defer { try? FileManager.default.removeItem(at: tempURL) } + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer( + policy: policy(allow: { _, _ in false }), + temporaryRoot: inspectableRoot + ) + await #expect { + _ = try await m.materialize(source: .file(tempURL), into: stage()) + } throws: { error in + guard case MaterializerError.disallowedContentType = error else { return false } + return true + } + + let contents = try FileManager.default.contentsOfDirectory( + at: inspectableRoot, + includingPropertiesForKeys: nil + ) + #expect(contents.isEmpty) + } + + @Test("camera image yields IMG_.jpg") + func cameraImageBasename() throws { + let image = makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .red) + let date = Date(timeIntervalSince1970: 0) // 1970-01-01 00-00-00 UTC + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: policy(), temporaryRoot: inspectableRoot) + let result = try m.materializeCameraImagePublic( + image: image, + capturedAt: date, + temporaryRoot: inspectableRoot + ) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasPrefix("IMG")) + #expect(result.displayName.hasSuffix(".jpg")) + #expect(result.kind == .image) + #expect(FileManager.default.fileExists(atPath: result.tempFileURL.path)) + } + + @Test("camera image with stripImageLocation removes GPS") + func cameraImageStripsGPS() throws { + // Build a JPEG with a synthetic GPS dict embedded. + let jpegWithGPS = try makeJPEGWithGPS() + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + // Write it as a UIImage-equivalent: decode → UIImage → test the strip path + // directly by materializing a camera image from a JPEG-backed UIImage. + let stripPolicy = MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { _, _ in true }, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: true + ) + + // Use file-source path which exercises stripGPS on raw JPEG bytes. + let sourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gps_test_\(UUID().uuidString).jpg") + try jpegWithGPS.write(to: sourceURL) + defer { try? FileManager.default.removeItem(at: sourceURL) } + + let m = UploadSourceMaterializer(policy: stripPolicy, temporaryRoot: inspectableRoot) + let result = try m.materializeFileImagePublic( + sourceURL: sourceURL, + contentType: .jpeg, + stem: "gps_test", + temporaryRoot: inspectableRoot + ) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + let outputData = try Data(contentsOf: result.tempFileURL) + guard let src = CGImageSourceCreateWithData(outputData as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] + else { + return // no properties at all means no GPS — test passes + } + #expect(props[kCGImagePropertyGPSDictionary] == nil) + } + + @Test("camera video over duration is rejected") + func cameraVideoOverDuration() async throws { + let videoURL = try await createBlankVideo(durationSeconds: 1.0) + defer { try? FileManager.default.removeItem(at: videoURL) } + + let shortCapPolicy = MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { _, _ in true }, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: 0.5, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: false + ) + let m = UploadSourceMaterializer(policy: shortCapPolicy) + await #expect { + _ = try await m.materialize(source: .cameraVideo(videoURL, capturedAt: Date()), into: stage()) + } throws: { error in + guard case MaterializerError.durationCapExceeded = error else { return false } + return true + } + } + + @Test("camera video exports to .mp4 by default") + func cameraVideoExportsToMP4() async throws { + let videoURL = try await createBlankVideo(durationSeconds: 1.0) + defer { try? FileManager.default.removeItem(at: videoURL) } + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: policy(), temporaryRoot: inspectableRoot) + let result = try await m.materialize(source: .cameraVideo(videoURL, capturedAt: Date()), into: stage()) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasSuffix(".mp4")) + #expect(result.kind == .video) + #expect(FileManager.default.fileExists(atPath: result.tempFileURL.path)) + } + + @Test(".file HEIC under policy that rejects HEIC but allows JPEG succeeds") + func fileHEICConvertedToJPEG() async throws { + let heicData = try makeSyntheticHEIC() + let sourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("test_\(UUID().uuidString).heic") + try heicData.write(to: sourceURL) + defer { try? FileManager.default.removeItem(at: sourceURL) } + + let heicRejectPolicy = MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { type, ext in + // Reject HEIC, allow JPEG + !(type == .heic || ext == "heic") + }, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: false + ) + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: heicRejectPolicy, temporaryRoot: inspectableRoot) + let result = try await m.materialize(source: .file(sourceURL), into: stage()) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasSuffix(".jpg") || result.displayName.hasSuffix(".jpeg")) + #expect(result.kind == .image) + } + + @Test(".file video with duration cap exceeded is rejected") + func fileVideoOverDuration() async throws { + let videoURL = try await createBlankVideo(durationSeconds: 1.0) + defer { try? FileManager.default.removeItem(at: videoURL) } + + let capPolicy = MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { _, _ in true }, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: 0.01, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: false + ) + let m = UploadSourceMaterializer(policy: capPolicy) + await #expect { + _ = try await m.materialize(source: .file(videoURL), into: stage()) + } throws: { error in + guard case MaterializerError.durationCapExceeded = error else { return false } + return true + } + } + + @Test(".file video re-exports to .mp4") + func fileVideoReexportsToMP4() async throws { + let videoURL = try await createBlankVideo(durationSeconds: 1.0) + defer { try? FileManager.default.removeItem(at: videoURL) } + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: policy(), temporaryRoot: inspectableRoot) + let result = try await m.materialize(source: .file(videoURL), into: stage()) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasSuffix(".mp4")) + #expect(result.kind == .video) + } + + @Test(".file GIF passes through raw-copied") + func fileGIFRawCopy() async throws { + let gifURL = try createTempGIF() + defer { try? FileManager.default.removeItem(at: gifURL) } + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: policy(), temporaryRoot: inspectableRoot) + let result = try await m.materialize(source: .file(gifURL), into: stage()) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasSuffix(".gif")) + #expect(result.kind == .image) + + // Verify byte-for-byte copy: the output should be the same file size. + let srcSize = (try? gifURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1 + let dstSize = + (try? result.tempFileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -2 + #expect(srcSize == dstSize) + } + + @Test(".file PDF passes through raw-copied") + func filePDFRawCopy() async throws { + let pdfURL = try createTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL) } + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: policy(), temporaryRoot: inspectableRoot) + let result = try await m.materialize(source: .file(pdfURL), into: stage()) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasSuffix(".pdf")) + #expect(result.kind == .document) + } + + @Test(".photoLibrary GIF passes through raw-copied") + func photoLibraryGIFRawCopy() async throws { + let gifURL = try createTempGIF() + defer { try? FileManager.default.removeItem(at: gifURL) } + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + // Resize + GPS-strip both enabled — the bug class this guards + // against is ImageIO flattening animated GIFs when either is on. + let transformingImagePolicy = MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { _, _ in true }, + imageMaxDimension: 1024, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: true + ) + + let m = UploadSourceMaterializer(policy: transformingImagePolicy, temporaryRoot: inspectableRoot) + let result = try await m.materialize( + source: .photoLibrary( + itemProvider: makeGIFItemProvider(vendingFile: gifURL), + suggestedName: "Animation", + hint: .gif + ), + into: stage() + ) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + #expect(result.displayName.hasSuffix(".gif")) + #expect(result.kind == .image) + + // Byte-for-byte copy: any ImageIO round-trip would alter the + // bytes even if the file size happened to match. + let srcBytes = try Data(contentsOf: gifURL) + let dstBytes = try Data(contentsOf: result.tempFileURL) + #expect(srcBytes == dstBytes) + } + + @Test(".photoLibrary GIF: validator rejection surfaces disallowedContentType") + func photoLibraryGIFRejected() async throws { + let gifURL = try createTempGIF() + defer { try? FileManager.default.removeItem(at: gifURL) } + + let m = UploadSourceMaterializer(policy: policy(allow: { _, ext in ext != "gif" })) + await #expect { + _ = try await m.materialize( + source: .photoLibrary( + itemProvider: makeGIFItemProvider(vendingFile: gifURL), + suggestedName: "Animation", + hint: .gif + ), + into: stage() + ) + } throws: { error in + guard case MaterializerError.disallowedContentType = error else { return false } + return true + } + } + + @Test("camera image: imageMaxDimension resizes before GPS strip") + func cameraImageResized() throws { + // 2000×2000 image, cap at 1024 — output pixel long edge must be <= 1024. + let image = makeSolidColorImage(size: CGSize(width: 2000, height: 2000), color: .blue) + + let resizePolicy = MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: { _, _ in true }, + imageMaxDimension: 1024, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: true + ) + + let inspectableRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TestMaterializerRoot-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: inspectableRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: inspectableRoot) } + + let m = UploadSourceMaterializer(policy: resizePolicy, temporaryRoot: inspectableRoot) + let result = try m.materializeCameraImagePublic( + image: image, + capturedAt: Date(), + temporaryRoot: inspectableRoot + ) + defer { try? FileManager.default.removeItem(at: result.tempFileURL.deletingLastPathComponent()) } + + // Verify dimensions are within the cap. + let data = try Data(contentsOf: result.tempFileURL) + guard + let src = CGImageSourceCreateWithData(data as CFData, nil), + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any], + let w = props[kCGImagePropertyPixelWidth] as? Int, + let h = props[kCGImagePropertyPixelHeight] as? Int + else { + Issue.record("Could not read output image dimensions") + return + } + #expect(max(w, h) <= 1024) + } + + @Test func materialize_imagePlayground_appliesImagePolicy_withoutSecurityScope() async throws { + let tempRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempRoot) } + + // Plant a small JPEG that Image Playground might have produced. + let imageURL = tempRoot.appendingPathComponent("Generated.jpg") + let image = UIImage(systemName: "photo")! + try image.jpegData(compressionQuality: 0.9)!.write(to: imageURL) + + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: tempRoot + ) + let result = try await materializer.materialize( + source: .imagePlayground(imageURL, suggestedName: "Generated"), + into: Progress(totalUnitCount: 100) + ) + + #expect(result.kind == .image) + #expect(result.displayName == "Generated.jpeg") + // Image policy ran (file exists at the destination). + #expect(FileManager.default.fileExists(atPath: result.tempFileURL.path)) + } + + @Test func materialize_imagePlayground_removesParentDirOnFailure() async throws { + let tempRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempRoot) } + + let nonExistent = tempRoot.appendingPathComponent("does-not-exist.heic") + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: tempRoot + ) + + await #expect(throws: (any Error).self) { + _ = try await materializer.materialize( + source: .imagePlayground(nonExistent, suggestedName: "x"), + into: Progress(totalUnitCount: 100) + ) + } + let remaining = try FileManager.default.contentsOfDirectory(atPath: tempRoot.path) + #expect(remaining.isEmpty) + } + + // MARK: - .remoteURL post-download dispatch + + @Test func remoteDispatch_gif_passthroughPreservesBytes() async throws { + let parentDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parentDir) } + + let sourceGIF = parentDir.appendingPathComponent("download.tmp") + let gifBytes = Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00]) // GIF89a header sentinel + try gifBytes.write(to: sourceGIF) + + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: parentDir + ) + let result = try await materializer.dispatchRemoteDownload( + localFile: sourceGIF, + contentType: .gif, + suggestedName: "happy-cat", + caption: nil, + parentDir: parentDir + ) + + let copied = try Data(contentsOf: URL(fileURLWithPath: result.params.filePath)) + #expect(copied == gifBytes) // byte-equal: animation preserved + #expect(URL(fileURLWithPath: result.params.filePath).pathExtension == "gif") + #expect(URL(fileURLWithPath: result.params.filePath).deletingPathExtension().lastPathComponent == "happy-cat") + // Display name includes the .gif extension, matching the contract + // applied uniformly across materializer branches. + #expect(result.displayName == "happy-cat.gif") + } + + @Test func remoteDispatch_passesCaptionThrough() async throws { + let parentDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parentDir) } + + let sourceGIF = parentDir.appendingPathComponent("a.tmp") + try Data([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]).write(to: sourceGIF) + + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: parentDir + ) + let result = try await materializer.dispatchRemoteDownload( + localFile: sourceGIF, + contentType: .gif, + suggestedName: "g", + caption: "Photo by Foo", + parentDir: parentDir + ) + #expect(result.params.caption == "Photo by Foo") + } + + @Test func remoteDispatch_rejectsNonImageNonGifContentType() async throws { + let parentDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parentDir) } + + let sourceFile = parentDir.appendingPathComponent("vid.tmp") + try Data([0x00]).write(to: sourceFile) + + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: parentDir + ) + await #expect(throws: MaterializerError.self) { + _ = try await materializer.dispatchRemoteDownload( + localFile: sourceFile, + contentType: .movie, + suggestedName: "x", + caption: nil, + parentDir: parentDir + ) + } + } + + @Test func remoteDispatch_image_passesCaptionThrough() async throws { + let parentDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parentDir) } + + let sourceJPEG = parentDir.appendingPathComponent("download.tmp") + try UIImage(systemName: "photo")!.jpegData(compressionQuality: 0.9)!.write(to: sourceJPEG) + + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: parentDir.appendingPathComponent("root") + ) + let result = try await materializer.dispatchRemoteDownload( + localFile: sourceJPEG, + contentType: .jpeg, + suggestedName: "photo", + caption: "Photo by Foo", + parentDir: parentDir + ) + #expect(result.params.caption == "Photo by Foo") + } + + @Test func remoteDispatch_imageBranchRejectsNonImageBytes() async throws { + let parentDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: parentDir) } + + let sourceFile = parentDir.appendingPathComponent("a.tmp") + try "404 Not Found".data(using: .utf8)!.write(to: sourceFile) + + let materializer = UploadSourceMaterializer( + policy: policy(), + temporaryRoot: parentDir + ) + await #expect(throws: MaterializerError.self) { + _ = try await materializer.dispatchRemoteDownload( + localFile: sourceFile, + contentType: .jpeg, + suggestedName: "x", + caption: nil, + parentDir: parentDir + ) + } + } +} + +// MARK: - Test-only public surface + +// These extensions expose internal methods for testing without duplicating logic. +extension UploadSourceMaterializer { + func materializeCameraImagePublic( + image: UIImage, + capturedAt: Date, + temporaryRoot: URL + ) throws -> MaterializedUpload { + let id = UUID() + let parentDir = temporaryRoot.appendingPathComponent(id.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + return try materializeCameraImage(parentDir: parentDir, image: image, capturedAt: capturedAt) + } + + func materializeFileImagePublic( + sourceURL: URL, + contentType: UTType, + stem: String, + temporaryRoot: URL + ) throws -> MaterializedUpload { + let id = UUID() + let parentDir = temporaryRoot.appendingPathComponent(id.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + return try materializeFileImage( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: contentType, + stem: stem + ) + } +} + +// MARK: - Test fixtures + +private func createTempPDF(name: String = "test_\(UUID().uuidString).pdf") throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) + let pdf = "%PDF-1.4\n%EOF\n".data(using: .ascii)! + try pdf.write(to: url) + return url +} + +private func makeGIFItemProvider(vendingFile gifURL: URL) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerFileRepresentation( + forTypeIdentifier: UTType.gif.identifier, + fileOptions: [], + visibility: .all + ) { completion in + completion(gifURL, false, nil) + return nil + } + return provider +} + +private func createTempGIF() throws -> URL { + // Minimal valid GIF89a: header + logical screen descriptor + trailer. + let gifBytes: [UInt8] = [ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a" + 0x01, 0x00, 0x01, 0x00, // width=1, height=1 + 0x00, // GCT flag off + 0x00, // background color + 0x00, // aspect ratio + 0x3B // trailer + ] + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("test_\(UUID().uuidString).gif") + try Data(gifBytes).write(to: url) + return url +} + +private func makeSolidColorImage(size: CGSize, color: UIColor) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + color.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } +} + +private func makeJPEGWithGPS() throws -> Data { + let image = makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .green) + guard let baseJPEG = image.jpegData(compressionQuality: 0.9) else { + throw NSError(domain: "Test", code: 1) + } + // Re-encode with a synthetic GPS dictionary injected via CGImageDestination. + guard let src = CGImageSourceCreateWithData(baseJPEG as CFData, nil) else { + throw NSError(domain: "Test", code: 2) + } + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) + else { + throw NSError(domain: "Test", code: 3) + } + let gpsDict: [CFString: Any] = [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ] + let props: [CFString: Any] = [kCGImagePropertyGPSDictionary: gpsDict] + CGImageDestinationAddImageFromSource(dst, src, 0, props as CFDictionary) + guard CGImageDestinationFinalize(dst) else { + throw NSError(domain: "Test", code: 4) + } + return out as Data +} + +/// Synthesize a minimal HEIC using CGImageDestination. Works on iOS 17+ simulator. +private func makeSyntheticHEIC() throws -> Data { + let image = makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .blue) + guard let cgImage = image.cgImage else { + throw NSError(domain: "Test", code: 10) + } + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData(out, UTType.heic.identifier as CFString, 1, nil) + else { + throw NSError(domain: "Test", code: 11) + } + CGImageDestinationAddImage(dst, cgImage, nil) + guard CGImageDestinationFinalize(dst) else { + throw NSError(domain: "Test", code: 12) + } + return out as Data +} + +/// Creates a 1-second blank H.264 video using AVAssetWriter. +private func createBlankVideo(durationSeconds: Double) async throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("blank_\(UUID().uuidString).mp4") + + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: 320, + AVVideoHeightKey: 240 + ] + let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + input.expectsMediaDataInRealTime = false + writer.add(input) + + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 320, + kCVPixelBufferHeightKey as String: 240 + ] + ) + + writer.startWriting() + writer.startSession(atSourceTime: .zero) + + // Write a single black frame at t=0. + var pixelBuffer: CVPixelBuffer? + CVPixelBufferCreate( + kCFAllocatorDefault, + 320, + 240, + kCVPixelFormatType_32BGRA, + [ + kCVPixelBufferCGImageCompatibilityKey: true, + kCVPixelBufferCGBitmapContextCompatibilityKey: true + ] as CFDictionary, + &pixelBuffer + ) + if let pb = pixelBuffer { + CVPixelBufferLockBaseAddress(pb, []) + let ptr = CVPixelBufferGetBaseAddress(pb) + memset(ptr, 0, CVPixelBufferGetDataSize(pb)) + CVPixelBufferUnlockBaseAddress(pb, []) + adaptor.append(pb, withPresentationTime: .zero) + } + + input.markAsFinished() + + return try await withCheckedThrowingContinuation { cont in + writer.endSession(atSourceTime: CMTime(seconds: durationSeconds, preferredTimescale: 600)) + writer.finishWriting { + if writer.status == .completed { + cont.resume(returning: url) + } else { + cont.resume(throwing: writer.error ?? NSError(domain: "Test", code: 20)) + } + } + } +}