btape 0.1.0 → 0.2.0

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.
data/lib/btape/runner.rb CHANGED
@@ -2,39 +2,85 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'ferrum'
5
+ require 'monitor'
6
+ require 'timeout'
5
7
  require 'tmpdir'
6
8
 
7
9
  module Btape
8
- # Drives a headless browser through the parsed commands and captures a
9
- # screenshot per step, handing the frames off to GifEncoder.
10
+ # The recording session around a script: opens the browser, starts the
11
+ # recorder, hands the commands to Executor, and turns the captured frames
12
+ # into a GIF.
10
13
  class Runner
11
14
  DEFAULT_VIEWPORT = [1280, 720].freeze
12
15
 
16
+ # How long unwinding gets. Set Timeout bounds the run, but the thing that
17
+ # usually trips it is a wedged browser, and both stopping the recorder and
18
+ # quitting the browser then talk to it — so without a deadline of their
19
+ # own they would hang the process after the timeout had already fired.
20
+ CLEANUP_TIMEOUT = 10
21
+
22
+ # Everything one run threads through recording, kept in one object rather
23
+ # than a parameter list that grows with each new command.
24
+ Context = Struct.new(:commands, :directory, :settings, :geometry, :sink, :output_path, :on_frame, :keep_frames,
25
+ keyword_init: true)
26
+
27
+ # An encoder passed here is used as given; otherwise one is built from the
28
+ # settings the tape and the caller supplied.
13
29
  def initialize(browser_factory: lambda { |options|
14
30
  Ferrum::Browser.new(**options)
15
- }, recorder_class: Recorder, gif_encoder: GifEncoder.new)
31
+ }, recorder_class: Recorder, gif_encoder: nil, logger: NullLogger.new)
16
32
  @browser_factory = browser_factory
17
33
  @recorder_class = recorder_class
18
34
  @gif_encoder = gif_encoder
35
+ @logger = logger
19
36
  end
20
37
 
21
- def run(commands, base_directory: Dir.pwd)
22
- output_path = resolve_output_path(commands, base_directory)
23
- width, height = resolve_viewport(commands)
24
-
25
- Dir.mktmpdir('btape-') { |directory| record(commands, directory, width, height, output_path) }
26
- output_path
38
+ # Returns a Result. Pass `frames_directory:` to keep the PNG frames after
39
+ # the run, `on_frame:` to be handed each one as it is captured, and
40
+ # `output:` to override the tape's Output with another path or with an IO
41
+ # to write the GIF into.
42
+ def run(commands, base_directory: Dir.pwd, settings: {}, frames_directory: nil, on_frame: nil, output: nil)
43
+ settings = Settings.from_commands(commands).merge(settings)
44
+ sink, output_path = resolve_output(commands, base_directory, output)
45
+ context = Context.new(
46
+ commands: commands,
47
+ settings: settings,
48
+ geometry: resolve_viewport(commands),
49
+ sink: sink,
50
+ output_path: output_path,
51
+ on_frame: on_frame,
52
+ keep_frames: !frames_directory.nil?
53
+ )
54
+
55
+ within_frames_directory(frames_directory, base_directory) do |directory|
56
+ context.directory = directory
57
+ record(context)
58
+ end
27
59
  end
28
60
 
29
61
  private
30
62
 
31
- def resolve_output_path(commands, base_directory)
32
- output = commands.find { |command| command.name == 'Output' }&.arguments&.first
33
- raise Error, 'script must contain an Output command' unless output
63
+ # Frames go to a temporary directory that is cleaned up as the run
64
+ # unwinds, unless the caller named a directory to keep them in.
65
+ def within_frames_directory(frames_directory, base_directory, &block)
66
+ return Dir.mktmpdir('btape-', &block) unless frames_directory
67
+
68
+ directory = File.expand_path(frames_directory, base_directory)
69
+ FileUtils.mkdir_p(directory)
70
+ block.call(directory)
71
+ end
34
72
 
