btape 0.1.0 → 0.3.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.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../parser'
4
+ require_relative '../settings'
5
+
6
+ module Btape
7
+ module LLM
8
+ # What the model is told before it is asked for a tape.
9
+ #
10
+ # The command list and the settings table are built from Parser and
11
+ # Settings rather than written out again here, so a command added to the
12
+ # language is a command the model is told about, and a setting cannot be
13
+ # described to it with a default it no longer has.
14
+ module Prompt
15
+ RULES = [
16
+ 'Answer with the contents of a .tape file and nothing else: no explanation, no code fences.',
17
+ 'The script must contain exactly one Output line, naming a .gif file, and it comes first.',
18
+ 'One command per line. Lines starting with # are comments.',
19
+ 'The line is split like a shell command, so any argument containing a space must be quoted ' \
20
+ 'with double quotes.',
21
+ 'Click, WaitFor and Frame take a CSS selector, or text=Some text to match on visible text.',
22
+ 'Durations are a number followed by ms or s, such as 500ms or 1.5s.',
23
+ 'Prefer WaitFor or WaitForJS over Sleep for anything the page has to finish doing; ' \
24
+ 'Sleep is for holding a finished frame on screen long enough to be seen.',
25
+ 'Use only the commands and settings listed above. Do not invent either, ' \
26
+ 'and do not use a shell, a comment or a blank line to stand in for one.'
27
+ ].freeze
28
+
29
+ EXAMPLE = <<~TAPE
30
+ # Signing in, recorded at half size
31
+ Output signin.gif
32
+ Viewport 1280x720
33
+ Set Scale 0.5
34
+
35
+ Goto http://localhost:3000/signin
36
+ WaitFor "#email"
37
+ Type "#email" "demo@example.com"
38
+ Type "#password" "correct horse"
39
+ Click "text=Sign in"
40
+ WaitFor "text=Welcome back" 5s
41
+ Sleep 2s
42
+ TAPE
43
+
44
+ # What a setting takes, as the coercions in Settings enforce it. A
45
+ # default is shown as a tape would have to write it rather than as
46
+ # Settings holds it, since `Set FrameDelay 0.1` is not something the
47
+ # parser would accept back.
48
+ VALUES = {
49
+ duration: 'a duration',
50
+ url: "a #{Settings::URL_SCHEMES.join(', ')} url",
51
+ count: 'a whole number, 0 or more',
52
+ positive_integer: 'a whole number, 1 or more',
53
+ positive_float: 'a number'
54
+ }.freeze
55
+
56
+ module_function
57
+
58
+ def system
59
+ <<~PROMPT
60
+ You write btape scripts. btape runs a .tape file against Chromium and records
61
+ the run as an animated GIF, so a tape is a short, deliberate demonstration
62
+ rather than a test: it moves at a pace somebody can watch.
63
+
64
+ The commands, one per line:
65
+
66
+ #{indent(commands)}
67
+
68
+ A run is configured with `Set NAME VALUE`:
69
+
70
+ #{indent(settings)}
71
+
72
+ Rules:
73
+
74
+ #{indent(RULES.map { |rule| "- #{rule}" }.join("\n"))}
75
+
76
+ An example of a whole tape:
77
+
78
+ #{indent(EXAMPLE)}
79
+ PROMPT
80
+ end
81
+
82
+ def user(description, context: nil)
83
+ return description if context.nil? || context.strip.empty?
84
+
85
+ <<~PROMPT
86
+ #{description}
87
+
88
+ Context about the page being recorded — prefer the selectors it names over
89
+ any you would otherwise guess at:
90
+
91
+ #{context}
92
+ PROMPT
93
+ end
94
+
95
+ # A tape that did not parse goes back with the parser's own complaint,
96
+ # which names the line and what was wrong with it. Saying so beats
97
+ # asking again and hoping, since the model can see what it wrote.
98
+ def repair(reason)
99
+ <<~PROMPT
100
+ btape rejected that tape: #{reason}
101
+
102
+ Answer with the whole corrected tape, and nothing else.
103
+ PROMPT
104
+ end
105
+
106
+ def commands
107
+ Parser::SIGNATURES.map { |name, arguments| "#{name} #{arguments}".strip }.join("\n")
108
+ end
109
+
110
+ def settings
111
+ Settings::DEFINITIONS.map do |name, (_attribute, coercion, default)|
112
+ takes = coercion.is_a?(Array) ? coercion.join('|') : VALUES.fetch(coercion)
113
+ "Set #{name} <#{takes}>#{" — #{literal(coercion, default)} by default" unless default.nil?}"
114
+ end.join("\n")
115
+ end
116
+
117
+ # A coerced default as a tape would write it: durations are held in
118
+ # seconds and go back to the ms or s they were read from.
119
+ def literal(coercion, default)
120
+ return default.to_s unless coercion == :duration
121
+ return "#{(default * 1000).round}ms" if default < 1
122
+
123
+ "#{default.to_i == default ? default.to_i : default}s"
124
+ end
125
+
126
+ # Blank lines are left alone, so that indenting a block into the prompt
127
+ # does not leave trailing whitespace through the middle of it.
128
+ def indent(text)
129
+ text.strip.gsub(/^(?=.)/, ' ')
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ # Stands in when no logger was given, so the rest of the code can log
5
+ # unconditionally. Only the three levels btape uses are defined, and any
6
+ # Logger — Rails' included — can be passed instead.
7
+ class NullLogger
8
+ def debug(*); end
9
+
10
+ def info(*); end
11
+
12
+ def warn(*); end
13
+ end
14
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chunky_png'
4
+
5
+ module Btape
6
+ # The 256 colours a GIF frame is written in, and the mapping from a pixel to
7
+ # one of them.
8
+ #
9
+ # Fixed spreads its colours evenly across the whole RGB cube regardless of
10
+ # what is being encoded, which costs nothing to build but wastes most of the
11
+ # palette on colours the image does not contain. Adaptive chooses the
12
+ # colours from the image itself, which is what keeps text edges and
13
+ # gradients from banding.
14
+ module Palette
15
+ SIZE = 256
16
+ # Colours are bucketed to five bits per channel before anything looks at
17
+ # them. It collapses a screenshot's million pixels into a few thousand
18
+ # distinct entries, and the error it introduces is below what the 256
19
+ # colours of a GIF can express anyway.
20
+ LEVELS = 32
21
+ CELLS = LEVELS**3
22
+
23
+ module_function
24
+
25
+ def build(quantizer, images)
26
+ quantizer.to_sym == :adaptive ? Adaptive.from(images) : Fixed.new
27
+ end
28
+
29
+ def cell(pixel)
30
+ ((ChunkyPNG::Color.r(pixel) >> 3) << 10) |
31
+ ((ChunkyPNG::Color.g(pixel) >> 3) << 5) |
32
+ (ChunkyPNG::Color.b(pixel) >> 3)
33
+ end
34
+
35
+ def rgb(cell)
36
+ [((cell >> 10) & 31) * 255 / 31, ((cell >> 5) & 31) * 255 / 31, (cell & 31) * 255 / 31]
37
+ end
38
+
39
+ # Pads a list of colours out to a full global colour table.
40
+ def colour_table(colours)
41
+ colours.map { |colour| colour.pack('C3') }.join.b.ljust(SIZE * 3, "\x00".b)
42
+ end
43
+
44
+ # Three bits of red, three of green, two of blue. Cheap, and independent
45
+ # of the image, but only eight distinct levels of red to spend on a
46
+ # gradient that may need far more.
47
+ class Fixed
48
+ def index_for(pixel)
49
+ (ChunkyPNG::Color.r(pixel) & 0xe0) |
50
+ ((ChunkyPNG::Color.g(pixel) & 0xe0) >> 3) |
51
+ (ChunkyPNG::Color.b(pixel) >> 6)
52
+ end
53
+
54
+ def to_gct
55
+ Palette.colour_table(
56
+ (0...SIZE).map { |i| [((i >> 5) & 7) * 255 / 7, ((i >> 2) & 7) * 255 / 7, (i & 3) * 255 / 3] }
57
+ )
58
+ end
59
+ end
60
+
61
+ # Colours chosen from the frames themselves by median cut.
62
+ class Adaptive
63
+ # Enough of each frame to describe what colours it uses. Reading every
64
+ # pixel of every frame would cost far more and change the answer very
65
+ # little.
66
+ MAX_SAMPLES_PER_IMAGE = 60_000
67
+
68
+ def self.from(images)
69
+ new(histogram(images))
70
+ end
71
+
72
+ def self.histogram(images)
73
+ counts = Hash.new(0)
74
+ images.each do |image|
75
+ pixels = image.pixels
76
+ stride = [pixels.length / MAX_SAMPLES_PER_IMAGE, 1].max
77
+ index = 0
78
+ while index < pixels.length
79
+ counts[Palette.cell(pixels[index])] += 1
80
+ index += stride
81
+ end
82
+ end
83
+ counts
84
+ end
85
+
86
+ def initialize(histogram)
87
+ @colours = MedianCut.new(histogram).colours
88
+ # One entry per five-bit colour cell, filled in as cells are met. A
89
+ # screenshot touches a small fraction of them, so searching the
90
+ # palette for the rest would be work thrown away.
91
+ @lookup = Array.new(CELLS)
92
+ end
93
+
94
+ attr_reader :colours
95
+
96
+ def index_for(pixel)
97
+ cell = Palette.cell(pixel)
98
+ @lookup[cell] || (@lookup[cell] = nearest(cell))
99
+ end
100
+
101
+ def to_gct
102
+ Palette.colour_table(@colours)
103
+ end
104
+
105
+ private
106
+
107
+ def nearest(cell)
108
+ red, green, blue = Palette.rgb(cell)
109
+ best = 0
110
+ shortest = nil
111
+ @colours.each_with_index do |(r, g, b), index|
112
+ distance = ((red - r)**2) + ((green - g)**2) + ((blue - b)**2)
113
+ next unless shortest.nil? || distance < shortest
114
+
115
+ shortest = distance
116
+ best = index
117
+ end
118
+ best
119
+ end
120
+ end
121
+
122
+ # Repeatedly splits the colours in use along their widest axis, so that
123
+ # each of the 256 boxes it ends up with holds a similar share of the
124
+ # pixels. Colours a frame leans on get more of the palette than colours it
125
+ # barely touches.
126
+ class MedianCut
127
+ # Colours in a box, as [red, green, blue, weight] entries. The stats a
128
+ # split needs are worked out once per box: they are read every round,
129
+ # and the box they describe never changes.
130
+ class Box
131
+ def initialize(entries)
132
+ @entries = entries
133
+ @bounds = compute_bounds
134
+ @axis = (0..2).max_by { |channel| @bounds[channel].last - @bounds[channel].first }
135
+ @weight = entries.sum(&:last)
136
+ end
137
+
138
+ attr_reader :entries, :axis, :weight
139
+
140
+ def splittable?
141
+ @entries.length > 1
142
+ end
143
+
144
+ # Both how far the box spreads and how many pixels are in it: a wide
145
+ # box nothing is using does not deserve the palette entry.
146
+ def priority
147
+ (@bounds[@axis].last - @bounds[@axis].first) * @weight
148
+ end
149
+
150
+ def split
151
+ sorted = @entries.sort_by { |entry| entry[@axis] }
152
+ [Box.new(sorted.shift(median_index(sorted))), Box.new(sorted)]
153
+ end
154
+
155
+ def colour
156
+ (0..2).map { |channel| @entries.sum { |entry| entry[channel] * entry.last } / @weight }
157
+ end
158
+
159
+ private
160
+
161
+ def compute_bounds
162
+ (0..2).map { |channel| @entries.map { |entry| entry[channel] }.minmax }
163
+ end
164
+
165
+ # Splits where half the weight lies, leaving at least one entry each
166
+ # side however lopsided the weights are.
167
+ def median_index(sorted)
168
+ half = @weight / 2.0
169
+ running = 0
170
+ taken = sorted.take_while do |entry|
171
+ running += entry.last
172
+ running < half
173
+ end.length
174
+ taken.clamp(1, sorted.length - 1)
175
+ end
176
+ end
177
+
178
+ def initialize(histogram, size: SIZE)
179
+ @entries = histogram.map { |cell, weight| Palette.rgb(cell) << weight }
180
+ @size = size
181
+ end
182
+
183
+ def colours
184
+ return [[0, 0, 0]] if @entries.empty?
185
+
186
+ boxes = [Box.new(@entries)]
187
+ while boxes.length < @size
188
+ index = widest(boxes)
189
+ break unless index
190
+
191
+ boxes[index, 1] = boxes[index].split
192
+ end
193
+ boxes.map(&:colour)
194
+ end
195
+
196
+ private
197
+
198
+ def widest(boxes)
199
+ boxes.each_index.select { |index| boxes[index].splittable? }.max_by { |index| boxes[index].priority }
200
+ end
201
+ end
202
+ end
203
+ end
data/lib/btape/parser.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'shellwords'
4
+ require_relative 'duration'
5
+ require_relative 'recorder'
6
+ require_relative 'settings'
4
7
 
