btape 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 83f37a580ea032f35ec04083a301339d43bb35bd13881799774d275527558e43
4
+ data.tar.gz: 6b47a03f994310102fc3534c621d7fe78e782e6ab39653a6c227b56838ba17c0
5
+ SHA512:
6
+ metadata.gz: 493ebb303b92ba896387e67236c61deaf5cfaed5ba746990e67c9be9ad06d88d1ea34985d3719a87b6edfbbafe06c59b8d052720b05471fc1215589db23da43d
7
+ data.tar.gz: b5cf6dcfc0995a623f080f30bd5903dc8fed6f456d42f697534a720494d7c8418bcfdeb3917cb0c4537120216382578456642671b849d07b2365c486e9f0a329
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 slidict
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # btape
2
+
3
+ `btape` is a small, VHS-inspired Ruby CLI that runs browser actions from a
4
+ `.tape` file and records them as an animated GIF. Ferrum controls Chromium
5
+ and captures PNG frames, and a pure-Ruby encoder produces the GIF. It
6
+ does not require Playwright, Selenium, ffmpeg, or an external service.
7
+
8
+ ## Commands
9
+
10
+ ```text
11
+ Output <path>
12
+ Viewport <width>x<height>
13
+ Goto <url>
14
+ Click <CSS selector or text=Text>
15
+ Type <CSS selector> <text>
16
+ Sleep <number>ms|s
17
+ ```
18
+
19
+ Arguments containing spaces must be quoted. Empty lines and lines beginning
20
+ with `#` are ignored. `Output` is required; `Viewport` defaults to `1280x720`.
21
+ Output paths are resolved relative to the tape file.
22
+
23
+ ```text
24
+ Output demo.gif
25
+ Viewport 1280x720
26
+ Goto http://localhost:3000
27
+ Click "text=Login"
28
+ Type "#email" "demo@example.com"
29
+ Sleep 1s
30
+ ```
31
+
32
+ ## Install and run
33
+
34
+ Chromium must be installed and discoverable by Ferrum. Then:
35
+
36
+ ```sh
37
+ bundle install
38
+ bundle exec btape demo.tape
39
+ bundle exec rake spec
40
+ ```
41
+
42
+ The recording interval is 100 ms (10 fps). Temporary PNG frames are removed
43
+ after a successful run. They are also isolated in the system temporary
44
+ directory and cleaned when an error unwinds the run.
45
+
46
+ ## Container development with dip or wip
47
+
48
+ The development image contains Ruby and Chromium.
49
+
50
+ ```sh
51
+ dip provision
52
+ dip test
53
+ dip demo
54
+
55
+ wip up
56
+ wip dispatch demo
57
+ wip dispatch btape examples/demo.tape
58
+ ```
59
+
60
+ `examples/demo.tape` drives a small static page bundled at
61
+ `examples/demo_app.html`, so the demo is self-contained and needs no other
62
+ service running. Edit the tape (or point `Goto` at a different URL) to record
63
+ something else.
64
+
65
+ ## MVP limitations
66
+
67
+ The GIF encoder uses a fixed 256-colour RGB332 palette to stay dependency-light.
68
+ This favors portability over photographic colour fidelity and file size. The
69
+ first matching element is used for `Click` and `Type`.
70
+
data/exe/btape ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'btape'
5
+
6
+ exit Btape::CLI.new.run(ARGV)
data/lib/btape/cli.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ # Entry point invoked by the `btape` executable: parses argv, runs the
5
+ # script, and reports success or failure.
6
+ class CLI
7
+ def initialize(out: $stdout, err: $stderr, runner: Runner.new)
8
+ @out = out
9
+ @err = err
10
+ @runner = runner
11
+ end
12
+
13
+ HELP_COMMANDS = ['Output PATH', 'Viewport WIDTHxHEIGHT', 'Goto URL', 'Click SELECTOR',
14
+ 'Type SELECTOR TEXT', 'Sleep DURATION'].freeze
15
+
16
+ def run(argv)
17
+ return print_help if argv.empty? || %w[help -h --help].include?(argv.first)
18
+ raise Error, 'usage: btape SCRIPT.tape' unless argv.length == 1
19
+
20
+ script = File.expand_path(argv.first)
21
+ commands = Parser.new.parse(File.read(script))
22
+ output = @runner.run(commands, base_directory: File.dirname(script))
23
+ @out.puts "Created #{output}"
24
+ 0
25
+ rescue Error, SystemCallError => e
26
+ @err.puts "btape: #{e.message}"
27
+ 1
28
+ end
29
+
30
+ private
31
+
32
+ def print_help
33
+ @out.puts 'Usage: btape SCRIPT.tape'
34
+ @out.puts
35
+ @out.puts 'Commands:'
36
+ HELP_COMMANDS.each { |command| @out.puts " #{command}" }
37
+ 0
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ class Error < StandardError; end
5
+
6
+ # Raised for a problem in the .tape script itself, carrying the line
7
+ # number so the CLI can report where the script went wrong.
8
+ class ScriptError < Error
9
+ attr_reader :line_number
10
+
11
+ def initialize(line_number, message)
12
+ @line_number = line_number
13
+ super("line #{line_number}: #{message}")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chunky_png'
4
+ require_relative 'lzw_compressor'
5
+
6
+ module Btape
7
+ # A deliberately small GIF89a encoder. RGB332 gives a fixed 256-colour palette,
8
+ # avoiding a native image or video dependency.
9
+ class GifEncoder
10
+ def initialize(delay: 10)
11
+ @delay = delay
12
+ @compressor = LzwCompressor.new
13
+ end
14
+
15
+ def write(png_paths, output)
16
+ raise Error, 'no screenshots were captured' if png_paths.empty?
17
+
18
+ images = png_paths.map { |path| ChunkyPNG::Image.from_file(path) }
19
+ width, height = images.first.dimension.to_a
20
+ raise Error, 'captured screenshots have different dimensions' unless images.all? do |image|
21
+ image.dimension.to_a == [width, height]
22
+ end
23
+
24
+ File.binwrite(output, gif(images, width, height))
25
+ end
26
+
27
+ private
28
+
29
+ def gif(images, width, height)
30
+ 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) }
34
+ data << ';'.b
35
+ end
36
+
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
44
+ end
45
+
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
48
+ end
49
+
50
+ def lzw(image)
51
+ pixels = image.pixels.map { |pixel| rgb332(pixel) }
52
+ codes = []
53
+ @compressor.call(pixels) { |code, width| codes << [code, width] }
54
+ pack_codes(codes)
55
+ end
56
+
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
+ def pack_codes(codes)
62
+ buffer = 0
63
+ count = 0
64
+ output = +''.b
65
+ codes.each do |code, bits|
66
+ buffer |= code << count
67
+ count += bits
68
+ while count >= 8
69
+ output << (buffer & 0xff).chr
70
+ buffer >>= 8
71
+ count -= 8
72
+ end
73
+ end
74
+ output << (buffer & 0xff).chr if count.positive?
75
+ output
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ class GifEncoder
5
+ # Variable-width LZW compression of a flat array of palette indices,
6
+ # producing (code, width) pairs ready for bit-packing into a GIF stream.
7
+ class LzwCompressor
8
+ CLEAR_CODE = 256
9
+ FINISH_CODE = 257
10
+ MAX_CODE_WIDTH = 12
11
+ MAX_DICTIONARY_SIZE = 1 << MAX_CODE_WIDTH
12
+
13
+ # Mutable state threaded through compress/advance: the growing code
14
+ # dictionary, the next free code, the current code width in bits, and
15
+ # whether a width bump is owed (see #advance).
16
+ State = Struct.new(:dictionary, :next_code, :code_width, :pending_width_bump)
17
+
18
+ def call(pixels, &emit)
19
+ emit.call(CLEAR_CODE, 9)
20
+ return emit.call(FINISH_CODE, 9) if pixels.empty?
21
+
22
+ compress(pixels, emit)
23
+ end
24
+
25
+ private
26
+
27
+ def compress(pixels, emit)
28
+ state = State.new(root_dictionary, FINISH_CODE + 1, 9, false)
29
+ current_code = pixels.first
30
+
31
+ pixels.drop(1).each do |pixel|
32
+ key = ((current_code + 1) << 8) | pixel
33
+ if state.dictionary.key?(key)
34
+ current_code = state.dictionary[key]
35
+ next
36
+ end
37
+
38
+ emit.call(current_code, state.code_width)
39
+ advance(state, key, emit)
40
+ current_code = pixel
41
+ end
42
+
43
+ emit.call(current_code, state.code_width)
44
+ emit.call(FINISH_CODE, state.code_width)
45
+ end
46
+
47
+ # A GIF decoder cannot add its own dictionary entry until it has seen two
48
+ # codes (it has nothing to extend after just the first), so its
49
+ # code-width bump for a given dictionary slot always lands one code later
50
+ # than the encoder's. Deferring the bump by one entry here keeps the two
51
+ # in lockstep.
52
+ def advance(state, key, emit)
53
+ if state.next_code == MAX_DICTIONARY_SIZE
54
+ emit.call(CLEAR_CODE, state.code_width)
55
+ return reset!(state)
56
+ end
57
+
58
+ state.dictionary[key] = state.next_code
59
+ state.next_code += 1
60
+ if state.pending_width_bump
61
+ state.code_width += 1 if state.code_width < MAX_CODE_WIDTH
62
+ state.pending_width_bump = false
63
+ end
64
+ state.pending_width_bump = true if state.next_code > (1 << state.code_width) - 1
65
+ end
66
+
67
+ def reset!(state)
68
+ state.dictionary = root_dictionary
69
+ state.next_code = FINISH_CODE + 1
70
+ state.code_width = 9
71
+ state.pending_width_bump = false
72
+ end
73
+
74
+ def root_dictionary
75
+ (0..255).to_h { |value| [value, value] }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Btape
6
+ Command = Struct.new(:name, :arguments, :line_number, keyword_init: true)
7
+
8
+ # Parses .tape scripts into a list of Command structs, raising ScriptError
9
+ # for unknown commands, wrong argument counts, or invalid argument values.
10
+ class Parser
11
+ ARITY = { 'Output' => 1, 'Viewport' => 1, 'Goto' => 1, 'Click' => 1, 'Type' => 2, 'Sleep' => 1 }.freeze
12
+
13
+ def parse(source)
14
+ source.each_line.with_index(1).filter_map do |line, number|
15
+ text = line.strip
16
+ next if text.empty? || text.start_with?('#')
17
+
18
+ parse_line(text, number)
19
+ rescue ArgumentError => e
20
+ raise ScriptError.new(number, e.message)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def parse_line(text, number)
27
+ words = Shellwords.shellsplit(text)
28
+ name = words.shift
29
+ raise ScriptError.new(number, "unknown command #{name.inspect}") unless ARITY.key?(name)
30
+
31
+ arity = ARITY.fetch(name)
32
+ unless words.length == arity
33
+ raise ScriptError.new(number, "#{name} expects #{arity} argument(s), got #{words.length}")
34
+ end
35
+
36
+ validate(name, words, number)
37
+ Command.new(name:, arguments: words.freeze, line_number: number)
38
+ end
39
+
40
+ def validate(name, arguments, number)
41
+ case name
42
+ when 'Viewport' then validate_viewport(arguments.first, number)
43
+ when 'Sleep' then validate_sleep(arguments.first, number)
44
+ end
45
+ end
46
+
47
+ def validate_viewport(value, number)
48
+ match = /\A(\d+)x(\d+)\z/.match(value)
49
+ valid = match&.captures&.all? { |part| part.to_i.positive? }
50
+ raise ScriptError.new(number, 'Viewport must be WIDTHxHEIGHT') unless valid
51
+ end
52
+
53
+ def validate_sleep(value, number)
54
+ return if /\A(\d+(?:\.\d+)?)(ms|s)\z/.match?(value)
55
+
56
+ raise ScriptError.new(number, 'Sleep duration must use ms or s (for example, 500ms or 1.5s)')
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ 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
+ class Recorder
7
+ def initialize(page, directory, interval: 0.1)
8
+ @page = page
9
+ @directory = directory
10
+ @interval = interval
11
+ @paths = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ attr_reader :paths
16
+
17
+ def start
18
+ capture
19
+ @running = true
20
+ @thread = Thread.new do
21
+ loop do
22
+ sleep @interval
23
+ capture
24
+ end
25
+ rescue StandardError => e
26
+ @error = e
27
+ end
28
+ end
29
+
30
+ def stop
31
+ return unless @running
32
+
33
+ @running = false
34
+ @thread&.kill
35
+ @thread&.join
36
+ capture
37
+ raise @error if @error
38
+ end
39
+
40
+ private
41
+
42
+ def capture
43
+ @mutex.synchronize do
44
+ path = File.join(@directory, format('frame-%06d.png', @paths.length))
45
+ screenshot(path)
46
+ @paths << path
47
+ end
48
+ end
49
+
50
+ def screenshot(path)
51
+ @page.screenshot(path: path)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'ferrum'
5
+ require 'tmpdir'
6
+
7
+ 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
+ class Runner
11
+ DEFAULT_VIEWPORT = [1280, 720].freeze
12
+
13
+ def initialize(browser_factory: lambda { |options|
14
+ Ferrum::Browser.new(**options)
15
+ }, recorder_class: Recorder, gif_encoder: GifEncoder.new)
16
+ @browser_factory = browser_factory
17
+ @recorder_class = recorder_class
18
+ @gif_encoder = gif_encoder
19
+ end
20
+
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
27
+ end
28
+
29
+ private
30
+
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
34
+
35
+ path = File.expand_path(output, base_directory)
36
+ FileUtils.mkdir_p(File.dirname(path))
37
+ path
38
+ end
39
+
40
+ def resolve_viewport(commands)
41
+ viewport = commands.find { |command| command.name == 'Viewport' }&.arguments&.first
42
+ viewport ? viewport.split('x').map(&:to_i) : DEFAULT_VIEWPORT
43
+ end
44
+
45
+ def record(commands, directory, width, height, output_path)
46
+ browser = nil
47
+ recorder = nil
48
+ 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
60
+ end
61
+ browser&.quit
62
+ end
63
+ end
64
+
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
71
+ end
72
+
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)
80
+ end
81
+ end
82
+
83
+ def type(browser, arguments)
84
+ element = find(browser, arguments.first)
85
+ element.focus
86
+ element.type(arguments.last)
87
+ end
88
+
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}")
97
+ end
98
+
99
+ def xpath_literal(text)
100
+ return %("#{text}") unless text.include?('"')
101
+
102
+ parts = text.split('"', -1).map { |part| %("#{part}") }
103
+ "concat(#{parts.join(%q(, '"', ))})"
104
+ end
105
+
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))
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Btape
4
+ VERSION = '0.1.0'
5
+ end
data/lib/btape.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'btape/version'
4
+ require_relative 'btape/error'
5
+ require_relative 'btape/parser'
6
+ require_relative 'btape/gif_encoder'
7
+ require_relative 'btape/recorder'
8
+ require_relative 'btape/runner'
9
+ require_relative 'btape/cli'
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: btape
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - btape contributors
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chunky_png
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ferrum
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.16'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.16'
40
+ description: A small VHS-inspired browser recorder driven by .tape files.
41
+ executables:
42
+ - btape
43
+ extensions: []
44
+ extra_rdoc_files: []
45
+ files:
46
+ - LICENSE
47
+ - README.md
48
+ - exe/btape
49
+ - lib/btape.rb
50
+ - lib/btape/cli.rb
51
+ - lib/btape/error.rb
52
+ - lib/btape/gif_encoder.rb
53
+ - lib/btape/lzw_compressor.rb
54
+ - lib/btape/parser.rb
55
+ - lib/btape/recorder.rb
56
+ - lib/btape/runner.rb
57
+ - lib/btape/version.rb
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.1'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.6.7
77
+ specification_version: 4
78
+ summary: Record browser automation scripts as animated GIFs
79
+ test_files: []