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.
- checksums.yaml +4 -4
- data/README.md +297 -9
- data/lib/btape/cli.rb +120 -11
- data/lib/btape/duration.rb +26 -0
- data/lib/btape/error.rb +4 -0
- data/lib/btape/executor.rb +186 -0
- data/lib/btape/generate_command.rb +123 -0
- data/lib/btape/gif_encoder.rb +88 -27
- data/lib/btape/llm/client.rb +141 -0
- data/lib/btape/llm/generator.rb +91 -0
- data/lib/btape/llm/prompt.rb +133 -0
- data/lib/btape/null_logger.rb +14 -0
- data/lib/btape/palette.rb +203 -0
- data/lib/btape/parser.rb +50 -4
- data/lib/btape/recorder.rb +62 -9
- data/lib/btape/result.rb +18 -0
- data/lib/btape/runner.rb +143 -59
- data/lib/btape/settings.rb +151 -0
- data/lib/btape/version.rb +1 -1
- data/lib/btape.rb +10 -0
- metadata +30 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
100
|
+
return if Duration.valid?(value)
|
|
55
101
|
|
|
56
|
-
raise ScriptError.new(number,
|
|
102
|
+
raise ScriptError.new(number, "Sleep duration #{Duration::DESCRIPTION}")
|
|
57
103
|
end
|
|
58
104
|
end
|
|
59
105
|
end
|
data/lib/btape/recorder.rb
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'monitor'
|
|
4
|
+
|
|
3
5
|
module Btape
|
|
4
|
-
# Captures
|
|
5
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/btape/result.rb
ADDED
|
@@ -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
|