5
8
  module Btape
6
9
  Command = Struct.new(:name, :arguments, :line_number, keyword_init: true)
@@ -8,7 +11,24 @@ module Btape
8
11
  # Parses .tape scripts into a list of Command structs, raising ScriptError
9
12
  # for unknown commands, wrong argument counts, or invalid argument values.
10
13
  class Parser
11
- ARITY = { 'Output' => 1, 'Viewport' => 1, 'Goto' => 1, 'Click' => 1, 'Type' => 2, 'Sleep' => 1 }.freeze
14
+ # An arity is either an exact count or a range, for commands whose last
15
+ # argument is optional.
16
+ ARITY = {
17
+ 'Output' => 1, 'Viewport' => 1, 'Goto' => 1, 'Click' => 1, 'Type' => 2, 'Sleep' => 1,
18
+ 'Set' => 2, 'Screenshot' => 0..1, 'Evaluate' => 1, 'WaitFor' => 1..2, 'WaitForJS' => 1..2,
19
+ 'Frame' => 1, 'Press' => 1..2
20
+ }.freeze
21
+
22
+ # The same commands as they read to somebody being told about them, which
23
+ # is what `btape help` prints and what a model is handed before it is
24
+ # asked for a tape. ARITY is what a script is held to; a spec keeps the
25
+ # two lists from drifting apart.
26
+ SIGNATURES = {
27
+ 'Output' => 'PATH', 'Viewport' => 'WIDTHxHEIGHT', 'Goto' => 'URL', 'Click' => 'SELECTOR',
28
+ 'Type' => 'SELECTOR TEXT', 'Press' => 'KEY [COUNT]', 'Frame' => 'SELECTOR|main',
29
+ 'Evaluate' => 'JAVASCRIPT', 'WaitFor' => 'SELECTOR [TIMEOUT]', 'WaitForJS' => 'JAVASCRIPT [TIMEOUT]',
30
+ 'Screenshot' => '[NAME]', 'Sleep' => 'DURATION', 'Set' => 'NAME VALUE'
31
+ }.freeze
12
32
 
13
33
  def parse(source)
14
34
  source.each_line.with_index(1).filter_map do |line, number|
@@ -29,7 +49,7 @@ module Btape
29
49
  raise ScriptError.new(number, "unknown command #{name.inspect}") unless ARITY.key?(name)
30
50
 
31
51
  arity = ARITY.fetch(name)
32
- unless words.length == arity
52
+ unless arity === words.length # rubocop:disable Style/CaseEquality
33
53
  raise ScriptError.new(number, "#{name} expects #{arity} argument(s), got #{words.length}")
34
54
  end
35
55
 
@@ -41,9 +61,35 @@ module Btape
41
61
  case name
42
62
  when 'Viewport' then validate_viewport(arguments.first, number)
43
63
  when 'Sleep' then validate_sleep(arguments.first, number)
64
+ when 'Set' then Settings.validate!(*arguments)
65
+ when 'Screenshot' then validate_frame_name(arguments.first, number)
66
+ when 'WaitFor', 'WaitForJS' then validate_wait_timeout(name, arguments, number)
67
+ when 'Press' then validate_press_count(arguments[1], number)
44
68
  end
45
69
  end
46
70
 
71
+ def validate_press_count(value, number)
72
+ return if value.nil? || (/\A\d+\z/.match?(value) && value.to_i.positive?)
73
+
74
+ raise ScriptError.new(number, 'Press count must be a positive integer')
75
+ end
76
+
77
+ def validate_wait_timeout(name, arguments, number)
78
+ timeout = arguments[1]
79
+ return if timeout.nil? || Duration.valid?(timeout)
80
+
81
+ raise ScriptError.new(number, "#{name} timeout #{Duration::DESCRIPTION}")
82
+ end
83
+
84
+ # The name becomes part of a filename, so keep it to something that
85
+ # cannot escape the frames directory. Recorder rejects the same names, but
86
+ # catching it here names the line the script has to fix.
87
+ def validate_frame_name(value, number)
88
+ return if value.nil? || Recorder::FRAME_NAME.match?(value)
89
+
90
+ raise ScriptError.new(number, 'Screenshot name must be letters, numbers, dashes, dots or underscores')
91
+ end
92
+
47
93
  def validate_viewport(value, number)
