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
170 changes: 145 additions & 25 deletions apple/runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ let minimumPinchVisibility: CFTimeInterval = 0.5
let swipeVisibilityTail: CFTimeInterval = 0.16
let trailOpacityKeyTimes: [NSNumber] = [0.0, 0.08, 0.62, 1.0]

// A compositor that cannot render frames still completes with an empty track, so a whole frame at
// or below this mean luma counts as black.
let compositedBlackFrameLuma: Double = 1.0
// Above this mean luma the raw capture is taken to hold visible content worth preserving. Below it
// the recording itself is dark, so a dark composite is the content and not a compositor failure.
let sourceVisibleFrameLuma: Double = 2.0

struct GestureEnvelope: Decodable {
let events: [GestureEvent]
}
Expand Down Expand Up @@ -126,10 +133,10 @@ func run() throws {
in: parentLayer
)

// Overlay burn-in forces a full re-encode; medium quality keeps simulator videos readable
// while avoiding very slow highest-quality exports. Pass --quality high to opt into
// the slower highest-quality export.
let presetName = exportPresetName(for: parsedArgs.exportQuality, compatibleWith: composition)
// Overlay burn-in forces a full re-encode. The export has to keep the captured track's own
// dimensions, so it uses the one preset that preserves arbitrary capture geometry; the hardware
// encoder makes the full-resolution re-encode cheap (measured ~1-2s for a 90s 1206x2622 clip).
let presetName = try exportPresetName(compatibleWith: composition)
let exporter = try makeRecordingExporter(
composition,
presetName: presetName,
Expand All @@ -141,17 +148,19 @@ func run() throws {
timeoutMessage: "Touch overlay export timed out.",
failureMessage: "Touch overlay export failed."
)
try verifyCompositedOverlay(
input: inputURL,
output: outputURL,
expectedRenderSize: renderSize
)
}

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

