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.
- checksums.yaml +4 -4
- data/README.md +140 -8
- data/lib/btape/cli.rb +101 -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/gif_encoder.rb +88 -27
- data/lib/btape/null_logger.rb +14 -0
- data/lib/btape/palette.rb +203 -0
- data/lib/btape/parser.rb +39 -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 +6 -0
- metadata +26 -1
data/lib/btape/gif_encoder.rb
CHANGED
|
@@ -2,62 +2,123 @@
|
|
|
2
2
|
|
|
3
3
|
require 'chunky_png'
|
|
4
4
|
require_relative 'lzw_compressor'
|
|
5
|
+
require_relative 'palette'
|
|
5
6
|
|
|
6
7
|
module Btape
|
|
7
|
-
# A deliberately small GIF89a encoder
|
|
8
|
-
#
|
|
8
|
+
# A deliberately small GIF89a encoder, so that recording needs no native
|
|
9
|
+
# image or video dependency.
|
|
9
10
|
class GifEncoder
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
# A GIF frame delay is two bytes of hundredths of a second.
|
|
12
|
+
MAX_DELAY = 0xffff
|
|
13
|
+
# Eight bits per pixel: one index into a 256-colour table.
|
|
14
|
+
MIN_CODE_SIZE = "\x08"
|
|
15
|
+
|
|
16
|
+
def self.for(settings)
|
|
17
|
+
new(
|
|
18
|
+
delay: (settings.frame_delay * 100).round,
|
|
19
|
+
loop_count: settings.loop_count,
|
|
20
|
+
quantizer: settings.quantizer,
|
|
21
|
+
scale: settings.scale,
|
|
22
|
+
width: settings.output_width
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(delay: 10, loop_count: 0, quantizer: :adaptive, scale: 1.0, width: nil, dedupe: true)
|
|
27
|
+
@delay = delay.clamp(1, MAX_DELAY)
|
|
28
|
+
@loop_count = loop_count
|
|
29
|
+
@quantizer = quantizer
|
|
30
|
+
@scale = scale
|
|
31
|
+
@width = width
|
|
32
|
+
@dedupe = dedupe
|
|
12
33
|
@compressor = LzwCompressor.new
|
|
13
34
|
end
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
# Returns the GIF as a binary String. Frames are PNG paths or ChunkyPNG
|
|
37
|
+
# canvases. Callers that are not writing to the filesystem — attaching the
|
|
38
|
+
# GIF to a record, say — want this rather than a file to read back.
|
|
39
|
+
def encode(frames)
|
|
40
|
+
raise Error, 'no screenshots were captured' if frames.empty?
|
|
17
41
|
|
|
18
|
-
images =
|
|
42
|
+
images = frames.map { |frame| resize(image(frame)) }
|
|
19
43
|
width, height = images.first.dimension.to_a
|
|
20
44
|
raise Error, 'captured screenshots have different dimensions' unless images.all? do |image|
|
|
21
45
|
image.dimension.to_a == [width, height]
|
|
22
46
|
end
|
|
23
47
|
|
|
24
|
-
|
|
48
|
+
palette = Palette.build(@quantizer, images)
|
|
49
|
+
gif(collapse(images.map { |image| index(image, palette) }), width, height, palette)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Writes to a path, or to anything that responds to write.
|
|
53
|
+
def write(frames, output)
|
|
54
|
+
data = encode(frames)
|
|
55
|
+
return output.write(data) if output.respond_to?(:write)
|
|
56
|
+
|
|
57
|
+
File.binwrite(output, data)
|
|
25
58
|
end
|
|
26
59
|
|
|
27
60
|
private
|
|
28
61
|
|
|
29
|
-
def
|
|
62
|
+
def image(frame)
|
|
63
|
+
frame.is_a?(ChunkyPNG::Canvas) ? frame : ChunkyPNG::Image.from_file(frame)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resize(image)
|
|
67
|
+
target = @width || (image.width * @scale).round
|
|
68
|
+
return image if target == image.width || target < 1
|
|
69
|
+
|
|
70
|
+
# A wide enough source rounds its scaled height down to nothing, and a
|
|
71
|
+
# zero-height canvas becomes a GIF no decoder can show.
|
|
72
|
+
height = [(image.height * target.to_f / image.width).round, 1].max
|
|
73
|
+
image.resample_bilinear(target, height)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def index(image, palette)
|
|
77
|
+
image.pixels.map { |pixel| palette.index_for(pixel) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A run that sits on one page for a second is a dozen identical frames.
|
|
81
|
+
# Holding the first for longer says the same thing in a fraction of the
|
|
82
|
+
# bytes, and spares a decoder the redundant frames.
|
|
83
|
+
def collapse(frames)
|
|
84
|
+
frames.each_with_object([]) do |pixels, collapsed|
|
|
85
|
+
previous = collapsed.last
|
|
86
|
+
if @dedupe && previous && previous.first == pixels
|
|
87
|
+
previous[1] = [previous[1] + @delay, MAX_DELAY].min
|
|
88
|
+
else
|
|
89
|
+
collapsed << [pixels, @delay]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def gif(frames, width, height, palette)
|
|
30
95
|
data = +'GIF89a'.b
|
|
31
|
-
data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette
|
|
32
|
-
data <<
|
|
33
|
-
|
|
96
|
+
data << [width, height, 0b11110111, 0, 0].pack('vvCCC') << palette.to_gct
|
|
97
|
+
data << netscape
|
|
98
|
+
frames.each { |pixels, delay| write_frame(data, pixels, delay, width, height) }
|
|
34
99
|
data << ';'.b
|
|
35
100
|
end
|
|
36
101
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
data << "\x08".b
|
|
42
|
-
compressed.bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
|
|
43
|
-
data << "\x00".b
|
|
102
|
+
# The application extension that carries the loop count. Zero loops
|
|
103
|
+
# forever, which is what an unattended recording usually wants.
|
|
104
|
+
def netscape
|
|
105
|
+
+"!\xFF\x0BNETSCAPE2.0\x03\x01".b << [@loop_count].pack('v') << "\x00".b
|
|
44
106
|
end
|
|
45
107
|
|
|
46
|
-
def
|
|
47
|
-
|
|
108
|
+
def write_frame(data, pixels, delay, width, height)
|
|
109
|
+
data << "!\xF9\x04\x00".b << [delay].pack('v') << "\x00\x00".b
|
|
110
|
+
data << ','.b << [0, 0, width, height, 0].pack('vvvvC')
|
|
111
|
+
data << MIN_CODE_SIZE.b
|
|
112
|
+
lzw(pixels).bytes.each_slice(255) { |slice| data << slice.length.chr << slice.pack('C*') }
|
|
113
|
+
data << "\x00".b
|
|
48
114
|
end
|
|
49
115
|
|
|
50
|
-
def lzw(
|
|
51
|
-
pixels = image.pixels.map { |pixel| rgb332(pixel) }
|
|
116
|
+
def lzw(pixels)
|
|
52
117
|
codes = []
|
|
53
118
|
@compressor.call(pixels) { |code, width| codes << [code, width] }
|
|
54
119
|
pack_codes(codes)
|
|
55
120
|
end
|
|
56
121
|
|
|
57
|
-
def rgb332(pixel)
|
|
58
|
-
(ChunkyPNG::Color.r(pixel) & 0xe0) | ((ChunkyPNG::Color.g(pixel) & 0xe0) >> 3) | (ChunkyPNG::Color.b(pixel) >> 6)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
122
|
def pack_codes(codes)
|
|
62
123
|
buffer = 0
|
|
63
124
|
count = 0
|
|
@@ -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,13 @@ 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
|
|
12
21
|
|
|
13
22
|
def parse(source)
|
|
14
23
|
source.each_line.with_index(1).filter_map do |line, number|
|
|
@@ -29,7 +38,7 @@ module Btape
|
|
|
29
38
|
raise ScriptError.new(number, "unknown command #{name.inspect}") unless ARITY.key?(name)
|
|
30
39
|
|
|
31
40
|
arity = ARITY.fetch(name)
|
|
32
|
-
unless words.length
|
|
41
|
+
unless arity === words.length # rubocop:disable Style/CaseEquality
|
|
33
42
|
raise ScriptError.new(number, "#{name} expects #{arity} argument(s), got #{words.length}")
|
|
34
43
|
end
|
|
35
44
|
|
|
@@ -41,9 +50,35 @@ module Btape
|
|
|
41
50
|
case name
|
|
42
51
|
when 'Viewport' then validate_viewport(arguments.first, number)
|
|
43
52
|
when 'Sleep' then validate_sleep(arguments.first, number)
|
|
53
|
+
when 'Set' then Settings.validate!(*arguments)
|
|
54
|
+
when 'Screenshot' then validate_frame_name(arguments.first, number)
|
|
55
|
+
when 'WaitFor', 'WaitForJS' then validate_wait_timeout(name, arguments, number)
|
|
56
|
+
when 'Press' then validate_press_count(arguments[1], number)
|
|
44
57
|
end
|
|
45
58
|
end
|
|
46
59
|
|
|
60
|
+
def validate_press_count(value, number)
|
|
61
|
+
return if value.nil? || (/\A\d+\z/.match?(value) && value.to_i.positive?)
|
|
62
|
+
|
|
63
|
+
raise ScriptError.new(number, 'Press count must be a positive integer')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_wait_timeout(name, arguments, number)
|
|
67
|
+
timeout = arguments[1]
|
|
68
|
+
return if timeout.nil? || Duration.valid?(timeout)
|
|
69
|
+
|
|
70
|
+
raise ScriptError.new(number, "#{name} timeout #{Duration::DESCRIPTION}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The name becomes part of a filename, so keep it to something that
|
|
74
|
+
# cannot escape the frames directory. Recorder rejects the same names, but
|
|
75
|
+
# catching it here names the line the script has to fix.
|
|
76
|
+
def validate_frame_name(value, number)
|
|
77
|
+
return if value.nil? || Recorder::FRAME_NAME.match?(value)
|
|
78
|
+
|
|
79
|
+
raise ScriptError.new(number, 'Screenshot name must be letters, numbers, dashes, dots or underscores')
|
|
80
|
+
end
|
|
81
|
+
|
|
47
82
|
def validate_viewport(value, number)
|
|
48
83
|
match = /\A(\d+)x(\d+)\z/.match(value)
|
|
49
84
|
valid = match&.captures&.all? { |part| part.to_i.positive? }
|
|
@@ -51,9 +86,9 @@ module Btape
|
|
|
51
86
|
end
|
|
52
87
|
|
|
53
88
|
def validate_sleep(value, number)
|
|
54
|
-
return if
|
|
89
|
+
return if Duration.valid?(value)
|
|
55
90
|
|
|
56
|
-
raise ScriptError.new(number,
|
|
91
|
+
raise ScriptError.new(number, "Sleep duration #{Duration::DESCRIPTION}")
|
|
57
92
|
end
|
|
58
93
|
end
|
|
59
94
|
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
|