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.
@@ -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. RGB332 gives a fixed 256-colour palette,
8
- # avoiding a native image or video dependency.
8
+ # A deliberately small GIF89a encoder, so that recording needs no native
9
+ # image or video dependency.
9
10
  class GifEncoder
10
- def initialize(delay: 10)
11
- @delay = delay
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
- def write(png_paths, output)
16
- raise Error, 'no screenshots were captured' if png_paths.empty?
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 = png_paths.map { |path| ChunkyPNG::Image.from_file(path) }
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
- File.binwrite(output, gif(images, width, height))
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 gif(images, width, height)
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 << "!\xFF\x0BNETSCAPE2.0\x03\x01\x00\x00\x00".b
33
- images.each { |image| write_frame(data, image, width, height) }
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
- def write_frame(data, image, width, height)
38
- data << "!\xF9\x04\x00".b << [@delay].pack('v') << "\x00\x00".b
39
- data << ','.b << [0, 0, width, height, 0].pack('vvvvC')
40
- compressed = lzw(image)
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 palette
47
- (0..255).map { |i| [((i >> 5) & 7) * 255 / 7, ((i >> 2) & 7) * 255 / 7, (i & 3) * 255 / 3].pack('C3') }.join.b
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(image)
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
- 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
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 == arity
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 /\A(\d+(?:\.\d+)?)(ms|s)\z/.match?(value)
89
+ return if Duration.valid?(value)
55
90
 
56
- raise ScriptError.new(number, 'Sleep duration must use ms or s (for example, 500ms or 1.5s)')
91
+ raise ScriptError.new(number, "Sleep duration #{Duration::DESCRIPTION}")
57
92
  end
58
93
  end
59
94
  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