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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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: [
Expand Down
705 changes: 705 additions & 0 deletions Modules/Sources/MediaTransformer/MediaTransformer.swift

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions Modules/Sources/MediaTransformer/MediaTransformerError.swift
Original file line number Diff line number Diff line change
@@ -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."
)
}
102 changes: 102 additions & 0 deletions Modules/Sources/MediaTransformer/MediaUploadPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

This file was deleted.

51 changes: 0 additions & 51 deletions Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading