agent-device 0.20.4 → 0.20.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +2 -0
  2. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.4.apk → agent-device-android-ime-helper-0.20.6.apk} +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.6.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.4.manifest.json → agent-device-android-ime-helper-0.20.6.manifest.json} +4 -4
  5. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.4.apk → agent-device-android-snapshot-helper-0.20.6.apk} +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.6.apk.sha256 +1 -0
  7. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.4.manifest.json → agent-device-android-snapshot-helper-0.20.6.manifest.json} +6 -6
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +28 -0
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +41 -1
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +298 -92
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +126 -187
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h +32 -0
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m +212 -0
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +118 -8
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +134 -27
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +23 -5
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +10 -0
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +0 -67
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +11 -1
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +2 -22
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +48 -11
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +0 -20
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +177 -0
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +105 -448
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift +4 -0
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +587 -0
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +18 -0
  29. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +72 -0
  30. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +89 -0
  31. package/dist/src/agent-device-client.js +2 -2
  32. package/dist/src/android.js +1 -1
  33. package/dist/src/app-log-request-scope.js +1 -1
  34. package/dist/src/app-resolution.js +1 -1
  35. package/dist/src/apps.js +5 -5
  36. package/dist/src/cli-config.js +1 -1
  37. package/dist/src/cli-help.js +34 -24
  38. package/dist/src/cli-runner.js +1 -1
  39. package/dist/src/cli.js +15 -15
  40. package/dist/src/command-metadata.js +1 -1
  41. package/dist/src/command-schema.js +3 -3
  42. package/dist/src/context.js +1 -1
  43. package/dist/src/debug-symbols.js +1 -1
  44. package/dist/src/find.js +1 -1
  45. package/dist/src/finders.js +1 -1
  46. package/dist/src/generic-settle.js +1 -0
  47. package/dist/src/generic.js +1 -1
  48. package/dist/src/ime-lifecycle.js +3 -3
  49. package/dist/src/index.d.ts +27 -7
  50. package/dist/src/input-actions.js +4 -4
  51. package/dist/src/interaction-runtime.js +1 -0
  52. package/dist/src/interaction-snapshot.js +1 -1
  53. package/dist/src/interaction-touch-reference-frame.js +1 -0
  54. package/dist/src/interaction.js +1 -1
  55. package/dist/src/interactor.js +2 -2
  56. package/dist/src/interactor2.js +1 -1
  57. package/dist/src/internal/bin.js +2 -2
  58. package/dist/src/internal/daemon.js +15 -15
  59. package/dist/src/lease-context.js +1 -1
  60. package/dist/src/lease.js +1 -1
  61. package/dist/src/limrun.d.ts +23 -2
  62. package/dist/src/linux.js +1 -1
  63. package/dist/src/process-exit.js +1 -0
  64. package/dist/src/react-native.js +1 -1
  65. package/dist/src/record-trace.js +1 -1
  66. package/dist/src/register-builtins.js +1 -1
  67. package/dist/src/registry.js +25 -24
  68. package/dist/src/remote-config.js +1 -1
  69. package/dist/src/remote-config2.js +1 -1
  70. package/dist/src/reporting.js +2 -2
  71. package/dist/src/runner-client.js +1 -1
  72. package/dist/src/runner-disposal.js +1 -1
  73. package/dist/src/runner-lease.js +1 -1
  74. package/dist/src/runtime.js +3 -3
  75. package/dist/src/screenshot-geometry.js +1 -1
  76. package/dist/src/screenshot-result.js +4 -4
  77. package/dist/src/sdk-android-adb.d.ts +20 -2
  78. package/dist/src/sdk-android-adb.js +1 -1
  79. package/dist/src/sdk-batch-runner.js +3 -3
  80. package/dist/src/sdk-contracts.d.ts +17 -2
  81. package/dist/src/sdk-finders.d.ts +4 -20
  82. package/dist/src/sdk-finders.js +1 -1
  83. package/dist/src/{interaction.d.ts → sdk-selectors.d.ts} +98 -3
  84. package/dist/src/sdk-selectors.js +1 -1
  85. package/dist/src/selector-runtime.js +1 -1
  86. package/dist/src/selector-vocabulary.js +1 -1
  87. package/dist/src/selectors.d.ts +1 -46
  88. package/dist/src/selectors.js +1 -1
  89. package/dist/src/server.js +3 -3
  90. package/dist/src/session-target-evidence.js +1 -1
  91. package/dist/src/session.js +9 -8
  92. package/dist/src/snapshot2.js +1 -1
  93. package/dist/src/text-surface.js +1 -0
  94. package/dist/src/viewport-dimension.js +13 -12
  95. package/dist/src/web.js +1 -1
  96. package/package.json +42 -34
  97. package/server.json +3 -3
  98. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.4.apk.sha256 +0 -1
  99. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.4.apk.sha256 +0 -1
  100. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -225
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.20.4",
3
+ "version": "0.20.6",
4
4
  "description": "Agent-native CLI for AI app automation across iOS, Android, tvOS, Android TV, macOS, Linux, and web.",
