Skip to content

Commit 707f639

Browse files
authored
Improvements (#5)
1 parent 1960afc commit 707f639

File tree

5 files changed

+294
-55
lines changed

5 files changed

+294
-55
lines changed

Package.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import PackageDescription
44

55
let package = Package(
66
name: "fx-upscale",
7-
platforms: [.macOS(.v13)],
7+
platforms: [.macOS(.v13), .iOS(.v16)],
88
products: [
99
.executable(name: "fx-upscale", targets: ["fx-upscale"]),
10+
.library(name: "Upscaling", targets: ["Upscaling"])
1011
],
1112
dependencies: [
1213
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
@@ -17,8 +18,10 @@ let package = Package(
1718
name: "fx-upscale",
1819
dependencies: [
1920
.product(name: "ArgumentParser", package: "swift-argument-parser"),
20-
.product(name: "SwiftTUI", package: "SwiftTUI")
21+
.product(name: "SwiftTUI", package: "SwiftTUI"),
22+
"Upscaling"
2123
]
2224
),
25+
.target(name: "Upscaling")
2326
]
2427
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import AVFoundation
2+
3+
extension CMFormatDescription {
4+
var videoCodecType: AVVideoCodecType? {
5+
switch mediaSubType {
6+
case .hevc: return .hevc
7+
case .h264: return .h264
8+
case .jpeg: return .jpeg
9+
case .proRes4444: return .proRes4444
10+
case .proRes422: return .proRes422
11+
case .proRes422HQ: return .proRes422HQ
12+
case .proRes422LT: return .proRes422LT
13+
case .proRes422Proxy: return .proRes422Proxy
14+
case .hevcWithAlpha: return .hevcWithAlpha
15+
default: return nil
16+
}
17+
}
18+
19+
var colorPrimaries: String? {
20+
switch extensions[
21+
kCMFormatDescriptionExtension_ColorPrimaries
22+
] as! CFString {
23+
case kCMFormatDescriptionColorPrimaries_ITU_R_709_2: AVVideoColorPrimaries_ITU_R_709_2
24+
#if os(macOS)
25+
case kCMFormatDescriptionColorPrimaries_EBU_3213: AVVideoColorPrimaries_EBU_3213
26+
#endif
27+
case kCMFormatDescriptionColorPrimaries_SMPTE_C: AVVideoColorPrimaries_SMPTE_C
28+
case kCMFormatDescriptionColorPrimaries_P3_D65: AVVideoColorPrimaries_P3_D65
29+
case kCMFormatDescriptionColorPrimaries_ITU_R_2020: AVVideoColorPrimaries_ITU_R_2020
30+
default: nil
31+
}
32+
}
33+
34+
var colorTransferFunction: String? {
35+
switch extensions[
36+
kCMFormatDescriptionExtension_TransferFunction
37+
] as! CFString {
38+
case kCMFormatDescriptionTransferFunction_ITU_R_709_2: AVVideoTransferFunction_ITU_R_709_2
39+
#if os(macOS)
40+
case kCMFormatDescriptionTransferFunction_SMPTE_240M_1995: AVVideoTransferFunction_SMPTE_240M_1995
41+
#endif
42+
case kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ: AVVideoTransferFunction_SMPTE_ST_2084_PQ
43+
case kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG: AVVideoTransferFunction_ITU_R_2100_HLG
44+
case kCMFormatDescriptionTransferFunction_Linear: AVVideoTransferFunction_Linear
45+
default: nil
46+
}
47+
}
48+
49+
var colorYCbCrMatrix: String? {
50+
switch extensions[
51+
kCMFormatDescriptionExtension_YCbCrMatrix
52+
] as! CFString {
53+
case kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2: AVVideoYCbCrMatrix_ITU_R_709_2
54+
case kCMFormatDescriptionYCbCrMatrix_ITU_R_601_4: AVVideoYCbCrMatrix_ITU_R_601_4
55+
#if os(macOS)
56+
case kCMFormatDescriptionYCbCrMatrix_SMPTE_240M_1995: AVVideoYCbCrMatrix_SMPTE_240M_1995
57+
#endif
58+
case kCMFormatDescriptionYCbCrMatrix_ITU_R_2020: AVVideoYCbCrMatrix_ITU_R_2020
59+
default: nil
60+
}
61+
}
62+
}

Sources/UpscalingCompositor.swift renamed to Sources/Upscaling/UpscalingCompositor.swift

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,33 @@
11
import AVFoundation
2+
#if canImport(MetalFX)
23
import MetalFX
4+
#endif
35

4-
class UpscalingCompositor: NSObject, AVVideoCompositing {
5-
// MARK: Internal
6+
// MARK: - UpscalingCompositor
67

7-
final class Instruction: NSObject, AVVideoCompositionInstructionProtocol {
8-
// MARK: Lifecycle
8+
public final class UpscalingCompositor: NSObject, AVVideoCompositing {
9+
// MARK: Public
910

10-
init(timeRange: CMTimeRange) {
11-
self.timeRange = timeRange
12-
}
13-
14-
// MARK: Internal
15-
16-
var timeRange: CMTimeRange
17-
let enablePostProcessing = true
18-
let containsTweening = false
19-
var requiredSourceTrackIDs: [NSValue]? = nil
20-
let passthroughTrackID = kCMPersistentTrackID_Invalid
21-
}
22-
23-
let sourcePixelBufferAttributes: [String: Any]? = [
11+
public let sourcePixelBufferAttributes: [String: Any]? = [
2412
kCVPixelBufferPixelFormatTypeKey as String: [kCVPixelFormatType_32BGRA],
2513
kCVPixelBufferIOSurfacePropertiesKey as String: [CFString: Any]()
2614
]
2715

28-
var requiredPixelBufferAttributesForRenderContext: [String: Any] = [
16+
public var requiredPixelBufferAttributesForRenderContext: [String: Any] = [
2917
kCVPixelBufferPixelFormatTypeKey as String: [kCVPixelFormatType_32BGRA],
3018
kCVPixelBufferIOSurfacePropertiesKey as String: [CFString: Any]()
3119
]
3220

33-
var inputSize = CGSize.zero
34-
var outputSize = CGSize.zero
21+
public var inputSize = CGSize.zero
22+
public var outputSize = CGSize.zero
3523

36-
func renderContextChanged(_: AVVideoCompositionRenderContext) {}
24+
public func renderContextChanged(_: AVVideoCompositionRenderContext) {}
3725

38-
func startRequest(_ asyncVideoCompositionRequest: AVAsynchronousVideoCompositionRequest) {
26+
public func startRequest(_ asyncVideoCompositionRequest: AVAsynchronousVideoCompositionRequest) {
3927
let sourceFrame = asyncVideoCompositionRequest.sourceFrame(
4028
byTrackID: CMPersistentTrackID(truncating: asyncVideoCompositionRequest.sourceTrackIDs[0])
4129
)!
30+
#if canImport(MetalFX)
4231
let destinationFrame = asyncVideoCompositionRequest.renderContext.newPixelBuffer()!
4332

4433
let commandBuffer = commandQueue.makeCommandBuffer()!
@@ -82,6 +71,9 @@ class UpscalingCompositor: NSObject, AVVideoCompositing {
8271
commandBuffer.waitUntilCompleted()
8372

8473
asyncVideoCompositionRequest.finish(withComposedVideoFrame: destinationFrame)
74+
#else
75+
asyncVideoCompositionRequest.finish(withComposedVideoFrame: sourceFrame)
76+
#endif
8577
}
8678

8779
// MARK: Private
@@ -94,6 +86,7 @@ class UpscalingCompositor: NSObject, AVVideoCompositing {
9486
return cvTextureCache
9587
}()
9688

89+
#if canImport(MetalFX)
9790
private lazy var spatialScaler: MTLFXSpatialScaler = {
9891
let spatialScalerDescriptor = MTLFXSpatialScalerDescriptor()
9992
spatialScalerDescriptor.inputWidth = Int(inputSize.width)
@@ -105,6 +98,7 @@ class UpscalingCompositor: NSObject, AVVideoCompositing {
10598
spatialScalerDescriptor.colorProcessingMode = .perceptual
10699
return spatialScalerDescriptor.makeSpatialScaler(device: device)!
107100
}()
101+
#endif
108102

109103
private lazy var intermediateOutputTextureDescriptor: MTLTextureDescriptor = {
110104
let textureDescriptor = MTLTextureDescriptor()
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import AVFoundation
2+
3+
// MARK: - UpscalingExportSession
4+
5+
public class UpscalingExportSession {
6+
// MARK: Lifecycle
7+
8+
public init(
9+
asset: AVAsset,
10+
outputURL: URL,
11+
outputFileType: AVFileType,
12+
outputSize: CGSize
13+
) {
14+
self.asset = asset
15+
self.outputURL = outputURL
16+
self.outputFileType = outputFileType
17+
self.outputSize = outputSize
18+
}
19+
20+
// MARK: Public
21+
22+
public static let maxSize = 16384
23+
24+
public let asset: AVAsset
25+
public let outputURL: URL
26+
public let outputFileType: AVFileType
27+
public let outputSize: CGSize
28+
29+
public func export() async throws {
30+
guard !FileManager.default.fileExists(atPath: outputURL.path(percentEncoded: false)) else {
31+
throw Error.outputURLAlreadyExists
32+
}
33+
let assetReader = try AVAssetReader(asset: asset)
34+
let assetWriter = try AVAssetWriter(outputURL: outputURL, fileType: outputFileType)
35+
assetWriter.metadata = try await asset.load(.metadata)
36+
37+
let assetDuration = try await asset.load(.duration)
38+
39+
for track in try await asset.load(.tracks) {
40+
switch track.mediaType {
41+
case .video:
42+
let naturalSize = try await track.load(.naturalSize)
43+
let nominalFrameRate = try await track.load(.nominalFrameRate)
44+
let formatDescription = try await track.load(.formatDescriptions).first
45+
46+
let videoOutput = AVAssetReaderVideoCompositionOutput(
47+
videoTracks: [track],
48+
videoSettings: nil
49+
)
50+
51+
videoOutput.alwaysCopiesSampleData = false
52+
videoOutput.videoComposition = {
53+
let videoComposition = AVMutableVideoComposition()
54+
videoComposition.customVideoCompositorClass = UpscalingCompositor.self
55+
videoComposition.colorPrimaries = formatDescription?.colorPrimaries
56+
videoComposition.colorTransferFunction = formatDescription?.colorTransferFunction
57+
videoComposition.colorYCbCrMatrix = formatDescription?.colorYCbCrMatrix
58+
videoComposition.frameDuration = CMTime(
59+
value: 1,
60+
timescale: nominalFrameRate > 0 ? CMTimeScale(nominalFrameRate) : 30
61+
)
62+
videoComposition.renderSize = outputSize
63+
let instruction = AVMutableVideoCompositionInstruction()
64+
instruction.timeRange = CMTimeRange(start: .zero, duration: assetDuration)
65+
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: track)
66+
instruction.layerInstructions = [layerInstruction]
67+
videoComposition.instructions = [instruction]
68+
return videoComposition
69+
}()
70+
(videoOutput.customVideoCompositor as! UpscalingCompositor).inputSize = naturalSize
71+
(videoOutput.customVideoCompositor as! UpscalingCompositor).outputSize = outputSize
72+
73+
if assetReader.canAdd(videoOutput) {
74+
assetReader.add(videoOutput)
75+
} else {
76+
throw Error.couldNotAddAssetReaderVideoOutput
77+
}
78+
79+
let videoCodec = formatDescription?.videoCodecType ?? .hevc
80+
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: [
81+
AVVideoWidthKey: outputSize.width,
82+
AVVideoHeightKey: outputSize.height,
83+
AVVideoCodecKey: videoCodec
84+
])
85+
videoInput.expectsMediaDataInRealTime = false
86+
if assetWriter.canAdd(videoInput) {
87+
assetWriter.add(videoInput)
88+
} else {
89+
throw Error.couldNotAddAssetWriterVideoInput
90+
}
91+
case .audio:
92+
let audioOutput = AVAssetReaderAudioMixOutput(audioTracks: [track], audioSettings: nil)
93+
audioOutput.alwaysCopiesSampleData = false
94+
if assetReader.canAdd(audioOutput) {
95+
assetReader.add(audioOutput)
96+
} else {
97+
throw Error.couldNotAddAssetReaderAudioOutput
98+
}
99+
100+
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
101+
audioInput.expectsMediaDataInRealTime = false
102+
if assetWriter.canAdd(audioInput) {
103+
assetWriter.add(audioInput)
104+
} else {
105+
throw Error.couldNotAddAssetWriterAudioInput
106+
}
107+
default: continue
108+
}
109+
}
110+
111+
assert(assetWriter.inputs.count == assetReader.outputs.count)
112+
113+
assetWriter.startWriting()
114+
assetReader.startReading()
115+
assetWriter.startSession(atSourceTime: .zero)
116+
117+
await withCheckedContinuation { continuation in
118+
// - Returns: Whether or not the input has read all available media data
119+
@Sendable func copyReadySamples(from output: AVAssetReaderOutput, to input: AVAssetWriterInput) -> Bool {
120+
while input.isReadyForMoreMediaData {
121+
if let sampleBuffer = output.copyNextSampleBuffer() {
122+
if !input.append(sampleBuffer) {
123+
return true
124+
}
125+
} else {
126+
input.markAsFinished()
127+
return true
128+
}
129+
}
130+
return false
131+
}
132+
133+
@Sendable func finish() {
134+
if assetWriter.status == .failed {
135+
try? FileManager.default.removeItem(at: outputURL)
136+
continuation.resume()
137+
} else if assetReader.status == .failed {
138+
assetWriter.cancelWriting()
139+
if [.cancelled, .failed].contains(assetWriter.status) {
140+
try? FileManager.default.removeItem(at: outputURL)
141+
}
142+
continuation.resume()
143+
} else {
144+
assetWriter.finishWriting {
145+
if [.cancelled, .failed].contains(assetWriter.status) {
146+
try? FileManager.default.removeItem(at: self.outputURL)
147+
}
148+
continuation.resume()
149+
}
150+
}
151+
}
152+
153+
actor FinishCount {
154+
var isFinished: Bool { count >= finishCount }
155+
private var count = 0
156+
private let finishCount: Int
157+
func increment() { count += 1 }
158+
init(finishCount: Int) { self.finishCount = finishCount }
159+
}
160+
let finishCount = FinishCount(finishCount: assetWriter.inputs.count)
161+
162+
let queue = DispatchQueue(label: String(describing: Self.self))
163+
for (input, output) in zip(assetWriter.inputs, assetReader.outputs) {
164+
input.requestMediaDataWhenReady(on: queue) {
165+
let finishedReading = copyReadySamples(from: output, to: input)
166+
if finishedReading {
167+
Task {
168+
await finishCount.increment()
169+
if await finishCount.isFinished { finish() }
170+
}
171+
}
172+
}
173+
}
174+
} as Void
175+
}
176+
}
177+
178+
// MARK: UpscalingExportSession.Error
179+
180+
extension UpscalingExportSession {
181+
enum Error: Swift.Error {
182+
case outputURLAlreadyExists
183+
case couldNotAddAssetReaderVideoOutput
184+
case couldNotAddAssetWriterVideoInput
185+
case couldNotAddAssetReaderAudioOutput
186+
case couldNotAddAssetWriterAudioInput
187+
}
188+
}

0 commit comments

Comments
 (0)