rubylens 0.1.0 → 0.2.1
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 +67 -39
- data/assets/runtime/report.js +1607 -251
- data/assets/shells/report.html +9 -7
- data/assets/shells/showcase.html +1 -0
- data/assets/styles/report.css +51 -25
- data/assets/styles/showcase.css +3 -0
- data/lib/rubylens/art_model_builder.rb +214 -72
- data/lib/rubylens/artifact_marker.rb +17 -0
- data/lib/rubylens/atomic_output.rb +27 -0
- data/lib/rubylens/cli.rb +53 -36
- data/lib/rubylens/clip/chrome_page.rb +183 -0
- data/lib/rubylens/clip/deadline_io.rb +28 -0
- data/lib/rubylens/clip/renderer.rb +113 -0
- data/lib/rubylens/clip/toolchain.rb +82 -0
- data/lib/rubylens/clip/web_socket_channel.rb +116 -0
- data/lib/rubylens/clip_generator.rb +102 -0
- data/lib/rubylens/default_output.rb +24 -0
- data/lib/rubylens/generator.rb +9 -10
- data/lib/rubylens/git_repository.rb +13 -7
- data/lib/rubylens/index/constant_reference_collector.rb +150 -0
- data/lib/rubylens/index/declaration_collector.rb +288 -0
- data/lib/rubylens/index/git_package_source.rb +202 -0
- data/lib/rubylens/index/location_index.rb +103 -0
- data/lib/rubylens/index/manifest.rb +62 -157
- data/lib/rubylens/index/rspec_extractor.rb +12 -10
- data/lib/rubylens/index/rubydex_adapter.rb +111 -301
- data/lib/rubylens/index/source_path.rb +12 -11
- data/lib/rubylens/model/dependency_aggregation.rb +11 -7
- data/lib/rubylens/morphology_classifier.rb +52 -12
- data/lib/rubylens/report_writer.rb +5 -20
- data/lib/rubylens/showcase_generator.rb +5 -9
- data/lib/rubylens/showcase_model.rb +12 -14
- data/lib/rubylens/showcase_writer.rb +0 -6
- data/lib/rubylens/version.rb +1 -1
- data/lib/rubylens.rb +7 -0
- metadata +16 -3
data/lib/rubylens/cli.rb
CHANGED
|
@@ -6,6 +6,35 @@ require_relative "../rubylens"
|
|
|
6
6
|
|
|
7
7
|
module RubyLens
|
|
8
8
|
class CLI
|
|
9
|
+
# One entry per generating command: everything that differs between
|
|
10
|
+
# report, showcase, and clip lives here, not in per-command methods.
|
|
11
|
+
COMMANDS = {
|
|
12
|
+
"report" => {
|
|
13
|
+
default_name: Generator::DEFAULT_REPORT_NAME,
|
|
14
|
+
generator: :generate_report,
|
|
15
|
+
warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
|
|
16
|
+
details_option: false,
|
|
17
|
+
output_kind: "HTML",
|
|
18
|
+
report_progress: false,
|
|
19
|
+
},
|
|
20
|
+
"clip" => {
|
|
21
|
+
default_name: ClipGenerator::DEFAULT_CLIP_NAME,
|
|
22
|
+
generator: :generate_clip,
|
|
23
|
+
warning: "RubyLens clips show the same picture as showcases: the project name plus the galaxy's shape and scale; --details adds aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
|
|
24
|
+
details_option: true,
|
|
25
|
+
output_kind: "MP4",
|
|
26
|
+
report_progress: true,
|
|
27
|
+
},
|
|
28
|
+
"showcase" => {
|
|
29
|
+
default_name: ShowcaseGenerator::DEFAULT_SHOWCASE_NAME,
|
|
30
|
+
generator: :generate_showcase,
|
|
31
|
+
warning: "RubyLens showcases disclose the project name and visual codebase structure; --details also includes aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
|
|
32
|
+
details_option: true,
|
|
33
|
+
output_kind: "HTML",
|
|
34
|
+
report_progress: false,
|
|
35
|
+
},
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
9
38
|
def initialize(arguments)
|
|
10
39
|
@arguments = arguments.dup
|
|
11
40
|
end
|
|
@@ -15,8 +44,7 @@ module RubyLens
|
|
|
15
44
|
command = arguments.shift
|
|
16
45
|
return print_version if %w[-v --version version].include?(command)
|
|
17
46
|
return print_help(0) if command.nil? || %w[-h --help help].include?(command)
|
|
18
|
-
return
|
|
19
|
-
return generate_showcase(arguments) if command == "showcase"
|
|
47
|
+
return generate(command, arguments) if COMMANDS.key?(command)
|
|
20
48
|
|
|
21
49
|
$stderr.puts "Unknown command: #{command}"
|
|
22
50
|
print_help(2)
|
|
@@ -27,28 +55,8 @@ module RubyLens
|
|
|
27
55
|
|
|
28
56
|
private
|
|
29
57
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
arguments,
|
|
33
|
-
command: "report",
|
|
34
|
-
default_name: "rubylens-report.html",
|
|
35
|
-
generator: RubyLens.method(:generate_report),
|
|
36
|
-
warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
|
|
37
|
-
)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def generate_showcase(arguments)
|
|
41
|
-
generate_html(
|
|
42
|
-
arguments,
|
|
43
|
-
command: "showcase",
|
|
44
|
-
default_name: "rubylens-showcase.html",
|
|
45
|
-
generator: RubyLens.method(:generate_showcase),
|
|
46
|
-
warning: "RubyLens showcases disclose the project name and visual codebase structure; --details also includes aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
|
|
47
|
-
details_option: true,
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def generate_html(arguments, command:, default_name:, generator:, warning:, details_option: false)
|
|
58
|
+
def generate(command, arguments)
|
|
59
|
+
specification = COMMANDS.fetch(command)
|
|
52
60
|
options = {}
|
|
53
61
|
help = false
|
|
54
62
|
parser = OptionParser.new do |option|
|
|
@@ -56,13 +64,13 @@ module RubyLens
|
|
|
56
64
|
option.separator ""
|
|
57
65
|
option.separator "TARGET defaults to the current working directory."
|
|
58
66
|
option.separator ""
|
|
59
|
-
option.on("-o", "--output FILE", "Output
|
|
67
|
+
option.on("-o", "--output FILE", "Output #{specification.fetch(:output_kind)} (default: TARGET/#{specification.fetch(:default_name)})") do |value|
|
|
60
68
|
options[:output] = value
|
|
61
69
|
end
|
|
62
70
|
option.on("--lockfile FILE", "Gemfile.lock used to select exact dependency versions") do |value|
|
|
63
71
|
options[:lockfile] = value
|
|
64
72
|
end
|
|
65
|
-
if details_option
|
|
73
|
+
if specification.fetch(:details_option)
|
|
66
74
|
option.on("--details", "Include aggregate statistics and cinematic code/dependency labels") do
|
|
67
75
|
options[:details] = true
|
|
68
76
|
end
|
|
@@ -81,19 +89,27 @@ module RubyLens
|
|
|
81
89
|
raise OptionParser::InvalidArgument, "unexpected argument: #{arguments.first}" unless arguments.empty?
|
|
82
90
|
|
|
83
91
|
generator_options = { path: target, output: options[:output], lockfile: options[:lockfile] }
|
|
84
|
-
generator_options[:details] = options.fetch(:details, false) if details_option
|
|
85
|
-
|
|
92
|
+
generator_options[:details] = options.fetch(:details, false) if specification.fetch(:details_option)
|
|
93
|
+
generator_options[:progress] = progress_printer if specification.fetch(:report_progress)
|
|
94
|
+
result = RubyLens.public_send(specification.fetch(:generator), **generator_options)
|
|
86
95
|
print_result(result)
|
|
87
|
-
$stderr.puts warning
|
|
96
|
+
$stderr.puts specification.fetch(:warning)
|
|
88
97
|
0
|
|
89
98
|
end
|
|
90
99
|
|
|
100
|
+
def progress_printer
|
|
101
|
+
last_step = -1
|
|
102
|
+
lambda do |rendered, total|
|
|
103
|
+
step = rendered * 10 / total
|
|
104
|
+
next unless step > last_step
|
|
105
|
+
|
|
106
|
+
last_step = step
|
|
107
|
+
$stderr.puts "Rendering clip: #{rendered * 100 / total}% (#{rendered}/#{total} frames)"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
91
111
|
def print_result(result)
|
|
92
|
-
$stdout.puts JSON.generate(
|
|
93
|
-
output: result.output_path,
|
|
94
|
-
counts: result.counts,
|
|
95
|
-
warnings: result.warnings,
|
|
96
|
-
)
|
|
112
|
+
$stdout.puts JSON.generate(result.to_payload)
|
|
97
113
|
end
|
|
98
114
|
|
|
99
115
|
def print_version
|
|
@@ -110,10 +126,11 @@ module RubyLens
|
|
|
110
126
|
|
|
111
127
|
Commands:
|
|
112
128
|
report [OPTIONS] [TARGET] Generate a private, interactive galaxy report
|
|
113
|
-
|
|
129
|
+
clip [OPTIONS] [TARGET] Generate a shareable galaxy video (MP4, needs Chrome + ffmpeg)
|
|
130
|
+
showcase [OPTIONS] [TARGET] Generate a shareable, cinematic galaxy showcase page
|
|
114
131
|
version Print the RubyLens version
|
|
115
132
|
|
|
116
|
-
Run `rubylens report --help` or `rubylens showcase --help` for options.
|
|
133
|
+
Run `rubylens report --help`, `rubylens clip --help`, or `rubylens showcase --help` for options.
|
|
117
134
|
HELP
|
|
118
135
|
status
|
|
119
136
|
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "../errors"
|
|
10
|
+
require_relative "deadline_io"
|
|
11
|
+
require_relative "web_socket_channel"
|
|
12
|
+
|
|
13
|
+
module RubyLens
|
|
14
|
+
module Clip
|
|
15
|
+
# Runs a headless Chrome/Chromium with an ephemeral DevTools port and a
|
|
16
|
+
# throwaway profile, attaches to the page that loads the showcase HTML,
|
|
17
|
+
# and exposes the two operations clip rendering needs: evaluate and
|
|
18
|
+
# screenshot. The port comes from the profile's DevToolsActivePort file,
|
|
19
|
+
# so parallel clips never race for a fixed port.
|
|
20
|
+
class ChromePage
|
|
21
|
+
HEADLESS_FLAGS = %w[
|
|
22
|
+
--headless=new
|
|
23
|
+
--remote-debugging-port=0
|
|
24
|
+
--no-first-run
|
|
25
|
+
--no-default-browser-check
|
|
26
|
+
--disable-background-networking
|
|
27
|
+
--disable-component-update
|
|
28
|
+
--disable-sync
|
|
29
|
+
--mute-audio
|
|
30
|
+
--hide-scrollbars
|
|
31
|
+
--force-device-scale-factor=1
|
|
32
|
+
--force-color-profile=srgb
|
|
33
|
+
--enable-unsafe-swiftshader
|
|
34
|
+
].freeze
|
|
35
|
+
LAUNCH_TIMEOUT_SECONDS = 30
|
|
36
|
+
|
|
37
|
+
def self.open(executable:, url:, width:, height:)
|
|
38
|
+
page = new(executable:, url:, width:, height:)
|
|
39
|
+
begin
|
|
40
|
+
yield page
|
|
41
|
+
ensure
|
|
42
|
+
page.close
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(executable:, url:, width:, height:)
|
|
47
|
+
@profile = Dir.mktmpdir("rubylens-clip-")
|
|
48
|
+
@next_id = 0
|
|
49
|
+
@responses = {}
|
|
50
|
+
flags = HEADLESS_FLAGS.dup
|
|
51
|
+
# Chrome refuses its sandbox as root (typical for containers and CI).
|
|
52
|
+
flags << "--no-sandbox" if Process.euid.zero?
|
|
53
|
+
flags << "--window-size=#{width},#{height}"
|
|
54
|
+
flags << "--user-data-dir=#{@profile}"
|
|
55
|
+
@pid = Process.spawn(executable, *flags, url, %i[out err] => File::NULL)
|
|
56
|
+
port = wait_for_devtools_port
|
|
57
|
+
@channel = WebSocketChannel.new("127.0.0.1", port, page_target_path(port))
|
|
58
|
+
# The headless window includes non-viewport space; pin the viewport so
|
|
59
|
+
# captures are exactly the stage size.
|
|
60
|
+
command("Emulation.setDeviceMetricsOverride", {
|
|
61
|
+
"width" => width, "height" => height, "deviceScaleFactor" => 1, "mobile" => false
|
|
62
|
+
}, timeout: 30)
|
|
63
|
+
rescue Errno::ENOENT => error
|
|
64
|
+
abort_start
|
|
65
|
+
raise Error, "could not launch Chrome at #{executable}: #{error.message}"
|
|
66
|
+
rescue SystemCallError => error
|
|
67
|
+
# A startup race (Chrome exiting or closing its port mid-attach) must
|
|
68
|
+
# surface as a normal rubylens error, not a raw Errno stack trace.
|
|
69
|
+
abort_start
|
|
70
|
+
raise Error, "could not attach to Chrome's DevTools endpoint: #{error.message}"
|
|
71
|
+
rescue StandardError
|
|
72
|
+
abort_start
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def evaluate(expression, await: false, timeout: 30)
|
|
77
|
+
result = command("Runtime.evaluate", {
|
|
78
|
+
"expression" => expression,
|
|
79
|
+
"returnByValue" => true,
|
|
80
|
+
"awaitPromise" => await,
|
|
81
|
+
}, timeout:)
|
|
82
|
+
if (details = result["exceptionDetails"])
|
|
83
|
+
raise Error, "the showcase page raised: #{details.dig("exception", "description") || details["text"]}"
|
|
84
|
+
end
|
|
85
|
+
result.dig("result", "value")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def screenshot_png(timeout: 60)
|
|
89
|
+
data = command("Page.captureScreenshot", { "format" => "png", "optimizeForSpeed" => true }, timeout:)
|
|
90
|
+
Base64.decode64(data.fetch("data"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close
|
|
94
|
+
begin
|
|
95
|
+
command("Browser.close", {}, timeout: 3) if @channel
|
|
96
|
+
rescue Error
|
|
97
|
+
terminate
|
|
98
|
+
end
|
|
99
|
+
Process.wait(@pid) if @pid
|
|
100
|
+
rescue Errno::ECHILD
|
|
101
|
+
nil
|
|
102
|
+
ensure
|
|
103
|
+
@channel&.close
|
|
104
|
+
FileUtils.remove_entry(@profile) if @profile && File.directory?(@profile)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# A failed start never reaches ChromePage.open's ensure, so the spawned
|
|
110
|
+
# browser and throwaway profile must be cleaned up before re-raising.
|
|
111
|
+
# The unconditional kill keeps close's Process.wait from hanging on a
|
|
112
|
+
# live Chrome with no channel.
|
|
113
|
+
def abort_start
|
|
114
|
+
terminate if @pid
|
|
115
|
+
close
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def terminate
|
|
119
|
+
Process.kill("KILL", @pid)
|
|
120
|
+
rescue Errno::ESRCH
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def command(method, params, timeout:)
|
|
125
|
+
id = (@next_id += 1)
|
|
126
|
+
@channel.send_text(JSON.generate({ "id" => id, "method" => method, "params" => params }))
|
|
127
|
+
loop do
|
|
128
|
+
if (message = @responses.delete(id))
|
|
129
|
+
if (error = message["error"])
|
|
130
|
+
raise Error, "Chrome rejected #{method}: #{error["message"]}"
|
|
131
|
+
end
|
|
132
|
+
return message.fetch("result")
|
|
133
|
+
end
|
|
134
|
+
message = JSON.parse(@channel.read_text(timeout:))
|
|
135
|
+
@responses[message["id"]] = message if message["id"]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def wait_for_devtools_port
|
|
140
|
+
port_path = File.join(@profile, "DevToolsActivePort")
|
|
141
|
+
deadline = DeadlineIO.deadline(LAUNCH_TIMEOUT_SECONDS)
|
|
142
|
+
while DeadlineIO.monotonic < deadline
|
|
143
|
+
if File.exist?(port_path)
|
|
144
|
+
# to_i reads the leading port line and tolerates a mid-write file.
|
|
145
|
+
port = File.read(port_path).to_i
|
|
146
|
+
return port if port.positive?
|
|
147
|
+
end
|
|
148
|
+
if Process.waitpid(@pid, Process::WNOHANG)
|
|
149
|
+
@pid = nil
|
|
150
|
+
raise Error, "Chrome exited while starting up; it may not support headless rendering here"
|
|
151
|
+
end
|
|
152
|
+
sleep 0.05
|
|
153
|
+
end
|
|
154
|
+
raise Error, "Chrome did not publish a DevTools port within #{LAUNCH_TIMEOUT_SECONDS} seconds"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def page_target_path(port)
|
|
158
|
+
deadline = DeadlineIO.deadline(LAUNCH_TIMEOUT_SECONDS)
|
|
159
|
+
while DeadlineIO.monotonic < deadline
|
|
160
|
+
begin
|
|
161
|
+
target = JSON.parse(http_get(port, "/json/list")).find { |info| info["type"] == "page" }
|
|
162
|
+
if (socket_url = target&.fetch("webSocketDebuggerUrl", nil))
|
|
163
|
+
return URI(socket_url).path
|
|
164
|
+
end
|
|
165
|
+
rescue Error, SystemCallError, JSON::ParserError, Timeout::Error, EOFError
|
|
166
|
+
# The HTTP endpoint can lag DevToolsActivePort; retry until the deadline.
|
|
167
|
+
end
|
|
168
|
+
sleep 0.05
|
|
169
|
+
end
|
|
170
|
+
raise Error, "Chrome never exposed the showcase page for capture"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The explicit nil proxy argument keeps this loopback request off any
|
|
174
|
+
# configured HTTP proxy.
|
|
175
|
+
def http_get(port, path, timeout: 10)
|
|
176
|
+
http = Net::HTTP.new("127.0.0.1", port, nil)
|
|
177
|
+
http.open_timeout = timeout
|
|
178
|
+
http.read_timeout = timeout
|
|
179
|
+
http.get(path).body
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module RubyLens
|
|
6
|
+
module Clip
|
|
7
|
+
# Deadline-bounded, non-blocking socket reads shared by the DevTools
|
|
8
|
+
# WebSocket channel and the local DevTools HTTP client.
|
|
9
|
+
module DeadlineIO
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
13
|
+
|
|
14
|
+
def deadline(timeout) = monotonic + timeout
|
|
15
|
+
|
|
16
|
+
def read_chunk(socket, deadline, closed_message:, timeout_message:)
|
|
17
|
+
remaining = deadline - monotonic
|
|
18
|
+
raise Error, timeout_message if remaining <= 0
|
|
19
|
+
IO.select([socket], nil, nil, remaining) or raise Error, timeout_message
|
|
20
|
+
chunk = socket.read_nonblock(1 << 20, exception: false)
|
|
21
|
+
raise Error, closed_message if chunk.nil?
|
|
22
|
+
chunk.is_a?(String) ? chunk : "".b
|
|
23
|
+
rescue SystemCallError, IOError => error
|
|
24
|
+
raise Error, "#{closed_message}: #{error.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "chrome_page"
|
|
7
|
+
require_relative "deadline_io"
|
|
8
|
+
|
|
9
|
+
module RubyLens
|
|
10
|
+
module Clip
|
|
11
|
+
# Renders a showcase HTML file into an H.264 MP4: headless Chrome steps
|
|
12
|
+
# the showcase's deterministic clip frames, and each captured frame is
|
|
13
|
+
# streamed straight into ffmpeg so no frame images ever touch disk. One
|
|
14
|
+
# render covers exactly one camera loop, so the video loops seamlessly.
|
|
15
|
+
class Renderer
|
|
16
|
+
STAGE_WIDTH = 1920
|
|
17
|
+
STAGE_HEIGHT = 1080
|
|
18
|
+
DEFAULT_FPS = 30
|
|
19
|
+
READY_TIMEOUT_SECONDS = 120
|
|
20
|
+
MARKER_COMMENT = "RubyLens clip"
|
|
21
|
+
|
|
22
|
+
def initialize(chrome:, ffmpeg:, fps: DEFAULT_FPS, frame_limit: nil, progress: nil)
|
|
23
|
+
@chrome = chrome
|
|
24
|
+
@ffmpeg = ffmpeg
|
|
25
|
+
@fps = Integer(fps)
|
|
26
|
+
@frame_limit = frame_limit
|
|
27
|
+
@progress = progress
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def render(showcase_html:, output:)
|
|
31
|
+
ChromePage.open(executable: @chrome, url: file_url(showcase_html), width: STAGE_WIDTH, height: STAGE_HEIGHT) do |page|
|
|
32
|
+
wait_until_ready(page)
|
|
33
|
+
preset = begin_clip(page)
|
|
34
|
+
frames = frame_count(preset)
|
|
35
|
+
encode(page, frames, output)
|
|
36
|
+
end
|
|
37
|
+
output
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def file_url(path)
|
|
43
|
+
"file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def wait_until_ready(page)
|
|
47
|
+
deadline = DeadlineIO.deadline(READY_TIMEOUT_SECONDS)
|
|
48
|
+
until page.evaluate("document.documentElement?.dataset.showcaseReady === 'true'")
|
|
49
|
+
raise Error, "the showcase page never finished loading" if DeadlineIO.monotonic > deadline
|
|
50
|
+
|
|
51
|
+
sleep 0.1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def begin_clip(page)
|
|
56
|
+
preset = page.evaluate("beginShowcaseClip()")
|
|
57
|
+
status = preset.is_a?(Hash) ? preset["status"] : nil
|
|
58
|
+
raise Error, "Chrome could not initialize WebGL2, which clip rendering requires" if status == "renderer-unavailable"
|
|
59
|
+
raise Error, "the page is not a RubyLens showcase" unless status == "ok"
|
|
60
|
+
|
|
61
|
+
preset
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def frame_count(preset)
|
|
65
|
+
frames = Integer(preset.fetch("durationMs")) * @fps / 1000
|
|
66
|
+
@frame_limit ? [frames, Integer(@frame_limit)].min : frames
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ffmpeg_arguments(output)
|
|
70
|
+
[
|
|
71
|
+
@ffmpeg, "-loglevel", "error", "-y",
|
|
72
|
+
"-f", "image2pipe", "-c:v", "png", "-framerate", @fps.to_s, "-i", "-",
|
|
73
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
|
|
74
|
+
"-movflags", "+faststart",
|
|
75
|
+
"-metadata", "comment=#{MARKER_COMMENT}",
|
|
76
|
+
"-f", "mp4", output
|
|
77
|
+
]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def encode(page, frames, output)
|
|
81
|
+
stdin, stderr, waiter = Open3.popen3(*ffmpeg_arguments(output)).then { |i, o, e, t| o.close; [i, e, t] }
|
|
82
|
+
stdin.binmode
|
|
83
|
+
stderr_reader = Thread.new { stderr.read }
|
|
84
|
+
begin
|
|
85
|
+
frames.times do |index|
|
|
86
|
+
page.evaluate("renderShowcaseClipFrame(#{index}, #{@fps})", await: true)
|
|
87
|
+
stdin.write(page.screenshot_png)
|
|
88
|
+
@progress&.call(index + 1, frames)
|
|
89
|
+
end
|
|
90
|
+
stdin.close
|
|
91
|
+
raise Error, "ffmpeg failed while encoding the clip: #{failure_reason(stderr_reader.value)}" unless waiter.value.success?
|
|
92
|
+
rescue Errno::EPIPE
|
|
93
|
+
raise Error, "ffmpeg stopped accepting frames: #{failure_reason(stderr_reader.value)}"
|
|
94
|
+
ensure
|
|
95
|
+
# On a capture-side failure, closing stdin lets ffmpeg finish and
|
|
96
|
+
# exit, the waiter reaps it, and the stderr reader ends on EOF, so
|
|
97
|
+
# only the original error surfaces.
|
|
98
|
+
stdin.close unless stdin.closed?
|
|
99
|
+
waiter.value
|
|
100
|
+
stderr_reader.value
|
|
101
|
+
stderr.close unless stderr.closed?
|
|
102
|
+
end
|
|
103
|
+
rescue Errno::ENOENT => error
|
|
104
|
+
raise Error, "could not run ffmpeg at #{@ffmpeg}: #{error.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def failure_reason(stderr_output)
|
|
108
|
+
lines = stderr_output.to_s.strip.lines.last(3).map(&:strip)
|
|
109
|
+
lines.empty? ? "no error output" : lines.join(" / ")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module RubyLens
|
|
6
|
+
module Clip
|
|
7
|
+
# Locates the two external tools clip rendering needs: a Chrome/Chromium
|
|
8
|
+
# binary and ffmpeg. Environment overrides win, then PATH, then well-known
|
|
9
|
+
# install locations. Discovery failures raise with install guidance so the
|
|
10
|
+
# command fails fast, before any indexing work.
|
|
11
|
+
module Toolchain
|
|
12
|
+
CHROME_ENV = "RUBYLENS_CHROME"
|
|
13
|
+
FFMPEG_ENV = "RUBYLENS_FFMPEG"
|
|
14
|
+
CHROME_COMMANDS = %w[google-chrome google-chrome-stable chromium chromium-browser chrome].freeze
|
|
15
|
+
CHROME_KNOWN_PATHS = [
|
|
16
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
17
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
18
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
19
|
+
].freeze
|
|
20
|
+
PLAYWRIGHT_CHROME_GLOBS = %w[
|
|
21
|
+
chromium
|
|
22
|
+
chromium-*/chrome-linux/chrome
|
|
23
|
+
chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def chrome_path
|
|
29
|
+
from_environment(CHROME_ENV) ||
|
|
30
|
+
from_path(CHROME_COMMANDS) ||
|
|
31
|
+
from_known_paths(CHROME_KNOWN_PATHS + playwright_chrome_candidates) ||
|
|
32
|
+
raise(Error, "clip rendering needs Chrome or Chromium; install Google Chrome, " \
|
|
33
|
+
"or point #{CHROME_ENV} at a Chrome-compatible browser binary")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ffmpeg_path
|
|
37
|
+
from_environment(FFMPEG_ENV) ||
|
|
38
|
+
from_path(%w[ffmpeg]) ||
|
|
39
|
+
raise(Error, "clip rendering needs ffmpeg; install it (for example `brew install ffmpeg` " \
|
|
40
|
+
"or `apt install ffmpeg`), or point #{FFMPEG_ENV} at an ffmpeg binary")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def from_environment(name)
|
|
44
|
+
value = ENV.fetch(name, nil)
|
|
45
|
+
return nil if value.nil? || value.empty?
|
|
46
|
+
raise Error, "#{name} is set to #{value}, which is not an executable file" unless executable?(value)
|
|
47
|
+
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def from_path(commands)
|
|
52
|
+
directories = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
53
|
+
commands.each do |command|
|
|
54
|
+
directories.each do |directory|
|
|
55
|
+
next if directory.empty?
|
|
56
|
+
|
|
57
|
+
candidate = File.join(directory, command)
|
|
58
|
+
return candidate if executable?(candidate)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def from_known_paths(paths)
|
|
65
|
+
paths.find { |path| executable?(path) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def playwright_chrome_candidates
|
|
69
|
+
root = ENV.fetch("PLAYWRIGHT_BROWSERS_PATH", nil)
|
|
70
|
+
root = File.expand_path("~/.cache/ms-playwright") if root.nil? || root.empty?
|
|
71
|
+
PLAYWRIGHT_CHROME_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.sort
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
# File.expand_path("~") raises when HOME is unset.
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def executable?(path)
|
|
78
|
+
File.file?(path) && File.executable?(path)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest/sha1"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "deadline_io"
|
|
9
|
+
|
|
10
|
+
module RubyLens
|
|
11
|
+
module Clip
|
|
12
|
+
# Minimal RFC 6455 client for Chrome's local DevTools WebSocket endpoint,
|
|
13
|
+
# so clip rendering needs no gem or Node dependencies. Client frames are
|
|
14
|
+
# masked text; ping, close, and fragmented server frames are handled;
|
|
15
|
+
# extensions are never negotiated.
|
|
16
|
+
class WebSocketChannel
|
|
17
|
+
HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
18
|
+
TEXT_OPCODE = 1
|
|
19
|
+
CONTINUATION_OPCODE = 0
|
|
20
|
+
CLOSE_OPCODE = 8
|
|
21
|
+
PING_OPCODE = 9
|
|
22
|
+
PONG_OPCODE = 10
|
|
23
|
+
|
|
24
|
+
def initialize(host, port, path, timeout: 15)
|
|
25
|
+
@socket = TCPSocket.new(host, port)
|
|
26
|
+
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
27
|
+
@buffer = +"".b
|
|
28
|
+
handshake(host, port, path, timeout)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def send_text(payload)
|
|
32
|
+
bytes = payload.b
|
|
33
|
+
length = bytes.bytesize
|
|
34
|
+
header = if length < 126 then [0x80 | TEXT_OPCODE, 0x80 | length].pack("CC")
|
|
35
|
+
elsif length < 65_536 then [0x80 | TEXT_OPCODE, 0x80 | 126, length].pack("CCn")
|
|
36
|
+
else [0x80 | TEXT_OPCODE, 0x80 | 127, length].pack("CCQ>")
|
|
37
|
+
end
|
|
38
|
+
mask = SecureRandom.bytes(4)
|
|
39
|
+
@socket.write(header + mask + apply_mask(bytes, mask))
|
|
40
|
+
rescue SystemCallError, IOError => error
|
|
41
|
+
raise Error, "lost the Chrome DevTools connection: #{error.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_text(timeout: 30)
|
|
45
|
+
deadline = DeadlineIO.deadline(timeout)
|
|
46
|
+
message = +"".b
|
|
47
|
+
loop do
|
|
48
|
+
finished, opcode, payload = read_frame(deadline)
|
|
49
|
+
case opcode
|
|
50
|
+
when TEXT_OPCODE, CONTINUATION_OPCODE then message << payload
|
|
51
|
+
when CLOSE_OPCODE then raise Error, "Chrome closed the DevTools connection"
|
|
52
|
+
when PING_OPCODE then send_control(PONG_OPCODE, payload)
|
|
53
|
+
end
|
|
54
|
+
return message.force_encoding(Encoding::UTF_8) if finished && opcode <= TEXT_OPCODE && !message.empty?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close
|
|
59
|
+
@socket.close unless @socket.closed?
|
|
60
|
+
rescue IOError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def handshake(host, port, path, timeout)
|
|
67
|
+
deadline = DeadlineIO.deadline(timeout)
|
|
68
|
+
key = SecureRandom.base64(16)
|
|
69
|
+
@socket.write("GET #{path} HTTP/1.1\r\nHost: #{host}:#{port}\r\n" \
|
|
70
|
+
"Upgrade: websocket\r\nConnection: Upgrade\r\n" \
|
|
71
|
+
"Sec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
|
|
72
|
+
response = +"".b
|
|
73
|
+
response << read_chunk(deadline) until response.include?("\r\n\r\n")
|
|
74
|
+
head, _, rest = response.partition("\r\n\r\n")
|
|
75
|
+
raise Error, "Chrome refused the DevTools WebSocket: #{head.lines.first&.strip}" unless head.start_with?("HTTP/1.1 101")
|
|
76
|
+
|
|
77
|
+
expected = Base64.strict_encode64(Digest::SHA1.digest(key + HANDSHAKE_GUID))
|
|
78
|
+
unless head.match?(/^sec-websocket-accept:\s*#{Regexp.escape(expected)}\s*$/i)
|
|
79
|
+
raise Error, "Chrome DevTools WebSocket handshake failed verification"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@buffer << rest
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply_mask(bytes, mask)
|
|
86
|
+
masked = bytes.dup
|
|
87
|
+
masked.bytesize.times { |index| masked.setbyte(index, masked.getbyte(index) ^ mask.getbyte(index % 4)) }
|
|
88
|
+
masked
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def send_control(opcode, payload)
|
|
92
|
+
mask = SecureRandom.bytes(4)
|
|
93
|
+
@socket.write([0x80 | opcode, 0x80 | payload.bytesize].pack("CC") + mask + apply_mask(payload, mask))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read_frame(deadline)
|
|
97
|
+
first, second = read_exact(2, deadline).unpack("CC")
|
|
98
|
+
length = second & 0x7F
|
|
99
|
+
length = read_exact(2, deadline).unpack1("n") if length == 126
|
|
100
|
+
length = read_exact(8, deadline).unpack1("Q>") if length == 127
|
|
101
|
+
[first.anybits?(0x80), first & 0x0F, read_exact(length, deadline)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def read_exact(length, deadline)
|
|
105
|
+
@buffer << read_chunk(deadline) while @buffer.bytesize < length
|
|
106
|
+
@buffer.slice!(0, length)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def read_chunk(deadline)
|
|
110
|
+
DeadlineIO.read_chunk(@socket, deadline,
|
|
111
|
+
closed_message: "Chrome closed the DevTools connection",
|
|
112
|
+
timeout_message: "timed out waiting for Chrome")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|