35
- path = File.expand_path(output, base_directory)
73
+ # Returns where the GIF goes and, when that is a file, its path. Writing
74
+ # into an IO leaves no path behind, so Result reports nil rather than a
75
+ # path nothing was written to.
76
+ def resolve_output(commands, base_directory, override)
77
+ declared = commands.find { |command| command.name == 'Output' }&.arguments&.first
78
+ raise Error, 'script must contain an Output command' unless declared
79
+ return [override, nil] if override.respond_to?(:write)
80
+
81
+ path = File.expand_path(override || declared, base_directory)
36
82
  FileUtils.mkdir_p(File.dirname(path))
37
- path
83
+ [path, path]
38
84
  end
39
85
 
40
86
  def resolve_viewport(commands)
@@ -42,70 +88,108 @@ module Btape
42
88
  viewport ? viewport.split('x').map(&:to_i) : DEFAULT_VIEWPORT
43
89
  end
44
90
 
45
- def record(commands, directory, width, height, output_path)
91
+ def record(context)
46
92
  browser = nil
47
93
  recorder = nil
94
+ lock = Monitor.new
48
95
  begin
49
- browser = @browser_factory.call(window_size: [width, height])
50
- recorder = @recorder_class.new(browser, directory)
51
- recorder.start
52
- execute(commands, browser)
53
- recorder.stop
54
- @gif_encoder.write(recorder.paths, output_path)
55
- ensure
56
- begin
57
- recorder&.stop
58
- rescue StandardError
59
- nil
96
+ timed(context.settings.timeout) do
97
+ browser = open_browser(context.settings, context.geometry)
98
+ recorder = build_recorder(browser, context, lock)
99
+ recorder.start
100
+ execute(context, browser, recorder, lock)
101
+ recorder.stop
60
102
  end
61
- browser&.quit
103
+ # Outside the deadline: it is there to bound the browser session, and
104
+ # interrupting a write part-way through would leave a truncated GIF at
105
+ # the output path that nothing downstream could tell from a whole one.
106
+ encoder(context.settings).write(recorder.paths, context.sink)
107
+ result(context, recorder)
108
+ ensure
109
+ cleanup(recorder, browser)
62
110
  end
63
111
  end
64
112
 
65
- def execute(commands, browser)
66
- commands.each do |command|
67
- perform(command, browser)
68
- rescue StandardError => e
69
- raise ScriptError.new(command.line_number, "#{command.name} failed: #{e.message}")
70
- end
113
+ # A page that never finishes loading would otherwise record until the
114
+ # frame limit or the disk stopped it.
115
+ def timed(seconds, &)
116
+ Timeout.timeout(seconds, TimeoutError, "run timed out after #{seconds}s", &)
117
+ end
118
+
119
+ def execute(context, browser, recorder, lock)
120
+ Executor.new(
121
+ browser: browser, recorder: recorder, settings: context.settings, lock: lock, logger: @logger
122
+ ).call(context.commands)
123
+ end
124
+
125
+ # Giving up on the cleanup is not a failure worth raising: the run has
126
+ # already produced its answer, or its exception, and this is only the
127
+ # tidying afterwards.
128
+ def cleanup(recorder, browser)
129
+ Timeout.timeout(CLEANUP_TIMEOUT) { unwind(recorder, browser) }
130
+ rescue Timeout::Error
131
+ @logger.warn("btape: cleanup did not finish within #{CLEANUP_TIMEOUT}s")
71
132
  end
72
133
 
73
- def perform(command, browser)
74
- case command.name
75
- when 'Output', 'Viewport' then nil
76
- when 'Goto' then browser.go_to(command.arguments.first)
77
- when 'Click' then find(browser, command.arguments.first).click
78
- when 'Type' then type(browser, command.arguments)
79
- when 'Sleep' then sleep_seconds(command.arguments.first)
134
+ # stop and quit are reached again here after they have already run, which
135
+ # is a no-op; the case that matters is the run failing before them. A
136
+ # failure to unwind must not replace the exception on its way out, since
137
+ # that is the one that says why the run failed — so it is logged, not
138
+ # swallowed and not raised.
139
+ def unwind(recorder, browser)
140
+ recorder&.stop
141
+ rescue StandardError => e
142
+ @logger.warn("btape: could not stop the recorder: #{e.message}")
143
+ ensure
144
+ begin
145
+ browser&.quit
146
+ rescue StandardError => e
147
+ @logger.warn("btape: could not quit the browser: #{e.message}")
80
148
  end
81
149
  end
82
150
 
83
- def type(browser, arguments)
84
- element = find(browser, arguments.first)
85
- element.focus
86
- element.type(arguments.last)
151
+ def encoder(settings)
152
+ @gif_encoder || GifEncoder.for(settings)
87
153
  end
88
154
 
89
- def find(browser, selector)
90
- element = if selector.start_with?('text=')
91
- literal = xpath_literal(selector.delete_prefix('text='))
92
- browser.at_xpath("//*[normalize-space(text())=#{literal}]")
93
- else
94
- browser.at_css(selector)
95
- end
96
- element || raise("element not found: #{selector}")
155
+ def build_recorder(browser, context, lock)
156
+ @recorder_class.new(
157
+ browser,
158
+ context.directory,
159
+ interval: 1.0 / context.settings.framerate,
160
+ mode: context.settings.capture_mode,
161
+ on_frame: context.on_frame,
162
+ max_frames: context.settings.max_frames,
163
+ lock: lock
164
+ )
97
165
  end
98
166
 
99
- def xpath_literal(text)
100
- return %("#{text}") unless text.include?('"')
167
+ def result(context, recorder)
168
+ width, height = context.geometry
169
+ Result.new(
170
+ output_path: context.output_path,
171
+ frame_paths: context.keep_frames ? recorder.paths.dup : [],
172
+ named_frames: context.keep_frames ? recorder.named_paths.dup : {},
173
+ frame_count: recorder.paths.length,
174
+ width: width,
175
+ height: height
176
+ )
177
+ end
101
178
 
102
- parts = text.split('"', -1).map { |part| %("#{part}") }
103
- "concat(#{parts.join(%q(, '"', ))})"
179
+ def open_browser(settings, geometry)
180
+ width, height = geometry
181
+ browser = @browser_factory.call(browser_options(settings, geometry))
182
+ # window_size only ever reaches Chrome as a launch flag, so a browser we
183
+ # connected to over ws_url keeps whatever size it was started with and
184
+ # has to be resized over the wire instead.
185
+ browser.resize(width: width, height: height) if settings.ws_url
186
+ browser
104
187
  end
105
188
 
106
- def sleep_seconds(duration)
107
- value, unit = duration.match(/\A(\d+(?:\.\d+)?)(ms|s)\z/).captures
108
- sleep(value.to_f / (unit == 'ms' ? 1000 : 1))
189
+ def browser_options(settings, geometry)
190
+ return { ws_url: settings.ws_url } if settings.ws_url
191
+
192
+ { window_size: geometry }
109
193
  end
110
194
  end
111
195
  end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'duration'
