.
675 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # This file is part of macosrec.
2 | #
3 | # Copyright (C) 2023 Álvaro Ramírez https://xenodium.com
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see .
17 |
18 | all: build
19 |
20 | build:
21 | swift build
22 |
23 | install: build
24 | mkdir -p ~/local/bin/
25 | cp .build/debug/macosrec ~/local/bin/
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "macosrec",
8 | platforms: [
9 | .macOS(.v13)
10 | ],
11 | dependencies: [
12 | .package(url: "https://github.com/apple/swift-argument-parser", from: "0.3.0")
13 | ],
14 | targets: [
15 | .executableTarget(
16 | name: "macosrec",
17 | dependencies: [
18 | .product(name: "ArgumentParser", package: "swift-argument-parser")
19 | ],
20 | path: "Sources",
21 | linkerSettings: [
22 | .linkedFramework("Speech")
23 | ])
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/README.org:
--------------------------------------------------------------------------------
1 | 👉 [[https://github.com/sponsors/xenodium][Support this work via GitHub Sponsors]]
2 |
3 | * macosrec
4 |
5 | Take screenshots or videos of macOS windows from the command line (also includes [[https://en.wikipedia.org/wiki/Optical_character_recognition][OCR]]).
6 |
7 | See [[https://xenodium.com/recordscreenshot-windows-the-lazy-way][Recording and screenshotting windows: the lazy way]].
8 |
9 | #+HTML:
Note: This gif was captured with macosrec
10 |
11 | #+begin_src shell
12 | $ macosrec
13 | USAGE: record-command [--version] [--list] [--hidden] [--screenshot ] [--record ] [--ocr] [--clipboard] [--mov] [--gif] [--save] [--abort] [--output ]
14 |
15 | OPTIONS:
16 | --version Show version.
17 | -l, --list List recordable windows.
18 | --hidden Also include hidden windows when listing.
19 | -x, --screenshot
20 | Take a screenshot.
21 | -r, --record
22 | Start recording.
23 | -c, --ocr Select and recognize text in screen region.
24 | -b, --clipboard Save --ocr text to clipboard.
25 | -m, --mov Record as mov.
26 | -g, --gif Record as gif.
27 | -s, --save Save active recording.
28 | -a, --abort Abort active recording.
29 | -o, --output
30 | -h, --help Show help information.
31 | #+end_src
32 | * Install
33 | ** Homebrew
34 | #+begin_src sh
35 | brew tap xenodium/macosrec
36 | brew install macosrec
37 | #+end_src
38 | ** Build
39 | #+begin_src sh
40 | swift build
41 | #+end_src
42 | * Screenshot
43 |
44 | Before taking a screenshot, identify the window number using =--list=.
45 |
46 | #+begin_src sh
47 | $ macosrec --list
48 |
49 | 21902 Emacs
50 | 22024 Dock - Desktop Picture - Stone.png
51 | 22035 Firefox - Planet Emacslife
52 | #+end_src
53 |
54 | Use =--screenshot= + window number to take a screenshot.
55 |
56 | #+begin_src sh
57 | ~ $ macosrec --screenshot 21902
58 | ~/Desktop/2023-04-14-08:21:45-Emacs.png
59 | #+end_src
60 |
61 | * Videos
62 |
63 | Before taking a video, identify the window number using =--list=.
64 |
65 | #+begin_src sh
66 | $ macosrec --list
67 |
68 | 21902 Emacs
69 | 22024 Dock - Desktop Picture - Stone.png
70 | 22035 Firefox - Planet Emacslife
71 | #+end_src
72 |
73 | Use =--record= + window number to start recording a video (gif).
74 |
75 | /Note: you can also use application name and it will use the first window it finds belonging to it./
76 |
77 | To end recording, send a SIGINT signal (Ctrl+C from terminal). Alternatively, running =macosrec --save= from another session would also end the recording.
78 |
79 | #+begin_src sh
80 | ~ $ macosrec --record 21902 --gif
81 | Saving...
82 | ~/Desktop/2023-04-14-08:21:45-Emacs.gif
83 | #+end_src
84 |
85 | /Note: you can also use application name and it will use the first window it finds belonging to it./
86 |
87 | #+begin_src sh
88 | ~ $ macosrec --record emacs --mov
89 | Saving...
90 | ~/Desktop/2023-04-14-08:21:45-Emacs.mov
91 | #+end_src
92 |
93 | ** Optimizing gif
94 |
95 | The gifs can get pretty large fairly quickly depending on the lenght of the recording. Consider using something like [[https://www.lcdf.org/gifsicle/][gifsicle]] to reduce size. For example:
96 |
97 | #+begin_src sh
98 | gifsicle -O3 large.gif --lossy=80 -o smaller.gif
99 | #+end_src
100 |
101 | * OCR
102 | ** Selecting a region
103 | The =--ocr= flag defaults to selecting a screen region (unless =--input=) is given.
104 | #+begin_src sh
105 | $ macosrec --ocr
106 |
107 | Hello this text was recognized
108 | #+end_src
109 |
110 | ** From existing image
111 |
112 | Use the =--input= flag:
113 |
114 | #+begin_src sh
115 | $ macosrec --ocr --input /path/to/image.png
116 |
117 | Hello this text was recognized
118 | #+end_src
119 |
120 | ** Save to clipboard
121 |
122 | Use the =--clipboard= flat:
123 |
124 | #+begin_src sh
125 | $ macosrec --ocr --input /path/to/image.png --clipboard
126 |
127 | Hello this text was recognized
128 | #+end_src
129 |
130 | * Speech to text
131 | Recognizing text with in speech =--speech-to-text= is only supported via =--input= audio file (i.e. .mp3).
132 |
133 | #+begin_src sh
134 | $ macosrec --speech-to-text --locale "en-GB" --input path/to/audio.mp3
135 |
136 | Hello this text was recognized
137 | #+end_src
138 |
139 | * Disclaimer
140 |
141 | I built this util to record demos I post at [[https://xenodium.com][xenodium.com]]. Does the job so far, but can likely take improvements, specially around image handling efficiency. PRs totally welcome.
142 |
143 | ** Resizing windows while recording (not supported)
144 |
145 | While a video will be recorded if you resize the window during the recording session, it's unlikely to produce a file with the expected outcome. This feature is currently unsupported and out of scope. Having said that, if anyone's keen to implement it, a PR is totally welcome.
146 |
147 | 👉 [[https://github.com/sponsors/xenodium][Support this work via GitHub Sponsors]]
148 |
--------------------------------------------------------------------------------
/Sources/main.swift:
--------------------------------------------------------------------------------
1 | /*
2 | * This file is part of macosrec.
3 | *
4 | * Copyright (C) 2024 Álvaro Ramírez https://xenodium.com
5 | *
6 | * This program is free software: you can redistribute it and/or modify
7 | * it under the terms of the GNU General Public License as published by
8 | * the Free Software Foundation, either version 3 of the License, or
9 | * (at your option) any later version.
10 | *
11 | * This program is distributed in the hope that it will be useful,
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | * GNU General Public License for more details.
15 | *
16 | * You should have received a copy of the GNU General Public License
17 | * along with this program. If not, see .
18 | */
19 |
20 | import AVFoundation
21 | import AppKit
22 | import ArgumentParser
23 | import Cocoa
24 | import Speech
25 | import Vision
26 |
27 | let packageVersion = "0.8.1"
28 |
29 | var recorder: WindowRecorder?
30 |
31 | signal(SIGINT) { _ in
32 | recorder?.save()
33 | }
34 |
35 | signal(SIGTERM) { _ in
36 | recorder?.abort()
37 | exit(1)
38 | }
39 |
40 | struct RecordCommand: ParsableCommand {
41 | @Flag(name: [.customLong("version")], help: "Show version.")
42 | var showVersion: Bool = false
43 |
44 | @Flag(name: .shortAndLong, help: "List recordable windows.")
45 | var list: Bool = false
46 |
47 | @Flag(name: .long, help: "Also include hidden windows when listing.")
48 | var hidden: Bool = false
49 |
50 | @Option(
51 | name: [.customShort("x"), .long],
52 | help: ArgumentHelp(
53 | "Take a screenshot.", valueName: "app name or window id"))
54 | var screenshot: String?
55 |
56 | @Option(
57 | name: .shortAndLong,
58 | help: ArgumentHelp(
59 | "Start recording.", valueName: "app name or window id")
60 | )
61 | var record: String?
62 |
63 | @Option(
64 | name: .shortAndLong,
65 | help: ArgumentHelp(
66 | "Input file (for --ocr or --speech-to-text only).",
67 | valueName: "input image or audio file")
68 | )
69 | var input: String?
70 |
71 | @Option(
72 | name: [.customShort("t"), .long],
73 | help: ArgumentHelp(
74 | "Locale, for example \"ja-JP\" (for --speech-to-text only).",
75 | valueName: "locale for speech to text")
76 | )
77 | var locale: String?
78 |
79 | @Flag(name: [.customShort("c"), .long], help: "Select and recognize text in screen region.")
80 | var ocr: Bool = false
81 |
82 | @Flag(
83 | name: [.customShort("z"), .customLong("speech-to-text")],
84 | help: "Recognize text in speech audio.")
85 | var speechToText: Bool = false
86 |
87 | @Flag(name: [.customShort("b"), .long], help: "Save --ocr text to clipboard.")
88 | var clipboard: Bool = false
89 |
90 | @Flag(name: .shortAndLong, help: "Record as mov.")
91 | var mov: Bool = false
92 |
93 | @Flag(name: .shortAndLong, help: "Record as gif.")
94 | var gif: Bool = false
95 |
96 | @Flag(name: .shortAndLong, help: "Save active recording.")
97 | var save: Bool = false
98 |
99 | @Flag(name: .shortAndLong, help: "Abort active recording.")
100 | var abort: Bool = false
101 |
102 | @Option(
103 | name: .shortAndLong,
104 | help: ArgumentHelp(valueName: "optional output file path"))
105 | var output: String?
106 |
107 | mutating func run() throws {
108 | if showVersion {
109 | guard let binPath = CommandLine.arguments.first else {
110 | print("Error: binary name not available")
111 | Darwin.exit(1)
112 | }
113 | print("\(URL(fileURLWithPath: binPath).lastPathComponent) \(packageVersion)")
114 | Darwin.exit(0)
115 | }
116 |
117 | if list {
118 | NSWorkspace.shared.printWindowList(includeHidden: hidden)
119 | Darwin.exit(0)
120 | }
121 |
122 | if hidden {
123 | print("Error: can't use --hidden with anything other than --list")
124 | Darwin.exit(1)
125 | }
126 |
127 | if speechToText {
128 | if ocr {
129 | print("Error: can't use --ocr and --speech-to-text simultaneously")
130 | Darwin.exit(1)
131 | }
132 |
133 | if screenshot != nil {
134 | print("Error: can't use --ocr and --screenshot simultaneously")
135 | Darwin.exit(1)
136 | }
137 |
138 | if record != nil {
139 | print("Error: can't use --ocr and --record simultaneously")
140 | Darwin.exit(1)
141 | }
142 |
143 | if mov || gif {
144 | print("Error: can't use --ocr with --mov or --gif")
145 | Darwin.exit(1)
146 | }
147 |
148 | if let output = output,
149 | URL(fileURLWithPath: output).pathExtension != "txt"
150 | {
151 | print("Error: --output file must end in .txt")
152 | Darwin.exit(1)
153 | }
154 |
155 | guard let input = input else {
156 | print("Error: Missing --input file")
157 | Darwin.exit(0)
158 | }
159 |
160 | guard let locale = locale else {
161 | print("Error: Missing --locale")
162 | Darwin.exit(0)
163 | }
164 |
165 | let url = URL(filePath: input)
166 | Recognizer.recognizeAudioText(in: url, locale: locale, saveToFile: output)
167 | return
168 | }
169 |
170 | if ocr {
171 | if screenshot != nil {
172 | print("Error: can't use --ocr and --screenshot simultaneously")
173 | Darwin.exit(1)
174 | }
175 |
176 | if speechToText {
177 | print("Error: can't use --ocr and --speech-to-text simultaneously")
178 | Darwin.exit(1)
179 | }
180 |
181 | if locale != nil {
182 | print("Error: can't use --ocr and --locale simultaneously")
183 | Darwin.exit(1)
184 | }
185 |
186 | if record != nil {
187 | print("Error: can't use --ocr and --record simultaneously")
188 | Darwin.exit(1)
189 | }
190 |
191 | if mov || gif {
192 | print("Error: can't use --ocr with --mov or --gif")
193 | Darwin.exit(1)
194 | }
195 |
196 | if let output = output,
197 | URL(fileURLWithPath: output).pathExtension != "txt"
198 | {
199 | print("Error: --output file must end in .txt")
200 | Darwin.exit(1)
201 | }
202 |
203 | if let input = input,
204 | let capturedImage = NSImage(contentsOfFile: input)
205 | {
206 | recognizeImageText(in: capturedImage, useClipboard: clipboard, saveToFile: output)
207 | Darwin.exit(0)
208 | }
209 |
210 | if let capturedImage = captureScreenImage() {
211 | recognizeImageText(in: capturedImage, useClipboard: clipboard, saveToFile: output)
212 | Darwin.exit(0)
213 | }
214 |
215 | Darwin.exit(0)
216 | }
217 |
218 | if let windowIdentifier = screenshot {
219 | if record != nil {
220 | print("Error: can't use --screenshot and --record simultaneously")
221 | Darwin.exit(1)
222 | }
223 |
224 | if locale != nil {
225 | print("Error: can't use --screenshot and --locale simultaneously")
226 | Darwin.exit(1)
227 | }
228 |
229 | if input != nil {
230 | print("Error: can't use --screenshot with --input")
231 | Darwin.exit(1)
232 | }
233 |
234 | if mov || gif {
235 | print("Error: can't use --screenshot with --mov or --gif")
236 | Darwin.exit(1)
237 | }
238 |
239 | if let output = output,
240 | URL(fileURLWithPath: output).pathExtension != "png"
241 | {
242 | print("Error: --png not compatible with \(output)")
243 | Darwin.exit(1)
244 | }
245 |
246 | let identifier = resolveWindowID(windowIdentifier)
247 | if let output = output {
248 | recorder = WindowRecorder(.png, for: identifier, URL(fileURLWithPath: output))
249 | } else {
250 | recorder = WindowRecorder(.png, for: identifier)
251 | }
252 | recorder?.save()
253 | Darwin.exit(0)
254 | }
255 |
256 | if let windowIdentifier = record {
257 | if recordingPid() != nil {
258 | print("Error: Already recording")
259 | Darwin.exit(1)
260 | }
261 |
262 | let mediaType: WindowRecorder.MediaType = {
263 | if screenshot != nil {
264 | print("Error: can't use --screenshot and --record simultaneously")
265 | Darwin.exit(1)
266 | }
267 |
268 | if locale != nil {
269 | print("Error: can't use --locale and --record simultaneously")
270 | Darwin.exit(1)
271 | }
272 |
273 | if input != nil {
274 | print("Error: can't use --record with --input")
275 | Darwin.exit(1)
276 | }
277 |
278 | if mov {
279 | if let output = output,
280 | URL(fileURLWithPath: output).pathExtension != "mov"
281 | {
282 | print("Error: --mov not compatible with \(output)")
283 | Darwin.exit(1)
284 | }
285 | return WindowRecorder.MediaType.mov
286 | }
287 |
288 | if gif {
289 | if let output = output,
290 | URL(fileURLWithPath: output).pathExtension != "gif"
291 | {
292 | print("Error: --gif not compatible with \(output)")
293 | Darwin.exit(1)
294 | }
295 | return WindowRecorder.MediaType.gif
296 | }
297 |
298 | guard let output = output else {
299 | // Default to mov otherwise
300 | return WindowRecorder.MediaType.mov
301 | }
302 |
303 | let ext = URL(fileURLWithPath: output).pathExtension
304 |
305 | if ext == "mov" {
306 | return WindowRecorder.MediaType.mov
307 | }
308 |
309 | if ext == "gif" {
310 | return WindowRecorder.MediaType.gif
311 | }
312 |
313 | print("Error: Unsupported extension .\(ext)")
314 | Darwin.exit(1)
315 | }()
316 |
317 | let identifier = resolveWindowID(windowIdentifier)
318 | if let output = output {
319 | recorder = WindowRecorder(mediaType, for: identifier, URL(fileURLWithPath: output))
320 | } else {
321 | recorder = WindowRecorder(mediaType, for: identifier)
322 | }
323 | recorder?.record()
324 | return
325 | }
326 |
327 | if save {
328 | guard let recordingPid = recordingPid() else {
329 | print("Error: No recording")
330 | Darwin.exit(1)
331 | }
332 | let result = kill(recordingPid, SIGINT)
333 | if result != 0 {
334 | print("Error: Could not stop recording")
335 | Darwin.exit(1)
336 | }
337 | Darwin.exit(0)
338 | }
339 |
340 | if abort {
341 | guard let recordingPid = recordingPid() else {
342 | print("Error: No recording")
343 | Darwin.exit(1)
344 | }
345 | let result = kill(recordingPid, SIGTERM)
346 | if result != 0 {
347 | print("Error: Could not abort recording")
348 | Darwin.exit(1)
349 | }
350 | Darwin.exit(0)
351 | }
352 | }
353 | }
354 |
355 | guard CommandLine.arguments.count > 1 else {
356 | print("\(RecordCommand.helpMessage())")
357 | exit(1)
358 | }
359 | RecordCommand.main()
360 | RunLoop.current.run()
361 |
362 | struct WindowInfo {
363 | let app: String
364 | let title: String
365 | let identifier: CGWindowID
366 | }
367 |
368 | extension NSWorkspace {
369 | func printWindowList(includeHidden: Bool) {
370 | for window in allWindows(includeHidden: includeHidden) {
371 | if window.title.isEmpty {
372 | print("\(window.identifier) \(window.app)")
373 | } else {
374 | print("\(window.identifier) \(window.app) - \(window.title)")
375 | }
376 | }
377 | }
378 |
379 | func window(identifiedAs windowIdentifier: CGWindowID) -> WindowInfo? {
380 | allWindows(includeHidden: true).first {
381 | $0.identifier == windowIdentifier
382 | }
383 | }
384 |
385 | func allWindows(includeHidden: Bool) -> [WindowInfo] {
386 | var windowInfos = [WindowInfo]()
387 | let windows =
388 | CGWindowListCopyWindowInfo(includeHidden ? .optionAll : .optionOnScreenOnly, kCGNullWindowID)
389 | as? [[String: Any]]
390 | for app in NSWorkspace.shared.runningApplications {
391 | for window in windows ?? [] {
392 | if let windowPid = window[kCGWindowOwnerPID as String] as? Int,
393 | windowPid == app.processIdentifier,
394 | let identifier = window[kCGWindowNumber as String] as? Int,
395 | let appName = app.localizedName
396 | {
397 | let title = window[kCGWindowName as String] as? String ?? ""
398 | windowInfos.append(
399 | WindowInfo(app: appName, title: title, identifier: CGWindowID(identifier)))
400 | }
401 | }
402 | }
403 | return windowInfos
404 | }
405 | }
406 |
407 | class WindowRecorder {
408 | private let window: WindowInfo
409 | private let fps: Int32 = 10
410 | private var timer: Timer?
411 | private var images = [CGImage]()
412 | private let urlOverride: URL?
413 | private let mediaType: MediaType
414 |
415 | enum MediaType {
416 | case gif
417 | case mov
418 | case png
419 | }
420 |
421 | var interval: Double {
422 | 1.0 / Double(fps)
423 | }
424 |
425 | init(_ mediaType: MediaType, for windowIdentifier: CGWindowID, _ urlOverride: URL? = nil) {
426 | guard let foundWindow = NSWorkspace.shared.window(identifiedAs: windowIdentifier) else {
427 | print("Error: window not found")
428 | exit(1)
429 | }
430 | self.urlOverride = urlOverride
431 | self.window = foundWindow
432 | self.mediaType = mediaType
433 | }
434 |
435 | func record() {
436 | timer?.invalidate()
437 | timer = Timer.scheduledTimer(
438 | withTimeInterval: TimeInterval(interval), repeats: true,
439 | block: { [weak self] _ in
440 | guard let self = self else {
441 | print("Error: No recorder")
442 | exit(1)
443 | }
444 | guard
445 | let image = self.windowImage()
446 | else {
447 | print("Error: No image from window")
448 | exit(1)
449 | }
450 | DispatchQueue.global(qos: .default).sync { [weak self] in
451 | guard let self = self else {
452 | print("Error: No recorder")
453 | exit(1)
454 | }
455 |
456 | guard let resizedImage = image.resize(compressionFactor: 1.0, scale: 0.7) else {
457 | print("Error: Could not resize frame")
458 | exit(1)
459 | }
460 | self.images.append(resizedImage)
461 | // self.images.append(image)
462 | }
463 | })
464 | }
465 |
466 | func abort() {
467 | print("Aborted")
468 | timer?.invalidate()
469 | }
470 |
471 | func save() {
472 | switch mediaType {
473 | case .gif:
474 | saveGif()
475 | case .mov:
476 | saveMov()
477 | case .png:
478 | savePng()
479 | }
480 | }
481 |
482 | private func savePng() {
483 | do {
484 | guard let image = windowImage() else {
485 | print("Error: No window image")
486 | exit(1)
487 | }
488 | guard let url = urlOverride ?? getDesktopFileURL(suffix: window.app, ext: ".png") else {
489 | print("Error: could craft URL to screenshot")
490 | exit(1)
491 | }
492 | guard let data = image.pngData(compressionFactor: 1) else {
493 | print("Error: No png data")
494 | exit(1)
495 | }
496 | try data.write(to: url)
497 | print("\((url.path as NSString).abbreviatingWithTildeInPath)")
498 | exit(0)
499 | } catch {
500 | print("Error: \(error.localizedDescription)")
501 | exit(1)
502 | }
503 | }
504 |
505 | private func windowImage() -> CGImage? {
506 | return CGWindowListCreateImage(
507 | CGRect.null, CGWindowListOption.optionIncludingWindow, self.window.identifier,
508 | CGWindowImageOption.boundsIgnoreFraming)
509 | }
510 |
511 | private func saveMov() {
512 | print("Saving mov...")
513 | timer?.invalidate()
514 |
515 | guard let url = urlOverride ?? getDesktopFileURL(suffix: window.app, ext: ".mov") else {
516 | print("Error: could craft URL to animation")
517 | exit(1)
518 | }
519 |
520 | createVideoFromImages(self.images, url, fps) { success, error in
521 | if success {
522 | print("\((url.path as NSString).abbreviatingWithTildeInPath)")
523 | exit(0)
524 | } else {
525 | print("Error: \(error?.localizedDescription ?? "Unknown")")
526 | exit(1)
527 | }
528 | }
529 | }
530 |
531 | private func saveGif() {
532 | print("Saving gif...")
533 | timer?.invalidate()
534 |
535 | guard let url = urlOverride ?? getDesktopFileURL(suffix: window.app, ext: ".gif") else {
536 | print("Error: could craft URL to animation")
537 | exit(1)
538 | }
539 |
540 | guard
541 | let destinationGIF = CGImageDestinationCreateWithURL(
542 | url as NSURL,
543 | kUTTypeGIF, images.count, nil)
544 | else {
545 | print("Error: No destination GIF")
546 | exit(1)
547 | }
548 |
549 | CGImageDestinationSetProperties(
550 | destinationGIF,
551 | [
552 | kCGImagePropertyGIFDictionary as String:
553 | [
554 | kCGImagePropertyGIFLoopCount as String: 0
555 | // kCGImagePropertyGIFHasGlobalColorMap as String: false,
556 | // kCGImagePropertyColorModel as String: kCGImagePropertyColorModelRGB,
557 | ] as [String: Any]
558 | ] as CFDictionary
559 | )
560 | images.reverse()
561 |
562 | while !images.isEmpty {
563 | guard let image = self.images.popLast() else {
564 | print("Error: invalid frame count")
565 | exit(1)
566 | }
567 | CGImageDestinationAddImage(
568 | destinationGIF, image,
569 | [
570 | kCGImagePropertyGIFDictionary as String:
571 | [(kCGImagePropertyGIFDelayTime as String): 1.0 / Double(fps)]
572 | ] as CFDictionary)
573 | }
574 |
575 | if CGImageDestinationFinalize(destinationGIF) {
576 | print("\((url.path as NSString).abbreviatingWithTildeInPath)")
577 | exit(0)
578 | } else {
579 | print("Error: could not save")
580 | exit(1)
581 | }
582 | }
583 | }
584 |
585 | extension CGImage {
586 | func pngData(compressionFactor: Float) -> Data? {
587 | NSBitmapImageRep(cgImage: self).representation(
588 | using: .png, properties: [NSBitmapImageRep.PropertyKey.compressionFactor: compressionFactor])
589 | }
590 |
591 | func resize(compressionFactor: Float, scale: Float) -> CGImage? {
592 | guard
593 | let pngData = pngData(compressionFactor: compressionFactor)
594 | else {
595 | return nil
596 | }
597 | guard let data = CGImageSourceCreateWithData(pngData as CFData, nil) else {
598 | return nil
599 | }
600 | var maxSideLength = width
601 | if height > width {
602 | maxSideLength = height
603 | }
604 | maxSideLength = Int(Float(maxSideLength) * scale)
605 | let options: [String: Any] = [
606 | kCGImageSourceThumbnailMaxPixelSize as String: maxSideLength,
607 | kCGImageSourceCreateThumbnailFromImageAlways as String: true,
608 | kCGImageSourceCreateThumbnailWithTransform as String: true,
609 | ]
610 | return CGImageSourceCreateThumbnailAtIndex(data, 0, options as CFDictionary)
611 | }
612 | }
613 |
614 | func recordingPid() -> pid_t? {
615 | let name = ProcessInfo.processInfo.processName
616 | let task = Process()
617 | task.launchPath = "/bin/ps"
618 | task.arguments = ["-A", "-o", "pid,comm"]
619 |
620 | let pipe = Pipe()
621 | task.standardOutput = pipe
622 | task.launch()
623 |
624 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
625 | guard let output = String(data: data, encoding: String.Encoding.utf8) else {
626 | return nil
627 | }
628 |
629 | let lines = output.components(separatedBy: "\n")
630 | for line in lines {
631 | if line.contains(name) && !line.contains("defunct") {
632 | let components = line.components(separatedBy: " ")
633 | let values = components.filter { $0 != "" }
634 | let found = pid_t(values[0])
635 | if found != getpid() {
636 | return found
637 | }
638 | }
639 | }
640 |
641 | return nil
642 | }
643 |
644 | func getDesktopFileURL(suffix: String, ext: String) -> URL? {
645 | let dateFormatter = DateFormatter()
646 | dateFormatter.dateFormat = "yyyy-MM-dd-HH:mm:ss"
647 | let timestamp = dateFormatter.string(from: Date())
648 | let fileName = timestamp + "-" + suffix + ext
649 |
650 | guard var desktopURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first
651 | else { return nil }
652 | desktopURL.appendPathComponent(fileName)
653 |
654 | return desktopURL
655 | }
656 |
657 | func resolveWindowID(_ windowIdentifier: String) -> CGWindowID {
658 | if let identifier = CGWindowID(windowIdentifier) {
659 | return identifier
660 | }
661 | if let window = NSWorkspace.shared.allWindows(includeHidden: true).filter({
662 | $0.app.trimmingCharacters(in: .whitespacesAndNewlines)
663 | .caseInsensitiveCompare(windowIdentifier.trimmingCharacters(in: .whitespacesAndNewlines))
664 | == .orderedSame
665 | }).first {
666 | return CGWindowID(window.identifier)
667 | }
668 | print("Error: Invalid window identifier")
669 | Darwin.exit(1)
670 | }
671 |
672 | func createVideoFromImages(
673 | _ images: [CGImage], _ outputFileURL: URL, _ fps: Int32,
674 | completion: @escaping (Bool, Error?) -> Void
675 | ) {
676 | var images = Array(images.reversed())
677 | let assetWriter: AVAssetWriter
678 | do {
679 | assetWriter = try AVAssetWriter(outputURL: outputFileURL, fileType: AVFileType.mov)
680 | } catch {
681 | completion(false, error)
682 | return
683 | }
684 |
685 | guard let firstFrame = images.first else {
686 | print("Error: No frames found")
687 | Darwin.exit(1)
688 | }
689 |
690 | let videoWidth = firstFrame.width
691 | let videoHeight = firstFrame.height
692 |
693 | let videoSettings: [String: AnyObject] = [
694 | AVVideoCodecKey: AVVideoCodecType.h264 as AnyObject,
695 | AVVideoWidthKey: videoWidth as AnyObject,
696 | AVVideoHeightKey: videoHeight as AnyObject,
697 | ]
698 |
699 | let assetWriterInput = AVAssetWriterInput(
700 | mediaType: AVMediaType.video, outputSettings: videoSettings)
701 | assetWriterInput.expectsMediaDataInRealTime = true
702 |
703 | let pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(
704 | assetWriterInput: assetWriterInput,
705 | sourcePixelBufferAttributes: [
706 | kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB),
707 | kCVPixelBufferWidthKey as String: videoWidth,
708 | kCVPixelBufferHeightKey as String: videoHeight,
709 | ]
710 | )
711 |
712 | assetWriter.add(assetWriterInput)
713 |
714 | if !assetWriter.startWriting() {
715 | completion(false, assetWriter.error)
716 | return
717 | }
718 |
719 | assetWriter.startSession(atSourceTime: CMTime.zero)
720 |
721 | var frameNumber = 0
722 |
723 | assetWriterInput.requestMediaDataWhenReady(on: .main) {
724 |
725 | if images.isEmpty {
726 | assetWriterInput.markAsFinished()
727 | assetWriter.finishWriting {
728 | completion(true, nil)
729 | }
730 | return
731 | }
732 |
733 | guard let cgImage = images.popLast() else {
734 | print("Error: invalid frame count")
735 | exit(1)
736 | }
737 |
738 | let presentationTime = CMTime(value: Int64(frameNumber), timescale: fps)
739 |
740 | if let pixelBuffer = createPixelBufferFromCGImage(
741 | cgImage: cgImage, width: videoWidth, height: videoHeight)
742 | {
743 | pixelBufferAdaptor.append(pixelBuffer, withPresentationTime: presentationTime)
744 | }
745 |
746 | frameNumber += 1
747 | }
748 | }
749 |
750 | func createPixelBufferFromCGImage(cgImage: CGImage, width: Int, height: Int) -> CVPixelBuffer? {
751 | var pixelBuffer: CVPixelBuffer?
752 | let options: [String: Any] = [
753 | kCVPixelBufferCGImageCompatibilityKey as String: true,
754 | kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
755 | ]
756 |
757 | let status = CVPixelBufferCreate(
758 | kCFAllocatorDefault, width, height, kCVPixelFormatType_32ARGB, options as CFDictionary,
759 | &pixelBuffer)
760 | if status != kCVReturnSuccess {
761 | return nil
762 | }
763 |
764 | CVPixelBufferLockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
765 | let pixelData = CVPixelBufferGetBaseAddress(pixelBuffer!)
766 |
767 | let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
768 | let context = CGContext(
769 | data: pixelData, width: width, height: height, bitsPerComponent: 8,
770 | bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer!), space: rgbColorSpace,
771 | bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue)
772 |
773 | if let context = context {
774 | context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
775 | }
776 |
777 | CVPixelBufferUnlockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0))
778 |
779 | return pixelBuffer
780 | }
781 |
782 | private func captureScreenImage() -> NSImage? {
783 | let process = Process()
784 | let screenCaptureURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
785 | process.executableURL = screenCaptureURL
786 | guard
787 | let outputFilePath =
788 | NSURL.fileURL(withPathComponents: [NSTemporaryDirectory(), "screen.png"])?.path
789 | else {
790 | return nil
791 | }
792 | process.arguments = ["-i", outputFilePath]
793 | do {
794 | try process.run()
795 | } catch {
796 | print(String(describing: error))
797 | Darwin.exit(1)
798 | }
799 | process.waitUntilExit()
800 | return NSImage(contentsOfFile: outputFilePath)
801 | }
802 |
803 | struct Recognizer {
804 | static func recognizeAudioText(in url: URL, locale: String, saveToFile outputPath: String?) {
805 | SFSpeechRecognizer.requestAuthorization { authStatus in
806 | switch authStatus {
807 | case .authorized:
808 | guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale)) else {
809 | print("Error: invalid locale \"\(locale)\"")
810 | Darwin.exit(1)
811 | }
812 | let request = SFSpeechURLRecognitionRequest(url: url)
813 | recognizer.recognitionTask(with: request) { result, error in
814 | guard let result = result else {
815 | print("Recognition failed: \(error?.localizedDescription ?? "Unknown error")")
816 | Darwin.exit(1)
817 | }
818 |
819 | if result.isFinal {
820 | if let outputPath = outputPath {
821 | let outputURL = URL(fileURLWithPath: outputPath)
822 | do {
823 | try result.bestTranscription.formattedString.write(
824 | to: outputURL, atomically: true, encoding: .utf8)
825 | Darwin.exit(0)
826 | } catch {
827 | print(String(describing: error))
828 | Darwin.exit(1)
829 | }
830 | } else {
831 | print(result.bestTranscription.formattedString)
832 | Darwin.exit(0)
833 | }
834 | }
835 | }
836 | case .denied:
837 | print("Speech recognition authorization denied.")
838 | Darwin.exit(1)
839 | case .restricted:
840 | print("Speech recognition authorization restricted.")
841 | Darwin.exit(1)
842 | case .notDetermined:
843 | print("Speech recognition authorization not determined.")
844 | Darwin.exit(1)
845 | @unknown default:
846 | Darwin.exit(1)
847 | }
848 | }
849 | }
850 | }
851 |
852 | func recognizeImageText(in image: NSImage, useClipboard: Bool, saveToFile outputPath: String?) {
853 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
854 | else {
855 | print("Error: Failed to load image.")
856 | Darwin.exit(1)
857 | }
858 | let requestHandler = VNImageRequestHandler(cgImage: cgImage, options: [:])
859 |
860 | let textRecognitionRequest = VNRecognizeTextRequest { (request, error) in
861 | guard error == nil else {
862 | print(String(describing: error))
863 | Darwin.exit(1)
864 | }
865 |
866 | var recognizedText = ""
867 | if let observations = request.results as? [VNRecognizedTextObservation] {
868 | for observation in observations {
869 | if let topCandidate = observation.topCandidates(1).first {
870 | recognizedText += topCandidate.string + "\n"
871 | }
872 | }
873 | recognizedText = recognizedText.trimmingCharacters(in: .whitespaces)
874 | if useClipboard {
875 | let pasteboard = NSPasteboard.general
876 | pasteboard.clearContents()
877 | pasteboard.setString(recognizedText, forType: .string)
878 | }
879 | if let outputPath = outputPath {
880 | let outputURL = URL(fileURLWithPath: outputPath)
881 | do {
882 | try recognizedText.write(to: outputURL, atomically: true, encoding: .utf8)
883 | } catch {
884 | print(String(describing: error))
885 | Darwin.exit(1)
886 | }
887 | } else {
888 | print(recognizedText)
889 | }
890 | }
891 | }
892 |
893 | textRecognitionRequest.automaticallyDetectsLanguage = true
894 |
895 | do {
896 | try requestHandler.perform([textRecognitionRequest])
897 | } catch {
898 | print(String(describing: error))
899 | Darwin.exit(1)
900 | }
901 | }
902 |
--------------------------------------------------------------------------------
/demo/record.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xenodium/macosrec/9d36edd0cbf0f17fdf380d84e2bfe3fcf49f38d7/demo/record.gif
--------------------------------------------------------------------------------