5
- "mcpName": "io.github.callstackincubator/agent-device",
5
+ "mcpName": "io.github.callstack/agent-device",
6
6
  "license": "MIT",
7
7
  "author": "Callstack",
8
8
  "homepage": "https://agent-device.dev/",
@@ -19,52 +19,52 @@
19
19
  "types": "dist/src/index.d.ts",
20
20
  "exports": {
21
21
  ".": {
22
- "import": "./dist/src/index.js",
23
- "types": "./dist/src/index.d.ts"
22
+ "types": "./dist/src/index.d.ts",
23
+ "import": "./dist/src/index.js"
24
24
  },
25
25
  "./io": {
26
- "import": "./dist/src/io.js",
27
- "types": "./dist/src/io.d.ts"
26
+ "types": "./dist/src/io.d.ts",
27
+ "import": "./dist/src/io.js"
28
28
  },
29
29
  "./artifacts": {
30
- "import": "./dist/src/artifacts.js",
31
- "types": "./dist/src/artifacts.d.ts"
30
+ "types": "./dist/src/artifacts.d.ts",
31
+ "import": "./dist/src/artifacts.js"
32
32
  },
33
33
  "./metro": {
34
- "import": "./dist/src/metro.js",
35
- "types": "./dist/src/metro.d.ts"
34
+ "types": "./dist/src/metro.d.ts",
35
+ "import": "./dist/src/metro.js"
36
36
  },
37
37
  "./batch": {
38
- "import": "./dist/src/batch.js",
39
- "types": "./dist/src/batch.d.ts"
38
+ "types": "./dist/src/batch.d.ts",
39
+ "import": "./dist/src/batch.js"
40
40
  },
41
41
  "./remote-config": {
42
- "import": "./dist/src/remote-config.js",
43
- "types": "./dist/src/remote-config.d.ts"
42
+ "types": "./dist/src/remote-config.d.ts",
43
+ "import": "./dist/src/remote-config.js"
44
44
  },
45
45
  "./install-source": {
46
- "import": "./dist/src/install-source.js",
47
- "types": "./dist/src/install-source.d.ts"
46
+ "types": "./dist/src/install-source.d.ts",
47
+ "import": "./dist/src/install-source.js"
48
48
  },
49
49
  "./android-adb": {
50
- "import": "./dist/src/android-adb.js",
51
- "types": "./dist/src/android-adb.d.ts"
50
+ "types": "./dist/src/android-adb.d.ts",
51
+ "import": "./dist/src/android-adb.js"
52
52
  },
53
53
  "./limrun": {
54
- "import": "./dist/src/limrun.js",
55
- "types": "./dist/src/limrun.d.ts"
54
+ "types": "./dist/src/limrun.d.ts",
55
+ "import": "./dist/src/limrun.js"
56
56
  },
57
57
  "./contracts": {
58
- "import": "./dist/src/contracts.js",
59
- "types": "./dist/src/contracts.d.ts"
58
+ "types": "./dist/src/contracts.d.ts",
59
+ "import": "./dist/src/contracts.js"
60
60
  },
61
61
  "./selectors": {
62
- "import": "./dist/src/selectors.js",
63
- "types": "./dist/src/selectors.d.ts"
62
+ "types": "./dist/src/selectors.d.ts",
63
+ "import": "./dist/src/selectors.js"
64
64
  },
65
65
  "./finders": {
66
- "import": "./dist/src/finders.js",
67
- "types": "./dist/src/finders.d.ts"
66
+ "types": "./dist/src/finders.d.ts",
67
+ "import": "./dist/src/finders.js"
68
68
  }
69
69
  },
