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
251 changes: 209 additions & 42 deletions SignalServiceKit/Attachments/SignalAttachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1264,70 +1264,237 @@ public class SignalAttachment: NSObject {
@MainActor
public static func compressVideoAsMp4(asset: AVAsset, baseFilename: String?, dataUTI: String, sessionCallback: (@MainActor (AVAssetExportSession) -> Void)? = nil) async throws -> SignalAttachment {
Logger.debug("")
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) else {

guard let videoTrack = asset.tracks(withMediaType: .video).first else {
Logger.warn("Video export requested for asset without video track.")
let attachment = SignalAttachment(dataSource: DataSourceValue(), dataUTI: dataUTI)
attachment.error = .couldNotConvertToMpeg4
return attachment
}

exportSession.shouldOptimizeForNetworkUse = true
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
let sourceDimensions = videoDimensions(for: videoTrack)
let sourceMaxDimension = max(sourceDimensions.width, sourceDimensions.height)
let recommendedMaxDimension = recommendedExportDimension(
for: asset,
videoTrack: videoTrack,
sourceDimensions: sourceDimensions
)
let candidatePresets = exportPresets(
for: asset,
sourceMaxDimension: sourceMaxDimension,
recommendedMaxDimension: recommendedMaxDimension
)

if candidatePresets.isEmpty {
Logger.warn("No compatible export presets found; falling back to failure.")
let attachment = SignalAttachment(dataSource: DataSourceValue(), dataUTI: dataUTI)
attachment.error = .couldNotConvertToMpeg4
return attachment
}

let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4")

var hadOversizedExport = false
var lastExportError: Error?

for preset in candidatePresets {
guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset.name) else {
Logger.warn("Failed to create export session for preset: \(preset.name)")
continue
}

guard exportSession.supportedFileTypes.contains(.mp4) else {
Logger.warn("Preset \(preset.name) does not support mp4 output.")
continue
}

let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4")

exportSession.shouldOptimizeForNetworkUse = true
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
exportSession.fileLengthLimit = Int64(OWSMediaUtils.kMaxFileSizeVideo)

if let sessionCallback {
sessionCallback(exportSession)
}

let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4")
Logger.debug("Starting video export using preset \(preset.name) (maxDimension: \(preset.maxDimension?.description ?? "nil"))")

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { @MainActor in
Logger.debug("Starting video export")
do {
try await exportSession.exportAsync(to: exportURL, as: .mp4)
} catch is CancellationError {
throw CancellationError()
} catch {
if Self.isMaximumFileSizeError(error) || (exportSession.error.map(Self.isMaximumFileSizeError) ?? false) {
Logger.info("Export aborted by file size limit for preset \(preset.name); trying lower preset.")
hadOversizedExport = true
} else {
Logger.warn("Export failed for preset \(preset.name) with error: \(error)")
lastExportError = error
}
continue
}

switch exportSession.status {
case .unknown:
throw OWSAssertionError("Unknown export status.")
case .waiting:
throw OWSAssertionError("Export status: .waiting.")
case .exporting:
throw OWSAssertionError("Export status: .exporting.")
case .completed:
break
case .failed:
if let error = exportSession.error {
owsFailDebug("Error: \(error)")
throw error
switch exportSession.status {
case .completed:
break
case .cancelled:
throw CancellationError()
case .failed:
if let error = exportSession.error {
if Self.isMaximumFileSizeError(error) {
Logger.info("Export session hit file size limit for preset \(preset.name); trying lower preset.")
hadOversizedExport = true
} else {
throw OWSAssertionError("Export failed without error.")
Logger.warn("Export session failed for preset \(preset.name): \(error)")
lastExportError = error
}
case .cancelled:
throw CancellationError()
@unknown default:
throw OWSAssertionError("Unknown export status: \(exportSession.status.rawValue)")
} else {
Logger.warn("Export session failed without error for preset \(preset.name)")
}
continue
case .waiting, .exporting, .unknown:
Logger.warn("Unexpected export state \(exportSession.status.rawValue) for preset \(preset.name)")
continue
@unknown default:
Logger.warn("Unknown export status \(exportSession.status.rawValue) for preset \(preset.name)")
continue
}

if let sessionCallback {
sessionCallback(exportSession)
do {
let dataSource = try DataSourcePath(fileUrl: exportURL, shouldDeleteOnDeallocation: true)
dataSource.sourceFilename = mp4Filename

let fileSize = dataSource.dataLength
if fileSize > OWSMediaUtils.kMaxFileSizeVideo {
Logger.info("Export with preset \(preset.name) produced \(fileSize) bytes (> \(OWSMediaUtils.kMaxFileSizeVideo)); trying lower preset.")
hadOversizedExport = true
// Drop this dataSource by allowing it to deallocate (and delete the file).
continue
}

Logger.debug("Completed video export with preset \(preset.name); file size \(fileSize) bytes.")
return SignalAttachment(dataSource: dataSource, dataUTI: UTType.mpeg4Movie.identifier)
} catch {
Logger.warn("Failed to build data source for exported video URL: \(error)")
lastExportError = error
continue
}
}

try await group.waitForAll()
let attachment = SignalAttachment(dataSource: DataSourceValue(), dataUTI: dataUTI)
if hadOversizedExport {
attachment.error = .fileSizeTooLarge
} else {
if let lastExportError {
Logger.warn("All export attempts failed. Last error: \(lastExportError)")
}
attachment.error = .couldNotConvertToMpeg4
}
return attachment
}

Logger.debug("Completed video export")
let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4")
private struct VideoExportPresetOption {
let name: String
let maxDimension: CGFloat?
}

do {
let dataSource = try DataSourcePath(fileUrl: exportURL, shouldDeleteOnDeallocation: true)
dataSource.sourceFilename = mp4Filename
private static func exportPresets(for asset: AVAsset, sourceMaxDimension: CGFloat, recommendedMaxDimension: CGFloat) -> [VideoExportPresetOption] {
let availablePresets = Set(AVAssetExportSession.exportPresets(compatibleWith: asset))

let attachment = SignalAttachment(dataSource: dataSource, dataUTI: UTType.mpeg4Movie.identifier)
if dataSource.dataLength > OWSMediaUtils.kMaxFileSizeVideo {
attachment.error = .fileSizeTooLarge
let presetCatalog: [VideoExportPresetOption] = [
VideoExportPresetOption(name: AVAssetExportPreset3840x2160, maxDimension: 3840),
VideoExportPresetOption(name: AVAssetExportPreset1920x1080, maxDimension: 1920),
VideoExportPresetOption(name: AVAssetExportPreset1280x720, maxDimension: 1280),
VideoExportPresetOption(name: AVAssetExportPreset960x540, maxDimension: 960),
VideoExportPresetOption(name: AVAssetExportPreset640x480, maxDimension: 640),
VideoExportPresetOption(name: AVAssetExportPresetMediumQuality, maxDimension: 640),
VideoExportPresetOption(name: AVAssetExportPresetLowQuality, maxDimension: 480)
]

// Avoid upscaling and keep within the recommended dimension with a ~10% cushion for bitrate flexibility.
// 352 px roughly matches the low-quality preset floor and prevents producing extremely small frames.
let dimensionUpperBound = max(min(sourceMaxDimension, recommendedMaxDimension * 1.1), 352)

var filteredPresets: [VideoExportPresetOption] = presetCatalog.filter { option in
guard availablePresets.contains(option.name) else {
return false
}
return attachment
} catch {
owsFailDebug("Failed to build data source for exported video URL")
let attachment = SignalAttachment(dataSource: DataSourceValue(), dataUTI: dataUTI)
attachment.error = .couldNotConvertToMpeg4
return attachment

if let maxDimension = option.maxDimension {
if maxDimension > sourceMaxDimension * 1.02 {
// Allow at most a 2% rounding buffer before we consider preset dimensions "upscaling".
return false
}
if maxDimension > dimensionUpperBound {
return false
}
}
return true
}

if filteredPresets.isEmpty {
filteredPresets = presetCatalog.filter { availablePresets.contains($0.name) }
}

return filteredPresets
}

private static func recommendedExportDimension(for asset: AVAsset, videoTrack: AVAssetTrack, sourceDimensions: CGSize) -> CGFloat {
let sourceMaxDimension = max(sourceDimensions.width, sourceDimensions.height)
let durationSeconds = max(asset.duration.seconds, 0)
guard durationSeconds > 0 else {
return sourceMaxDimension
}

let targetBitBudget = (Double(OWSMediaUtils.kMaxFileSizeVideo) * 8.0) / durationSeconds

let nominalFrameRate = videoTrack.nominalFrameRate > 0 ? Double(videoTrack.nominalFrameRate) : 30.0
let videoBitrateBudget = max(targetBitBudget * 0.8, targetBitBudget - 128_000.0) // Reserve room for audio and mux overhead.
guard videoBitrateBudget > 0 else {
return max(sourceMaxDimension * 0.5, 352)
}

let sourcePixelsPerFrame = Double(max(sourceDimensions.width, 1) * max(sourceDimensions.height, 1))
guard sourcePixelsPerFrame > 0 else {
return sourceMaxDimension
}

let desiredBitsPerPixel: Double = 0.1 // Heuristic BPP target for H.264 that keeps 30 fps video within limit.
let allowablePixelsPerFrame = videoBitrateBudget / (nominalFrameRate * desiredBitsPerPixel)

if allowablePixelsPerFrame >= sourcePixelsPerFrame {
return sourceMaxDimension
}

let scale = sqrt(allowablePixelsPerFrame / sourcePixelsPerFrame)
let scaledDimension = sourceMaxDimension * CGFloat(scale)

// Ensure we never shrink below 352 px so that outputs remain within standard low-quality preset bounds.
return max(min(scaledDimension, sourceMaxDimension), 352)
}

private static func videoDimensions(for track: AVAssetTrack) -> CGSize {
let naturalSize = track.naturalSize
let transform = track.preferredTransform
let transformedSize = naturalSize.applying(transform)

let orientedWidth = abs(transformedSize.width)
let orientedHeight = abs(transformedSize.height)

if orientedWidth > 0 && orientedHeight > 0 {
return CGSize(width: orientedWidth, height: orientedHeight)
}

let fallbackWidth = max(abs(naturalSize.width), 1)
let fallbackHeight = max(abs(naturalSize.height), 1)
return CGSize(width: fallbackWidth, height: fallbackHeight)
}

private static func isMaximumFileSizeError(_ error: Error) -> Bool {
// Recognize AVFoundation file length limit errors so we can surface `.fileSizeTooLarge`.
let nsError = error as NSError
return nsError.domain == AVFoundationErrorDomain && nsError.code == AVError.maximumFileSizeReached.rawValue
}

public func isVideoThatNeedsCompression() -> Bool {
Expand Down
4 changes: 2 additions & 2 deletions SignalServiceKit/Messages/Attachments/OWSMediaUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,8 @@ public enum OWSMediaUtils {
*
* https://github.com/signalapp/Signal-Android/blob/c4bc2162f23e0fd6bc25941af8fb7454d91a4a35/app/src/main/java/org/thoughtcrime/securesms/mms/PushMediaConstraints.java
*/
public static let kMaxFileSizeAnimatedImage = UInt(25 * 1024 * 1024)
public static let kMaxFileSizeImage = UInt(8 * 1024 * 1024)
public static let kMaxFileSizeAnimatedImage = UInt(95 * 1000 * 1000)
public static let kMaxFileSizeImage = UInt(95 * 1000 * 1000)
// Cloudflare limits uploads to 100 MB. To avoid hitting those limits,
// we use limits that are 5% lower for the unencrypted content.
public static let kMaxFileSizeVideo = UInt(95 * 1000 * 1000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,23 +300,23 @@ extension TSAttachmentMigration {
videoStillFrameFile: nil
)
case .image:
guard byteSize < 8 * 1024 * 1024 /* SignalAttachment.kMaxFileSizeImage */ else {
guard byteSize < SignalServiceKit.OWSMediaUtils.kMaxFileSizeImage else {
throw AttachmentTooLargeError()
}
return try validateImageContentType(
unencryptedFileUrl,
mimeType: &mimeType
) ?? invalidResult
case .animatedImage:
guard byteSize < 25 * 1024 * 1024 /* SignalAttachment.kMaxFileSizeAnimatedImage */ else {
guard byteSize < SignalServiceKit.OWSMediaUtils.kMaxFileSizeAnimatedImage else {
throw AttachmentTooLargeError()
}
return try validateImageContentType(
unencryptedFileUrl,
mimeType: &mimeType
) ?? invalidResult
case .video:
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeVideo */ else {
guard byteSize < SignalServiceKit.OWSMediaUtils.kMaxFileSizeVideo else {
throw AttachmentTooLargeError()
}
return try validateVideoContentType(
Expand All @@ -326,7 +326,7 @@ extension TSAttachmentMigration {
attachmentKey: attachmentKey,
) ?? invalidResult
case .audio:
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeAudio */ else {
guard byteSize < SignalServiceKit.OWSMediaUtils.kMaxFileSizeAudio else {
throw AttachmentTooLargeError()
}
return try validateAudioContentType(
Expand Down