219 lines
8.7 KiB
Swift
219 lines
8.7 KiB
Swift
//
|
|
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import AVFoundation
|
|
public import SignalServiceKit
|
|
|
|
/// Represents an attachment that's fully valid and ready to send.
|
|
///
|
|
/// See also ``PreviewableAttachment``.
|
|
///
|
|
/// These are attachments that have been fully processed and are ready to
|
|
/// send as-is. The bytes representing these attachments meet the criteria
|
|
/// for sending via Signal.
|
|
public struct SendableAttachment {
|
|
public let dataSource: DataSourcePath
|
|
public let dataUTI: String
|
|
public let sourceFilename: FilteredFilename?
|
|
public let mimeType: String
|
|
public let renderingFlag: AttachmentReference.RenderingFlag
|
|
|
|
private init(
|
|
dataSource: DataSourcePath,
|
|
dataUTI: String,
|
|
mimeType: String,
|
|
renderingFlag: AttachmentReference.RenderingFlag,
|
|
) {
|
|
self.dataSource = dataSource
|
|
self.dataUTI = dataUTI
|
|
self.sourceFilename = dataSource.sourceFilename.map(FilteredFilename.init(rawValue:))
|
|
self.mimeType = mimeType
|
|
self.renderingFlag = renderingFlag
|
|
}
|
|
|
|
private init(nonImagePreviewableAttachment previewableAttachment: PreviewableAttachment) {
|
|
self.init(
|
|
dataSource: previewableAttachment.dataSource,
|
|
dataUTI: previewableAttachment.dataUTI,
|
|
mimeType: previewableAttachment.mimeType,
|
|
renderingFlag: previewableAttachment.renderingFlag,
|
|
)
|
|
}
|
|
|
|
@concurrent
|
|
public static func forPreviewableAttachment(
|
|
_ attachment: PreviewableAttachment,
|
|
imageQualityLevel: ImageQualityLevel,
|
|
) async throws -> Self {
|
|
switch attachment.attachmentType {
|
|
case .animatedImage where attachment.dataUTI == UTType.png.identifier:
|
|
let strippedData = try NormalizedImage.removeImageMetadata(fromPngData: attachment.dataSource.readData())
|
|
let dataSource = try DataSourcePath(writingTempFileData: strippedData, fileExtension: "png")
|
|
return SendableAttachment(
|
|
dataSource: dataSource,
|
|
dataUTI: attachment.dataUTI,
|
|
mimeType: attachment.mimeType,
|
|
renderingFlag: attachment.renderingFlag,
|
|
)
|
|
case .animatedImage:
|
|
// Other animated images aren't re-encoded.
|
|
break
|
|
case .image(let normalizedImage):
|
|
let finalizedImage = try normalizedImage.finalizeImage(imageQuality: imageQualityLevel)
|
|
return SendableAttachment(
|
|
dataSource: finalizedImage.dataSource,
|
|
dataUTI: finalizedImage.dataUTI,
|
|
mimeType: MimeTypeUtil.mimeTypeForDataSource(finalizedImage.dataSource, dataUTI: finalizedImage.dataUTI),
|
|
renderingFlag: attachment.renderingFlag,
|
|
)
|
|
case .other:
|
|
break
|
|
}
|
|
return Self(nonImagePreviewableAttachment: attachment)
|
|
}
|
|
|
|
/// A default filename to use if one isn't provided by the user.
|
|
var defaultFilename: String {
|
|
let kDefaultAttachmentName = "signal"
|
|
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
|
let dateString = dateFormatter.string(from: Date())
|
|
|
|
var defaultFilename = "\(kDefaultAttachmentName)-\(dateString)"
|
|
if let fileExtension = MimeTypeUtil.fileExtensionForUtiType(self.dataUTI) {
|
|
defaultFilename += ".\(fileExtension)"
|
|
}
|
|
return defaultFilename
|
|
}
|
|
|
|
// MARK: - Video Segmenting
|
|
|
|
public struct SegmentAttachmentResult {
|
|
public let original: SendableAttachment
|
|
public let segmented: [SendableAttachment]?
|
|
|
|
public init(_ original: SendableAttachment, segmented: [SendableAttachment]? = nil) {
|
|
assert(segmented?.isEmpty != true)
|
|
self.original = original
|
|
self.segmented = segmented
|
|
}
|
|
}
|
|
|
|
/// If the attachment is a video longer than `storyVideoSegmentMaxDuration`,
|
|
/// segments into separate attachments under that duration.
|
|
/// Otherwise returns a result with only the original and nil segmented attachments.
|
|
public func segmentedIfNecessary(
|
|
segmentDuration: TimeInterval,
|
|
attachmentLimits: OutgoingAttachmentLimits,
|
|
) async throws -> SegmentAttachmentResult {
|
|
guard SignalAttachment.videoUTISet.contains(self.dataUTI) else {
|
|
return SegmentAttachmentResult(self, segmented: nil)
|
|
}
|
|
let asset = AVURLAsset(url: self.dataSource.fileUrl)
|
|
let cmDuration = asset.duration
|
|
let duration = cmDuration.seconds
|
|
guard duration > segmentDuration else {
|
|
// No need to segment, we are done.
|
|
return SegmentAttachmentResult(self, segmented: nil)
|
|
}
|
|
|
|
var startTime: TimeInterval = 0
|
|
var segmentFileUrls = [URL]()
|
|
while startTime < duration {
|
|
segmentFileUrls.append(try await Self.trimAsset(
|
|
asset,
|
|
from: startTime,
|
|
duration: segmentDuration,
|
|
totalDuration: cmDuration,
|
|
))
|
|
startTime += segmentDuration
|
|
}
|
|
|
|
let segments = try segmentFileUrls.map { url in
|
|
let dataSource = DataSourcePath(fileUrl: url, ownership: .owned)
|
|
// [15M] TODO: This doesn't transfer all SignalAttachment fields.
|
|
let attachment = try PreviewableAttachment.videoAttachment(dataSource: dataSource, dataUTI: self.dataUTI, attachmentLimits: attachmentLimits)
|
|
return Self(nonImagePreviewableAttachment: attachment)
|
|
}
|
|
return SegmentAttachmentResult(self, segmented: segments)
|
|
}
|
|
|
|
fileprivate static func trimAsset(
|
|
_ asset: AVURLAsset,
|
|
from startTime: TimeInterval,
|
|
duration: TimeInterval,
|
|
totalDuration: CMTime,
|
|
) async throws -> URL {
|
|
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
|
|
throw OWSAssertionError("Failed to start export session for segmentation")
|
|
}
|
|
|
|
// tmp url is ok, it gets moved when converted to a Attachment later anyway.
|
|
let outputUrl = OWSFileSystem.temporaryFileUrl(
|
|
fileExtension: asset.url.pathExtension,
|
|
isAvailableWhileDeviceLocked: true,
|
|
)
|
|
exportSession.outputURL = outputUrl
|
|
/// This is hardcoded here and in our media editor. That's in signalUI, so hard to link the two.
|
|
exportSession.outputFileType = AVFileType.mp4
|
|
// Puts file metadata in the right place for streaming validation.
|
|
exportSession.shouldOptimizeForNetworkUse = true
|
|
|
|
let cmStart = CMTime(seconds: startTime, preferredTimescale: totalDuration.timescale)
|
|
let endTime = min(startTime + duration, totalDuration.seconds)
|
|
let cmEnd = CMTime(seconds: endTime, preferredTimescale: totalDuration.timescale)
|
|
exportSession.timeRange = CMTimeRange(start: cmStart, end: cmEnd)
|
|
|
|
await exportSession.export()
|
|
|
|
switch exportSession.status {
|
|
case .completed:
|
|
return outputUrl
|
|
case .cancelled, .failed:
|
|
throw OWSAssertionError("Video segmentation export session failed")
|
|
case .unknown, .waiting, .exporting:
|
|
fallthrough
|
|
@unknown default:
|
|
throw OWSAssertionError("Video segmentation failed with unknown status: \(exportSession.status)")
|
|
}
|
|
}
|
|
|
|
// MARK: - ForSending
|
|
|
|
public struct ForSending {
|
|
public let dataSource: AttachmentDataSource
|
|
public let renderingFlag: AttachmentReference.RenderingFlag
|
|
|
|
public init(dataSource: AttachmentDataSource, renderingFlag: AttachmentReference.RenderingFlag) {
|
|
self.dataSource = dataSource
|
|
self.renderingFlag = renderingFlag
|
|
}
|
|
}
|
|
|
|
public func forSending(attachmentContentValidator: any AttachmentContentValidator) async throws -> ForSending {
|
|
let dataSource = try await attachmentContentValidator.validateSendableAttachmentContents(self, shouldUseDefaultFilename: true)
|
|
return ForSending(
|
|
dataSource: dataSource,
|
|
renderingFlag: self.renderingFlag,
|
|
)
|
|
}
|
|
}
|
|
|
|
extension AttachmentContentValidator {
|
|
public func validateSendableAttachmentContents(
|
|
_ sendableAttachment: SendableAttachment,
|
|
shouldUseDefaultFilename: Bool,
|
|
) async throws -> AttachmentDataSource {
|
|
let pendingAttachment = try await validateDataSourceContents(
|
|
sendableAttachment.dataSource,
|
|
mimeType: sendableAttachment.mimeType,
|
|
renderingFlag: sendableAttachment.renderingFlag,
|
|
sourceFilename: sendableAttachment.sourceFilename?.rawValue ?? (shouldUseDefaultFilename ? sendableAttachment.defaultFilename : nil),
|
|
)
|
|
return .pendingAttachment(pendingAttachment)
|
|
}
|
|
}
|