70
70
  "engines": {
@@ -103,14 +103,15 @@
103
103
  "package:android-ime-helper:npm": "rm -rf android/ime-helper/dist && AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") android/ime-helper/dist",
104
104
  "build:macos-helper": "swift build -c release --package-path apple/macos-helper",
105
105
  "build:macos-helper:clean": "swift package --package-path apple/macos-helper clean && pnpm build:macos-helper",
106
- "package:npm": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android",
106
+ "build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android",
107
+ "package:npm": "pnpm build:package && pnpm check:package",
108
+ "release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release",
109
+ "release:publish": "pnpm release:prepare && npm publish --ignore-scripts .tmp/release/*.tgz",
107
110
  "ad": "node bin/agent-device.mjs",
108
111
  "bench:help-conformance": "node scripts/help-conformance-bench.mjs",
109
112
  "maestro:conformance": "node --experimental-strip-types --test packages/maestro/test/conformance/verify.test.ts packages/maestro/test/conformance/differential/run.test.ts packages/maestro/test/conformance/differential/invariants.test.ts",
110
113
  "maestro:conformance:regenerate": "node --experimental-strip-types scripts/maestro-conformance/regenerate.mjs",
111
114
  "maestro:conformance:differential": "node --experimental-strip-types packages/maestro/test/conformance/differential/run.ts",
112
- "prototype:program-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/program-boundary.ts",
113
- "prototype:session-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/session-boundary.ts",
114
115
  "size": "node scripts/size-report.mjs",
115
116
  "perf": "node --experimental-strip-types scripts/perf/run.ts",
116
117
  "mutation:run": "node --experimental-strip-types scripts/mutation/run.ts",
@@ -129,23 +130,26 @@
129
130
  "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts",
130
131
  "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts",
131
132
  "check:coverage-changed:test": "node --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts",
132
- "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts && node --experimental-strip-types scripts/layering/check.ts",
133
+ "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts",
133
134
  "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
134
135
  "depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts",
135
136
  "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",
136
137
  "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts",
138
+ "check:package": "node --experimental-strip-types scripts/check-package.ts",
137
139
  "check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
138
140
  "check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
141
+ "check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
142
+ "check:tmpdir-leaks:test": "node --experimental-strip-types --test scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts",
139
143
  "check:freerange": "fr",
140
144
  "check:quick": "pnpm lint && pnpm typecheck",
141
145
  "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
142
146
  "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
143
147
  "version": "pnpm sync:mcp-metadata && git add server.json",
144
- "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files",
145
- "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke",
148
+ "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
149
+ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
146
150
  "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
147
151
  "prepack": "pnpm check:mcp-metadata && pnpm package:npm",
148
- "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
152
+ "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
149
153
  "test-app:install": "pnpm install --dir examples/test-app",
150
154
  "test-app:start": "pnpm --dir examples/test-app start",
151
155
  "test-app:ios": "pnpm --dir examples/test-app ios",
@@ -241,18 +245,20 @@
241
245
  ],
242
246
  "dependencies": {
243
247
  "@limrun/api": "^0.24.5",
244
- "pngjs": "^7.0.0",
245
248
  "yaml": "^2.9.0"
246
249
  },
