Skip to content

Commit 449247d

Browse files
andrewjl-muxcjpillsbury
authored andcommitted
Project Input standardization (#48)
Upload Input Inspection and Standardization 1 (#17) Add scaffolding Add initializers Add internal and external state mapping Remove duplicate status enum and add inline docs to external status Add inline API docs to PHAsset-based MuxUpload constructor Consolidate all `MuxUpload` options into a single struct `UploadOptions` Declare asynchronous MuxUpload constructor in PHAsset extension Place extension methods into dedicated directories Polish inline API documentation Add new API documentation and note the placeholder implementation Add option variants as static members: defaults, disabled inputStandardization Deprecate existing initializer, normally this API should be removed prior to GA, but since it was the only initializer exposed up to this point removing it would break everybody. Instead deprecate and remove at a later date. Store all MuxUpload-related options in UploadInfo Use correct starting byte parameter when restarting upload Hardcode request options when exporting AVAsset from PhotosKit chunkSize -> chunkSizeInBytes Input standardization 2 (#41) Maintain handle to canonical input asset inside UploadInput If input standardized, standardized input URL is passed to UploadInfo instead of the original input URL used for initializer Note: SDK probably needs to re-export a high quality asset anyway so possibly need a bridging status Add dedicated internal initializer for MuxUpload error with unknown error code Disable input standardization when running tests Add inspection logic Request local and remote assets Status -> TransportStatus Rely on input status in MuxUpload computed property getters Expose progress from internal upload state if available Track transport status inside of UploadInput Transport status start time optional Add TransportStatus docs Inspect and check video track frame rate Migrate tests for MuxUpload input result handler Input standardization 3 (#46) Standardize via export session Back out outputURL construction into MuxUpload Expose hook for client to cancel upload if standardization failed Call cancellation hook if inspection fails. We're not sure if the input is standard or not so better to be safe and confirm Export based on maximum resolution set by client Cleaner non standard input handler invocation (#57) Add CustomStringConvertible conformance to maximum resolution (#56) Update input state when upload is finished (#63) Update input state when the upload succeeds or fails Tweak input state definition, keep an error in the failed case and the success struct in the success case NFC: remove some if let checks for the handler closure properties in MuxUpload and replace with ? operator. Keeps the existing logic. Replace video file get with actual implementation (#64) Reporting updates (#49) Only mark upload as started if its ready Add upload failed and input standardization events Add standardization failure event handler to reporter Add standardization success event handler to reporter Rename method for upload success Rename upload event to upload succeeded Support dispatching multiple events at the same time Naive non-thread safe implementation reporter networking Change event type casing, individual files for events Fix reporter test Actually report upload failure Read off file size Use Date in events and serialize to ISO8601 string Finangle duration and start time Set correct version in correct place Keep a session UUID Route every request through one chokepoint Upload failed test Shared test encoder Input standardization failed test Input standardization succeeded test Better organize expected json strings Use a shared instance Remove dupe file Thread asset duration through inspection and standardization Pass export error back Include non standard input reasons Workaround duration load pause Avoid mutating while iterating when UploadManager makes delegate calls Set custom HTTP request header for reporting (#67) Remove Equatable and Hashable conformance from MuxUpload (#68) Safe storage for MuxUpload (#71) Intended to prevent a crash if MuxUpload is extended by the SDK client to conform to Equatable or Hashable protocols
1 parent 4523d0e commit 449247d

33 files changed

+2561
-367
lines changed

Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/ThumbnailModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class ThumbnailModel: ObservableObject {
5252
private var thumbnailGenerator: AVAssetImageGenerator?
5353

5454
@Published var thumbnail: CGImage?
55-
@Published var uploadProgress: MuxUpload.Status?
55+
@Published var uploadProgress: MuxUpload.TransportStatus?
5656

5757
init(asset: AVAsset, upload: MuxUpload) {
5858
self.asset = asset

Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/UploadCreationModel.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ class UploadCreationModel : ObservableObject {
5656

5757
/// Prepares a Photos Asset for upload by exporting it to a local temp file
5858
func tryToPrepare(from pickerResult: PHPickerResult) {
59+
if case ExportState.preparing = exportState {
60+
return
61+
}
62+
5963
// Cancel anything that was already happening
6064
if let assetRequestId = assetRequestId {
6165
PHImageManager.default().cancelImageRequest(assetRequestId)
@@ -92,7 +96,8 @@ class UploadCreationModel : ObservableObject {
9296
}
9397

9498
let exportOptions = PHVideoRequestOptions()
95-
//exportOptions.deliveryMode = .highQualityFormat
99+
exportOptions.isNetworkAccessAllowed = true
100+
exportOptions.deliveryMode = .highQualityFormat
96101
assetRequestId = PHImageManager.default().requestExportSession(forVideo: phAsset, options: exportOptions, exportPreset: AVAssetExportPresetHighestQuality, resultHandler: {(exportSession, info) -> Void in
97102
DispatchQueue.main.async {
98103
guard let exportSession = exportSession else {

Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/UploadListModel.swift

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,27 @@ class UploadListModel : ObservableObject {
1515
UploadManager.shared.addUploadsUpdatedDelegate(
1616
Delegate(
1717
handler: { uploads in
18-
var uploadSet = Set(self.lastKnownUploads)
19-
uploads.forEach {
20-
uploadSet.insert($0)
21-
}
22-
self.lastKnownUploads = Array(uploadSet)
23-
.sorted(
24-
by: { lhs, rhs in
25-
lhs.uploadStatus.startTime >= rhs.uploadStatus.startTime
18+
19+
var lastKnownUploadsToUpdate = self.lastKnownUploads
20+
21+
for updatedUpload in uploads {
22+
if !lastKnownUploadsToUpdate.contains(
23+
where: {
24+
$0.uploadURL == updatedUpload.uploadURL &&
25+
$0.videoFile == updatedUpload.videoFile
26+
}
27+
) {
28+
lastKnownUploadsToUpdate.append(updatedUpload)
2629
}
27-
)
28-
}
30+
}
31+
32+
self.lastKnownUploads = lastKnownUploadsToUpdate
33+
.sorted(
34+
by: { lhs, rhs in
35+
(lhs.uploadStatus?.startTime ?? 0) >= (rhs.uploadStatus?.startTime ?? 0)
36+
}
37+
)
38+
}
2939
)
3040
)
3141
}

Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/UploadListView.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ struct UploadListScreen: View {
2323
}
2424
}
2525

26+
extension MuxUpload {
27+
var objectIdentifier: ObjectIdentifier {
28+
ObjectIdentifier(self)
29+
}
30+
}
31+
2632
fileprivate struct ListContainerView: View {
2733

2834
@EnvironmentObject var viewModel: UploadListModel
@@ -33,7 +39,7 @@ fileprivate struct ListContainerView: View {
3339
} else {
3440
ScrollView {
3541
LazyVStack {
36-
ForEach(viewModel.lastKnownUploads, id: \.self) { upload in
42+
ForEach(viewModel.lastKnownUploads, id: \.objectIdentifier) { upload in
3743
ListItem(upload: upload)
3844
}
3945
}
@@ -124,12 +130,12 @@ fileprivate struct ListItem: View {
124130
}
125131
}
126132

127-
private func statusLine(status: MuxUpload.Status?) -> String {
128-
guard let status = status, let progress = status.progress, status.startTime > 0 else {
133+
private func statusLine(status: MuxUpload.TransportStatus?) -> String {
134+
guard let status = status, let progress = status.progress, let startTime = status.startTime, startTime > 0 else {
129135
return "missing status"
130136
}
131137

132-
let totalTimeSecs = status.updatedTime - status.startTime
138+
let totalTimeSecs = status.updatedTime - (status.startTime ?? 0)
133139
let totalTimeMs = Int64((totalTimeSecs) * 1000)
134140
let kbytesPerSec = (progress.completedUnitCount) / totalTimeMs // bytes/milli = kb/sec
135141
let fourSigs = NumberFormatter()
@@ -146,7 +152,7 @@ fileprivate struct ListItem: View {
146152
return "\(formattedMBytes) MB in \(formattedTime)s (\(formattedDataRate) KB/s)"
147153
}
148154

149-
private func elapsedBytesOfTotal(status: MuxUpload.Status) -> String {
155+
private func elapsedBytesOfTotal(status: MuxUpload.TransportStatus) -> String {
150156
guard let progress = status.progress else {
151157
return "unknown"
152158
}
@@ -157,7 +163,7 @@ fileprivate struct ListItem: View {
157163
self.upload = upload
158164
_thumbnailModel = StateObject(
159165
wrappedValue: {
160-
ThumbnailModel(asset: AVAsset(url: upload.videoFile), upload: upload)
166+
ThumbnailModel(asset: AVAsset(url: upload.videoFile!), upload: upload)
161167
}()
162168
)
163169
}

Sources/MuxUploadSDK/Extensions/NSMutableURLRequest.swift renamed to Sources/MuxUploadSDK/Extensions/NSMutableURLRequest+Reporting.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import Foundation
77
extension NSMutableURLRequest {
88
static func makeJSONPost(
99
url: URL,
10-
httpBody: Data
10+
httpBody: Data,
11+
additionalHTTPHeaders: [String: String]
1112
) -> NSMutableURLRequest {
1213
let request = NSMutableURLRequest(
1314
url: url,
@@ -18,6 +19,10 @@ extension NSMutableURLRequest {
1819
request.httpMethod = "POST"
1920
request.setValue("application/json", forHTTPHeaderField: "Accept")
2021
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
22+
for keypair in additionalHTTPHeaders {
23+
request.setValue(keypair.value, forHTTPHeaderField: keypair.key)
24+
}
25+
2126
request.httpBody = httpBody
2227

2328
return request
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// UploadInputFormatInspectionResult.swift
3+
//
4+
5+
import AVFoundation
6+
import Foundation
7+
8+
enum UploadInputFormatInspectionResult {
9+
10+
enum NonstandardInputReason {
11+
case videoCodec
12+
case audioCodec
13+
case videoGOPSize
14+
case videoFrameRate
15+
case videoResolution
16+
case videoBitrate
17+
case pixelAspectRatio
18+
case videoEditList
19+
case audioEditList
20+
case unexpectedMediaFileParameters
21+
case unsupportedPixelFormat
22+
}
23+
24+
case inspectionFailure(duration: CMTime)
25+
case standard(duration: CMTime)
26+
case nonstandard(
27+
reasons: [NonstandardInputReason],
28+
duration: CMTime
29+
)
30+
31+
var isStandard: Bool {
32+
if case Self.standard = self {
33+
return true
34+
} else {
35+
return false
36+
}
37+
}
38+
39+
var sourceInputDuration: CMTime {
40+
switch self {
41+
case .inspectionFailure(duration: let duration):
42+
return duration
43+
case .standard(duration: let duration):
44+
return duration
45+
case .nonstandard(_, duration: let duration):
46+
return duration
47+
}
48+
}
49+
50+
var nonstandardInputReasons: [NonstandardInputReason]? {
51+
if case Self.nonstandard(let nonstandardInputReasons, _) = self {
52+
return nonstandardInputReasons
53+
} else {
54+
return nil
55+
}
56+
}
57+
58+
}
59+
60+
extension UploadInputFormatInspectionResult.NonstandardInputReason: CustomStringConvertible {
61+
var description: String {
62+
switch self {
63+
case .audioCodec:
64+
return "audio_codec"
65+
case .audioEditList:
66+
return "audio_edit_list"
67+
case .pixelAspectRatio:
68+
return "pixel_aspect_ratio"
69+
case .videoBitrate:
70+
return "video_bitrate"
71+
case .videoCodec:
72+
return "video_codec"
73+
case .videoEditList:
74+
return "video_edit_list"
75+
case .videoFrameRate:
76+
return "video_frame_rate"
77+
case .videoGOPSize:
78+
return "video_gop_size"
79+
case .videoResolution:
80+
return "video_resolution"
81+
case .unexpectedMediaFileParameters:
82+
return "unexpected_media_file_parameters"
83+
case .unsupportedPixelFormat:
84+
return "unsupported_pixel_format"
85+
}
86+
}
87+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//
2+
// UploadInputInspector.swift
3+
//
4+
5+
import AVFoundation
6+
import CoreMedia
7+
import Foundation
8+
9+
protocol UploadInputInspector {
10+
func performInspection(
11+
sourceInput: AVAsset,
12+
completionHandler: @escaping (UploadInputFormatInspectionResult) -> ()
13+
)
14+
}
15+
16+
class AVFoundationUploadInputInspector: UploadInputInspector {
17+
18+
static let shared = AVFoundationUploadInputInspector()
19+
20+
func performInspection(
21+
sourceInput: AVAsset,
22+
completionHandler: @escaping (UploadInputFormatInspectionResult) -> ()
23+
) {
24+
sourceInput.loadValuesAsynchronously(
25+
forKeys: [
26+
"duration"
27+
]
28+
) {
29+
// FIXME: Trying to avoid the callback pyramid of doom
30+
// here, newer AVAsset APIs use Concurrency
31+
// but Concurrency itself has very primitive
32+
// task sequencing. Replace with async AVAsset
33+
// methods.
34+
let sourceInputDuration = sourceInput.duration
35+
self.performInspection(
36+
sourceInput: sourceInput,
37+
sourceInputDuration: sourceInputDuration,
38+
completionHandler: completionHandler
39+
)
40+
}
41+
}
42+
43+
func performInspection(
44+
sourceInput: AVAsset,
45+
sourceInputDuration: CMTime,
46+
completionHandler: @escaping (UploadInputFormatInspectionResult) -> ()
47+
) {
48+
// TODO: Eventually load audio tracks too
49+
if #available(iOS 15, *) {
50+
sourceInput.loadTracks(
51+
withMediaType: .video
52+
) { tracks, error in
53+
if error != nil {
54+
completionHandler(
55+
.inspectionFailure(duration: sourceInputDuration)
56+
)
57+
return
58+
}
59+
60+
if let tracks {
61+
self.inspect(
62+
sourceInputDuration: sourceInputDuration,
63+
tracks: tracks,
64+
completionHandler: completionHandler
65+
)
66+
}
67+
}
68+
} else {
69+
sourceInput.loadValuesAsynchronously(
70+
forKeys: [
71+
"tracks"
72+
]
73+
) {
74+
// Non-blocking if "tracks" is already loaded
75+
let tracks = sourceInput.tracks(
76+
withMediaType: .video
77+
)
78+
79+
self.inspect(
80+
sourceInputDuration: sourceInputDuration,
81+
tracks: tracks,
82+
completionHandler: completionHandler
83+
)
84+
}
85+
}
86+
}
87+
88+
func inspect(
89+
sourceInputDuration: CMTime,
90+
tracks: [AVAssetTrack],
91+
completionHandler: @escaping (UploadInputFormatInspectionResult) -> ()
92+
) {
93+
switch tracks.count {
94+
case 0:
95+
// Nothing to inspect, therefore nothing to standardize
96+
// declare as already standard
97+
completionHandler(
98+
.standard(duration: sourceInputDuration)
99+
)
100+
case 1:
101+
if let track = tracks.first {
102+
track.loadValuesAsynchronously(
103+
forKeys: [
104+
"formatDescriptions",
105+
"nominalFrameRate"
106+
]
107+
) {
108+
guard let formatDescriptions = track.formatDescriptions as? [CMFormatDescription] else {
109+
completionHandler(
110+
.inspectionFailure(
111+
duration: sourceInputDuration
112+
)
113+
)
114+
return
115+
}
116+
117+
guard let formatDescription = formatDescriptions.first else {
118+
completionHandler(
119+
.inspectionFailure(duration: sourceInputDuration)
120+
)
121+
return
122+
}
123+
124+
var nonStandardReasons: [UploadInputFormatInspectionResult.NonstandardInputReason] = []
125+
126+
let videoDimensions = CMVideoFormatDescriptionGetDimensions(
127+
formatDescription
128+
)
129+
130+
if max(videoDimensions.width, videoDimensions.height) > 1920 {
131+
nonStandardReasons.append(.videoResolution)
132+
}
133+
134+
let videoCodecType = formatDescription.mediaSubType
135+
136+
let standard = CMFormatDescription.MediaSubType.h264
137+
138+
if videoCodecType != standard {
139+
nonStandardReasons.append(.videoCodec)
140+
}
141+
142+
let frameRate = track.nominalFrameRate
143+
if frameRate > 120.0 {
144+
nonStandardReasons.append(.videoFrameRate)
145+
}
146+
147+
if nonStandardReasons.isEmpty {
148+
completionHandler(
149+
.standard(duration: sourceInputDuration)
150+
)
151+
} else {
152+
completionHandler(.nonstandard(reasons: nonStandardReasons, duration: sourceInputDuration))
153+
}
154+
155+
}
156+
}
157+
default:
158+
// Inspection fails for multi-video track inputs
159+
// for the time being
160+
completionHandler(
161+
.inspectionFailure(duration: sourceInputDuration)
162+
)
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)