while index < arguments.count {
Expand All @@ -168,11 +177,13 @@ func parseArguments(
eventsPath = try recordingOptionValue(arguments, nextIndex, "--events")
index += 2
case "--quality":
// Accepted for CLI parity with the other backends and still validated, but the composited
// export always preserves the captured geometry (see `exportPresetName`), so the tier never
// picks a resolution-capping preset and is not retained.
let rawValue = try recordingOptionValue(arguments, nextIndex, "--quality")
guard let parsed = ExportQuality(rawValue: rawValue) else {
guard ExportQuality(rawValue: rawValue) != nil else {
throw RecordingScriptError.invalidArgs("--quality must be one of: medium, high")
}
exportQuality = parsed
index += 2
default:
throw RecordingScriptError.invalidArgs("Unknown argument: \(argument)")
Expand All @@ -184,31 +195,140 @@ func parseArguments(
"Usage: recording-overlay.swift --input <video> --output <video> --events <json> [--quality <medium|high>]"
)
}
return (inputPath, outputPath, eventsPath, exportQuality)
return (inputPath, outputPath, eventsPath)
}

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
/// The composited overlay must keep the captured track's dimensions, so it can only use a preset
/// that preserves source geometry. The fixed-canvas presets rescale the long edge —
/// `AVAssetExportPresetMediumQuality` caps it at 480px, which is exactly what collapsed every
/// touch-bearing recording to ~220x480 (#2707). Only `HighestQuality` preserves the capture, and it
/// is the export for every quality tier, so `--quality` never trades capture resolution away. If a
/// composition offers no geometry-preserving preset the export refuses rather than rescaling.
func exportPresetName(compatibleWith asset: AVAsset) throws -> String {
guard AVAssetExportSession.exportPresets(compatibleWith: asset).contains(AVAssetExportPresetHighestQuality) else {
throw RecordingScriptError.exportFailed(
"No geometry-preserving export preset is available; refusing to rescale the capture."
)
}
return AVAssetExportPresetHighestQuality
}

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

/// Guards the compositor's own contract before the caller adopts its output: the burn-in must keep
/// the captured track's dimensions, must produce frames, and must not silently become an all-black
/// track. Any failure throws, dropping the overlay and keeping the raw capture rather than
/// publishing a broken file with a success exit.
func verifyCompositedOverlay(input: URL, output: URL, expectedRenderSize: CGSize) throws {
let composited = AVURLAsset(url: output)
guard let track = composited.tracks(withMediaType: .video).first else {
throw RecordingScriptError.exportFailed("Touch overlay export produced no video track.")
}

let producedSize = resolvedRenderSize(for: track)
if !renderSizeMatches(producedSize, expectedRenderSize) {
throw RecordingScriptError.exportFailed(
"Touch overlay export changed the track geometry: \(Int(producedSize.width))x\(Int(producedSize.height)) instead of \(Int(expectedRenderSize.width))x\(Int(expectedRenderSize.height))."
)
}

// A compositor that produced no decodable frames is a failure, not an absence of evidence.
guard let compositedLuma = meanFrameLuma(of: composited) else {
throw RecordingScriptError.exportFailed("Touch overlay export produced no decodable frames.")
}

let source = AVURLAsset(url: input)
let sourceDuration = CMTimeGetSeconds(source.duration)
let producedDuration = CMTimeGetSeconds(composited.duration)
if sourceDuration.isFinite, sourceDuration > 1, producedDuration.isFinite,
producedDuration < sourceDuration * 0.5
{
throw RecordingScriptError.exportFailed(
"Touch overlay export truncated the track to \(Int(producedDuration))s of \(Int(sourceDuration))s."
)
}

if compositedLuma <= compositedBlackFrameLuma,
let sourceLuma = meanFrameLuma(of: source),
sourceLuma > sourceVisibleFrameLuma
{
throw RecordingScriptError.exportFailed(
"Touch overlay export produced an all-black track while the raw capture had visible content."
)
}
}

func renderSizeMatches(_ produced: CGSize, _ expected: CGSize) -> Bool {
// The encoder rounds the stored frame to its coding-block grid, so an honest match tolerates a
// few pixels regardless of size. A geometry collapse (the #2707 failure) misses by hundreds.
let tolerance: CGFloat = 32
return abs(produced.width - expected.width) <= tolerance
&& abs(produced.height - expected.height) <= tolerance
}

func meanFrameLuma(of asset: AVURLAsset, sampleCount: Int = 5) -> Double? {
let duration = CMTimeGetSeconds(asset.duration)
guard duration.isFinite, duration > 0 else { return nil }
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.requestedTimeToleranceBefore = .positiveInfinity
generator.requestedTimeToleranceAfter = .positiveInfinity
// Only the mean brightness is wanted, so decode a thumbnail rather than the full frame.
generator.maximumSize = CGSize(width: 64, height: 64)

var total = 0.0
var sampled = 0
for index in 0..<sampleCount {
let fraction = (Double(index) + 0.5) / Double(sampleCount)
let time = CMTime(seconds: fraction * duration, preferredTimescale: 600)
guard let image = try? generator.copyCGImage(at: time, actualTime: nil),
let luma = frameMeanLuma(image)
else { continue }
total += luma
sampled += 1
}
guard sampled > 0 else { return nil }
return total / Double(sampled)
}

func frameMeanLuma(_ image: CGImage) -> Double? {
let width = image.width
let height = image.height
guard width > 0, height > 0 else { return nil }
let bytesPerRow = width * 4
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
let drawn = pixels.withUnsafeMutableBytes { raw -> Bool in
guard
let context = CGContext(
data: raw.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
else { return false }
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard drawn else { return nil }

var total = 0.0
var count = 0
for offset in stride(from: 0, to: bytesPerRow * height, by: 4) {
total += 0.299 * Double(pixels[offset])
+ 0.587 * Double(pixels[offset + 1])
+ 0.114 * Double(pixels[offset + 2])
count += 1
}
guard count > 0 else { return nil }
return total / Double(count)
}

func resolvedFrameDuration(for track: AVAssetTrack) -> CMTime {
let minFrameDuration = track.minFrameDuration
if minFrameDuration.isValid && !minFrameDuration.isIndefinite && minFrameDuration.seconds > 0 {
Expand Down
67 changes: 52 additions & 15 deletions docs/adr/0025-foldable-apple-panels.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Status

Accepted (2026-09-20; the pose-settle rule amended 2026-09-21 under #2730). Covers iPhone Duo
(iOS 27.1, `iPhone19,4`) and any Apple device that reports more than one integrated CoreDevice
display.
Accepted (2026-09-20; the pose-settle rule amended 2026-09-21 under #2730; the touch-overlay
export diagnosis corrected 2026-09-22 under #2707). Covers iPhone Duo (iOS 27.1, `iPhone19,4`) and
any Apple device that reports more than one integrated CoreDevice display.

An iPhone Duo carries two integrated panels — Apple's **outer display** and **inner display** —
and lights one of them at a time. Which one is lit is the device pose. Two independent facts
Expand Down Expand Up @@ -307,18 +307,55 @@ and honors it per panel; sampled mean luma over the whole frame:
| no `--display` | 2006x2852 | 241.42 |

`record start`/`record stop` exit 0 in both poses, and with `--hide-touches` the export keeps the
captured geometry (`2006x2852`, mean luma 241.42). Without it the touch-overlay exporter loses the
track geometry, and on a long clip the frames too.

The trigger is the overlay drawing touch events, not panel rotation. Measured on an iPhone 17
(iOS 27.0), which has no rotated panel: four seconds with no interaction exports `1206x2622`
intact, ten seconds containing two taps exports `220x480`, and the same two taps under
`--hide-touches` export `1206x2622` with the screen content changing across frames. A 97-second
recording with touches exported `480x220` at mean luma 0.00 throughout. An earlier draft of this
section blamed the inner panel's `rot90` track, which was wrong: every failing sample then available
had merely been captured on that panel, and the one non-rotated sample that looked intact had
contained no touches to draw. Feeding an untouched raw `simctl` capture straight into
`recording-overlay.swift` reproduces a `0x0` zero-duration output. Tracked in #2707.
captured geometry (`2006x2852`, mean luma 241.42). Without it, and before #2707 was fixed, the
touch-overlay exporter lost the track geometry, and on a long clip the frames too.

### The touch-overlay export lost geometry for any capture, not a rotated one (#2707)

The trigger was the overlay drawing touch events, not panel rotation. The #2707 report measured this
on an iPhone 17 simulator (iOS 27.0, non-rot90 panel), Xcode 27.1 beta: four seconds with no
interaction exported `1206x2622` intact, ten seconds containing two taps exported `220x480`, and the
same two taps under `--hide-touches` exported `1206x2622` with the screen content changing across
frames. A 97-second recording with touches exported `480x220` at mean luma 0.00 throughout.

An earlier draft of this section blamed the inner panel's `rot90` track. That was wrong and predated
this matrix: every failing sample then available had merely been captured on that panel, and the one
non-rotated sample that looked intact had contained no touches to draw. Feeding an untouched raw
`simctl` capture straight into `recording-overlay.swift` just copies it through, which is why
`--hide-touches` and an empty gesture list kept the capture intact — the collapse lived in the
overlay export path, not the panel.

The cause was the export preset, measured against a synthetic capture on this host: the burn-in is a
full re-encode through `AVAssetExportSession`, and the default `medium` tier selected
`AVAssetExportPresetMediumQuality`, a fixed-canvas preset that rescales the long edge to 480px — a
`1206x2622` capture lands on `220x480`, a landscape capture on `480x220`. Only
`AVAssetExportPresetHighestQuality` preserves arbitrary capture geometry, and the hardware encoder
makes the full-resolution re-encode cheap: 90s at `1206x2622` re-encoded in ~1–2s, so `high` was
never actually slower.

`recording-overlay.swift` now always exports through the geometry-preserving preset at both quality
tiers, so `--quality` no longer trades capture resolution away. It also verifies its own output
before the caller adopts it — the composited track's resolved size must match the capture, and a
track that went uniformly black while the raw had visible content is rejected — and on either failure
it throws instead of publishing a broken file, so the overlay is dropped, the raw capture is kept,
and the choice is reported on the `record stop` response as `overlayWarning`. Feeding the fixed tool
the same synthetic captures re-measures the failing rows as matching `--hide-touches`:

| Overlay | Interactions | Exported size (before → after) | Black? |
| --- | --- | --- | --- |
| default | two taps, 1206x2622 source | `220x480` → `1206x2622` | no |
| default | taps + scroll, landscape source | `480x220` → source size | no |
| `--hide-touches` | two taps | `1206x2622` → `1206x2622` | no |

The `after` column is this host's offline synthetic harness, which reproduces the collapse and
confirms the fix preserves geometry. The completion condition's on-device re-measure of these rows on
a non-rot90 target and on the Duo inner panel is carried by `test/integration/recording-overlay.test.ts`,
a device-lane case gated behind `AGENT_DEVICE_RECORDING_E2E` that compares a touched export to a
`--hide-touches` control, asserts the same track size, and asserts the overlay actually drew.

`packages/capture-kit/src/recording/mp4-track-size.ts` reads a track's transform-applied display size
so the device-lane test can assert a touched export equals a `--hide-touches` control for the same
scripted tap, and a `rot90` panel is compared upright rather than on its sideways coding grid.

## Verified on a booted Duo

Expand Down
15 changes: 9 additions & 6 deletions docs/agents/device-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ toolchain per command:
three poses, active panel capture, and an app interaction. Duo coverage remains local until GHA
supports the runtime. To inspect the angle independently:
`xcrun devicectl device motion hinge-angle --device <udid> --session-timeout 1 --timeout 5`.
- When a recording must show touches, assume it cannot. The touch-overlay exporter loses the track
geometry whenever it has touch events to draw — `220x480` on a plain iPhone 17 as well as on the
inner panel — and returns all-black frames on long clips, always with exit 0. Record with
`record start --hide-touches` and make the interaction legible through its on-screen effect
(typed text, navigation, a counter) instead of a cursor. The raw `simctl` capture behind it is
correct. See ADR 0025 and #2707.
- Touch overlays export at the captured track size again (#2707). The burn-in used to re-encode
through a fixed 480px preset, so a recording with touches collapsed to `220x480` (landscape
`480x220`) on any panel — not just the `rot90` inner one — and went all-black on long clips,
always with exit 0. That preset is gone: both quality tiers export through the one
geometry-preserving preset, and the compositor now checks its own output (size and non-black)
against the raw before publishing it — on failure it drops the overlay, keeps the raw capture, and
reports it as `overlayWarning` on `record stop` rather than returning a broken file. Only reach
for `record start --hide-touches` when you want the fastest raw capture, not to dodge the defect.
See ADR 0025 and #2707.
- An app must adopt the UIScene lifecycle to launch on iOS 27.1 at all: a legacy
`UIApplicationDelegate` app traps at launch inside
`___UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption`, which reads like a broken
Expand Down
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
"types": "./src/recording/mp4-duration.ts",
"default": "./src/recording/mp4-duration.ts"
},
"./recording-mp4-track-size": {
"types": "./src/recording/mp4-track-size.ts",
"default": "./src/recording/mp4-track-size.ts"
},
"./recording-output-path": {
"types": "./src/recording/output-path.ts",
"default": "./src/recording/output-path.ts"
Expand Down
Loading
Loading