247
250
  "devDependencies": {
251
+ "@agent-device/ad-replay": "workspace:*",
248
252
  "@agent-device/ad-script": "workspace:*",
249
253
  "@agent-device/contracts": "workspace:*",
250
254
  "@agent-device/kernel": "workspace:*",
255
+ "@agent-device/selectors": "workspace:*",
251
256
  "@agent-device/maestro": "workspace:*",
252
257
  "@agent-device/provider-limrun": "workspace:*",
253
258
  "@agent-device/provider-webdriver": "workspace:*",
254
259
  "@agent-device/replay-test": "workspace:*",
255
260
  "@agent-device/xml": "workspace:*",
261
+ "@arethetypeswrong/cli": "^0.18.5",
256
262
  "@chenglou/freerange": "^0.0.1",
257
263
  "@stryker-mutator/core": "9.6.1",
258
264
  "@stryker-mutator/vitest-runner": "9.6.1",
@@ -264,6 +270,8 @@
264
270
  "oxc-parser": "^0.138.0",
265
271
  "oxfmt": "^0.42.0",
266
272
  "oxlint": "^1.69.0",
273
+ "pngjs": "^7.0.0",
274
+ "publint": "^0.3.22",
267
275
  "tsdown": "^0.22.4",
268
276
  "typescript": "^7.0.2",
269
277
  "vite": "^8.0.16",
package/server.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
- "name": "io.github.callstackincubator/agent-device",
3
+ "name": "io.github.callstack/agent-device",
4
4
  "title": "agent-device",
5
5
  "description": "Let AI agents inspect, control, and debug real iOS, Android, desktop, and TV apps",
6
6
  "repository": {
7
7
  "url": "https://github.com/callstack/agent-device",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.20.4",
10
+ "version": "0.20.6",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "agent-device",
15
- "version": "0.20.4",
15
+ "version": "0.20.6",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -1 +0,0 @@
1
- 019ebbda52976df5cff05f9e5a764c8d6e280be3640e8dea0a2d7cca6c25021f agent-device-android-ime-helper-0.20.4.apk
@@ -1 +0,0 @@
1
- e3799afe5549776e047744efe46939a9146a4742a5328bb2e22f0b9d6abd2b52 agent-device-android-snapshot-helper-0.20.4.apk
@@ -1,225 +0,0 @@
1
- import AVFoundation
2
- import Foundation
3
-
4
- enum ResizeError: Error, CustomStringConvertible {
5
- case invalidArgs(String)
6
- case missingVideoTrack
7
- case exportFailed(String)
8
-
9
- var description: String {
10
- switch self {
11
- case .invalidArgs(let message):
12
- return message
13
- case .missingVideoTrack:
14
- return "Input video does not contain a video track."
15
- case .exportFailed(let message):
16
- return message
17
- }
18
- }
19
- }
20
-
21
- do {
22
- try run()
23
- } catch {
24
- fputs("recording-resize: \(error)\n", stderr)
25
- exit(1)
26
- }
27
-
28
- func run() throws {
29
- let arguments = Array(CommandLine.arguments.dropFirst())
30
- let parsedArgs = try parseArguments(arguments)
31
- let inputURL = URL(fileURLWithPath: parsedArgs.inputPath)
32
- let outputURL = URL(fileURLWithPath: parsedArgs.outputPath)
33
-
34
- if FileManager.default.fileExists(atPath: outputURL.path) {
35
- try FileManager.default.removeItem(at: outputURL)
36
- }
37
-
38
- let asset = AVURLAsset(url: inputURL)
39
- guard let sourceVideoTrack = asset.tracks(withMediaType: .video).first else {
40
- throw ResizeError.missingVideoTrack
41
- }
42
-
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)
50
- let composition = AVMutableComposition()
51
- let fullRange = CMTimeRange(start: .zero, duration: asset.duration)
52
-
53
- guard let compositionVideoTrack = composition.addMutableTrack(
54
- withMediaType: .video,
55
- preferredTrackID: kCMPersistentTrackID_Invalid
56
- ) else {
57
- throw ResizeError.exportFailed("Failed to create composition video track.")
58
- }
59
- try compositionVideoTrack.insertTimeRange(fullRange, of: sourceVideoTrack, at: .zero)
60
-
61
- if let sourceAudioTrack = asset.tracks(withMediaType: .audio).first,
62
- let compositionAudioTrack = composition.addMutableTrack(
63
- withMediaType: .audio,
64
- preferredTrackID: kCMPersistentTrackID_Invalid
65
- ) {
66
- try? compositionAudioTrack.insertTimeRange(fullRange, of: sourceAudioTrack, at: .zero)
67
- }
68
-
69
- let scale = renderSize.width / sourceRenderSize.width
70
- let videoComposition = AVMutableVideoComposition()
71
- videoComposition.renderSize = renderSize
72
- videoComposition.frameDuration = resolvedFrameDuration(for: sourceVideoTrack)
73
-
74
- let instruction = AVMutableVideoCompositionInstruction()
75
- instruction.timeRange = fullRange
76
- let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
77
- // Scale the full preferred transform (including translation) to match the smaller render canvas.
78
- let scaledTransform = scaledPreferredTransform(sourceVideoTrack.preferredTransform, scale: scale)
79
- layerInstruction.setTransform(scaledTransform, at: .zero)
80
- instruction.layerInstructions = [layerInstruction]
81
- videoComposition.instructions = [instruction]
82
-
83
- let presetName = exportPresetName(for: parsedArgs.exportQuality, compatibleWith: composition)
84
- guard let exporter = AVAssetExportSession(asset: composition, presetName: presetName) else {
85
- throw ResizeError.exportFailed("Failed to create export session.")
86
- }
87
-
88
- exporter.outputURL = outputURL
89
- exporter.outputFileType = .mp4
90
- exporter.videoComposition = videoComposition
91
- exporter.shouldOptimizeForNetworkUse = true
92
-
93
- let semaphore = DispatchSemaphore(value: 0)
94
- exporter.exportAsynchronously {
95
- semaphore.signal()
96
- }
97
- if semaphore.wait(timeout: .now() + 120) == .timedOut {
98
- exporter.cancelExport()
99
- throw ResizeError.exportFailed("Resize export timed out.")
100
- }
101
-
102
- if exporter.status != .completed {
103
- throw ResizeError.exportFailed(exporter.error?.localizedDescription ?? "Resize export failed.")
104
- }
105
- }
106
-
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) {
115
- var inputPath: String?
116
- var outputPath: String?
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
121
- var index = 0
122
-
123
- while index < arguments.count {
124
- let argument = arguments[index]
125
- let nextIndex = index + 1
126
- switch argument {
127
- case "--input":
128
- guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--input requires a value") }
129
- inputPath = arguments[nextIndex]
130
- index += 2
131
- case "--output":
132
- guard nextIndex < arguments.count else { throw ResizeError.invalidArgs("--output requires a value") }
133
- outputPath = arguments[nextIndex]
134
- 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
142
- case "--quality":
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")
148
- }
149
- exportQuality = parsed
150
- index += 2
151
- default:
152
- throw ResizeError.invalidArgs("Unknown argument: \(argument)")
153
- }
154
- }
155
-
156
- guard let inputPath, let outputPath, let maxSize else {
157
- throw ResizeError.invalidArgs(
158
- "Usage: recording-resize.swift --input <video> --output <video> --max-size <px> [--quality <medium|high>]"
159
- )
160
- }
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
- }
179
- }
180
-
181
- func resolvedRenderSize(for track: AVAssetTrack) -> CGSize {
182
- let transformed = track.naturalSize.applying(track.preferredTransform)
183
- return CGSize(width: abs(transformed.width), height: abs(transformed.height))
184
- }
185
-
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
190
- return CGSize(
191
- width: scaledDimension(renderSize.width, scale: scale),
192
- height: scaledDimension(renderSize.height, scale: scale)
193
- )
194
- }
195
-
196
- func scaledDimension(_ value: CGFloat, scale: CGFloat) -> CGFloat {
197
- let evenValue = Int((Double(value * scale) / 2.0).rounded()) * 2
198
- return CGFloat(max(2, evenValue))
199
- }
200
-
201
- func resolvedFrameDuration(for track: AVAssetTrack) -> CMTime {
202
- let minFrameDuration = track.minFrameDuration
203
- if minFrameDuration.isValid && !minFrameDuration.isIndefinite && minFrameDuration.seconds > 0 {
204
- return minFrameDuration
205
- }
206
-
207
- let nominalFrameRate = track.nominalFrameRate
208
- if nominalFrameRate > 0 {
209
- let timescale = Int32(max(1, round(nominalFrameRate)))
210
- return CMTime(value: 1, timescale: timescale)
211
- }
212
-
213
- return CMTime(value: 1, timescale: 60)
214
- }
215
-
216
- func scaledPreferredTransform(_ transform: CGAffineTransform, scale: CGFloat) -> CGAffineTransform {
217
- CGAffineTransform(
218
- a: transform.a * scale,
219
- b: transform.b * scale,
220
- c: transform.c * scale,
221
- d: transform.d * scale,
222
- tx: transform.tx * scale,
223
- ty: transform.ty * scale
224
- )
225
- }