Skip to content

Commit 34c14a5

Browse files
authored
refactor(recording): share the AVFoundation export pipeline between overlay and trim (#1943)
* wip: shared recording export support * refactor(recording): share the AVFoundation export pipeline between overlay and trim Extracts RecordingExportSupport.swift (error vocabulary, flag-value reading, composition assembly, bounded export wait) so recording-overlay and recording-trim stop carrying three near-identical copies of the same mechanics. Entry points move to @main because multi-file swiftc reserves top-level statements for main.swift. compileSwiftSourceFile gains extraSourcePaths: extra units join the cache key and reach swiftc, and overlay.ts passes the shared support file for both scripts. Error messages and per-script stderr prefixes are unchanged; trim's error-precedence order (missing video track before invalid range) is preserved by resolving the track before the range guards.
1 parent 1e68bf2 commit 34c14a5

7 files changed

Lines changed: 270 additions & 182 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import AVFoundation
2+
import Foundation
3+
4+
/// Shared mechanics for the recording post-processing scripts (overlay burn-in, start trim).
5+
/// Each script keeps its own argument grammar and export-quality policy; this file owns the
6+
/// error vocabulary, flag-value reading, composition assembly, and the bounded export wait.
7+
8+
enum RecordingScriptError: Error, CustomStringConvertible {
9+
case invalidArgs(String)
10+
case invalidTrimRange
11+
case missingVideoTrack
12+
case exportFailed(String)
13+
14+
var description: String {
15+
switch self {
16+
case .invalidArgs(let message):
17+
return message
18+
case .invalidTrimRange:
19+
return "Trim start must be before the end of the recording."
20+
case .missingVideoTrack:
21+
return "Input video does not contain a video track."
22+
case .exportFailed(let message):
23+
return message
24+
}
25+
}
26+
}
27+
28+
func recordingOptionValue(_ arguments: [String], _ nextIndex: Int, _ flag: String) throws -> String {
29+
guard nextIndex < arguments.count else {
30+
throw RecordingScriptError.invalidArgs("\(flag) requires a value")
31+
}
32+
return arguments[nextIndex]
33+
}
34+
35+
func removeStaleOutput(_ outputURL: URL) throws {
36+
if FileManager.default.fileExists(atPath: outputURL.path) {
37+
try FileManager.default.removeItem(at: outputURL)
38+
}
39+
}
40+
41+
func sourceVideoTrack(of asset: AVURLAsset) throws -> AVAssetTrack {
42+
guard let track = asset.tracks(withMediaType: .video).first else {
43+
throw RecordingScriptError.missingVideoTrack
44+
}
45+
return track
46+
}
47+
48+
/// Copies `videoTrack` for `timeRange` into a fresh composition, carrying the optional audio track
49+
/// along when present. The caller owns any `preferredTransform` policy: trim propagates the source
50+
/// transform directly, while overlay re-applies it through a video-composition layer instruction
51+
/// instead.
52+
func makeRecordingComposition(
53+
asset: AVURLAsset,
54+
videoTrack: AVAssetTrack,
55+
timeRange: CMTimeRange
56+
) throws -> AVMutableComposition {
57+
let composition = AVMutableComposition()
58+
guard let compositionVideoTrack = composition.addMutableTrack(
59+
withMediaType: .video,
60+
preferredTrackID: kCMPersistentTrackID_Invalid
61+
) else {
62+
throw RecordingScriptError.exportFailed("Failed to create composition video track.")
63+
}
64+
try compositionVideoTrack.insertTimeRange(timeRange, of: videoTrack, at: .zero)
65+
66+
if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first,
67+
let compositionAudioTrack = composition.addMutableTrack(
68+
withMediaType: .audio,
69+
preferredTrackID: kCMPersistentTrackID_Invalid
70+
) {
71+
try? compositionAudioTrack.insertTimeRange(timeRange, of: sourceAudioTrack, at: .zero)
72+
}
73+
return composition
74+
}
75+
76+
func makeRecordingExporter(
77+
_ composition: AVAsset,
78+
presetName: String,
79+
outputURL: URL,
80+
videoComposition: AVMutableVideoComposition? = nil
81+
) throws -> AVAssetExportSession {
82+
guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
83+
throw RecordingScriptError.exportFailed("Failed to create export session.")
84+
}
85+
exporter.outputURL = outputURL
86+
exporter.outputFileType = .mp4
87+
exporter.videoComposition = videoComposition
88+
exporter.shouldOptimizeForNetworkUse = true
89+
return exporter
90+
}
91+
92+
/// Bounded asynchronous export: signals completion through a semaphore and cancels after 120s so a
93+
/// wedged encoder cannot hang the recording pipeline past the caller's own timeout budget.
94+
func runRecordingExport(
95+
_ exporter: AVAssetExportSession,
96+
timeoutMessage: String,
97+
failureMessage: String
98+
) throws {
99+
let semaphore = DispatchSemaphore(value: 0)
100+
exporter.exportAsynchronously {
101+
semaphore.signal()
102+
}
103+
if semaphore.wait(timeout: .now() + 120) == .timedOut {
104+
exporter.cancelExport()
105+
throw RecordingScriptError.exportFailed(timeoutMessage)
106+
}
107+
108+
if exporter.status != .completed {
109+
throw RecordingScriptError.exportFailed(exporter.error?.localizedDescription ?? failureMessage)
110+
}
111+
}

