Skip to content

Commit 73dc7f8

Browse files
ryanda9910thymikee
andauthored
feat(recording): align quality and max-size controls (#816)
* feat(recording): make iOS export quality configurable Wire the existing recording-export-quality enum through the record command down to the Swift export preset. Adds a `--export-quality <medium|high>` option for iOS recordings that controls the AVAssetExportSession preset used when a recording is re-encoded. `medium` stays the default and selects AVAssetExportPresetMediumQuality, which preserves the fast simulator-friendly export. `high` opts into AVAssetExportPresetHighestQuality for evidence-grade output. This is separate from the existing integer `--quality <5-10>` capture flag that scales render resolution. Closes #568 * fix(recording): apply export quality to touch-overlay export path The --export-quality flag was only wired into the resize export path. The touch-overlay re-encode (finalizeRecordingOverlay -> overlayRecordingTouches -> recording-overlay.swift) ignored it and always picked AVAssetExportPresetMediumQuality, so record stop with --export-quality high had no effect when the stop path re-encodes only to burn in touch overlays. Thread the recording's exportQuality through finalizeRecordingOverlay and overlayRecordingTouches, pass it as --export-quality to recording-overlay.swift, and resolve the preset there via the same exportPresetName() helper used by recording-resize.swift. Medium stays the default when the arg is absent, so behavior is unchanged for callers that do not set it. * feat: align recording quality and size flags --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 87a6ac7 commit 73dc7f8

40 files changed

Lines changed: 555 additions & 218 deletions

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -420,24 +420,24 @@ extension RunnerTests {
420420
if let requestedFps = command.fps, (requestedFps < minRecordingFps || requestedFps > maxRecordingFps) {
421421
return Response(ok: false, error: ErrorPayload(message: "recordStart fps must be between \(minRecordingFps) and \(maxRecordingFps)"))
422422
}
423-
if let requestedQuality = command.quality, (requestedQuality < minRecordingQuality || requestedQuality > maxRecordingQuality) {
424-
return Response(ok: false, error: ErrorPayload(message: "recordStart quality must be between \(minRecordingQuality) and \(maxRecordingQuality)"))
423+
if let requestedMaxSize = command.maxSize, requestedMaxSize < 1 {
424+
return Response(ok: false, error: ErrorPayload(message: "recordStart maxSize must be a positive integer"))
425425
}
426426
do {
427427
let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
428428
let fpsLabel = command.fps.map(String.init) ?? String(RunnerTests.defaultRecordingFps)
429-
let qualityLabel = command.quality.map(String.init) ?? "native"
429+
let maxSizeLabel = command.maxSize.map(String.init) ?? "native"
430430
NSLog(
431-
"AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@ quality=%@",
431+
"AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@ maxSize=%@",
432432
requestedOutPath,
433433
resolvedOutPath,
434434
fpsLabel,
435-
qualityLabel
435+
maxSizeLabel
436436
)
437437
let recorder = ScreenRecorder(
438438
outputPath: resolvedOutPath,
439439
fps: command.fps.map { Int32($0) },
440-
quality: command.quality
440+
maxSize: command.maxSize
441441
)
442442
try recorder.start { [weak self] in
443443
return self?.captureRunnerFrame()

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ struct Command: Codable {
136136
let velocity: Double?
137137
let outPath: String?
138138
let fps: Int?
139-
let quality: Int?
139+
let maxSize: Int?
140140
let interactiveOnly: Bool?
141141
let depth: Int?
142142
let scope: String?

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extension RunnerTests {
77
final class ScreenRecorder {
88
private let outputPath: String
99
private let fps: Int32?
10-
private let quality: Int?
10+
private let maxSize: Int?
1111
private var effectiveFps: Int32 {
1212
max(1, fps ?? RunnerTests.defaultRecordingFps)
1313
}
@@ -26,10 +26,10 @@ extension RunnerTests {
2626
private var startedSession = false
2727
private var startError: Error?
2828

29-
init(outputPath: String, fps: Int32?, quality: Int?) {
29+
init(outputPath: String, fps: Int32?, maxSize: Int?) {
3030
self.outputPath = outputPath
3131
self.fps = fps
32-
self.quality = quality
32+
self.maxSize = maxSize
3333
}
3434

3535
func start(captureFrame: @escaping () -> RunnerImage?) throws {
@@ -262,10 +262,14 @@ extension RunnerTests {
262262
}
263263

264264
private func scaledDimensions(width: Int, height: Int) -> CGSize {
265-
guard let quality, quality < 10 else {
265+
guard let maxSize, maxSize > 0 else {
266266
return CGSize(width: width, height: height)
267267
}
268-
let scale = Double(quality) / 10.0
268+
let longest = max(width, height)
269+
guard longest > maxSize else {
270+
return CGSize(width: width, height: height)
271+
}
272+
let scale = Double(maxSize) / Double(longest)
269273
return CGSize(
270274
width: scaledEvenDimension(width, scale: scale),
271275
height: scaledEvenDimension(height, scale: scale)

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ final class RunnerTests: XCTestCase {
4949
let xctestIdleKeepaliveInterval: TimeInterval = 5.0
5050
let minRecordingFps = 1
5151
let maxRecordingFps = 120
52-
let minRecordingQuality = 5
53-
let maxRecordingQuality = 10
5452
var needsPostSnapshotInteractionDelay = false
5553
var needsFirstInteractionDelay = false
5654
var activeRecording: ScreenRecorder?

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

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,9 @@ func run() throws {
146146
)
147147

148148
// Overlay burn-in forces a full re-encode; medium quality keeps simulator videos readable
149-
// while avoiding very slow highest-quality exports.
150-
let presetName = AVAssetExportSession.exportPresets(compatibleWith: composition)
151-
.contains(AVAssetExportPresetMediumQuality)
152-
? AVAssetExportPresetMediumQuality
153-
: AVAssetExportPresetHighestQuality
149+
// while avoiding very slow highest-quality exports. Pass --quality high to opt into
150+
// the slower highest-quality export.
151+
let presetName = exportPresetName(for: parsedArgs.exportQuality, compatibleWith: composition)
154152
guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
155153
throw OverlayError.exportFailed("Failed to create export session.")
156154
}
@@ -174,10 +172,20 @@ func run() throws {
174172
}
175173
}
176174

177-
func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, eventsPath: String) {
175+
enum ExportQuality: String {
176+
case medium
177+
case high
178+
}
179+
180+
func parseArguments(
181+
_ arguments: [String]
182+
) throws -> (inputPath: String, outputPath: String, eventsPath: String, exportQuality: ExportQuality) {
178183
var inputPath: String?
179184
var outputPath: String?
180185
var eventsPath: String?
186+
// Export quality defaults to medium so existing callers keep the fast, simulator-friendly
187+
// export. Pass --quality high to opt into a slower highest-quality export.
188+
var exportQuality: ExportQuality = .medium
181189
var index = 0
182190

183191
while index < arguments.count {
@@ -196,15 +204,43 @@ func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputP
196204
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--events requires a value") }
197205
eventsPath = arguments[nextIndex]
198206
index += 2
207+
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")
213+
}
214+
exportQuality = parsed
215+
index += 2
199216
default:
200217
throw OverlayError.invalidArgs("Unknown argument: \(argument)")
201218
}
202219
}
203220

204221
guard let inputPath, let outputPath, let eventsPath else {
205-
throw OverlayError.invalidArgs("Usage: recording-overlay.swift --input <video> --output <video> --events <json>")
222+
throw OverlayError.invalidArgs(
223+
"Usage: recording-overlay.swift --input <video> --output <video> --events <json> [--quality <medium|high>]"
224+
)
225+
}
226+
return (inputPath, outputPath, eventsPath, exportQuality)
227+
}
228+
229+
func exportPresetName(
230+
for exportQuality: ExportQuality,
231+
compatibleWith asset: AVAsset
232+
) -> String {
233+
switch exportQuality {
234+
case .high:
235+
return AVAssetExportPresetHighestQuality
236+
case .medium:
237+
// Prefer the faster medium preset, falling back to highest quality only when medium is
238+
// not available for this composition.
239+
let compatible = AVAssetExportSession.exportPresets(compatibleWith: asset)
240+
return compatible.contains(AVAssetExportPresetMediumQuality)
241+
? AVAssetExportPresetMediumQuality
242+
: AVAssetExportPresetHighestQuality
206243
}
207-
return (inputPath, outputPath, eventsPath)
208244
}
209245

210246
func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {

ios-runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ func run() throws {
4040
throw ResizeError.missingVideoTrack
4141
}
4242

43-
let renderSize = scaledRenderSize(for: sourceVideoTrack, quality: parsedArgs.quality)
43+
let sourceRenderSize = resolvedRenderSize(for: sourceVideoTrack)
44+
if max(sourceRenderSize.width, sourceRenderSize.height) <= CGFloat(parsedArgs.maxSize) {
45+
try FileManager.default.copyItem(at: inputURL, to: outputURL)
46+
return
47+
}
48+
49+
let renderSize = scaledRenderSize(sourceRenderSize, maxSize: parsedArgs.maxSize)
4450
let composition = AVMutableComposition()
4551
let fullRange = CMTimeRange(start: .zero, duration: asset.duration)
4652

@@ -60,7 +66,7 @@ func run() throws {
6066
try? compositionAudioTrack.insertTimeRange(fullRange, of: sourceAudioTrack, at: .zero)
6167
}
6268

63-
let scale = CGFloat(parsedArgs.quality) / 10.0
69+
let scale = renderSize.width / sourceRenderSize.width
6470
let videoComposition = AVMutableVideoComposition()
6571
videoComposition.renderSize = renderSize
6672
videoComposition.frameDuration = resolvedFrameDuration(for: sourceVideoTrack)
@@ -74,7 +80,8 @@ func run() throws {
7480
instruction.layerInstructions = [layerInstruction]
7581
videoComposition.instructions = [instruction]
7682

77-
guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
83+
let presetName = exportPresetName(for: parsedArgs.exportQuality, compatibleWith: composition)
84+
guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
7885
throw ResizeError.exportFailed("Failed to create export session.")
7986
}
8087

@@ -97,10 +104,20 @@ func run() throws {
97104
}
98105
}
99106

100-
func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, quality: Int) {
107+
enum ExportQuality: String {
108+
case medium
109+
case high
110+
}
111+
112+
func parseArguments(
113+
_ arguments: [String]
114+
) throws -> (inputPath: String, outputPath: String, maxSize: Int, exportQuality: ExportQuality) {
101115
var inputPath: String?
102116
var outputPath: String?
103-
var quality: Int?
117+
var maxSize: Int?
118+
// Export quality defaults to medium so re-encoded recordings stay fast by default.
119+
// Pass --quality high to opt into a slower highest-quality export.
120+
var exportQuality: ExportQuality = .medium
104121
var index = 0
105122

106123
while index < arguments.count {
@@ -115,35 +132,61 @@ func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputP
115132
guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--output requires a value") }
116133
outputPath = arguments[nextIndex]
117134
index += 2
135+
case "--max-size":
136+
guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--max-size requires a value") }
137+
guard let parsed = Int(arguments[nextIndex]), parsed >= 1 else {
138+
throw ResizeError.invalidArgs("--max-size must be a positive integer")
139+
}
140+
maxSize = parsed
141+
index += 2
118142
case "--quality":
119-
guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--quality requires a value") }
120-
guard let parsed = Int(arguments[nextIndex]), parsed >= 5, parsed <= 10 else {
121-
throw ResizeError.invalidArgs("--quality must be an integer between 5 and 10")
143+
guard nextIndex < arguments.count else {
144+
throw ResizeError.invalidArgs("--quality requires a value")
145+
}
146+
guard let parsed = ExportQuality(rawValue: arguments[nextIndex]) else {
147+
throw ResizeError.invalidArgs("--quality must be one of: medium, high")
122148
}
123-
quality = parsed
149+
exportQuality = parsed
124150
index += 2
125151
default:
126152
throw ResizeError.invalidArgs("Unknown argument: \(argument)")
127153
}
128154
}
129155

130-
guard let inputPath, let outputPath, let quality else {
156+
guard let inputPath, let outputPath, let maxSize else {
131157
throw ResizeError.invalidArgs(
132-
"Usage: recording-resize.swift --input <video> --output <video> --quality <5-10>"
158+
"Usage: recording-resize.swift --input <video> --output <video> --max-size <px> [--quality <medium|high>]"
133159
)
134160
}
135-
return (inputPath, outputPath, quality)
161+
return (inputPath, outputPath, maxSize, exportQuality)
162+
}
163+
164+
func exportPresetName(
165+
for exportQuality: ExportQuality,
166+
compatibleWith asset: AVAsset
167+
) -> String {
168+
switch exportQuality {
169+
case .high:
170+
return AVAssetExportPresetHighestQuality
171+
case .medium:
172+
// Mirror the touch-overlay export: prefer the faster medium preset, falling back to
173+
// highest quality only when medium is not available for this composition.
174+
let compatible = AVAssetExportSession.exportPresets(compatibleWith: asset)
175+
return compatible.contains(AVAssetExportPresetMediumQuality)
176+
? AVAssetExportPresetMediumQuality
177+
: AVAssetExportPresetHighestQuality
178+
}
136179
}
137180

138181
func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {
139182
let transformed = track.naturalSize.applying(track.preferredTransform)
140183
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
141184
}
142185

143-
func scaledRenderSize(for track: AVAssetTrack, quality: Int) -> CGSize {
144-
let renderSize = resolvedRenderSize(for: track)
145-
guard quality < 10 else { return renderSize }
146-
let scale = CGFloat(quality) / 10.0
186+
func scaledRenderSize(_ renderSize: CGSize, maxSize: Int) -> CGSize {
187+
let longest = max(renderSize.width, renderSize.height)
188+
guard longest > CGFloat(maxSize) else { return renderSize }
189+
let scale = CGFloat(maxSize) / longest
147190
return CGSize(
148191
width: scaledDimension(renderSize.width, scale: scale),
149192
height: scaledDimension(renderSize.height, scale: scale)

ios-runner/RUNNER_PROTOCOL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Examples:
3232
```
3333

3434
```json
35-
{ "command": "recordStart", "outPath": "/tmp/demo.mp4", "fps": 30, "quality": 7 }
35+
{ "command": "recordStart", "outPath": "/tmp/demo.mp4", "fps": 30, "maxSize": 720 }
3636
```
3737

3838
```json

src/backend.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { ClickButton } from './core/click-button.ts';
1515
import type { DeviceRotation } from './core/device-rotation.ts';
1616
import type { ScrollDirection } from './core/scroll-gesture.ts';
1717
import type { SessionSurface } from './core/session-surface.ts';
18+
import type { RecordingExportQuality } from './core/recording-export-quality.ts';
1819
import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
1920
import type {
2021
SnapshotCaptureAnalysis,
@@ -282,7 +283,8 @@ export type BackendInstallResult = Record<string, unknown> & {
282283
export type BackendRecordingOptions = {
283284
outPath?: string;
284285
fps?: number;
285-
quality?: number;
286+
maxSize?: number;
287+
quality?: RecordingExportQuality;
286288
showTouches?: boolean;
287289
};
288290

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
304304
out: options.out,
305305
count: options.count,
306306
fps: options.fps,
307+
screenshotMaxSize: options.maxSize,
307308
quality: options.quality,
308309
hideTouches: options.hideTouches,
309310
intervalMs: options.intervalMs,

src/client-types.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts';
1717
import type { BackMode } from './core/back-mode.ts';
1818
import type { ClickButton } from './core/click-button.ts';
19+
import type { RecordingExportQuality } from './core/recording-export-quality.ts';
1920
import type { DeviceRotation } from './core/device-rotation.ts';
2021
import type {
2122
ScrollDirection,
@@ -768,13 +769,12 @@ export type NetworkOptions = AgentDeviceRequestOverrides & {
768769
include?: NetworkIncludeMode;
769770
};
770771

771-
type RecordingQuality = 5 | 6 | 7 | 8 | 9 | 10;
772-
773772
export type RecordOptions = AgentDeviceRequestOverrides & {
774773
action: 'start' | 'stop';
775774
path?: string;
776775
fps?: number;
777-
quality?: RecordingQuality;
776+
maxSize?: number;
777+
quality?: RecordingExportQuality;
778778
hideTouches?: boolean;
779779
};
780780

@@ -854,7 +854,8 @@ type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
854854
forceFull?: boolean;
855855
count?: number;
856856
fps?: number;
857-
quality?: RecordingQuality;
857+
maxSize?: number;
858+
quality?: RecordingExportQuality;
858859
hideTouches?: boolean;
859860
intervalMs?: number;
860861
delayMs?: number;

0 commit comments

Comments
 (0)