48
94
  match = /\A(\d+)x(\d+)\z/.match(value)
49
95
  valid = match&.captures&.all? { |part| part.to_i.positive? }
@@ -51,9 +97,9 @@ module Btape
51
97
  end
52
98
 
53
99
  def validate_sleep(value, number)
54
- return if /\A(\d+(?:\.\d+)?)(ms|s)\z/.match?(value)
100
+ return if Duration.valid?(value)
55
101
 
56
- raise ScriptError.new(number, 'Sleep duration must use ms or s (for example, 500ms or 1.5s)')
102
+ raise ScriptError.new(number, "Sleep duration #{Duration::DESCRIPTION}")
57
103
  end
58
104
  end
59
105
  end
@@ -1,20 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'monitor'
4
+
3
5
  module Btape
4
- # Captures periodic screenshots of a page on a background thread until
5
- # stopped, building the frame sequence GifEncoder turns into a GIF.
6
+ # Captures the frames a GIF is built from.
7
+ #
8
+ # In :interval mode it screenshots the page on a background thread until
9
+ # stopped. In :manual mode it captures only when the script asks with a
10
+ # Screenshot command, which is what a tape wants when it needs one frame per
11
+ # page rather than a few hundred near-identical ones.
6
12
  class Recorder
7
- def initialize(page, directory, interval: 0.1)
13
+ # A name becomes part of a filename, so it may not carry anything that
14
+ # would put the frame somewhere other than the frames directory. Parser
15
+ # checks it too, to report a bad Screenshot line with its line number;
16
+ # this is the check for everyone else, since capture is public API.
17
+ FRAME_NAME = /\A[\w.-]+\z/
18
+
19
+ def initialize(page, directory, interval: 0.1, mode: :interval, on_frame: nil, max_frames: nil,
20
+ lock: Monitor.new)
8
21
  @page = page