apple/runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift

Lines changed: 42 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -30,40 +30,33 @@ struct GestureEvent: Decodable {
3030
let edge: String?
3131
}
3232

33-
enum OverlayError: Error, CustomStringConvertible {
34-
case invalidArgs(String)
35-
case missingVideoTrack
36-
case exportFailed(String)
33+
enum ExportQuality: String {
34+
case medium
35+
case high
36+
}
3737

38-
var description: String {
39-
switch self {
40-
case .invalidArgs(let message):
41-
return message
42-
case .missingVideoTrack:
43-
return "Input video does not contain a video track."
44-
case .exportFailed(let message):
45-
return message
38+
/// Entry point: `@main` because multi-file swiftc compilation reserves top-level statements for
39+
/// `main.swift`, which cannot be shared per-script.
40+
@main
41+
enum RecordingOverlay {
42+
static func main() {
43+
do {
44+
try run()
45+
} catch {
46+
fputs("recording-overlay: \(error)\n", stderr)
47+
exit(1)
4648
}
4749
}
4850
}
4951

50-
do {
51-
try run()
52-
} catch {
53-
fputs("recording-overlay: \(error)\n", stderr)
54-
exit(1)
55-
}
56-
5752
func run() throws {
5853
let arguments = Array(CommandLine.arguments.dropFirst())
5954
let parsedArgs = try parseArguments(arguments)
6055
let inputURL = URL(fileURLWithPath: parsedArgs.inputPath)
6156
let outputURL = URL(fileURLWithPath: parsedArgs.outputPath)
6257
let eventsURL = URL(fileURLWithPath: parsedArgs.eventsPath)
6358

64-
if FileManager.default.fileExists(atPath: outputURL.path) {
65-
try FileManager.default.removeItem(at: outputURL)
66-
}
59+
try removeStaleOutput(outputURL)
6760

6861
let payload = try Data(contentsOf: eventsURL)
6962
let envelope = try JSONDecoder().decode(GestureEnvelope.self, from: payload)
@@ -74,27 +67,15 @@ func run() throws {
7467
}
7568

7669
let asset = AVURLAsset(url: inputURL)
77-
guard let sourceVideoTrack = asset.tracks(withMediaType: .video).first else {
78-
throw OverlayError.missingVideoTrack
79-
}
80-
81-
let composition = AVMutableComposition()
82-
guard let compositionVideoTrack = composition.addMutableTrack(
83-
withMediaType: .video,
84-
preferredTrackID: kCMPersistentTrackID_Invalid
85-
) else {
86-
throw OverlayError.exportFailed("Failed to create composition video track.")
87-
}
88-
70+
let sourceVideoTrack = try sourceVideoTrack(of: asset)
8971
let fullRange = CMTimeRange(start: .zero, duration: asset.duration)
90-
try compositionVideoTrack.insertTimeRange(fullRange, of: sourceVideoTrack, at: .zero)
91-
92-
if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first,
93-
let compositionAudioTrack = composition.addMutableTrack(
94-
withMediaType: .audio,
95-
preferredTrackID: kCMPersistentTrackID_Invalid
96-
) {
97-
try? compositionAudioTrack.insertTimeRange(fullRange, of: sourceAudioTrack, at: .zero)
72+
let composition = try makeRecordingComposition(
73+
asset: asset,
74+
videoTrack: sourceVideoTrack,
75+
timeRange: fullRange
76+
)
77+
guard let compositionVideoTrack = composition.tracks(withMediaType: .video).first else {
78+
throw RecordingScriptError.exportFailed("Failed to create composition video track.")
9879
}
9980

10081
let renderSize = resolvedRenderSize(for: sourceVideoTrack)
@@ -149,32 +130,17 @@ func run() throws {
149130
// while avoiding very slow highest-quality exports. Pass --quality high to opt into
150131
// the slower highest-quality export.
151132
let presetName = exportPresetName(for: parsedArgs.exportQuality, compatibleWith: composition)
152-
guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
153-
throw OverlayError.exportFailed("Failed to create export session.")
154-
}
155-
156-
exporter.outputURL = outputURL
157-
exporter.outputFileType = .mp4
158-
exporter.videoComposition = videoComposition
159-
exporter.shouldOptimizeForNetworkUse = true
160-
161-
let semaphore = DispatchSemaphore(value: 0)
162-
exporter.exportAsynchronously {
163-
semaphore.signal()
164-
}
165-
if semaphore.wait(timeout: .now() + 120) == .timedOut {
166-
exporter.cancelExport()
167-
throw OverlayError.exportFailed("Touch overlay export timed out.")
168-
}
169-
170-
if exporter.status != .completed {
171-
throw OverlayError.exportFailed(exporter.error?.localizedDescription ?? "Touch overlay export failed.")
172-
}
173-
}
174-
175-
enum ExportQuality: String {
176-
case medium
177-
case high
133+
let exporter = try makeRecordingExporter(
134+
composition,
135+
presetName: presetName,
136+
outputURL: outputURL,
137+
videoComposition: videoComposition
138+
)
139+
try runRecordingExport(
140+
exporter,
141+
timeoutMessage: "Touch overlay export timed out.",
142+
failureMessage: "Touch overlay export failed."
143+
)
178144
}
179145

180146
func parseArguments(
@@ -193,33 +159,28 @@ func parseArguments(
193159
let nextIndex = index + 1
194160
switch argument {
195161
case "--input":
196-
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--input requires a value") }
197-
inputPath = arguments[nextIndex]
162+
inputPath = try recordingOptionValue(arguments, nextIndex, "--input")
198163
index += 2
199164
case "--output":
200-
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--output requires a value") }
201-
outputPath = arguments[nextIndex]
165+
outputPath = try recordingOptionValue(arguments, nextIndex, "--output")
202166
index += 2
203167
case "--events":
204-
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--events requires a value") }
205-
eventsPath = arguments[nextIndex]
168+
eventsPath = try recordingOptionValue(arguments, nextIndex, "--events")
206169
index += 2
207170
case "--quality":
208-
guard nextIndex < arguments.count else {
209-
throw OverlayError.invalidArgs("--quality requires a value")
210-
}
211-
guard let parsed = ExportQuality(rawValue: arguments[nextIndex]) else {
212-
throw OverlayError.invalidArgs("--quality must be one of: medium, high")
171+
let rawValue = try recordingOptionValue(arguments, nextIndex, "--quality")
172+
guard let parsed = ExportQuality(rawValue: rawValue) else {
173+
throw RecordingScriptError.invalidArgs("--quality must be one of: medium, high")
213174
}
214175
exportQuality = parsed
215176
index += 2
216177
default:
217-
throw OverlayError.invalidArgs("Unknown argument: \(argument)")
178+
throw RecordingScriptError.invalidArgs("Unknown argument: \(argument)")
218179
}
219180
}
220181

221182
guard let inputPath, let outputPath, let eventsPath else {
222-
throw OverlayError.invalidArgs(
183+
throw RecordingScriptError.invalidArgs(
223184
"Usage: recording-overlay.swift --input <video> --output <video> --events <json> [--quality <medium|high>]"
224185
)
225186
}

0 commit comments

Comments
 (0)