├── Brewfile ├── README.md └── capture_ios_sim.rb /Brewfile: -------------------------------------------------------------------------------- 1 | brew 'gifsicle' 2 | brew 'ffmpeg' 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📱 Capture iOS simulator to animated GIF 2 | 3 | ## Usage 4 | ``` 5 | Usage: ./capture_ios_sim.rb [options] 6 | -v, --[no-]verbose Run verbosely 7 | -r, --framerate N Frames per second of output gif (default: 15) 8 | -w, --width N Width of output gif (default: 300) 9 | -o, --output Output gif path (default: ./capture.gif) 10 | -k, --keep Keep .mov file along with generated gif (default: false) 11 | ``` 12 | 13 | ## Setup 14 | ``` 15 | git clone git@github.com:tdesert/capture_ios_sim.git 16 | cd ./capture_ios_sim 17 | chmod +x ./capture_ios_sim.rb 18 | brew bundle 19 | ./capture_ios_sim.rb 20 | ``` 21 | -------------------------------------------------------------------------------- /capture_ios_sim.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'date' 4 | require 'open3' 5 | require 'optparse' 6 | 7 | ### 8 | # OPTIONS 9 | ### 10 | 11 | DEFAULT_FILENAME = "capture.gif" 12 | 13 | LOG_LEVEL = { 14 | verbose: 0, 15 | default: 1 16 | } 17 | 18 | OPTIONS = { 19 | frame_rate: 15, 20 | width: 300, 21 | log_level: LOG_LEVEL[:default], 22 | output_file: "#{Dir.pwd}/#{DEFAULT_FILENAME}", 23 | keep_mov: false 24 | } 25 | 26 | OptionParser.new do |opts| 27 | opts.banner = "Usage: #{$0} [options]" 28 | 29 | opts.on("-v", "--[no-]verbose", "Run verbosely") do |v| 30 | OPTIONS[:log_level] = LOG_LEVEL[:verbose] 31 | end 32 | opts.on("-r", "--framerate N", Integer, "Frames per second of output gif (default: #{OPTIONS[:frame_rate]})") do |v| 33 | OPTIONS[:frame_rate] = v 34 | end 35 | opts.on("-w", "--width N", Integer, "Width of output gif (default: #{OPTIONS[:width]})") do |v| 36 | OPTIONS[:width] = v 37 | end 38 | opts.on("-o", "--output ", String, "Output gif path (default: #{OPTIONS[:output_file]})") do |v| 39 | OPTIONS[:output_file] = sanitize_filepath(v, "gif") 40 | end 41 | opts.on("-k", "--keep", "Keep .mov file along with generated gif (default: #{OPTIONS[:keep_mov]})") do |v| 42 | OPTIONS[:keep_mov] = v 43 | end 44 | end.parse! 45 | 46 | ### 47 | # MACROS 48 | ### 49 | 50 | def verbose(message) 51 | puts "🗣 #{message}" unless OPTIONS[:log_level] > LOG_LEVEL[:verbose] 52 | end 53 | 54 | def clean() 55 | verbose("Clean #{MOV_FILE}") 56 | `rm -rf #{MOV_FILE}` 57 | 58 | verbose("Clean #{GIF_FILE}") 59 | `rm -rf #{GIF_FILE}` 60 | end 61 | 62 | def fail(message) 63 | puts "☠ #{message}" 64 | exit(1) 65 | end 66 | 67 | def cmd(command) 68 | verbose("Run: #{command}...") 69 | stdin, stdout, stderr, wait_thr = Open3.popen3(command) 70 | pid = wait_thr[:pid] 71 | verbose("PID: #{wait_thr.pid}") 72 | 73 | yield(pid) if block_given? 74 | code = wait_thr.value 75 | fail("Your computer does not support Metal") if code.termsig == SIGIOT 76 | fail("#{command} failed (#{code}): #{stderr.read}") if code != 0 77 | 78 | stdout.read 79 | end 80 | 81 | def sanitize_filepath(filepath, default_ext, replace_ext = false) 82 | if replace_ext == false then 83 | ext = File.extname(filepath).downcase 84 | if ext.length == 0 then 85 | ext = ".#{default_ext}" 86 | end 87 | else 88 | ext = ".#{default_ext}" 89 | end 90 | return File.join(File.dirname(filepath), "#{File.basename(filepath,File.extname(filepath))}#{ext}") 91 | end 92 | 93 | ### 94 | # CONSTANTS 95 | ### 96 | 97 | SIGIOT = 6 98 | 99 | timestamp = DateTime.now.to_time.to_i.to_s 100 | MOV_FILE = "/tmp/capture-#{timestamp}.mov" 101 | GIF_FILE = "/tmp/capture-#{timestamp}.gif" 102 | verbose("Output files: #{MOV_FILE}, #{GIF_FILE}") 103 | 104 | ### 105 | # Script 106 | ### 107 | 108 | begin 109 | 110 | # Check dependencies 111 | commands = [:ffmpeg, :xcrun, :gifsicle].map do |cmd| 112 | path = `which #{cmd}` 113 | fail("Command [#{cmd}] is missing. Please install it (brew bundle install).") if path.length == 0 114 | {cmd => path[0...path.length - 1]} 115 | end.reduce({}, :merge) 116 | 117 | verbose("Options: #{OPTIONS.to_s}") 118 | 119 | print "📱 Launch your simulator, then hit return key to start recording..." 120 | readline 121 | 122 | # xcrun: start capture on booted ios sim 123 | cmd("#{commands[:xcrun]} simctl io booted recordVideo #{MOV_FILE}") do |pid| 124 | print "🔴 Recording simulator... Type return key to end capture..." 125 | readline 126 | Process.kill("INT", pid) 127 | end 128 | 129 | # ffmpeg: convert .mov to .gif 130 | cmd("#{commands[:ffmpeg]} -i #{MOV_FILE} -pix_fmt rgb24 -vf scale=#{OPTIONS[:width]}:-1 -r #{OPTIONS[:frame_rate]} -f gif - | #{commands[:gifsicle]} -O3 -d3 --delay #{OPTIONS[:frame_rate]} > #{GIF_FILE}") do |pid| 131 | puts "🎬 Processing ffmpeg..." 132 | end 133 | 134 | # final output 135 | cmd("cp #{GIF_FILE} #{OPTIONS[:output_file]}") 136 | 137 | # keep .mov file if needed 138 | if OPTIONS[:keep_mov] then 139 | mov_fileoutput = sanitize_filepath(OPTIONS[:output_file], "mov", true) 140 | cmd("cp #{MOV_FILE} #{mov_fileoutput}") 141 | end 142 | 143 | puts "✅ Done! Your gif: #{OPTIONS[:output_file]}" 144 | cmd("open #{OPTIONS[:output_file]}") 145 | 146 | rescue Interrupt # Intercept Ctrl-C 147 | 148 | puts "" 149 | puts "🚪 Exit" 150 | exit 0 151 | 152 | ensure 153 | 154 | clean() 155 | 156 | end 157 | --------------------------------------------------------------------------------