9
22
  @directory = directory
10
23
  @interval = interval
24
+ @mode = mode.to_sym
25
+ @on_frame = on_frame
26
+ @max_frames = max_frames
27
+ # Shared with the executor, so a screenshot and a command are never in
28
+ # flight against the same page at once.
29
+ @lock = lock
11
30
  @paths = []
12
- @mutex = Mutex.new
31
+ @named_paths = {}
13
32
  end
14
33
 
15
- attr_reader :paths
34
+ attr_reader :paths, :named_paths
16
35
 
17
36
  def start
37
+ return if manual?
38
+
18
39
  capture
19
40
  @running = true
20
41
  @thread = Thread.new do
@@ -37,14 +58,46 @@ module Btape
37
58
  raise @error if @error
38
59
  end
39
60
 
40
- private
61
+ # Captures one frame now. A name puts it at a predictable path, so a
62
+ # caller can pick a particular frame out of the run.
63
+ def capture(name: nil)
64
+ validate_name!(name)
65
+ path, index = @lock.synchronize do
66
+ exhausted! if @max_frames && @paths.length >= @max_frames
41
67
 
42
- def capture
43
- @mutex.synchronize do
44
- path = File.join(@directory, format('frame-%06d.png', @paths.length))
68
+ path = File.join(@directory, filename(name))
45
69
  screenshot(path)
70
+ index = @paths.length
46
71
  @paths << path
72
+ @named_paths[name] = path if name
73
+ [path, index]
47
74
  end
75
+ # Outside the lock: the callback is the caller's code and may take as
76
+ # long as it likes without holding up the next capture.
77
+ @on_frame&.call(path, index)
78
+ path
79
+ end
80
+
81
+ private
82
+
83
+ # A page that hangs would otherwise be screenshotted until the disk ran
84
+ # out, which on a shared host takes more than this run down with it.
85
+ def exhausted!
86
+ raise Error, "stopped after #{@max_frames} frames; raise Set MaxFrames to record for longer"
87
+ end
88
+
89
+ def manual?
90
+ @mode == :manual
91
+ end
92
+
93
+ def validate_name!(name)
94
+ return if name.nil? || FRAME_NAME.match?(name)
95
+
96
+ raise Error, "frame name must be letters, numbers, dashes, dots or underscores: #{name.inspect}"
97
+ end
98
+
99
+ def filename(name)
100
+ name ? "frame-#{name}.png" : format('frame-%06d.png', @paths.length)
48
101
  end
49
102
 
50
103
  def screenshot(path)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ # What a run produced. `output_path` is where the GIF was written, or nil
5
+ # when the caller passed an IO for it to be written into instead.
6
+ #
7
+ # Callers that want the frames themselves (to attach a still somewhere, say)
8
+ # pass `frames_directory:` to Runner#run and read `frame_paths`.
9
+ #
10
+ # Without a `frames_directory:` the frames live in a temporary directory
11
+ # that is removed as the run unwinds, so `frame_paths` and `named_frames`
12
+ # are empty rather than lists of paths that no longer exist. `frame_count`
13
+ # is always the number of frames that went into the GIF.
14
+ #
15
+ # `named_frames` maps the name a `Screenshot NAME` command gave a frame to
16
+ # its path, for picking one particular frame out of a run.
17
+ Result = Struct.new(:output_path, :frame_paths, :named_frames, :frame_count, :width, :height, keyword_init: true)
18
+ end