Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,9 @@ func run() throws {
)

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

func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, eventsPath: String) {
enum ExportQuality: String {
case medium
case high
}

func parseArguments(
_ arguments: [String]
) throws -> (inputPath: String, outputPath: String, eventsPath: String, exportQuality: ExportQuality) {
var inputPath: String?
var outputPath: String?
var eventsPath: String?
// Export quality defaults to medium so existing callers keep the fast, simulator-friendly
// export. Pass --export-quality high to opt into a slower highest-quality export.
var exportQuality: ExportQuality = .medium
var index = 0

while index < arguments.count {
Expand All @@ -196,15 +204,43 @@ func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputP
guard nextIndex < arguments.count else { throw OverlayError.invalidArgs("--events requires a value") }
eventsPath = arguments[nextIndex]
index += 2
case "--export-quality":
guard nextIndex < arguments.count else {
throw OverlayError.invalidArgs("--export-quality requires a value")
}
guard let parsed = ExportQuality(rawValue: arguments[nextIndex]) else {
throw OverlayError.invalidArgs("--export-quality must be one of: medium, high")
}
exportQuality = parsed
index += 2
default:
throw OverlayError.invalidArgs("Unknown argument: \(argument)")
}
}

guard let inputPath, let outputPath, let eventsPath else {
throw OverlayError.invalidArgs("Usage: recording-overlay.swift --input <video> --output <video> --events <json>")
throw OverlayError.invalidArgs(
"Usage: recording-overlay.swift --input <video> --output <video> --events <json> [--export-quality <medium|high>]"
)
}
return (inputPath, outputPath, eventsPath, exportQuality)
}

func exportPresetName(
for exportQuality: ExportQuality,
compatibleWith asset: AVAsset
) -> String {
switch exportQuality {
case .high:
return AVAssetExportPresetHighestQuality
case .medium:
// Prefer the faster medium preset, falling back to highest quality only when medium is
// not available for this composition.
let compatible = AVAssetExportSession.exportPresets(compatibleWith: asset)
return compatible.contains(AVAssetExportPresetMediumQuality)
? AVAssetExportPresetMediumQuality
: AVAssetExportPresetHighestQuality
}
return (inputPath, outputPath, eventsPath)
}

func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ func run() throws {
instruction.layerInstructions = [layerInstruction]
videoComposition.instructions = [instruction]

guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
let presetName = exportPresetName(for: parsedArgs.exportQuality, compatibleWith: composition)
guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
throw ResizeError.exportFailed("Failed to create export session.")
}

Expand All @@ -97,10 +98,20 @@ func run() throws {
}
}

func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputPath: String, quality: Int) {
enum ExportQuality: String {
case medium
case high
}

func parseArguments(
_ arguments: [String]
) throws -> (inputPath: String, outputPath: String, quality: Int, exportQuality: ExportQuality) {
var inputPath: String?
var outputPath: String?
var quality: Int?
// Export quality defaults to medium so existing callers keep the fast, simulator-friendly
// export. Pass --export-quality high to opt into a slower highest-quality export.
var exportQuality: ExportQuality = .medium
var index = 0

while index < arguments.count {
Expand All @@ -122,17 +133,43 @@ func parseArguments(_ arguments: [String]) throws -> (inputPath: String, outputP
}
quality = parsed
index += 2
case "--export-quality":
guard nextIndex < arguments.count else {
throw ResizeError.invalidArgs("--export-quality requires a value")
}
guard let parsed = ExportQuality(rawValue: arguments[nextIndex]) else {
throw ResizeError.invalidArgs("--export-quality must be one of: medium, high")
}
exportQuality = parsed
index += 2
default:
throw ResizeError.invalidArgs("Unknown argument: \(argument)")
}
}

guard let inputPath, let outputPath, let quality else {
throw ResizeError.invalidArgs(
"Usage: recording-resize.swift --input <video> --output <video> --quality <5-10>"
"Usage: recording-resize.swift --input <video> --output <video> --quality <5-10> [--export-quality <medium|high>]"
)
}
return (inputPath, outputPath, quality)
return (inputPath, outputPath, quality, exportQuality)
}

func exportPresetName(
for exportQuality: ExportQuality,
compatibleWith asset: AVAsset
) -> String {
switch exportQuality {
case .high:
return AVAssetExportPresetHighestQuality
case .medium:
// Mirror the touch-overlay export: prefer the faster medium preset, falling back to
// highest quality only when medium is not available for this composition.
let compatible = AVAssetExportSession.exportPresets(compatibleWith: asset)
return compatible.contains(AVAssetExportPresetMediumQuality)
? AVAssetExportPresetMediumQuality
: AVAssetExportPresetHighestQuality
}
}

func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {
Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
count: options.count,
fps: options.fps,
quality: options.quality,
exportQuality: options.exportQuality,
hideTouches: options.hideTouches,
intervalMs: options.intervalMs,
delayMs: options.delayMs,
Expand Down
3 changes: 3 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts';
import type { BackMode } from './core/back-mode.ts';
import type { ClickButton } from './core/click-button.ts';
import type { RecordingExportQuality } from './core/recording-export-quality.ts';
import type { DeviceRotation } from './core/device-rotation.ts';
import type {
ScrollDirection,
Expand Down Expand Up @@ -775,6 +776,7 @@ export type RecordOptions = AgentDeviceRequestOverrides & {
path?: string;
fps?: number;
quality?: RecordingQuality;
exportQuality?: RecordingExportQuality;
hideTouches?: boolean;
};

Expand Down Expand Up @@ -855,6 +857,7 @@ type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
count?: number;
fps?: number;
quality?: RecordingQuality;
exportQuality?: RecordingExportQuality;
hideTouches?: boolean;
intervalMs?: number;
delayMs?: number;
Expand Down
16 changes: 16 additions & 0 deletions src/commands/recording/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ describe('recording command interface', () => {
});
});