4
+
5
+ module Btape
6
+ # The knobs a script turns with `Set NAME VALUE`, along with their defaults,
7
+ # their coercions, and the validation the parser calls while reading a tape.
8
+ #
9
+ # Values arrive from three places, in increasing order of precedence: the
10
+ # defaults here, the `Set` lines in the tape, and whatever the caller passes
11
+ # to Runner#run or the CLI passes from its flags.
12
+ class Settings
13
+ CAPTURE_MODES = %w[interval manual].freeze
14
+ QUANTIZERS = %w[adaptive rgb332].freeze
15
+ URL_SCHEMES = %w[ws:// wss:// http:// https://].freeze
16
+
17
+ # Script name => [attribute, coercion, default]. Defaults are already
18
+ # coerced, so durations are seconds and not "100ms".
19
+ DEFINITIONS = {
20
+ 'WsUrl' => [:ws_url, :url, nil],
21
+ 'CaptureMode' => [:capture_mode, CAPTURE_MODES, 'interval'],
22
+ 'Framerate' => [:framerate, :positive_float, 10.0],
23
+ 'FrameDelay' => [:frame_delay, :duration, 0.1],
24
+ 'Loop' => [:loop_count, :count, 0],
25
+ 'Scale' => [:scale, :positive_float, 1.0],
26
+ 'OutputWidth' => [:output_width, :positive_integer, nil],
27
+ 'Quantizer' => [:quantizer, QUANTIZERS, 'adaptive'],
28
+ 'Timeout' => [:timeout, :duration, 120.0],
29
+ 'WaitTimeout' => [:wait_timeout, :duration, 10.0],
30
+ 'WaitInterval' => [:wait_interval, :duration, 0.1],
31
+ 'WaitStable' => [:wait_stable, :positive_integer, 1],
32
+ 'MaxFrames' => [:max_frames, :positive_integer, 600]
33
+ }.freeze
34
+
35
+ ATTRIBUTES = DEFINITIONS.each_value.map(&:first).freeze
36
+
37
+ ATTRIBUTES.each { |attribute| define_method(attribute) { @values.fetch(attribute) } }
38
+
39
+ class << self
40
+ def defaults
41
+ DEFINITIONS.each_value.to_h { |attribute, _coercion, default| [attribute, default] }
42
+ end
43
+
44
+ # Validates one `Set` line and returns the coerced value. Raises
45
+ # ArgumentError, which Parser turns into a ScriptError carrying the line.
46
+ def validate!(name, value)
47
+ attribute, coercion, = DEFINITIONS.fetch(name) { raise ArgumentError, "unknown setting #{name.inspect}" }
48
+ [attribute, coerce(coercion, value, name)]
49
+ end
50
+
51
+ def from_commands(commands)
52
+ values = commands.select { |command| command.name == 'Set' }.to_h do |command|
53
+ validate!(*command.arguments)
54
+ rescue ArgumentError => e
55
+ raise ScriptError.new(command.line_number, e.message)
56
+ end
57
+ new(values)
58
+ end
59
+
60
+ private
61
+
62
+ def coerce(coercion, value, name)
63
+ return enum(coercion, value, name) if coercion.is_a?(Array)
64
+
65
+ case coercion
66
+ when :duration then duration(value, name)
67
+ when :url then url(value, name)
68
+ when :count then integer(value, name, minimum: 0)
69
+ when :positive_integer then integer(value, name, minimum: 1)
70
+ when :positive_float then positive_float(value, name)
71
+ end
72
+ end
73
+
74
+ def enum(allowed, value, name)
75
+ return value if allowed.include?(value)
76
+
77
+ raise ArgumentError, "Set #{name} must be one of #{allowed.join(', ')}"
78
+ end
79
+
80
+ def duration(value, name)
81
+ raise ArgumentError, "Set #{name} #{Duration::DESCRIPTION}" unless Duration.valid?(value)
82
+
83
+ Duration.parse(value)
84
+ end
85
+
86
+ # A String key means the value has not been coerced yet, but nothing
87
+ # says it arrived as a String: `merge('Loop' => 3)` is a reasonable
88
+ # thing for a caller to write. These read it as the text a Set line
89
+ # would have carried, so a value of the wrong type fails validation
90
+ # rather than raising TypeError past the ArgumentError rescue.
91
+ def url(value, name)
92
+ text = value.to_s
93
+ return text if URL_SCHEMES.any? { |scheme| text.start_with?(scheme) }
94
+
95
+ raise ArgumentError, "Set #{name} must start with #{URL_SCHEMES.join(', ')}"
96
+ end
97
+
98
+ def integer(value, name, minimum:)
99
+ text = value.to_s
100
+ number = Integer(text, 10) if /\A\d+\z/.match?(text)
101
+ raise ArgumentError, "Set #{name} must be an integer of at least #{minimum}" if number.nil? || number < minimum
102
+
103
+ number
104
+ end
105
+
106
+ def positive_float(value, name)
107
+ text = value.to_s
108
+ number = Float(text) if /\A\d+(?:\.\d+)?\z/.match?(text)
109
+ raise ArgumentError, "Set #{name} must be a positive number" unless number&.positive?
110
+
111
+ number
112
+ end
113
+ end
114
+
115
+ def initialize(values = {})
116
+ @values = defaults_merged_with(values).freeze
117
+ freeze
118
+ end
119
+
120
+ def merge(overrides)
121
+ return self if overrides.nil? || (overrides.respond_to?(:empty?) && overrides.empty?)
122
+
123
+ self.class.new(@values.merge(normalize(overrides)))
124
+ end
125
+
126
+ def to_h
127
+ @values.dup
128
+ end
129
+
130
+ private
131
+
132
+ def defaults_merged_with(values)
133
+ values.is_a?(Settings) ? values.to_h : self.class.defaults.merge(normalize(values))
134
+ end
135
+
136
+ # Symbol keys are attributes the caller has already coerced (`frame_delay:
137
+ # 0.15`); String keys are script names that go through the same validation
138
+ # a `Set` line would. nil values are dropped so callers can pass unset
139
+ # flags straight through.
140
+ def normalize(values)
141
+ values.to_h.compact.to_h do |key, value|
142
+ next self.class.validate!(key, value) if key.is_a?(String)
143
+ raise Error, "unknown setting #{key.inspect}" unless ATTRIBUTES.include?(key)
144
+
145
+ [key, value]
146
+ end
147
+ rescue ArgumentError => e
148
+ raise Error, e.message
149
+ end
150
+ end
151
+ end
data/lib/btape/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Btape
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/btape.rb CHANGED
@@ -2,8 +2,14 @@
2
2
 
3
3
  require_relative 'btape/version'
4
4
  require_relative 'btape/error'
5
+ require_relative 'btape/null_logger'
6
+ require_relative 'btape/duration'
7
+ require_relative 'btape/settings'
5
8
  require_relative 'btape/parser'
9
+ require_relative 'btape/result'
10
+ require_relative 'btape/palette'
6
11
  require_relative 'btape/gif_encoder'
7
12
  require_relative 'btape/recorder'
13
+ require_relative 'btape/executor'
8
14
  require_relative 'btape/runner'
9
15
  require_relative 'btape/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: btape
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - btape contributors
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.16'
40
+ - !ruby/object:Gem::Dependency
41
+ name: logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.5'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.5'
40
54
  description: A small VHS-inspired browser recorder driven by .tape files.
41
55
  executables:
42
56
  - btape
@@ -48,16 +62,27 @@ files:
48
62
  - exe/btape
49
63
  - lib/btape.rb
50
64
  - lib/btape/cli.rb
65
+ - lib/btape/duration.rb
51
66
  - lib/btape/error.rb
67
+ - lib/btape/executor.rb
52
68
  - lib/btape/gif_encoder.rb
53
69
  - lib/btape/lzw_compressor.rb
70
+ - lib/btape/null_logger.rb
71
+ - lib/btape/palette.rb
54
72
  - lib/btape/parser.rb
55
73
  - lib/btape/recorder.rb
74
+ - lib/btape/result.rb
56
75
  - lib/btape/runner.rb
76
+ - lib/btape/settings.rb
57
77
  - lib/btape/version.rb
78
+ homepage: https://github.com/slidict/btape
58
79
  licenses:
59
80
  - MIT
60
81
  metadata:
82
+ source_code_uri: https://github.com/slidict/btape
83
+ changelog_uri: https://github.com/slidict/btape/releases
84
+ bug_tracker_uri: https://github.com/slidict/btape/issues
85
+ documentation_uri: https://github.com/slidict/btape#readme
61
86
  rubygems_mfa_required: 'true'
62
87
  rdoc_options: []
63
88
  require_paths: