From bb4d94a081d21f36b5d57fec683bae2444defb98 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:50:54 -0600 Subject: [PATCH 1/2] Add MediaTransformer, a standalone image/video transform module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MediaTransformer applies the upload policy's transforms — HEIC→JPEG conversion, resize, EXIF-orientation flatten, GPS/location strip, video duration cap and passthrough remux — streaming each result to disk through pure ImageIO/AVFoundation. It depends on no app code, so it lands as a leaf module: `MediaUploadPolicy` moves in from `WordPressMediaLibrary` (nothing there referenced it yet) and `MediaTransformerError` carries its own localized strings, scoped to the failures the engine actually throws. Nothing consumes it yet — the upload materializer that will comes separately — so `WordPressMediaLibrary` gains no dependency on it. `MediaTransformerTests` exercises the engine directly (47 tests) with UIKit-free CoreGraphics fixtures, registered in `WordPressUnitTests.xctestplan` so iOS CI runs it. --- Modules/Package.swift | 12 +- .../MediaTransformer/MediaTransformer.swift | 705 +++++++++++++++++ .../MediaTransformerError.swift | 72 ++ .../MediaTransformer/MediaUploadPolicy.swift | 102 +++ .../Models/MediaUploadPolicy.swift | 69 -- .../Strings/Strings.swift | 51 -- .../ImageTestFixtures.swift | 418 ++++++++++ .../MediaTransformerTests.swift | 720 ++++++++++++++++++ .../Resources/test-image-animated.webp | Bin 0 -> 188 bytes .../Resources/test-image-bomb.webp | Bin 0 -> 4682 bytes .../Resources/test-image-cmyk-ps.jpg | Bin 0 -> 5704 bytes .../Resources/test-image-cmyk.jpg | Bin 0 -> 346 bytes .../Resources/test-image.avif | Bin 0 -> 477 bytes .../Resources/test-image.webp | Bin 0 -> 68 bytes .../VideoTestFixtures.swift | 88 +++ .../WordPressUnitTests.xctestplan | 7 + 16 files changed, 2123 insertions(+), 121 deletions(-) create mode 100644 Modules/Sources/MediaTransformer/MediaTransformer.swift create mode 100644 Modules/Sources/MediaTransformer/MediaTransformerError.swift create mode 100644 Modules/Sources/MediaTransformer/MediaUploadPolicy.swift delete mode 100644 Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift create mode 100644 Modules/Tests/MediaTransformerTests/ImageTestFixtures.swift create mode 100644 Modules/Tests/MediaTransformerTests/MediaTransformerTests.swift create mode 100644 Modules/Tests/MediaTransformerTests/Resources/test-image-animated.webp create mode 100644 Modules/Tests/MediaTransformerTests/Resources/test-image-bomb.webp create mode 100644 Modules/Tests/MediaTransformerTests/Resources/test-image-cmyk-ps.jpg create mode 100644 Modules/Tests/MediaTransformerTests/Resources/test-image-cmyk.jpg create mode 100644 Modules/Tests/MediaTransformerTests/Resources/test-image.avif create mode 100644 Modules/Tests/MediaTransformerTests/Resources/test-image.webp create mode 100644 Modules/Tests/MediaTransformerTests/VideoTestFixtures.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 7c5caa5d05fe..18274bdda21c 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -28,7 +28,8 @@ let package = Package( .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), .library(name: "WordPressKit", targets: ["WordPressKit"]), .library(name: "WordPressData", targets: ["WordPressData"]), - .library(name: "WordPressMediaLibrary", targets: ["WordPressMediaLibrary"]) + .library(name: "WordPressMediaLibrary", targets: ["WordPressMediaLibrary"]), + .library(name: "MediaTransformer", targets: ["MediaTransformer"]) ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), @@ -162,6 +163,15 @@ let package = Package( .product(name: "WordPressAPI", package: "wordpress-rs") ] ), + // The image/video upload transform engine. A leaf module (only system + // frameworks), so the root `Package.swift` cross-platform harness can + // build and `swift test` it standalone — no Xcode, no wordpress-rs. + .target(name: "MediaTransformer"), + .testTarget( + name: "MediaTransformerTests", + dependencies: ["MediaTransformer"], + resources: [.process("Resources")] + ), .target( name: "ShareExtensionCore", dependencies: [ diff --git a/Modules/Sources/MediaTransformer/MediaTransformer.swift b/Modules/Sources/MediaTransformer/MediaTransformer.swift new file mode 100644 index 000000000000..8bfbdd856e42 --- /dev/null +++ b/Modules/Sources/MediaTransformer/MediaTransformer.swift @@ -0,0 +1,705 @@ +import AVFoundation +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Applies the upload policy's transforms to a picked or downloaded image or +/// video, streaming the result to a destination file: HEIC→JPEG conversion, +/// resize, EXIF-orientation flatten, and GPS strip for images (`plan`/`write`); +/// duration cap, passthrough remux, and location strip for video +/// (`planVideo`/`writeVideo`). +/// +/// This is pure ImageIO and owns no upload state: it does not name files, +/// allocate staging directories, or build `MaterializedUpload`. A caller `plan`s +/// a transform from the image header, uses the plan's `contentType` / +/// `fileExtension` to name and allow-check the output, then hands the plan back +/// to `write(_:to:)`. The split keeps filename allocation — which is +/// session-scoped, for server-side dedup — with the materializer. +/// +/// Memory is bounded to what each operation actually demands: ImageIO faults the +/// compressed source in on demand (a URL input is never held whole), the encoder +/// streams straight to disk, and the pixel work never exceeds one decode and one +/// encode. When no transform is needed the bytes pass through unchanged — a URL +/// input is clone-copied (an APFS copy-on-write clone, zero bytes through RAM), +/// so web-safe in-cap images survive byte-for-byte. Any ImageIO failure throws — +/// the transform never falls back to the original bytes, so a required GPS strip +/// can never silently ship the location. +public struct MediaTransformer: Sendable { + private let policy: MediaUploadPolicy + + public init(policy: MediaUploadPolicy) { + self.policy = policy + } + + /// The origin of the bytes to transform. Disk-backed sources (`.file`, + /// `.imagePlayground`, and the downloaded `.remoteURL` temp) pass `.url` so + /// ImageIO can fault the compressed bytes in on demand and the no-transform + /// case can clone the file instead of round-tripping it through RAM. The + /// in-memory sources — the photo library's `NSItemProvider` (→ `Data`) and + /// the camera's `UIImage` (→ JPEG `Data`) — have no file to point at, so they + /// pass `.data`. + public enum Input { + case url(URL) + case data(Data) + + /// `typeHint` (the caller's declared type) is passed as + /// `kCGImageSourceTypeIdentifierHint` so ImageIO picks the right decoder + /// when the bytes alone are ambiguous — notably a RAW (`.data`) source, + /// whose magic bytes otherwise sniff as TIFF and yield the small embedded + /// preview instead of the full-resolution image. Harmless when the hint + /// is wrong: ImageIO validates the magic bytes and uses the real type. + func makeImageSource(typeHint: UTType?) -> CGImageSource? { + let options = typeHint.map { + [kCGImageSourceTypeIdentifierHint: $0.identifier] as CFDictionary + } + switch self { + case .url(let url): return CGImageSourceCreateWithURL(url as CFURL, options) + case .data(let data): return CGImageSourceCreateWithData(data as CFData, options) + } + } + } + + /// A decided image transform plus the content type and file extension it will + /// produce. The caller reads `contentType` / `fileExtension` to name and + /// allow-check the destination, then passes the plan to `write(_:to:)`; the + /// remaining fields carry the already-opened image source across so the header + /// isn't read twice. + public struct Plan { + public let contentType: UTType + public let fileExtension: String + + fileprivate let input: Input + fileprivate let source: CGImageSource + fileprivate let sourceProperties: [CFString: Any] + fileprivate let transforms: ImageTransforms + fileprivate let actualType: UTType + } + + /// The set of transforms the single-pass image write must apply, computed + /// once from the image header and the policy. + fileprivate struct ImageTransforms: OptionSet { + let rawValue: Int + + static let convert = ImageTransforms(rawValue: 1 << 0) + static let resize = ImageTransforms(rawValue: 1 << 1) + static let stripLocation = ImageTransforms(rawValue: 1 << 2) + /// Rotate the pixels to match the EXIF orientation tag, then reset the + /// tag — a re-encode, so it forces a web-safe output like the others. + static let normalizeOrientation = ImageTransforms(rawValue: 1 << 3) + /// Re-encode a CMYK source to RGB through the colour-managed thumbnail + /// path — browsers render 4-component JPEGs inconsistently. A re-encode, + /// so it forces a web-safe output like the others. + static let normalizeColorSpace = ImageTransforms(rawValue: 1 << 4) + } + + /// Header-validates, sniffs the real content type, and decides which + /// transforms the policy requires (JPEG conversion, resize, GPS strip). Reads + /// only the image header — the pixels are touched by `write`, not here. + /// + /// Validation is header-level: a non-image body (e.g. an HTML error page + /// served as image/jpeg) is rejected, but a truncated image with an intact + /// header still passes, matching V1. + public func plan(_ input: Input, declaredType: UTType) throws -> Plan { + guard + let source = input.makeImageSource(typeHint: declaredType), + CGImageSourceGetCount(source) >= 1 + else { + throw MediaTransformerError.invalidImageData + } + // Read the container's *primary* image (HEIF honors the `pitm` box, which + // needn't be item 0). Every downstream index uses the same frame so the + // transform, cap decision, and location check all agree on it. + let primaryIndex = CGImageSourceGetPrimaryImageIndex(source) + guard + let props = CGImageSourceCopyPropertiesAtIndex(source, primaryIndex, nil) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int + else { + throw MediaTransformerError.invalidImageData + } + + // The sniffed container type is the truth; the declared type (picker + // hint, Content-Type header, file extension) can lie — a HEIC served + // as image/jpeg must still be converted. + let actualType = CGImageSourceGetType(source).flatMap { UTType($0 as String) } ?? declaredType + + var transforms: ImageTransforms = [] + var effectiveType = actualType + // Convert a non-web-safe source to JPEG when the policy asks, or when the + // source format can't be written back at all (AVIF is decode-only before + // iOS 26) — keeping an unwritable type would fail every encode. + if !Self.webSafeImageTypes.contains(actualType), + policy.convertHEICToJPEG || !Self.isEncodable(actualType) + { + effectiveType = .jpeg + transforms.insert(.convert) + } + // A CMYK JPEG is a valid, "web-safe" JPEG by type, but browsers render + // 4-component JPEGs inconsistently — Firefox and Chrome historically showed + // a broken image, and most still do a naive CMYK→RGB conversion that ignores + // the ICC profile (Adobe "inverted" CMYK then renders with inverted colours). + // Route it through the colour-managed thumbnail re-encode, the only path that + // actually converts to RGB — `AddImageFromSource` copies the CMYK data verbatim. + if Self.isCMYK(props) { + transforms.insert(.normalizeColorSpace) + } + if let cap = policy.imageMaxDimension, cap > 0, max(width, height) > cap { + transforms.insert(.resize) + } + // "Remove Location" covers GPS coordinates AND textual place names + // (IPTC/XMP City/State/Country) that reverse-geocoding apps embed. + if policy.stripLocation, Self.hasLocation(props) { + transforms.insert(.stripLocation) + } + // Flatten a non-identity EXIF orientation into the pixels when the + // policy asks, so a viewer that ignores the orientation tag (older + // WordPress, some preview clients) still renders the image upright. + // Gated on a real rotation (orientation 2–8) — an already-upright image + // (tag absent or `1`) would gain nothing but a needless recompress. + // Harmless alongside `.resize`, which bakes orientation regardless. + if policy.normalizeImageOrientation, + let orientation = (props[kCGImagePropertyOrientation] as? NSNumber)?.intValue, + (2...8).contains(orientation) + { + transforms.insert(.normalizeOrientation) + } + // A resize, format conversion, or orientation flatten re-encodes the + // pixels, and that re-encode target must be web-renderable and + // ImageIO-writable (e.g. an oversized DNG with HEIC conversion disabled + // still can't be written back as DNG). A pure GPS strip is excluded: + // `stripGPSLosslessly` can rewrite only the metadata and keep the source + // format, so forcing JPEG here would needlessly transcode — and degrade + // — a located HEIC the policy asked to keep as HEIC. + if !transforms.intersection([.resize, .convert, .normalizeOrientation, .normalizeColorSpace]).isEmpty, + !Self.webSafeImageTypes.contains(effectiveType) + { + effectiveType = .jpeg + } + + // Decompression-bomb backstop (see `maxSourcePixels`): only the unbounded + // decode paths — WebP (no scaled decode), or a full-resolution re-encode + // (convert, colour-space normalise, or orientation flatten) not first + // bounded by a resize — can be OOM'd by a crafted large source; the + // scale-decoding resize path stays memory-bounded at any size. + let decodesUnbounded = + actualType == .webP + || (!transforms.intersection([.convert, .normalizeColorSpace, .normalizeOrientation]).isEmpty + && !transforms.contains(.resize)) + if decodesUnbounded, width * height > Self.maxSourcePixels { + throw MediaTransformerError.invalidImageData + } + + let ext = + effectiveType.preferredFilenameExtension + ?? declaredType.preferredFilenameExtension ?? "bin" + + return Plan( + contentType: effectiveType, + fileExtension: ext, + input: input, + source: source, + sourceProperties: props, + transforms: transforms, + actualType: actualType + ) + } + + /// Executes `plan`, streaming the transformed image to `destURL`. + /// + /// When no transform is needed the bytes pass through unchanged — a URL input + /// is clone-copied (APFS copy-on-write), in-memory bytes are written straight + /// out. Otherwise the pixels are decoded at most once and encoded at most + /// once. Any ImageIO failure throws — never a silent fallback to the original + /// bytes, so a required GPS strip can't ship the location. + public func write(_ plan: Plan, to destURL: URL) throws { + if plan.transforms.isEmpty { + // Upload-ready as-is: no decode, no encode. A file input is cloned + // (copy-on-write on APFS); in-memory bytes are written straight out. + switch plan.input { + case .url(let url): + try FileManager.default.copyItem(at: url, to: destURL) + case .data(let data): + try data.write(to: destURL) + } + } else { + try transformImage( + source: plan.source, + sourceProperties: plan.sourceProperties, + actualType: plan.actualType, + to: plan.contentType, + applying: plan.transforms, + writingTo: destURL + ) + } + } + + /// Single ImageIO write applying `transforms`, streamed to `destURL` rather + /// than accumulated in memory. At most one decode and one encode. Strategies, + /// chosen to preserve the EXIF orientation tag whenever pixels are not + /// resampled (a dropped tag once shipped rotated HEIC uploads): + /// - GPS strip only, no format change: `stripGPSLosslessly` copies the + /// encoded image and rewrites only the metadata — no decode, no recompress, + /// and the orientation tag stays paired with its untouched pixels. Falls + /// through to the decode path below when the container won't drop GPS this + /// way (checked per-write). + /// - resize or orientation flatten: thumbnail with the rotation baked into + /// the pixels, the tag stamped upright, and the remaining metadata carried + /// over — the same treatment as V1 `MediaImageExporter.ImageSourceWriter`. + /// Resize caps the longest edge; a flatten-only pass omits the cap and + /// reproduces the image at full resolution, changing nothing but the + /// orientation. + /// - any other re-encode (format conversion, a format-changing GPS strip, or + /// the lossless path's PNG fallback): `CGImageDestinationAddImageFromSource`, + /// which tiles the transcode and carries pixels, orientation, and metadata + /// across the container change, dropping GPS via a `kCFNull` override when + /// the strip is needed. + private func transformImage( + source: CGImageSource, + sourceProperties: [CFString: Any], + actualType: UTType, + to effectiveType: UTType, + applying transforms: ImageTransforms, + writingTo destURL: URL + ) throws { + // Lossless GPS strip: no resize and no format change, so the encoded + // image can be copied verbatim while only its metadata is rewritten. + if transforms == [.stripLocation], + effectiveType == actualType, + stripGPSLosslessly(source: source, type: actualType, writingTo: destURL) + { + return + } + + guard + let dst = CGImageDestinationCreateWithURL( + destURL as CFURL, + effectiveType.identifier as CFString, + 1, + nil + ) + else { + throw MediaTransformerError.imageEncodeFailed + } + + // The container's primary image — the same frame `plan` measured. + let primaryIndex = CGImageSourceGetPrimaryImageIndex(source) + + if transforms.contains(.resize) || transforms.contains(.normalizeOrientation) + || transforms.contains(.normalizeColorSpace) + { + var thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + // A resize caps the longest edge; a flatten-only pass omits the cap + // so ImageIO reproduces the full-resolution pixels with just the + // rotation baked in. `.resize` is only ever set with a positive cap. + if transforms.contains(.resize), let cap = policy.imageMaxDimension { + thumbnailOptions[kCGImageSourceThumbnailMaxPixelSize] = cap + } + guard + let thumb = CGImageSourceCreateThumbnailAtIndex( + source, + primaryIndex, + thumbnailOptions as CFDictionary + ) + else { + throw MediaTransformerError.imageEncodeFailed + } + var props = sourceProperties + if transforms.contains(.stripLocation) { + props.removeValue(forKey: kCGImagePropertyGPSDictionary) + props = Self.removingIPTCLocation(from: props) + } + // The thumbnail baked the EXIF rotation into the pixels, so stamp + // the orientation upright everywhere it lives (V1 parity) and drop + // the stale pixel-dimension records; the destination writes the + // real dimensions itself. + props[kCGImagePropertyOrientation] = CGImagePropertyOrientation.up.rawValue + if var tiff = props[kCGImagePropertyTIFFDictionary] as? [CFString: Any] { + tiff.removeValue(forKey: kCGImagePropertyTIFFOrientation) + props[kCGImagePropertyTIFFDictionary] = tiff + } + if var iptc = props[kCGImagePropertyIPTCDictionary] as? [CFString: Any] { + iptc.removeValue(forKey: kCGImagePropertyIPTCImageOrientation) + props[kCGImagePropertyIPTCDictionary] = iptc + } + if var exif = props[kCGImagePropertyExifDictionary] as? [CFString: Any] { + exif.removeValue(forKey: kCGImagePropertyExifPixelXDimension) + exif.removeValue(forKey: kCGImagePropertyExifPixelYDimension) + props[kCGImagePropertyExifDictionary] = exif + } + props.removeValue(forKey: kCGImagePropertyPixelWidth) + props.removeValue(forKey: kCGImagePropertyPixelHeight) + props[kCGImageDestinationLossyCompressionQuality] = policy.imageJpegQuality + CGImageDestinationAddImage(dst, thumb, props as CFDictionary) + } else { + // Any re-encode that isn't a resize: a format conversion + // (e.g. HEIC→JPEG), a GPS strip that changes format, or the lossless + // path's fallback (e.g. a PNG, whose GPS lives in a binary `eXIf` + // chunk `CopyImageSource` won't rewrite). `AddImageFromSource` tiles + // the transcode — the resident set is the working tiles, not the + // whole decoded bitmap — and carries the orientation tag (and other + // metadata) across the container change. Decoding to a bare CGImage + // and re-adding it would drop the tag and render a non-upright source + // (e.g. a 180°-oriented HEIC) upside down. + // + // `kCFNull` drops GPS from the metadata `AddImageFromSource` rewrites + // from the source; every non-GPS record (capture date, camera make, + // orientation) rides along. This reaches PNG only as the fallback, + // where it is pixel-lossless anyway (PNG is a lossless codec). + var options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: policy.imageJpegQuality + ] + if transforms.contains(.stripLocation) { + options[kCGImagePropertyGPSDictionary] = kCFNull + options[kCGImagePropertyIPTCDictionary] = Self.iptcLocationNulls + } + CGImageDestinationAddImageFromSource(dst, source, primaryIndex, options as CFDictionary) + } + + guard CGImageDestinationFinalize(dst) else { + throw transforms.contains(.stripLocation) + ? MediaTransformerError.locationStripFailed + : MediaTransformerError.imageEncodeFailed + } + + // `Finalize` can return `true` for a structurally-empty output: a + // truncated source (interrupted download, corrupt HEIC) passes the + // header read in `plan`, then `AddImageFromSource` writes only the + // metadata markers and no image data, yet `Finalize` still succeeds. + // Re-read the output and require a decodable frame so a broken image is + // never enqueued as a successful upload — fail-closed, upholding this + // type's "any ImageIO failure throws" contract. + guard Self.fileHasDecodableImage(destURL) else { + try? FileManager.default.removeItem(at: destURL) + throw MediaTransformerError.imageEncodeFailed + } + } + + /// Strips GPS metadata from `source` and writes the result to `destURL` + /// without decoding or recompressing: `CGImageDestinationCopyImageSource` + /// copies the encoded image verbatim and rewrites only the metadata + /// container, so the pixels and their JPEG quality are untouched and the + /// EXIF orientation tag stays paired with them. Every non-GPS record + /// (capture date, camera make, ...) is preserved — the "Remove Location" + /// policy strips location only, unlike a blanket metadata exclude. + /// + /// Returns `true` only after re-reading the output and confirming the GPS + /// block is actually gone. This check is load-bearing, not paranoia: PNG + /// keeps its location in a binary `eXIf` chunk that `CopyImageSource` copies + /// verbatim no matter which metadata options are set (`kCGImageDestinationMetadata`, + /// `ShouldExcludeGPS`, and `ShouldExcludeXMP` were all verified to leave it + /// intact — the options only rewrite the XMP representation, never `eXIf`), + /// so a PNG lands here with its GPS still readable. When the check fails the + /// caller falls back to the `AddImageFromSource` strip — which rewrites the + /// metadata from scratch (dropping GPS) and, for PNG (a lossless codec), + /// re-encodes without any compression loss. Fail-closed: any failure removes + /// the partial output and returns `false`. + private func stripGPSLosslessly( + source: CGImageSource, + type: UTType, + writingTo destURL: URL + ) -> Bool { + guard + let metadata = CGImageSourceCopyMetadataAtIndex(source, CGImageSourceGetPrimaryImageIndex(source), nil), + let mutableMetadata = CGImageMetadataCreateMutableCopy(metadata) + else { + return false + } + + // Drop every GPS tag from the metadata we carry over. The standard EXIF + // GPS tags are all named `GPS*`, so a case-insensitive "gps" match on the + // tag path catches them without disturbing anything else. + var gpsPaths: [String] = [] + CGImageMetadataEnumerateTagsUsingBlock( + mutableMetadata, + nil, + [kCGImageMetadataEnumerateRecursively: true] as CFDictionary + ) { path, _ in + if (path as String).range(of: "gps", options: .caseInsensitive) != nil { + gpsPaths.append(path as String) + } + return true + } + for path in gpsPaths { + CGImageMetadataRemoveTagWithPath(mutableMetadata, nil, path as CFString) + } + + let options: [CFString: Any] = [ + kCGImageDestinationMetadata: mutableMetadata, + // Backstop in case a GPS tag hid behind a non-obvious path. + kCGImageMetadataShouldExcludeGPS: true + ] + guard + let dst = CGImageDestinationCreateWithURL( + destURL as CFURL, + type.identifier as CFString, + 1, + nil + ), + CGImageDestinationCopyImageSource(dst, source, options as CFDictionary, nil), + !Self.fileHasLocation(destURL) + else { + try? FileManager.default.removeItem(at: destURL) + return false + } + return true + } + + /// Whether the image at `url` still carries an EXIF GPS dictionary — the + /// post-write check that keeps `stripGPSLosslessly` honest. Internal (not + /// private) so the tests assert with this exact predicate instead of a copy. + public static func fileHasGPS(_ url: URL) -> Bool { + guard + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + else { + return false + } + return props[kCGImagePropertyGPSDictionary] != nil + } + + /// Whether the image written to `url` has a decodable primary frame with + /// real pixel dimensions. Guards the re-encode paths against a + /// `CGImageDestinationFinalize` that returns `true` for a metadata-only stub + /// produced from a truncated source — a header re-read (no full decode) + /// mirroring the validation `plan` runs on the input. Internal so the tests + /// assert with this exact predicate. + static func fileHasDecodableImage(_ url: URL) -> Bool { + guard + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetCount(source) >= 1, + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + else { + return false + } + return props[kCGImagePropertyPixelWidth] != nil && props[kCGImagePropertyPixelHeight] != nil + } + + // MARK: - Location & format helpers + + /// IPTC place-name fields the "Remove Location" policy strips alongside the + /// EXIF GPS coordinates. Reverse-geocoding apps (Lightroom, third-party + /// cameras) embed these; the iOS Camera never does. Caption, byline, + /// copyright, and keywords are deliberately left intact. + private nonisolated(unsafe) static let iptcLocationKeys: [CFString] = [ + kCGImagePropertyIPTCCity, + kCGImagePropertyIPTCProvinceState, + kCGImagePropertyIPTCSubLocation, + kCGImagePropertyIPTCCountryPrimaryLocationName, + kCGImagePropertyIPTCCountryPrimaryLocationCode, + kCGImagePropertyIPTCContentLocationName, + kCGImagePropertyIPTCContentLocationCode + ] + + /// Whether `props` carries any location — GPS coordinates or IPTC place + /// names — so the strip also fires for a photo tagged with only a textual + /// location and no coordinates. + private static func hasLocation(_ props: [CFString: Any]) -> Bool { + if props[kCGImagePropertyGPSDictionary] != nil { return true } + if let iptc = props[kCGImagePropertyIPTCDictionary] as? [CFString: Any] { + return iptcLocationKeys.contains { iptc[$0] != nil } + } + return false + } + + /// Whether the source's primary image is CMYK — a 4-component colour model + /// browsers render inconsistently, so it's re-encoded to RGB. + private static func isCMYK(_ props: [CFString: Any]) -> Bool { + (props[kCGImagePropertyColorModel] as? String) == (kCGImagePropertyColorModelCMYK as String) + } + + /// An IPTC dictionary override that nulls only the location subkeys. Merged + /// into the `AddImageFromSource` options, it clears the place names (and the + /// XMP tags ImageIO keeps in sync) while leaving caption/byline/copyright. + private static var iptcLocationNulls: [CFString: Any] { + Dictionary(uniqueKeysWithValues: iptcLocationKeys.map { ($0, kCFNull as Any) }) + } + + /// Removes the IPTC location subkeys from a full properties dictionary — the + /// resize path, which re-writes the whole dictionary rather than merging. + private static func removingIPTCLocation(from props: [CFString: Any]) -> [CFString: Any] { + guard var iptc = props[kCGImagePropertyIPTCDictionary] as? [CFString: Any] else { return props } + for key in iptcLocationKeys { iptc.removeValue(forKey: key) } + var result = props + result[kCGImagePropertyIPTCDictionary] = iptc + return result + } + + /// Post-write location check for the lossless strip's fail-closed re-read. + /// `CopyImageSource` copies the IIM IPTC block verbatim (like PNG's `eXIf` + /// GPS), so a place-name-tagged file survives the metadata rewrite; when it + /// does, the caller re-encodes through the location-nulling + /// `AddImageFromSource` path instead of shipping the location. + static func fileHasLocation(_ url: URL) -> Bool { + guard + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + else { + return false + } + return hasLocation(props) + } + + /// The container types this OS can *write*. AVIF, for instance, is + /// decode-only before iOS 26, so keeping it as the effective type would fail + /// every encode — `plan` converts such a source to JPEG regardless of policy. + private static let encodableTypes: Set = + Set((CGImageDestinationCopyTypeIdentifiers() as? [String]) ?? []) + + private static func isEncodable(_ type: UTType) -> Bool { + encodableTypes.contains(type.identifier) + } + + /// Decompression-bomb backstop: the largest source area an *unbounded* decode + /// path may hold. Scale-decoding formats (JPEG/HEIC/TIFF/PNG) resize within a + /// bounded working set via the thumbnail path, so this only gates WebP (no + /// scaled decode) and full-resolution `AddImageFromSource` transcodes. Set + /// above legitimate phone photos (~48MP) and below the multi-hundred- + /// megapixel range that OOMs the memory-constrained share extension. + private static let maxSourcePixels = 100_000_000 + + // MARK: - Video + + /// A validated, decided video export: the output content type/extension and + /// the chosen `AVAssetExportSession` preset (passthrough remux or re-encode). + /// The source URL and output file type ride along for `writeVideo`. Carries + /// only `Sendable` values, so it can cross the `await` between `planVideo` + /// and `writeVideo`. + public struct VideoPlan { + public let contentType: UTType + public let fileExtension: String + + fileprivate let sourceURL: URL + fileprivate let outputType: AVFileType + fileprivate let preset: String + } + + /// Validates the duration cap and picks a passthrough or re-encode preset for + /// the video at `sourceURL`. Reads the asset's duration and dimensions (hence + /// `async`); throws `durationCapExceeded` for an over-long source. + public func planVideo(for sourceURL: URL) async throws -> VideoPlan { + let asset = AVURLAsset(url: sourceURL) + let duration = try await asset.load(.duration).seconds + // A non-finite duration (NaN for an indefinite/unreadable asset) must not + // slip past a cap that exists — `NaN > cap` is false, so an unmeasurable + // source would otherwise bypass the limit. Reject it fail-closed. + if let cap = policy.videoMaxDurationSeconds, !duration.isFinite || duration > cap { + throw MediaTransformerError.durationCapExceeded + } + let outputType = AVFileType(rawValue: policy.videoOutputContentType.identifier) + let ext = policy.videoOutputContentType.preferredFilenameExtension ?? "mp4" + let preset = try await resolveVideoExportPreset(for: asset, outputType: outputType) + return VideoPlan( + contentType: policy.videoOutputContentType, + fileExtension: ext, + sourceURL: sourceURL, + outputType: outputType, + preset: preset + ) + } + + /// Exports `plan` to `destURL`, driving `progress` from a sibling poll of the + /// session's `.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. + public func writeVideo(_ plan: VideoPlan, to destURL: URL, progress: Progress) async throws { + let asset = AVURLAsset(url: plan.sourceURL) + guard let exportSession = AVAssetExportSession(asset: asset, presetName: plan.preset) else { + throw MediaTransformerError.videoExportSessionUnavailable + } + exportSession.shouldOptimizeForNetworkUse = true + if policy.stripLocation { + // `stripLocation` is the single "Remove Location" setting and + // governs video too. `forSharing()` drops the QuickTime location atom + // (and other identifying metadata), matching V1 MediaVideoExporter. + // Verified to strip movie-level location on a passthrough remux — the + // form iPhone captures use. `forSharing()` is an `AVMetadataItem` + // filter, so location carried in a per-sample timed metadata track + // may survive a passthrough copy; that case isn't exercised by our + // tests — verify on a device-captured clip before relying on it. + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + } + + // `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 { [progress] in + while !Task.isCancelled { + progress.completedUnitCount = Int64( + (Double(progress.totalUnitCount) * Double(session.progress)).rounded() + ) + try? await Task.sleep(for: .milliseconds(100)) + } + } + defer { pollTask.cancel() } + + do { + try await exportSession.export(to: destURL, as: plan.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 MediaTransformerError.videoExportFailed(underlyingError: error) + } + + // Snap progress to full — the final poll may have been just shy of 1.0 + // when export returned. + progress.completedUnitCount = progress.totalUnitCount + } + + /// Chooses the export preset for `asset`. A source already within the + /// resolution cap (or an uncapped one) is remuxed with + /// `AVAssetExportPresetPassthrough` — the elementary streams are copied, not + /// transcoded — as long as it can be written into `outputType`. The metadata + /// filter still drops the movie-level QuickTime location atom on a passthrough + /// export, so an in-limit located video is no longer re-encoded end to + /// end just to be re-containered or to strip its location. Only a source that + /// exceeds the cap, or can't be remuxed into `outputType` (an exotic codec), + /// falls back to the policy's re-encode preset. + func resolveVideoExportPreset(for asset: AVAsset, outputType: AVFileType) async throws -> String { + if try await videoExceedsResolutionCap(asset) { + return policy.videoExportPreset + } + guard + let probe = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough), + await compatibleFileTypes(of: probe).contains(outputType) + else { + return policy.videoExportPreset + } + return AVAssetExportPresetPassthrough + } + + /// Whether `asset`'s longest (orientation-corrected) edge exceeds + /// `policy.videoMaxDimension`. A missing cap means no limit. A source with no + /// readable video track is treated as over-cap, so it re-encodes rather than + /// risk passing an unmeasured file through the cap. + private func videoExceedsResolutionCap(_ asset: AVAsset) async throws -> Bool { + guard let cap = policy.videoMaxDimension, cap > 0 else { return false } + guard let track = try await asset.loadTracks(withMediaType: .video).first else { return true } + let (naturalSize, preferredTransform) = try await track.load(.naturalSize, .preferredTransform) + let size = naturalSize.applying(preferredTransform) + return max(abs(size.width), abs(size.height)) > CGFloat(cap) + } + + /// The output container types `session` can write. Bridges the + /// completion-handler API (the async form isn't back-deployed on the floor). + private func compatibleFileTypes(of session: AVAssetExportSession) async -> [AVFileType] { + await withCheckedContinuation { continuation in + session.determineCompatibleFileTypes { continuation.resume(returning: $0) } + } + } + + /// Raster types that upload without format conversion. Everything else + /// (HEIC, HEIF, TIFF, WebP, BMP, DNG, ...) is converted to JPEG, matching + /// V1 `ItemProviderMediaExporter.supportedImageTypes`. GIF and SVG never + /// reach the transformer — the materializer raw-copies them. + private static let webSafeImageTypes: Set = [.png, .jpeg] +} diff --git a/Modules/Sources/MediaTransformer/MediaTransformerError.swift b/Modules/Sources/MediaTransformer/MediaTransformerError.swift new file mode 100644 index 000000000000..3705bf6adbd6 --- /dev/null +++ b/Modules/Sources/MediaTransformer/MediaTransformerError.swift @@ -0,0 +1,72 @@ +import Foundation + +/// The failures `MediaTransformer` can throw while planning or writing a +/// transform. Scoped to what the engine itself produces — image validation and +/// encode, GPS/location strip, and video export. File access, downloads, and the +/// upload allow-list belong to the caller that drives the transformer, and carry +/// their own errors. +public enum MediaTransformerError: LocalizedError { + case durationCapExceeded + case invalidImageData + case imageEncodeFailed + case locationStripFailed + case videoExportFailed(underlyingError: Error) + case videoExportSessionUnavailable + + public var errorDescription: String? { + switch self { + case .durationCapExceeded: return Strings.durationCap + case .invalidImageData: return Strings.invalidImage + case .imageEncodeFailed: return Strings.imageEncode + case .locationStripFailed: return Strings.locationStripFailed + case .videoExportFailed(let underlyingError): + return String.localizedStringWithFormat( + Strings.videoExport, + underlyingError.localizedDescription + ) + case .videoExportSessionUnavailable: return Strings.videoExportNoExporter + } + } +} + +// MARK: - Localized strings + +/// The messages `MediaTransformerError` renders. They live with the error (not +/// in `WordPressMediaLibrary`'s `Strings`) so this module stays self-contained. +/// The `NSLocalizedString` keys are unchanged from their previous home, so +/// GlotPress extraction is unaffected. +private enum Strings { + static let durationCap = NSLocalizedString( + "mediaLibrary.upload.error.durationCap", + value: "This video is longer than your site allows.", + comment: "Error shown when a picked video exceeds the duration cap configured for the blog." + ) + static let invalidImage = NSLocalizedString( + "mediaLibrary.upload.error.invalidImage", + value: "The selected file isn't a valid image.", + comment: "Error shown when picked or downloaded bytes do not decode as an image." + ) + static let imageEncode = NSLocalizedString( + "mediaLibrary.upload.error.imageEncode", + value: "Couldn't convert the photo for upload.", + comment: "Error shown when re-encoding an image (e.g. HEIC to JPEG) fails before upload." + ) + static let locationStripFailed = NSLocalizedString( + "mediaLibrary.upload.error.locationStrip", + value: "Couldn't remove the location from the photo for upload.", + comment: + "Error shown when stripping GPS/location metadata from an image fails and the Remove Location setting is on." + ) + static let videoExport = NSLocalizedString( + "mediaLibrary.upload.error.videoExport", + value: "Couldn't prepare the video for upload: %1$@", + comment: + "Error shown when AVAssetExportSession fails before upload. %1$@ is the underlying error description." + ) + static let videoExportNoExporter = NSLocalizedString( + "mediaLibrary.upload.error.videoExport.noExporter", + value: "No exporter is available for the selected video quality.", + comment: + "Error shown when no AVAssetExportSession can be created for the configured export preset." + ) +} diff --git a/Modules/Sources/MediaTransformer/MediaUploadPolicy.swift b/Modules/Sources/MediaTransformer/MediaUploadPolicy.swift new file mode 100644 index 000000000000..03431878b3c7 --- /dev/null +++ b/Modules/Sources/MediaTransformer/MediaUploadPolicy.swift @@ -0,0 +1,102 @@ +import Foundation +import UniformTypeIdentifiers + +/// Upload policy injected by the app target. The module honors this struct +/// but never derives it — `Blog.allowedFileTypes`, user-media settings, etc. +/// stay on the app side. Picker affordance and upload validation are split +/// because the materializer validates the effective post-transform type and +/// extension, not just the source file the picker exposed. +public struct MediaUploadPolicy: Sendable { + /// UTTypes the document picker (`.fileImporter`) offers. May include + /// broad fallbacks like `.content` when the server allow-list is empty. + /// **Not** the upload validator. Photos and camera pickers do not read + /// this field — they have their own hard-coded image/video filters. + public let filePickerContentTypes: [UTType] + + /// Real upload allow/deny gate. Called by the materializer just before + /// enqueue with the *effective* `(UTType, file-extension)` pair after + /// any transform. App target typically backs this with + /// `Blog.allowedFileTypes` + the default mobile-allowed-extensions list. + public let isAllowedForUpload: @Sendable (_ contentType: UTType, _ fileExtension: String) -> Bool + + /// Resize the longest edge of images to at most this many pixels. `nil` + /// means no cap. Applied before JPEG re-encode. + public let imageMaxDimension: Int? + + /// JPEG quality for re-encoded images (0.0...1.0). Used both when + /// resizing and when converting HEIC → JPEG. + public let imageJpegQuality: Double + + /// If true, HEIC sources are converted to JPEG before upload. + public let convertHEICToJPEG: Bool + + /// If true, an image whose EXIF orientation tag is non-identity is + /// physically rotated upright and the tag reset to normal before upload, so + /// viewers that ignore orientation metadata (older WordPress, some preview + /// clients) still render it the right way up. An already-upright image (no + /// tag, or orientation `1`) is left untouched — no needless recompress. + public let normalizeImageOrientation: Bool + + /// Video duration cap in seconds. Over-duration videos are rejected + /// (V1 parity, no trim). + public let videoMaxDurationSeconds: TimeInterval? + + /// Longest-edge threshold, in pixels, that decides whether a video is + /// re-encoded. A source at or under it (or an uncapped policy, `nil`) is + /// remuxed without re-encoding when its codec allows, so it isn't transcoded + /// just to be re-containered or to drop location metadata. A source that + /// exceeds it is re-encoded with `videoExportPreset`. + /// + /// This is a **threshold, not a render size**: the actual output resolution + /// of a re-encode is the preset's, not this value (`AVAssetExportSession` + /// preset *names* can't express an arbitrary target size, and V1 sized video + /// by preset too). Set it to match the resolution `videoExportPreset` + /// produces — e.g. `1280` alongside `AVAssetExportPreset1280x720`. A mismatch + /// (say `720` with a resolution-preserving preset like + /// `AVAssetExportPresetHighestQuality`) re-encodes over-threshold sources + /// without actually shrinking them. + public let videoMaxDimension: Int? + + /// `AVAssetExportSession` preset name used **when a re-encode is needed** + /// (the source exceeds `videoMaxDimension`, or can't be remuxed into + /// `videoOutputContentType`). Determines the re-encode's output resolution + /// **and** quality — e.g. `AVAssetExportPreset1280x720` caps the longest edge + /// at 720p, `AVAssetExportPresetHighestQuality` preserves the source size. + public let videoExportPreset: String + + /// Output container UTType for re-exported videos. Default + /// `.mpeg4Movie`. Drives the file extension of the materialized temp + /// file and the effective MIME type the validator checks against. + public let videoOutputContentType: UTType + + /// The "Remove Location" setting. If true, GPS EXIF is stripped from + /// images and identifying metadata (via `AVMetadataItemFilter.forSharing()`) + /// is filtered from re-exported videos before upload. + public let stripLocation: Bool + + public init( + filePickerContentTypes: [UTType], + isAllowedForUpload: @escaping @Sendable (UTType, String) -> Bool, + imageMaxDimension: Int?, + imageJpegQuality: Double, + convertHEICToJPEG: Bool, + normalizeImageOrientation: Bool, + videoMaxDurationSeconds: TimeInterval?, + videoMaxDimension: Int?, + videoExportPreset: String, + videoOutputContentType: UTType, + stripLocation: Bool + ) { + self.filePickerContentTypes = filePickerContentTypes + self.isAllowedForUpload = isAllowedForUpload + self.imageMaxDimension = imageMaxDimension + self.imageJpegQuality = imageJpegQuality + self.convertHEICToJPEG = convertHEICToJPEG + self.normalizeImageOrientation = normalizeImageOrientation + self.videoMaxDurationSeconds = videoMaxDurationSeconds + self.videoMaxDimension = videoMaxDimension + self.videoExportPreset = videoExportPreset + self.videoOutputContentType = videoOutputContentType + self.stripLocation = stripLocation + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift deleted file mode 100644 index ee56b2dd24d0..000000000000 --- a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -/// Upload policy injected by the app target. The module honors this struct -/// but never derives it — `Blog.allowedFileTypes`, user-media settings, etc. -/// stay on the app side. Picker affordance and upload validation are split -/// because the materializer validates the effective post-transform type and -/// extension, not just the source file the picker exposed. -public struct MediaUploadPolicy: Sendable { - /// UTTypes the document picker (`.fileImporter`) offers. May include - /// broad fallbacks like `.content` when the server allow-list is empty. - /// **Not** the upload validator. Photos and camera pickers do not read - /// this field — they have their own hard-coded image/video filters. - let filePickerContentTypes: [UTType] - - /// Real upload allow/deny gate. Called by the materializer just before - /// enqueue with the *effective* `(UTType, file-extension)` pair after - /// any transform. App target typically backs this with - /// `Blog.allowedFileTypes` + the default mobile-allowed-extensions list. - let isAllowedForUpload: @Sendable (_ contentType: UTType, _ fileExtension: String) -> Bool - - /// Resize the longest edge of images to at most this many pixels. `nil` - /// means no cap. Applied before JPEG re-encode. - let imageMaxDimension: Int? - - /// JPEG quality for re-encoded images (0.0...1.0). Used both when - /// resizing and when converting HEIC → JPEG. - let imageJpegQuality: Double - - /// If true, HEIC sources are converted to JPEG before upload. - let convertHEICToJPEG: Bool - - /// Video duration cap in seconds. Over-duration videos are rejected - /// (V1 parity, no trim). - let videoMaxDurationSeconds: TimeInterval? - - /// `AVAssetExportSession` preset name. Controls quality only. - let videoExportPreset: String - - /// Output container UTType for re-exported videos. Default - /// `.mpeg4Movie`. Drives the file extension of the materialized temp - /// file and the effective MIME type the validator checks against. - let videoOutputContentType: UTType - - /// If true, strip GPS EXIF before upload. - let stripImageLocation: Bool - - public init( - filePickerContentTypes: [UTType], - isAllowedForUpload: @escaping @Sendable (UTType, String) -> Bool, - imageMaxDimension: Int?, - imageJpegQuality: Double, - convertHEICToJPEG: Bool, - videoMaxDurationSeconds: TimeInterval?, - videoExportPreset: String, - videoOutputContentType: UTType, - stripImageLocation: Bool - ) { - self.filePickerContentTypes = filePickerContentTypes - self.isAllowedForUpload = isAllowedForUpload - self.imageMaxDimension = imageMaxDimension - self.imageJpegQuality = imageJpegQuality - self.convertHEICToJPEG = convertHEICToJPEG - self.videoMaxDurationSeconds = videoMaxDurationSeconds - self.videoExportPreset = videoExportPreset - self.videoOutputContentType = videoOutputContentType - self.stripImageLocation = stripImageLocation - } -} diff --git a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift index 394781fe5bae..e27a422d2993 100644 --- a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift +++ b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift @@ -118,57 +118,6 @@ enum Strings { comment: "Accessibility label for a cell whose underlying media couldn't be loaded" ) - // MARK: - Upload error messages - - static let uploadErrorSecurityScopedAccess = NSLocalizedString( - "mediaLibrary.upload.error.securityScopedAccess", - value: "Couldn't access the selected file.", - comment: "Error shown when iOS denies access to a file picked via Files." - ) - static let uploadErrorFileNotFound = NSLocalizedString( - "mediaLibrary.upload.error.fileNotFound", - value: "The selected file could not be found.", - comment: "Error shown when a picked file no longer exists on disk." - ) - static let uploadErrorDurationCap = NSLocalizedString( - "mediaLibrary.upload.error.durationCap", - value: "This video is longer than your site allows.", - comment: "Error shown when a picked video exceeds the duration cap configured for the blog." - ) - static let uploadErrorDisallowedType = NSLocalizedString( - "mediaLibrary.upload.error.disallowedType", - value: "This file type isn't allowed for upload on your site.", - comment: "Error shown when a picked file's type is not in the blog's allowed list." - ) - static let uploadErrorHEICConversion = NSLocalizedString( - "mediaLibrary.upload.error.heicConversion", - value: "Couldn't convert the photo for upload.", - comment: "Error shown when HEIC-to-JPEG conversion fails before upload." - ) - static let uploadErrorVideoExport = NSLocalizedString( - "mediaLibrary.upload.error.videoExport", - value: "Couldn't prepare the video for upload: %1$@", - comment: - "Error shown when AVAssetExportSession fails before upload. %1$@ is the underlying error description." - ) - static let uploadErrorVideoExportNoExporter = NSLocalizedString( - "mediaLibrary.upload.error.videoExport.noExporter", - value: "No exporter is available for the selected video quality.", - comment: - "Error shown when no AVAssetExportSession can be created for the configured export preset." - ) - static let uploadErrorUnknownContentType = NSLocalizedString( - "mediaLibrary.upload.error.unknownContentType", - value: "Couldn't determine the file type.", - comment: "Error shown when no UTType can be derived from the picker output." - ) - static let materializerErrorRemoteDownloadFailed = NSLocalizedString( - "mediaLibrary.materializer.remoteDownloadFailed", - value: "Couldn't download the selected media: %1$@", - comment: - "Failed-row label when a remote media download (e.g. Stock Photos) failed before upload. %1$@ is the underlying error description." - ) - // MARK: - Upload fallback display names static let uploadFallbackPhotoName = NSLocalizedString( diff --git a/Modules/Tests/MediaTransformerTests/ImageTestFixtures.swift b/Modules/Tests/MediaTransformerTests/ImageTestFixtures.swift new file mode 100644 index 000000000000..1e61619b9306 --- /dev/null +++ b/Modules/Tests/MediaTransformerTests/ImageTestFixtures.swift @@ -0,0 +1,418 @@ +import AVFoundation +import CoreGraphics +import Foundation +import ImageIO +import MediaTransformer +import UniformTypeIdentifiers + +// Shared image fixtures for the media upload tests. Used by both +// `UploadSourceMaterializerTests` (end-to-end, in `WordPressMediaLibraryTests`) +// and `MediaTransformerTests` (the transform engine in isolation). +// +// This module is UIKit-free — fixtures render through CoreGraphics rather than +// `UIGraphicsImageRenderer` — so `MediaTransformer` and its tests build on macOS +// for a fast `swift test` without Xcode. + +enum FixtureError: Error { case encodingFailed, imageUnavailable } + +let fixtureDateTimeOriginal = "2026:01:01 12:00:00" + +/// The handful of solid fill colours the image fixtures use. Modelled as an enum +/// (rather than `UIColor`) so call sites keep reading `color: .red` while the +/// module stays UIKit-free. +enum FixtureColor { + case red, green, blue + + var cgColor: CGColor { + switch self { + case .red: return CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + case .green: return CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) + case .blue: return CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + } + } +} + +/// A `MediaUploadPolicy` with test defaults; pass only the knobs a test cares +/// about. Video fields are fixed — the image transformer never reads them. +func makeUploadPolicy( + allow: @escaping @Sendable (UTType, String) -> Bool = { _, _ in true }, + imageMaxDimension: Int? = nil, + imageJpegQuality: Double = 0.9, + convertHEICToJPEG: Bool = true, + videoMaxDurationSeconds: TimeInterval? = nil, + videoMaxDimension: Int? = nil, + stripLocation: Bool = false, + normalizeImageOrientation: Bool = false +) -> MediaUploadPolicy { + MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: allow, + imageMaxDimension: imageMaxDimension, + imageJpegQuality: imageJpegQuality, + convertHEICToJPEG: convertHEICToJPEG, + normalizeImageOrientation: normalizeImageOrientation, + videoMaxDurationSeconds: videoMaxDurationSeconds, + videoMaxDimension: videoMaxDimension, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripLocation: stripLocation + ) +} + +/// A solid-colour image. Opaque device-RGB — no alpha channel — matching the old +/// opaque renderer format so a later re-encode doesn't trip ImageIO's "opaque +/// image with AlphaLast" warnings. +func makeSolidColorImage(size: CGSize, color: FixtureColor) throws -> CGImage { + let width = Int(size.width) + let height = Int(size.height) + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) + else { throw FixtureError.encodingFailed } + context.setFillColor(color.cgColor) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { throw FixtureError.imageUnavailable } + return image +} + +/// A high-frequency image (per-pixel varying color) whose JPEG re-encode is +/// visibly lossy, unlike a flat fill — so a lossless strip is distinguishable +/// from a decode + recompress by comparing decoded pixels. +func makeDetailedImage(size: CGSize) throws -> CGImage { + let width = Int(size.width) + let height = Int(size.height) + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) + else { throw FixtureError.encodingFailed } + for y in 0.. Data { + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData(out, type.identifier as CFString, 1, nil) + else { + throw FixtureError.encodingFailed + } + CGImageDestinationAddImage(dst, image, properties as CFDictionary) + guard CGImageDestinationFinalize(dst) else { + throw FixtureError.encodingFailed + } + return out as Data +} + +/// Synthetic JPEG carrying both a GPS dictionary and an EXIF capture date, +/// for GPS-stripping tests and tests that pin the resize path's metadata +/// handling. +func makeJPEGWithGPSAndDate() throws -> Data { + let image = try makeSolidColorImage(size: CGSize(width: 128, height: 128), color: .green) + let gps: [CFString: Any] = [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ] + let exif: [CFString: Any] = [ + kCGImagePropertyExifDateTimeOriginal: fixtureDateTimeOriginal + ] + return try encodeImage( + image, + as: .jpeg, + properties: [ + kCGImagePropertyGPSDictionary: gps, + kCGImagePropertyExifDictionary: exif + ] + ) +} + +/// Synthetic JPEG with GPS + EXIF date over high-frequency content, for proving +/// the GPS strip is a lossless container rewrite (decoded pixels unchanged) +/// rather than a decode + re-encode. +func makeDetailedJPEGWithGPSAndDate() throws -> Data { + try encodeImage( + makeDetailedImage(size: CGSize(width: 96, height: 96)), + as: .jpeg, + properties: [ + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ], + kCGImagePropertyExifDictionary: [ + kCGImagePropertyExifDateTimeOriginal: fixtureDateTimeOriginal + ] + ] + ) +} + +/// Synthetic PNG carrying a GPS dictionary. PNG keeps its GPS in a binary eXIf +/// chunk a metadata-only rewrite can't touch, so this drives the strip's +/// re-encode fallback. +func makePNGWithGPS() throws -> Data { + try encodeImage( + makeSolidColorImage(size: CGSize(width: 48, height: 48), color: .green), + as: .png, + properties: [ + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ] + ] + ) +} + +/// Synthetic HEIC, optionally carrying a specific EXIF orientation tag and/or a +/// GPS dictionary. Works on iOS 17+ simulator. +func makeSyntheticHEIC( + orientation: CGImagePropertyOrientation? = nil, + gps: Bool = false +) throws -> Data { + let image = try makeSolidColorImage(size: CGSize(width: 64, height: 64), color: .blue) + var properties: [CFString: Any] = [:] + if let orientation { + properties[kCGImagePropertyOrientation] = orientation.rawValue + } + if gps { + properties[kCGImagePropertyGPSDictionary] = [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ] + } + return try encodeImage(image, as: .heic, properties: properties) +} + +func imageProperties(of url: URL) throws -> [CFString: Any] { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] + else { throw FixtureError.imageUnavailable } + return props +} + +func imageType(of url: URL) -> UTType? { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let type = CGImageSourceGetType(src) + else { return nil } + return UTType(type as String) +} + +/// The GPS-presence check both suites assert with. Delegates to production's +/// fail-closed post-write predicate so the test oracle can't drift from the +/// exact check `stripGPSLosslessly` trusts. +func imageHasGPS(_ url: URL) -> Bool { + MediaTransformer.fileHasGPS(url) +} + +/// The colour model ImageIO reports for `url` (e.g. "RGB", "CMYK"). +func imageColorModel(of url: URL) -> String? { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] + else { return nil } + return props[kCGImagePropertyColorModel] as? String +} + +/// Samples the sRGB pixel at (`x`, `y`) of the image at `url`, drawing through a +/// colour-managed context so the recovered hue can be asserted — this catches an +/// inverted or corrupt CMYK→RGB conversion, not just a colour-model relabel. +func imageSampleRGB(of url: URL, x: Int, y: Int) -> (r: Int, g: Int, b: Int)? { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(src, 0, nil), + x >= 0, y >= 0, x < image.width, y < image.height, + let space = CGColorSpace(name: CGColorSpace.sRGB) + else { return nil } + let width = image.width + let height = image.height + var buffer = [UInt8](repeating: 0, count: width * height * 4) + let drew = buffer.withUnsafeMutableBytes { raw -> Bool in + guard + let context = CGContext( + data: raw.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drew else { return nil } + let i = (y * width + x) * 4 + return (Int(buffer[i]), Int(buffer[i + 1]), Int(buffer[i + 2])) +} + +/// Decodes `url` to a raw RGBA pixel buffer so two images can be compared for +/// pixel-exact equality independent of their container/metadata bytes. +func decodedPixels(of url: URL) throws -> Data { + guard + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { throw FixtureError.imageUnavailable } + let width = image.width + let height = image.height + var buffer = [UInt8](repeating: 0, count: width * height * 4) + let drew = buffer.withUnsafeMutableBytes { raw -> Bool in + guard + let context = CGContext( + data: raw.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) + else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drew else { throw FixtureError.encodingFailed } + return Data(buffer) +} + +/// Synthetic JPEG carrying textual IPTC place names (City/State/Country) plus a +/// non-location caption/byline and EXIF GPS — the third-party / reverse- +/// geocoding-app shape the "Remove Location" strip must fully cover without +/// dropping the caption. +func makeJPEGWithTextualLocation() throws -> Data { + try encodeImage( + makeSolidColorImage(size: CGSize(width: 64, height: 64), color: .green), + as: .jpeg, + properties: [ + kCGImagePropertyIPTCDictionary: [ + kCGImagePropertyIPTCCity: "Cupertino", + kCGImagePropertyIPTCProvinceState: "CA", + kCGImagePropertyIPTCCountryPrimaryLocationName: "USA", + kCGImagePropertyIPTCSubLocation: "Infinite Loop", + kCGImagePropertyIPTCCaptionAbstract: "A nice photo", + kCGImagePropertyIPTCByline: "Jane Doe" + ], + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ] + ] + ) +} + +/// The IPTC sub-dictionary of a written image (for asserting a location strip +/// dropped place names while keeping the caption). +func imageIPTC(of url: URL) throws -> [CFString: Any] { + (try imageProperties(of: url))[kCGImagePropertyIPTCDictionary] as? [CFString: Any] ?? [:] +} + +/// A multi-image HEIC whose primary is item 1 (a distinct size from item 0), so +/// a hardcoded index-0 read would pick the wrong frame. +func makeMultiImageHEIC(item0: CGSize, primary: CGSize) throws -> Data { + let out = NSMutableData() + guard let dst = CGImageDestinationCreateWithData(out, UTType.heic.identifier as CFString, 2, nil) else { + throw FixtureError.encodingFailed + } + let cg0 = try makeSolidColorImage(size: item0, color: .red) + let cg1 = try makeSolidColorImage(size: primary, color: .blue) + CGImageDestinationAddImage(dst, cg0, nil) + CGImageDestinationAddImage(dst, cg1, [kCGImagePropertyPrimaryImage: true] as CFDictionary) + guard CGImageDestinationFinalize(dst) else { throw FixtureError.encodingFailed } + return out as Data +} + +/// A Display-P3 (wide-gamut) JPEG, for asserting the transform keeps the profile. +func makeP3JPEG(size: CGSize) throws -> Data { + guard + let p3 = CGColorSpace(name: CGColorSpace.displayP3), + let ctx = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: p3, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) + else { throw FixtureError.encodingFailed } + ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1) + ctx.fill(CGRect(origin: .zero, size: size)) + guard let cg = ctx.makeImage() else { throw FixtureError.imageUnavailable } + let out = NSMutableData() + guard let dst = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else { + throw FixtureError.encodingFailed + } + CGImageDestinationAddImage(dst, cg, [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary) + guard CGImageDestinationFinalize(dst) else { throw FixtureError.encodingFailed } + return out as Data +} + +/// Whether the image at `url` decodes to a wide-gamut (Display P3) color space. +func imageIsWideGamut(of url: URL) -> Bool { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let img = CGImageSourceCreateImageAtIndex(src, 0, nil) + else { return false } + return img.colorSpace?.isWideGamutRGB ?? false +} + +/// A HEIC whose right half is transparent, for asserting alpha is flattened when +/// a non-web-safe alpha source is converted to JPEG. +func makeTransparentHEIC(size: CGSize) throws -> Data { + let width = Int(size.width) + let height = Int(size.height) + // Alpha-enabled context; only the left half is filled, so the right half + // stays transparent and the HEIC carries a real alpha channel. + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { throw FixtureError.encodingFailed } + context.setFillColor(FixtureColor.red.cgColor) + context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height)) + guard let image = context.makeImage() else { throw FixtureError.imageUnavailable } + return try encodeImage(image, as: .heic) +} + +/// Whether the image at `url` reports an alpha channel. +func imageHasAlpha(of url: URL) -> Bool { + ((try? imageProperties(of: url))?[kCGImagePropertyHasAlpha] as? Bool) ?? false +} diff --git a/Modules/Tests/MediaTransformerTests/MediaTransformerTests.swift b/Modules/Tests/MediaTransformerTests/MediaTransformerTests.swift new file mode 100644 index 000000000000..e14411795458 --- /dev/null +++ b/Modules/Tests/MediaTransformerTests/MediaTransformerTests.swift @@ -0,0 +1,720 @@ +import AVFoundation +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +@testable import MediaTransformer + +/// Direct tests for the image transform engine, exercised through its +/// `plan` → `write` API without the materializer, staging directories, or +/// filename allocation around it. +@Suite("MediaTransformer") +final class MediaTransformerTests { + /// Per-test scratch directory for source fixtures and transform output. + private let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + init() throws { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: root) + } + + // MARK: - plan: content type + extension decision + + @Test("plan: web-safe in-cap JPEG needs no transform") + func planJPEGPassthrough() throws { + let jpeg = try encodeImage(makeSolidColorImage(size: CGSize(width: 40, height: 40), color: .red), as: .jpeg) + let plan = try MediaTransformer(policy: makeUploadPolicy()).plan(.data(jpeg), declaredType: .jpeg) + #expect(plan.contentType == .jpeg) + #expect(plan.fileExtension == "jpeg") + } + + @Test("plan: web-safe in-cap PNG stays PNG") + func planPNGPassthrough() throws { + let png = try encodeImage(makeSolidColorImage(size: CGSize(width: 40, height: 40), color: .red), as: .png) + let plan = try MediaTransformer(policy: makeUploadPolicy()).plan(.data(png), declaredType: .png) + #expect(plan.contentType == .png) + #expect(plan.fileExtension == "png") + } + + @Test("plan: HEIC converts to JPEG when the policy asks") + func planHEICConverts() throws { + let heic = try makeSyntheticHEIC() + let plan = try MediaTransformer(policy: makeUploadPolicy(convertHEICToJPEG: true)) + .plan(.data(heic), declaredType: .heic) + #expect(plan.contentType == .jpeg) + #expect(plan.fileExtension == "jpeg") + } + + @Test("plan: HEIC stays HEIC when conversion is disabled and nothing else applies") + func planHEICStaysHEICWithoutConversion() throws { + let heic = try makeSyntheticHEIC() + let plan = try MediaTransformer(policy: makeUploadPolicy(convertHEICToJPEG: false)) + .plan(.data(heic), declaredType: .heic) + #expect(plan.contentType == .heic) + } + + @Test("plan: the sniffed container type wins over a lying declared type") + func planSniffsRealType() throws { + // HEIC bytes announced as JPEG must still be seen as HEIC (and converted). + let heic = try makeSyntheticHEIC() + let plan = try MediaTransformer(policy: makeUploadPolicy()).plan(.data(heic), declaredType: .jpeg) + #expect(plan.contentType == .jpeg) // converted from the real HEIC, not passed through + } + + @Test("plan: a re-encode of a non-web-safe type targets JPEG") + func planNonWebSafeReencodeTargetsJPEG() throws { + // TIFF with conversion disabled: a resize still forces a re-encode, and + // TIFF isn't web-writable, so the target must fall back to JPEG. + let tiff = try encodeImage(makeSolidColorImage(size: CGSize(width: 128, height: 128), color: .red), as: .tiff) + let plan = try MediaTransformer(policy: makeUploadPolicy(imageMaxDimension: 64, convertHEICToJPEG: false)) + .plan(.data(tiff), declaredType: .tiff) + #expect(plan.contentType == .jpeg) + } + + // MARK: - plan: validation + + @Test("plan: non-image bytes are rejected") + func planRejectsNonImage() throws { + let transformer = MediaTransformer(policy: makeUploadPolicy()) + #expect(throws: MediaTransformerError.self) { + try transformer.plan(.data(Data("definitely not an image".utf8)), declaredType: .jpeg) + } + } + + @Test("plan: an HTML error body served as image/jpeg is rejected") + func planRejectsHTMLBody() throws { + let transformer = MediaTransformer(policy: makeUploadPolicy()) + let error = try? transformer.plan( + .data(Data("404 Not Found".utf8)), + declaredType: .jpeg + ) + #expect(error == nil) + } + + @Test("plan: empty data is rejected") + func planRejectsEmpty() throws { + let transformer = MediaTransformer(policy: makeUploadPolicy()) + #expect(throws: MediaTransformerError.self) { + try transformer.plan(.data(Data()), declaredType: .jpeg) + } + } + + // MARK: - write: no-transform passthrough + + @Test("write: a no-transform URL input is copied byte-for-byte") + func writeURLPassthroughByteIdentical() throws { + let jpeg = try encodeImage(makeSolidColorImage(size: CGSize(width: 50, height: 50), color: .red), as: .jpeg) + let source = try fixture(jpeg, ext: "jpg") + let out = try transform(.url(source), declaredType: .jpeg, policy: makeUploadPolicy()) + #expect(try Data(contentsOf: out) == jpeg) + } + + @Test("write: a no-transform Data input is written unchanged") + func writeDataPassthroughByteIdentical() throws { + let jpeg = try encodeImage(makeSolidColorImage(size: CGSize(width: 50, height: 50), color: .red), as: .jpeg) + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy()) + #expect(try Data(contentsOf: out) == jpeg) + } + + @Test("write: HEIC kept as HEIC (conversion off) is copied byte-for-byte") + func writeHEICPassthroughByteIdentical() throws { + let heic = try makeSyntheticHEIC() + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy(convertHEICToJPEG: false)) + #expect(imageType(of: out) == .heic) + #expect(try Data(contentsOf: out) == heic) + } + + @Test("write: strip policy with no GPS present is a no-op passthrough") + func writeStripWithoutGPSIsPassthrough() throws { + let jpeg = try encodeImage(makeSolidColorImage(size: CGSize(width: 48, height: 48), color: .green), as: .jpeg) + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy(stripLocation: true)) + #expect(!imageHasGPS(out)) + #expect(try Data(contentsOf: out) == jpeg) // untouched — nothing to strip + } + + // MARK: - write: lossless GPS strip + + @Test("write: JPEG GPS strip is lossless and keeps other EXIF") + func writeJPEGStripIsLossless() throws { + let jpeg = try makeDetailedJPEGWithGPSAndDate() + let source = try fixture(jpeg, ext: "jpg") + let out = try transform(.url(source), declaredType: .jpeg, policy: makeUploadPolicy(stripLocation: true)) + + let props = try imageProperties(of: out) + #expect(props[kCGImagePropertyGPSDictionary] == nil) + let exif = props[kCGImagePropertyExifDictionary] as? [CFString: Any] + #expect(exif?[kCGImagePropertyExifDateTimeOriginal] as? String == fixtureDateTimeOriginal) + #expect(imageType(of: out) == .jpeg) + // Lossless container rewrite: decoding the output yields the source pixels. + #expect(try decodedPixels(of: out) == decodedPixels(of: source)) + } + + @Test("write: located HEIC converts to JPEG and strips GPS") + func writeHEICWithGPSConvertsAndStrips() throws { + let heic = try makeSyntheticHEIC(gps: true) + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy(stripLocation: true)) + #expect(imageType(of: out) == .jpeg) + #expect(!imageHasGPS(out)) + } + + /// A strip-only transform is not a re-encode, so keeping HEIC (conversion + /// off) must strip location without transcoding the container to JPEG — the + /// located photo stays HEIC just like its un-located sibling would. + @Test("write: located HEIC with conversion off strips GPS and stays HEIC") + func writeHEICWithGPSStripsAndStaysHEIC() throws { + let heic = try makeSyntheticHEIC(gps: true) + let out = try transform( + .data(heic), + declaredType: .heic, + policy: makeUploadPolicy(convertHEICToJPEG: false, stripLocation: true) + ) + #expect(imageType(of: out) == .heic) // kept as HEIC, not forced to JPEG + #expect(!imageHasGPS(out)) + } + + @Test("write: PNG GPS strip falls back to a pixel-lossless re-encode, stays PNG") + func writePNGWithGPSStripsLosslessly() throws { + let png = try makePNGWithGPS() + let source = try fixture(png, ext: "png") + let out = try transform(.url(source), declaredType: .png, policy: makeUploadPolicy(stripLocation: true)) + #expect(imageType(of: out) == .png) + #expect(!imageHasGPS(out)) + // PNG is a lossless codec, so the fallback re-encode changes no pixels. + #expect(try decodedPixels(of: out) == decodedPixels(of: source)) + } + + @Test("write: .data and .url inputs strip identically") + func writeInputParity() throws { + let jpeg = try makeJPEGWithGPSAndDate() + let source = try fixture(jpeg, ext: "jpg") + let policy = makeUploadPolicy(stripLocation: true) + let fromData = try transform(.data(jpeg), declaredType: .jpeg, policy: policy) + let fromURL = try transform(.url(source), declaredType: .jpeg, policy: policy) + #expect(!imageHasGPS(fromData)) + #expect(!imageHasGPS(fromURL)) + #expect(try decodedPixels(of: fromData) == decodedPixels(of: fromURL)) + } + + // MARK: - write: conversion + + @Test("write: HEIC converts to JPEG") + func writeHEICConverts() throws { + let heic = try makeSyntheticHEIC() + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy()) + #expect(imageType(of: out) == .jpeg) + } + + @Test("write: TIFF is normalized to JPEG") + func writeTIFFConverts() throws { + let tiff = try encodeImage(makeSolidColorImage(size: CGSize(width: 64, height: 64), color: .red), as: .tiff) + let out = try transform(.data(tiff), declaredType: .tiff, policy: makeUploadPolicy()) + #expect(imageType(of: out) == .jpeg) + } + + @Test("write: HEIC→JPEG conversion preserves EXIF orientation") + func writeConversionPreservesOrientation() throws { + let heic = try makeSyntheticHEIC(orientation: .down) // 180° + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy()) + let orientation = try imageProperties(of: out)[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == CGImagePropertyOrientation.down.rawValue) + } + + @Test("write: the sniffed type wins — HEIC-as-JPEG is really converted") + func writeSniffedTypeWins() throws { + let heic = try makeSyntheticHEIC() + let source = try fixture(heic, ext: "jpg") // lies with a .jpg extension + let out = try transform(.url(source), declaredType: .jpeg, policy: makeUploadPolicy()) + #expect(imageType(of: out) == .jpeg) + #expect(try Data(contentsOf: out) != heic) // genuinely re-encoded, not passed through + } + + /// `write` re-reads its output and rejects a metadata-only stub that + /// `CGImageDestinationFinalize` reports as success — the fail-closed guard + /// against a truncated source shipping a broken image as a successful upload. + /// Verified through the predicate directly, since the exact truncation that + /// produces such a stub is decoder-version-specific (reproduces on macOS, + /// not on the iOS 17 simulator's more lenient decoder). + @Test("fileHasDecodableImage accepts a real image, rejects a header-only stub") + func fileHasDecodableImageGuard() throws { + let valid = try fixture( + try encodeImage(makeSolidColorImage(size: CGSize(width: 24, height: 24), color: .red), as: .jpeg), + ext: "jpg" + ) + #expect(MediaTransformer.fileHasDecodableImage(valid)) + + // SOI + JFIF APP0 + EOI, but no frame (no SOF/scan): a plausible JPEG + // header with no decodable image — the shape a stub-y Finalize leaves. + let stubBytes = Data([ + 0xFF, 0xD8, + 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, + 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0xFF, 0xD9 + ]) + let stub = try fixture(stubBytes, ext: "jpg") + #expect(!MediaTransformer.fileHasDecodableImage(stub)) + } + + // MARK: - write: location strip (coordinates + textual place names) + + /// "Remove Location" must drop IPTC place names (which reverse-geocoding apps + /// embed) as well as GPS coordinates, while keeping the caption and other + /// non-location IPTC. This located JPEG has an IIM block `CopyImageSource` + /// can't rewrite, so it exercises the fail-closed re-read → convert fallback. + @Test("write: strip removes textual IPTC location but keeps caption") + func writeStripsTextualLocation() throws { + let jpeg = try makeJPEGWithTextualLocation() + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy(stripLocation: true)) + let iptc = try imageIPTC(of: out) + #expect(iptc[kCGImagePropertyIPTCCity] == nil) + #expect(iptc[kCGImagePropertyIPTCCountryPrimaryLocationName] == nil) + #expect(iptc[kCGImagePropertyIPTCCaptionAbstract] as? String == "A nice photo") + #expect(!imageHasGPS(out)) + } + + @Test("write: resize strip removes textual IPTC location but keeps caption") + func writeResizeStripsTextualLocation() throws { + let jpeg = try makeJPEGWithTextualLocation() + let out = try transform( + .data(jpeg), + declaredType: .jpeg, + policy: makeUploadPolicy(imageMaxDimension: 32, stripLocation: true) + ) + let iptc = try imageIPTC(of: out) + #expect(iptc[kCGImagePropertyIPTCCity] == nil) + #expect(iptc[kCGImagePropertyIPTCCaptionAbstract] as? String == "A nice photo") + #expect(!imageHasGPS(out)) + } + + // MARK: - write: multi-image HEIC primary selection + + /// A multi-image HEIC container can mark a non-first item as primary. The + /// transform must read that primary frame — item 0 is 40×40, the primary + /// (item 1) is 80×60, so a hardcoded index-0 read would ship the wrong size. + @Test("write: a multi-image HEIC uses the primary item, not index 0") + func writeMultiImageHEICUsesPrimary() throws { + let heic = try makeMultiImageHEIC( + item0: CGSize(width: 40, height: 40), + primary: CGSize(width: 80, height: 60) + ) + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy()) + let props = try imageProperties(of: out) + #expect(props[kCGImagePropertyPixelWidth] as? Int == 80) + #expect(props[kCGImagePropertyPixelHeight] as? Int == 60) + } + + // MARK: - write: color fidelity + + /// A resize must not collapse a wide-gamut (Display P3) photo to sRGB — the + /// embedded ICC profile rides through so a color-managed web renders it right. + @Test("write: resize preserves Display P3 wide gamut") + func writeResizePreservesP3() throws { + let p3 = try makeP3JPEG(size: CGSize(width: 128, height: 128)) + let out = try transform(.data(p3), declaredType: .jpeg, policy: makeUploadPolicy(imageMaxDimension: 32)) + #expect(imageIsWideGamut(of: out)) + } + + // MARK: - write: AVIF input + + /// AVIF is decode-only before iOS 26, so it can't be synthesized in-test on + /// the iOS 17 floor — this uses a committed binary fixture. AVIF isn't + /// web-safe, so it converts to JPEG. + @Test("write: an AVIF input converts to a valid JPEG") + func writeAVIFConvertsToJPEG() throws { + let avif = try Data(contentsOf: #require(Bundle.module.url(forResource: "test-image", withExtension: "avif"))) + let out = try transform(.data(avif), declaredType: #require(UTType("public.avif")), policy: makeUploadPolicy()) + #expect(imageType(of: out) == .jpeg) + } + + // MARK: - write: WebP input (decode-only) + + /// WebP decodes on the iOS 17 floor but can't be encoded, so a committed + /// fixture stands in. Not web-safe and not ImageIO-writable → force-converts + /// to JPEG (`!isEncodable`), even with HEIC→JPEG conversion off. + @Test("write: a static WebP converts to a valid JPEG") + func writeStaticWebPConvertsToJPEG() throws { + let data = try Data(contentsOf: #require(Bundle.module.url(forResource: "test-image", withExtension: "webp"))) + let out = try transform( + .data(data), + declaredType: #require(UTType("org.webmproject.webp")), + policy: makeUploadPolicy(convertHEICToJPEG: false) + ) + #expect(imageType(of: out) == .jpeg) + #expect(MediaTransformer.fileHasDecodableImage(out)) + } + + /// An animated WebP converts to a single-frame JPEG of its primary frame — + /// never a broken multi-frame artifact. + @Test("write: an animated WebP converts to a single-frame JPEG") + func writeAnimatedWebPConvertsToJPEG() throws { + let data = try Data( + contentsOf: #require(Bundle.module.url(forResource: "test-image-animated", withExtension: "webp")) + ) + let out = try transform( + .data(data), + declaredType: #require(UTType("org.webmproject.webp")), + policy: makeUploadPolicy() + ) + #expect(imageType(of: out) == .jpeg) + let src = try #require(CGImageSourceCreateWithURL(out as CFURL, nil)) + #expect(CGImageSourceGetCount(src) == 1) // one frame, not an animation + } + + /// A WebP whose header declares a >100 MP canvas is rejected before any + /// decode — the decompression-bomb backstop (`maxSourcePixels`). The fixture + /// is an 11000×11000 flat-colour lossless WebP: 4.7 KB on disk, 121 MP if + /// decoded. WebP has no scaled-decode, so the header check is the only guard. + @Test("plan: a WebP decompression bomb is rejected before decode") + func planWebPBombThrows() throws { + let data = try Data( + contentsOf: #require(Bundle.module.url(forResource: "test-image-bomb", withExtension: "webp")) + ) + let transformer = MediaTransformer(policy: makeUploadPolicy()) + #expect(throws: MediaTransformerError.self) { + _ = try transformer.plan(.data(data), declaredType: #require(UTType("org.webmproject.webp"))) + } + } + + // MARK: - write: CMYK JPEG → RGB (profile-honoured) + + /// Patch-centre coordinates in the CMYK fixtures — cyan, magenta, yellow, black. + private static let cmykPatchCentres = [(8, 8), (24, 8), (8, 24), (24, 24)] + + /// Runs CMYK fixture `resource` through the pipeline; returns the converted + /// output URL and its four patch-centre sRGB samples (cyan, magenta, yellow, + /// black — in that order). + private func convertCMYK(_ resource: String) throws -> (url: URL, patches: [(r: Int, g: Int, b: Int)]) { + let data = try Data( + contentsOf: #require(Bundle.module.url(forResource: resource, withExtension: "jpg")) + ) + let out = try transform(.data(data), declaredType: .jpeg, policy: makeUploadPolicy()) + let patches = try Self.cmykPatchCentres.map { try #require(imageSampleRGB(of: out, x: $0.0, y: $0.1)) } + return (out, patches) + } + + /// A CMYK JPEG must convert to RGB before upload — browsers render 4-component + /// JPEGs inconsistently — and the conversion must *honour the embedded ICC + /// profile*, not apply a fixed formula. Two fixtures share identical CMYK pixels + /// (pure cyan / magenta / yellow / black patches) but carry different profiles: + /// • `test-image-cmyk` — untagged, so ImageIO applies its default Generic + /// CMYK, a LUT profile: pure cyan lands at sRGB (0, 164, 218), not (0, 255, + /// 255), and black is a "rich black" (27, 25, 25), not (0, 0, 0). + /// • `test-image-cmyk-ps` — tagged PostScript CMYK, the naive analytic + /// profile: pure cyan is (0, 255, 255), black is (0, 0, 0). + /// Same bytes → different output proves the profile drives the result. Golden + /// values are ImageIO's colour-managed conversion; ±12 absorbs JPEG + CMM drift + /// yet stays far tighter than the 80–130-level gap a profile-blind conversion + /// would show. + @Test("write: CMYK converts to RGB honouring the embedded colour profile") + func writeCMYKConvertsHonouringProfile() throws { + let generic = try convertCMYK("test-image-cmyk") // default Generic CMYK (LUT) + let naive = try convertCMYK("test-image-cmyk-ps") // naive PostScript CMYK + + #expect(imageColorModel(of: generic.url) == "RGB") // converted, not shipped as CMYK + #expect(imageColorModel(of: naive.url) == "RGB") + + let tol = 12 + func expectClose(_ got: (r: Int, g: Int, b: Int), _ want: (Int, Int, Int), _ label: String) { + #expect( + abs(got.r - want.0) <= tol && abs(got.g - want.1) <= tol && abs(got.b - want.2) <= tol, + "\(label): got \(got), want ~\(want) ±\(tol)" + ) + } + // Generic CMYK (LUT) golden — cyan, magenta, yellow, black. + expectClose(generic.patches[0], (0, 164, 218), "generic cyan") + expectClose(generic.patches[1], (216, 16, 125), "generic magenta") + expectClose(generic.patches[2], (255, 241, 6), "generic yellow") + expectClose(generic.patches[3], (27, 25, 25), "generic black") + // Naive PostScript golden. + expectClose(naive.patches[0], (0, 255, 255), "naive cyan") + expectClose(naive.patches[1], (255, 0, 255), "naive magenta") + expectClose(naive.patches[2], (255, 255, 0), "naive yellow") + expectClose(naive.patches[3], (0, 0, 0), "naive black") + + // Profile respected: identical CMYK, different profile → the LUT profile + // pulls colours far off the naive conversion. + #expect(abs(generic.patches[0].g - naive.patches[0].g) > 60) // cyan green: 164 vs 255 + #expect(abs(generic.patches[3].r - naive.patches[3].r) > 15) // black: 27 vs 0 + } + + // MARK: - write: alpha flatten + + /// Converting a non-web-safe alpha source (HEIC) to JPEG flattens the + /// transparency — JPEG has no alpha channel. Documented fidelity loss; PNG, + /// being web-safe, keeps its alpha by passing through instead. + @Test("write: converting a transparent HEIC flattens alpha") + func writeConvertFlattensAlpha() throws { + let heic = try makeTransparentHEIC(size: CGSize(width: 40, height: 40)) + let src = try fixture(heic, ext: "heic") + try #require(imageHasAlpha(of: src)) // sanity: the source really has alpha + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy()) + #expect(imageType(of: out) == .jpeg) + #expect(!imageHasAlpha(of: out)) + } + + // MARK: - write: EXIF orientation (all 8) + + /// Every EXIF orientation (1–8, incl. the mirrored 2/4/5/7) must bake upright + /// on a resize: the 90°/270° values (5–8) swap the aspect, and the baked + /// output carries no residual rotation tag. Extends the existing 3/6-only + /// coverage across the whole table in one pass. + @Test( + "write: resize bakes every EXIF orientation upright", + arguments: [1, 2, 3, 4, 5, 6, 7, 8] as [UInt32] + ) + func writeResizeBakesAllOrientations(orientation: UInt32) throws { + // Landscape 80×60 source tagged with `orientation`. + let jpeg = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .blue), + as: .jpeg, + properties: [kCGImagePropertyOrientation: orientation] + ) + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy(imageMaxDimension: 40)) + let props = try imageProperties(of: out) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + // Orientations 5–8 rotate 90°, so a landscape source becomes portrait + // once baked upright; 1–4 stay landscape. + if (5...8).contains(orientation) { + #expect(height > width) + } else { + #expect(width > height) + } + // No residual rotation in the baked output. + let outOrientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(outOrientation == nil || outOrientation == 1) + } + + // MARK: - write: resize + + @Test("write: an over-cap image is resized within the cap") + func writeResizeWithinCap() throws { + let jpeg = try encodeImage(makeSolidColorImage(size: CGSize(width: 200, height: 120), color: .blue), as: .jpeg) + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy(imageMaxDimension: 64)) + let props = try imageProperties(of: out) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(max(width, height) <= 64) + } + + @Test("write: resize strips GPS but keeps other EXIF") + func writeResizeStripsGPSKeepsEXIF() throws { + let jpeg = try makeJPEGWithGPSAndDate() + let out = try transform( + .data(jpeg), + declaredType: .jpeg, + policy: makeUploadPolicy(imageMaxDimension: 32, stripLocation: true) + ) + let props = try imageProperties(of: out) + #expect(props[kCGImagePropertyGPSDictionary] == nil) + let exif = props[kCGImagePropertyExifDictionary] as? [CFString: Any] + #expect(exif?[kCGImagePropertyExifDateTimeOriginal] as? String == fixtureDateTimeOriginal) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + #expect(width <= 32) + } + + @Test("write: resize with strip off keeps GPS") + func writeResizeKeepsGPSWhenStripOff() throws { + let jpeg = try makeJPEGWithGPSAndDate() + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy(imageMaxDimension: 32)) + #expect(imageHasGPS(out)) + } + + @Test("write: PNG stays PNG through a resize") + func writePNGResizeKeepsType() throws { + let png = try encodeImage(makeSolidColorImage(size: CGSize(width: 128, height: 128), color: .red), as: .png) + let out = try transform(.data(png), declaredType: .png, policy: makeUploadPolicy(imageMaxDimension: 32)) + #expect(imageType(of: out) == .png) + let width = try #require(try imageProperties(of: out)[kCGImagePropertyPixelWidth] as? Int) + #expect(width <= 32) + } + + @Test("write: resize bakes EXIF orientation into the pixels") + func writeResizeBakesOrientation() throws { + // Landscape pixels with a 90° tag → baked resize is portrait + upright. + let heic = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .blue), + as: .heic, + properties: [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] + ) + let out = try transform(.data(heic), declaredType: .heic, policy: makeUploadPolicy(imageMaxDimension: 40)) + let props = try imageProperties(of: out) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(height > width) + let orientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + } + + // MARK: - write: orientation normalize + + @Test("write: normalize bakes a non-upright JPEG upright and drops the tag") + func writeNormalizeBakesOrientation() throws { + // Landscape 80×60 pixels tagged 90° (.right) display as portrait. With + // normalize on and no resize, the rotation is baked into full-resolution + // pixels and the tag reset — no viewer needs to honor orientation. + let jpeg = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .blue), + as: .jpeg, + properties: [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] + ) + let out = try transform( + .data(jpeg), + declaredType: .jpeg, + policy: makeUploadPolicy(normalizeImageOrientation: true) + ) + let props = try imageProperties(of: out) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(height > width) // pixels physically rotated to portrait + #expect(max(width, height) == 80) // full resolution — not resized + let orientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + #expect(imageType(of: out) == .jpeg) + } + + @Test("write: normalize is a byte-for-byte passthrough for an upright image") + func writeNormalizeUprightIsPassthrough() throws { + // No orientation tag → nothing to bake → no recompress. + let jpeg = try encodeImage( + makeSolidColorImage(size: CGSize(width: 50, height: 50), color: .red), + as: .jpeg + ) + let out = try transform( + .data(jpeg), + declaredType: .jpeg, + policy: makeUploadPolicy(normalizeImageOrientation: true) + ) + #expect(try Data(contentsOf: out) == jpeg) + } + + @Test("write: normalize off leaves a non-upright image's tag and bytes intact") + func writeNormalizeOffKeepsTag() throws { + // Default policy relies on the server/browser to honor orientation, so a + // sideways JPEG passes through untouched — tag and pixels both preserved. + let jpeg = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .blue), + as: .jpeg, + properties: [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] + ) + let out = try transform(.data(jpeg), declaredType: .jpeg, policy: makeUploadPolicy()) + let orientation = try imageProperties(of: out)[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == CGImagePropertyOrientation.right.rawValue) + #expect(try Data(contentsOf: out) == jpeg) + } + + @Test("write: HEIC→JPEG conversion bakes orientation when normalize is on") + func writeConvertBakesOrientationWhenNormalizeOn() throws { + // Contrast with `writeConversionPreservesOrientation` (normalize off, + // which carries the tag across): with normalize on, the convert path + // bakes the rotation and clears the tag. + let heic = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .blue), + as: .heic, + properties: [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] + ) + let out = try transform( + .data(heic), + declaredType: .heic, + policy: makeUploadPolicy(normalizeImageOrientation: true) + ) + #expect(imageType(of: out) == .jpeg) + let props = try imageProperties(of: out) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(height > width) + let orientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + } + + @Test("write: normalize bakes orientation, strips GPS, and keeps other EXIF") + func writeNormalizeStripsGPSKeepsEXIF() throws { + // The real phone-photo combo: a located, dated, sideways JPEG. + let jpeg = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .green), + as: .jpeg, + properties: [ + kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue, + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ], + kCGImagePropertyExifDictionary: [ + kCGImagePropertyExifDateTimeOriginal: fixtureDateTimeOriginal + ] + ] + ) + let out = try transform( + .data(jpeg), + declaredType: .jpeg, + policy: makeUploadPolicy(stripLocation: true, normalizeImageOrientation: true) + ) + let props = try imageProperties(of: out) + #expect(props[kCGImagePropertyGPSDictionary] == nil) + let exif = props[kCGImagePropertyExifDictionary] as? [CFString: Any] + #expect(exif?[kCGImagePropertyExifDateTimeOriginal] as? String == fixtureDateTimeOriginal) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(height > width) + let orientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + } + + // MARK: - Video + + /// A source already within the resolution cap is remuxed (streams copied), + /// not transcoded — so it isn't re-encoded end to end just to strip location. + @Test("video within the resolution cap is remuxed, not re-encoded") + func videoWithinCapUsesPassthrough() async throws { + let videoURL = try await sharedBlankVideoTask.value // 320×240 + let preset = try await MediaTransformer(policy: makeUploadPolicy(videoMaxDimension: 1024)) + .resolveVideoExportPreset(for: AVURLAsset(url: videoURL), outputType: .mp4) + #expect(preset == AVAssetExportPresetPassthrough) + } + + @Test("an uncapped video is remuxed, not re-encoded") + func videoUncappedUsesPassthrough() async throws { + let videoURL = try await sharedBlankVideoTask.value + let preset = try await MediaTransformer(policy: makeUploadPolicy()) + .resolveVideoExportPreset(for: AVURLAsset(url: videoURL), outputType: .mp4) + #expect(preset == AVAssetExportPresetPassthrough) + } + + /// Only a source that exceeds the cap falls back to a full re-encode. + @Test("video over the resolution cap falls back to the re-encode preset") + func videoOverCapUsesReencode() async throws { + let videoURL = try await sharedBlankVideoTask.value // 320×240 + let preset = try await MediaTransformer(policy: makeUploadPolicy(videoMaxDimension: 100)) + .resolveVideoExportPreset(for: AVURLAsset(url: videoURL), outputType: .mp4) + #expect(preset == AVAssetExportPresetMediumQuality) + } +} + +// MARK: - Helpers + +extension MediaTransformerTests { + /// Writes fixture bytes to a fresh file under the per-test root. + private func fixture(_ data: Data, ext: String) throws -> URL { + let url = root.appendingPathComponent("src-\(UUID().uuidString).\(ext)") + try data.write(to: url) + return url + } + + /// Plans and writes `input` through a transformer, returning the output URL + /// (named from the plan's extension, exactly as the materializer would). + private func transform( + _ input: MediaTransformer.Input, + declaredType: UTType, + policy: MediaUploadPolicy + ) throws -> URL { + let transformer = MediaTransformer(policy: policy) + let plan = try transformer.plan(input, declaredType: declaredType) + let output = root.appendingPathComponent("out-\(UUID().uuidString).\(plan.fileExtension)") + try transformer.write(plan, to: output) + return output + } +} diff --git a/Modules/Tests/MediaTransformerTests/Resources/test-image-animated.webp b/Modules/Tests/MediaTransformerTests/Resources/test-image-animated.webp new file mode 100644 index 0000000000000000000000000000000000000000..6924a08f8fba83d1bb23b039f9b5c519d8bf05d6 GIT binary patch literal 188 zcmWIYbaUInz`zjh>J$(bU=hIuWHSLVHyAnkdHS*edH;ccfx*$w*G&V+g8+z%6rggT pJ|BLdpg#8jCI)tazy1H%l@|SH#HF4ARsDtk9VDq2xcDEc9ssSmBe?(o literal 0 HcmV?d00001 diff --git a/Modules/Tests/MediaTransformerTests/Resources/test-image-bomb.webp b/Modules/Tests/MediaTransformerTests/Resources/test-image-bomb.webp new file mode 100644 index 0000000000000000000000000000000000000000..b8d4d5a106f378463a5481490669d38886b0cd36 GIT binary patch literal 4682 zcmWIYbaQhOVqge&bqWXzu<$Vjvh}~e+RMcru&66!>wiaKA(c<{drXy#-a9%>`tsji z^5TCMt|RyBMFb`P|NVZyo_ON9{r{im>;HY8|Njq3`hbf6eI~^sQcWk`3!^5FhSg|F zBrV&I=9$qvGn!{c^9%_sh0!8ow8$7OGDeGx(IR8CBpfXXM@z!dl909~A#g+nHaJ6K OVgrxn0QJsiW&i+q4yoM$ literal 0 HcmV?d00001 diff --git a/Modules/Tests/MediaTransformerTests/Resources/test-image-cmyk-ps.jpg b/Modules/Tests/MediaTransformerTests/Resources/test-image-cmyk-ps.jpg new file mode 100644 index 0000000000000000000000000000000000000000..23f3afcad48daff51b066d71a77a42b9c887fcf9 GIT binary patch literal 5704 zcmb7I3sh9c8UB~uyHA8&-j4-V@CAx4Zv;eEUh-U?3O*pPEC_-kqT(Y8HCAJ7li1oA z+a^tpO-@W~G&LS#sZ9>3jY?|q_>R=$qZLwtrl7d>c>B-J8m`_m=cKde-kJHoZ~poJ z+5f+D@9b#Upvat&m75L{34q5p1d z;{_hoEA8!Fq-}~^U_|+^0bF50qzT(vY|B!b8rKm$q!4SIt*c6lv7U{W9!$^ztxylO zV1+g?KmiovJr(kx9CGaa7FdmFJv4%~x0j(S*{&-Una!{fHP%BNtU$d6k^W-4wpQ2x zRmigPW$+2#;CS*8+qcqjFMv|sX%2AJ=0i>1)JC42+I05jy@Y=j` z8(;s+Wv^HAX5+li=JV0#0 zH;>L8E|x7F4SF>?G;x%hB1D^fHVy^r^XHWhDUOTL6-68Fs~57+)@v(mbq7nI=GgL* z<3eWIBCq;nX>2}g>AcPE^`qtsh4ERVF%6N+Lr48N_aCW7;T?*u%&QxUdy?CdITY|_ z_?y#*q!B{2cBl2eAvE*j^!r@57_Iuka$8x-LRV!j9<02F%9H=o8~!rAYzBus*2G0ccQ)Ln}hE7ekz`~ z+%oT`J}qHaz)eZN7%llt;uTpl3$24b9J%>n-_4oZ-@Lgn2Ve%JMF}IU!?Q0a+9L0+@vTgHz~@>K8h~s$p@=0ad0+d ze;nZI0>%uXRjk~kV_CULQC4nJl$CuHEs2_IvjoMI`PvqKF1Cu5n{+HIHz~@>O^ULz zkD__+P93Y7#j=W(1#~Pc3nptlV_06(cttYsJV-$67IRlcJfXG)+y*jJheB zY-iOTnP$!hLaSK0Nym!!O)95%@xCcU#XF-AO*vweS8tnqNh@FIC$@@}n^c2%-xO*V z@0&tYyfX^Xgg`yK^i!5qtSq1!#5iWm+5YKdCez(NxW^7YnJ z{_AGzb1KEr&dN>4S}}5yqO9DcC@cFY8g(+k+tTN8*UvkvNgP+K+@xd0`zDpsyLjIe zqT-!Vh=zwx(^cJaDU$0lH;Juc}}OqMc2pqc?UIH+6ZsET6vG~_PHv8 zFCgy&Pp$%&13veY9x$Ol=M0i7gm);2`qDKO;93%{YZ&T6e{LB8veBRKVgRJ*4`JCp zq4gig=@j-G(H~MzL4T+gT9bK6ziS<>*P%b8AC5dJ`b~QHk3FERlcA@OC%@Na-HJS) zZ%@1zi;S?5&VIsD4X5W2p8r_t&ac-4gxgF#JJFvM`wyTiRx{p?v)2f}_+}*V9*wzvy5;9UMQ{q6aMx{aN|5^Aiz(WZJFvui2aMDSnI*CjN zd=L2GIi3_lOi&a`wF(gr?ek(}pwt&nY^CJXcA_X$YPpgO&^;$61pYd>$Rs$SREHrk zAAY|pqG8pC?Xx4@YLlS&7uPSgL+4+z_K!%;o!--b4|ID!3g`~IrHW}-6>z9sR&(e~ K^Vv=*f9-#dbSp{# literal 0 HcmV?d00001 diff --git a/Modules/Tests/MediaTransformerTests/Resources/test-image-cmyk.jpg b/Modules/Tests/MediaTransformerTests/Resources/test-image-cmyk.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c11fc8ad0663d8c7d7868497be562896b3839889 GIT binary patch literal 346 zcmex=Sg%M~WvmgrtgFVB#4#TXxl?{4ELTNS(!BcKsZeZAP!gIz4#?!}o h?mcA?oU~f@u+{@k)2uw%RShgDtBx0(+QIz)CIF8FHo5=+ literal 0 HcmV?d00001 diff --git a/Modules/Tests/MediaTransformerTests/Resources/test-image.avif b/Modules/Tests/MediaTransformerTests/Resources/test-image.avif new file mode 100644 index 0000000000000000000000000000000000000000..ee24c4cca727d14333929bb917e4fb24cedf0bc3 GIT binary patch literal 477 zcmZQzU{FXasVqn=%S>Yc0^iJlA`m+_GZBc>3>g?06?0Qd5M7kDhTp$}FqnruR0pW=PIjKxgxx_L9 zpaU2f8Z!%u3V?J%W8eydgC+4-Zuvj1s{Wa})$VBZ6PAvd+ot|hIl_K- WwP?ik URL { + let url = directory.appendingPathComponent("blank_\(UUID().uuidString).mp4") + + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + writer.metadata = metadata + 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)) + } + } + } +} + +func videoHasLocation(_ url: URL) async throws -> Bool { + let asset = AVURLAsset(url: url) + for format in try await asset.load(.availableMetadataFormats) { + for item in try await asset.loadMetadata(for: format) { + let identifier = (item.identifier?.rawValue ?? "").lowercased() + if identifier.contains("loci") || identifier.contains("location") { return true } + } + } + return false +} diff --git a/Tests/KeystoneTests/WordPressUnitTests.xctestplan b/Tests/KeystoneTests/WordPressUnitTests.xctestplan index f172c2102e2f..30980a1dbe14 100644 --- a/Tests/KeystoneTests/WordPressUnitTests.xctestplan +++ b/Tests/KeystoneTests/WordPressUnitTests.xctestplan @@ -118,6 +118,13 @@ "name" : "WordPressMediaLibraryTests" } }, + { + "target" : { + "containerPath" : "container:..\/Modules", + "identifier" : "MediaTransformerTests", + "name" : "MediaTransformerTests" + } + }, { "target" : { "containerPath" : "container:..\/Modules", From dc911a422b5cfa123b2c7af6a76d3f73ba9b1d6f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:50:54 -0600 Subject: [PATCH 2/2] Run MediaTransformer's tests with swift test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `MediaTransformerTests` entry to the root `WordPressCrossPlatformModules` package so `swift test` builds and runs the transform engine on the macOS host — no Xcode, no simulator, no wordpress-rs. --- Package.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Package.swift b/Package.swift index f9ff5b633880..ad3a50ef2194 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,15 @@ let package = Package( "WPUserAgentTests.swift" ], swiftSettings: [.swiftLanguageMode(.v5)] + ), + // The image/video upload transform engine. A leaf module (system + // frameworks only), so `swift test` here exercises it on the macOS host + // — no Xcode, no simulator, no wordpress-rs. + .testTarget( + name: "MediaTransformerTests", + dependencies: [.product(name: "MediaTransformer", package: "Modules")], + path: "Modules/Tests/MediaTransformerTests", + resources: [.process("Resources")] ) ] )