rubylens 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.
@@ -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
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "artifact_marker"
4
+ require_relative "atomic_output"
5
+ require_relative "default_output"
6
+ require_relative "showcase_generator"
7
+ require_relative "clip/renderer"
8
+ require_relative "clip/toolchain"
9
+
10
+ module RubyLens
11
+ ClipResult = Data.define(:output_path, :showcase_path, :counts, :warnings) do
12
+ def to_payload = { output: output_path, showcase: showcase_path, counts: counts, warnings: warnings }
13
+ end
14
+
15
+ # Generates a shareable MP4 of a project's showcase. The showcase HTML is
16
+ # written first (it is the render input and stays useful on its own), then
17
+ # headless Chrome and ffmpeg turn it into one seamless 1080p camera loop.
18
+ # The toolchain is checked before any indexing so a missing browser or
19
+ # ffmpeg fails in under a second with install guidance.
20
+ class ClipGenerator
21
+ DEFAULT_CLIP_NAME = "rubylens-clip.mp4"
22
+ # faststart parks the metadata (and so the marker) right behind ftyp, so
23
+ # every clip RubyLens writes matches within this head window.
24
+ MARKER_SCAN_HEAD_BYTES = 512 * 1024
25
+
26
+ def initialize(path: Dir.pwd, output: nil, lockfile: nil, details: false, progress: nil, renderer: nil)
27
+ @path = path
28
+ @output = output
29
+ @lockfile = lockfile
30
+ @details = details
31
+ @progress = progress
32
+ @renderer = renderer
33
+ end
34
+
35
+ def call
36
+ renderer = @renderer || build_renderer
37
+ root = File.realpath(@path)
38
+ output, showcase_output = resolve_outputs(root)
39
+ showcase_result = ShowcaseGenerator.new(
40
+ path: @path, output: showcase_output, lockfile: @lockfile, details: @details,
41
+ ).call
42
+ render_atomically(renderer, showcase_result.output_path, output)
43
+
44
+ ClipResult.new(
45
+ output_path: output,
46
+ showcase_path: showcase_result.output_path,
47
+ counts: showcase_result.counts,
48
+ warnings: showcase_result.warnings,
49
+ )
50
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error
51
+ raise Error, error.message
52
+ end
53
+
54
+ private
55
+
56
+ def build_renderer
57
+ Clip::Renderer.new(
58
+ chrome: Clip::Toolchain.chrome_path,
59
+ ffmpeg: Clip::Toolchain.ffmpeg_path,
60
+ progress: @progress,
61
+ )
62
+ end
63
+
64
+ # With no --output, both artifacts use their default names, Git-excluded
65
+ # locally, and an existing default clip is only replaced if it is ours.
66
+ # With --output FILE.mp4, the MP4 is written exactly where requested, like
67
+ # other custom output paths; the derived FILE.html companion was never
68
+ # named by the user, so an unrelated existing file there is refused.
69
+ def resolve_outputs(root)
70
+ if @output.nil?
71
+ default = DefaultOutput.resolve(root: root, name: DEFAULT_CLIP_NAME, description: "clip") do |existing|
72
+ ArtifactMarker.present?(existing, Clip::Renderer::MARKER_COMMENT, head_bytes: MARKER_SCAN_HEAD_BYTES)
73
+ end
74
+ return [default, nil]
75
+ end
76
+
77
+ # Expanded once here so the guard, the writer, and the reported result
78
+ # all refer to the same real paths.
79
+ output = File.expand_path(@output)
80
+ companion = showcase_companion_path(output)
81
+ if File.exist?(companion) && !ArtifactMarker.present?(companion, ShowcaseWriter::MARKER)
82
+ raise Error, "clip companion path #{companion} already exists and is not a RubyLens showcase"
83
+ end
84
+
85
+ [output, companion]
86
+ end
87
+
88
+ def showcase_companion_path(output)
89
+ base = output.sub(/\.mp4\z/i, "")
90
+ base = output if base.empty?
91
+ "#{base}.html"
92
+ end
93
+
94
+ def render_atomically(renderer, showcase_html, output)
95
+ AtomicOutput.replace(output) do |temporary|
96
+ renderer.render(showcase_html: showcase_html, output: temporary)
97
+ rescue Error => error
98
+ raise Error, "#{error.message} (the showcase HTML was still written to #{showcase_html})"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "git_repository"
5
+
6
+ module RubyLens
7
+ # Safety policy shared by every generator's default output path: an existing
8
+ # file is only replaced when the given check recognizes it as our own kind of
9
+ # artifact, and the path plus its temporary-file pattern are added to the
10
+ # repository's local Git excludes.
11
+ module DefaultOutput
12
+ module_function
13
+
14
+ def resolve(root:, name:, description:)
15
+ output = File.join(root, name)
16
+ if File.exist?(output) && !yield(output)
17
+ raise Error, "default #{description} path already exists and is not a RubyLens #{description}"
18
+ end
19
+
20
+ GitRepository.new(root).exclude_local(output, description: description)
21
+ output
22
+ end
23
+ end
24
+ end
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "artifact_marker"
4
+ require_relative "default_output"
5
+
3
6
  module RubyLens
4
- Result = Data.define(:output_path, :counts, :warnings)
7
+ Result = Data.define(:output_path, :counts, :warnings) do
8
+ def to_payload = { output: output_path, counts: counts, warnings: warnings }
9
+ end
5
10
 
6
11
  class GenerationPipeline
7
12
  def initialize(root:, lockfile: nil)
@@ -32,17 +37,11 @@ module RubyLens
32
37
 
33
38
  def call
34
39
  root = File.realpath(@path)
35
- report_writer = ReportWriter.new
36
- output = @output
37
- if output.nil?
38
- output = File.join(root, DEFAULT_REPORT_NAME)
39
- if File.exist?(output) && !report_writer.rubylens_report?(output)
40
- raise Error, "default report path already exists and is not a RubyLens report"
41
- end
42
- GitRepository.new(root).exclude_local(output)
40
+ output = @output || DefaultOutput.resolve(root: root, name: DEFAULT_REPORT_NAME, description: "report") do |existing|
41
+ ArtifactMarker.present?(existing, ReportWriter::MARKER)
43
42
  end
44
43
  model, warnings = GenerationPipeline.new(root:, lockfile: @lockfile).call
45
- output_path = report_writer.write(model, output: output)
44
+ output_path = ReportWriter.new.write(model, output: output)
46
45
 
47
46
  RubyLens::Result.new(output_path: output_path, counts: model.fetch("totals").freeze, warnings:)
48
47
  rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error