test('reads the export-quality flag into record input', () => {
expect(
recordCliReader(['start', './capture.mp4'], {
exportQuality: 'high',
} as CliFlags),
).toMatchObject({
action: 'start',
path: './capture.mp4',
exportQuality: 'high',
});
});

test('leaves export quality unset when the flag is omitted', () => {
expect(recordCliReader(['start'], NO_FLAGS).exportQuality).toBeUndefined();
});

test('reads trace CLI input', () => {
expect(traceCliReader(['stop', './diagnostics.trace'], NO_FLAGS)).toEqual({
action: 'stop',
Expand Down
9 changes: 6 additions & 3 deletions src/commands/recording/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RecordOptions } from '../../client-types.ts';
import { RECORDING_EXPORT_QUALITIES } from '../../core/recording-export-quality.ts';
import { AppError } from '../../utils/errors.ts';
import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts';
import { defineExecutableCommand } from '../command-contract.ts';
Expand Down Expand Up @@ -30,6 +31,7 @@ export const recordCommandMetadata = defineFieldCommandMetadata(
path: stringField(),
fps: integerField(),
quality: jsonSchemaField<RecordOptions['quality']>(integerSchema()),
exportQuality: enumField(RECORDING_EXPORT_QUALITIES),
hideTouches: booleanField(),
},
);
Expand Down Expand Up @@ -62,13 +64,13 @@ export const recordingCommandDefinitions = [

const recordCliSchema = {
usageOverride:
'record start [path] [--fps <n>] [--quality <5-10>] [--hide-touches] | record stop',
'record start [path] [--fps <n>] [--quality <5-10>] [--export-quality <medium|high>] [--hide-touches] | record stop',
listUsageOverride: 'record start [path] | record stop',
helpDescription:
'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks',
'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks. On iOS, --export-quality trades export speed (medium, default) for higher-quality output (high)',
summary: 'Start or stop screen recording',
positionalArgs: ['start|stop', 'path?'],
allowedFlags: ['fps', 'quality', 'hideTouches'],
allowedFlags: ['fps', 'quality', 'exportQuality', 'hideTouches'],
} as const satisfies CommandSchemaOverride;

const traceCliSchema = {
Expand All @@ -91,6 +93,7 @@ export const recordCliReader: CliReader = (positionals, flags) => ({
path: positionals[1],
fps: flags.fps,
quality: flags.quality as RecordOptions['quality'],
exportQuality: flags.exportQuality,
hideTouches: flags.hideTouches,
});

Expand Down
33 changes: 33 additions & 0 deletions src/core/__tests__/recording-export-quality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest';
import {
DEFAULT_RECORDING_EXPORT_QUALITY,
RECORDING_EXPORT_QUALITIES,
isRecordingExportQuality,
recordingExportQualityToPreset,
} from '../recording-export-quality.ts';

describe('recording export quality', () => {
test('defaults to the fast medium export to preserve existing behavior', () => {
expect(DEFAULT_RECORDING_EXPORT_QUALITY).toBe('medium');
expect(RECORDING_EXPORT_QUALITIES).toEqual(['medium', 'high']);
});

test('maps each quality to its AVAssetExportPreset constant', () => {
expect(recordingExportQualityToPreset('medium')).toBe('AVAssetExportPresetMediumQuality');
expect(recordingExportQualityToPreset('high')).toBe('AVAssetExportPresetHighestQuality');
});

test('default quality maps to the medium-quality preset', () => {
expect(recordingExportQualityToPreset(DEFAULT_RECORDING_EXPORT_QUALITY)).toBe(
'AVAssetExportPresetMediumQuality',
);
});

test('guards recognize valid values and reject everything else', () => {
expect(isRecordingExportQuality('medium')).toBe(true);
expect(isRecordingExportQuality('high')).toBe(true);
expect(isRecordingExportQuality('highest')).toBe(false);
expect(isRecordingExportQuality(undefined)).toBe(false);
expect(isRecordingExportQuality(10)).toBe(false);
});
});
23 changes: 23 additions & 0 deletions src/core/recording-export-quality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const RECORDING_EXPORT_QUALITIES = ['medium', 'high'] as const;
export type RecordingExportQuality = (typeof RECORDING_EXPORT_QUALITIES)[number];

export const DEFAULT_RECORDING_EXPORT_QUALITY: RecordingExportQuality = 'medium';

/**
* Maps an export-quality preset to the matching `AVAssetExportPreset` constant
* consumed by `recording-resize.swift`. `medium` keeps the fast, simulator-friendly
* export that is the default; `high` opts into the slower highest-quality export for
* evidence or artifact-grade recordings.
*/
const RECORDING_EXPORT_QUALITY_PRESETS: Record<RecordingExportQuality, string> = {
medium: 'AVAssetExportPresetMediumQuality',
high: 'AVAssetExportPresetHighestQuality',
};

export function recordingExportQualityToPreset(quality: RecordingExportQuality): string {
return RECORDING_EXPORT_QUALITY_PRESETS[quality];
}

export function isRecordingExportQuality(value: unknown): value is RecordingExportQuality {
return (RECORDING_EXPORT_QUALITIES as readonly unknown[]).includes(value);
}
Loading