vizcore 0.1.0 → 1.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module Sync
5
+ # Minimal OSC 1.0 message parser for control sync.
6
+ class OscMessage
7
+ attr_reader :address, :arguments
8
+
9
+ # @param data [String]
10
+ # @return [Vizcore::Sync::OscMessage, nil]
11
+ def self.parse(data)
12
+ parser = Parser.new(data)
13
+ address = parser.read_string
14
+ return nil unless address&.start_with?("/")
15
+
16
+ tags = parser.read_string
17
+ arguments = parser.read_arguments(tags)
18
+ new(address: address, arguments: arguments)
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ # @param address [String]
24
+ # @param arguments [Array]
25
+ def initialize(address:, arguments: [])
26
+ @address = address
27
+ @arguments = Array(arguments)
28
+ end
29
+
30
+ # @api private
31
+ class Parser
32
+ # @param data [String]
33
+ def initialize(data)
34
+ @data = data.to_s.b
35
+ @offset = 0
36
+ end
37
+
38
+ # @return [String, nil]
39
+ def read_string
40
+ start = @offset
41
+ @offset += 1 while @offset < @data.bytesize && @data.getbyte(@offset) != 0
42
+ return nil if @offset >= @data.bytesize
43
+
44
+ value = @data.byteslice(start...@offset).to_s.force_encoding(Encoding::UTF_8)
45
+ @offset += 1
46
+ align_offset
47
+ value
48
+ end
49
+
50
+ # @param tags [String, nil]
51
+ # @return [Array]
52
+ def read_arguments(tags)
53
+ return [] if tags.to_s.empty?
54
+ return [] unless tags.start_with?(",")
55
+
56
+ tags[1..].to_s.each_char.map do |tag|
57
+ read_argument(tag)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def read_argument(tag)
64
+ case tag
65
+ when "i"
66
+ read_int32
67
+ when "f"
68
+ read_float32
69
+ when "s"
70
+ read_string
71
+ when "T"
72
+ true
73
+ when "F"
74
+ false
75
+ else
76
+ nil
77
+ end
78
+ end
79
+
80
+ def read_int32
81
+ value = read_bytes(4).unpack1("N")
82
+ value >= 0x80000000 ? value - 0x100000000 : value
83
+ end
84
+
85
+ def read_float32
86
+ read_bytes(4).unpack1("g")
87
+ end
88
+
89
+ def read_bytes(length)
90
+ raise ArgumentError, "OSC payload truncated" if @offset + length > @data.bytesize
91
+
92
+ @data.byteslice(@offset, length).tap do
93
+ @offset += length
94
+ end
95
+ end
96
+
97
+ def align_offset
98
+ @offset += 1 while (@offset % 4).positive?
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "osc_message"
5
+
6
+ module Vizcore
7
+ module Sync
8
+ # Receives OSC UDP messages on a background thread.
9
+ class OscReceiver
10
+ DEFAULT_HOST = "127.0.0.1"
11
+ MAX_PACKET_SIZE = 65_536
12
+
13
+ # @param port [Integer]
14
+ # @param host [String]
15
+ # @param handler [#call]
16
+ # @param error_reporter [#call, nil]
17
+ def initialize(port:, host: DEFAULT_HOST, handler:, error_reporter: nil)
18
+ @host = host.to_s
19
+ @port = Integer(port)
20
+ @handler = handler
21
+ @error_reporter = error_reporter
22
+ @socket = nil
23
+ @thread = nil
24
+ @running = false
25
+ end
26
+
27
+ # @return [Vizcore::Sync::OscReceiver]
28
+ def start
29
+ return self if @thread&.alive?
30
+
31
+ @socket = UDPSocket.new
32
+ @socket.bind(@host, @port)
33
+ @running = true
34
+ @thread = Thread.new { receive_loop }
35
+ self
36
+ end
37
+
38
+ # @return [nil]
39
+ def stop
40
+ @running = false
41
+ @socket&.close
42
+ @thread&.join(1)
43
+ nil
44
+ rescue StandardError
45
+ nil
46
+ ensure
47
+ @socket = nil
48
+ @thread = nil
49
+ end
50
+
51
+ private
52
+
53
+ def receive_loop
54
+ while @running
55
+ begin
56
+ data, = @socket.recvfrom(MAX_PACKET_SIZE)
57
+ message = OscMessage.parse(data)
58
+ @handler.call(message) if message
59
+ rescue IOError, SystemCallError
60
+ break unless @running
61
+ rescue StandardError => e
62
+ @error_reporter&.call("OSC message ignored: #{e.message}")
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sync/osc_message"
4
+ require_relative "sync/osc_receiver"
@@ -2,11 +2,13 @@
2
2
 
3
3
  # {{project_name}} MIDI mapping example.
4
4
  Vizcore.define do
5
+ set :global_intensity, 0.65
6
+
5
7
  midi :controller, device: :default
6
8
 
7
9
  scene :warmup do
8
10
  layer :warm_bg do
9
- shader :neon_grid
11
+ shader :waveform_ribbon
10
12
  map frequency_band(:mid) => :intensity
11
13
  end
12
14
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vizcore"
4
+
5
+ module {{plugin_module}}
6
+ LAYER_TYPE = :{{plugin_type}}
7
+
8
+ Vizcore.register_layer_capability(
9
+ type: LAYER_TYPE,
10
+ aliases: [:{{plugin_name}}],
11
+ params: {
12
+ intensity: "Float",
13
+ color: "CSS color",
14
+ blend: "Blend mode",
15
+ opacity: "Float"
16
+ },
17
+ mappable_params: %i[intensity opacity],
18
+ description: "{{plugin_title}} browser-rendered plugin layer."
19
+ )
20
+ end
@@ -0,0 +1,23 @@
1
+ # {{plugin_title}}
2
+
3
+ Vizcore plugin scaffold generated by `vizcore plugin new`.
4
+
5
+ ## Files
6
+
7
+ - `lib/{{plugin_name}}.rb` registers the Ruby layer capability used by validation and docs.
8
+ - `frontend/{{plugin_name}}-renderer.js` registers a browser renderer through `globalThis.VizcorePlugins`.
9
+ - `examples/{{plugin_name}}_scene.rb` shows the layer type in a scene.
10
+
11
+ ## Try It
12
+
13
+ ```ruby
14
+ require_relative "lib/{{plugin_name}}"
15
+ ```
16
+
17
+ Serve `frontend/{{plugin_name}}-renderer.js` with your Vizcore frontend and load
18
+ it after the built-in app script. The browser runtime exposes
19
+ `globalThis.VizcorePlugins.apiVersion`; this scaffold expects version 1 or
20
+ newer. The browser renderer returns line points, so Vizcore can composite it
21
+ with normal layer `blend` and `opacity` params. Shader plugins can instead
22
+ register with `registerShaderRenderer(type, renderer)` and return a GLSL
23
+ fragment shader string or `{ kind: "shader", fragmentShader }`.
@@ -0,0 +1,43 @@
1
+ const layerType = "{{plugin_type}}";
2
+
3
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
4
+
5
+ export const {{plugin_renderer}} = ({ audio, time, layer }) => {
6
+ const params = layer?.params || {};
7
+ const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
8
+ const intensity = clamp(Number(params.intensity ?? 1), 0, 4);
9
+ const radius = 0.25 + amplitude * intensity * 0.18;
10
+ const phase = Number(time || 0) * 0.8;
11
+ const points = [];
12
+
13
+ for (let index = 0; index < 18; index += 1) {
14
+ const angle = phase + index * Math.PI / 9;
15
+ const nextAngle = angle + Math.PI * 0.35;
16
+ points.push(
17
+ Math.cos(angle) * radius,
18
+ Math.sin(angle) * radius,
19
+ Math.cos(nextAngle) * (radius + 0.18),
20
+ Math.sin(nextAngle) * (radius + 0.18),
21
+ );
22
+ }
23
+
24
+ return {
25
+ kind: "lines",
26
+ color: [0.45 + amplitude * 0.35, 0.9, 1.0],
27
+ points,
28
+ };
29
+ };
30
+
31
+ const register = () => {
32
+ const runtime = globalThis.VizcorePlugins;
33
+ if (!runtime?.registerLayerRenderer || Number(runtime.apiVersion || 0) < 1) {
34
+ return false;
35
+ }
36
+
37
+ runtime.registerLayerRenderer(layerType, {{plugin_renderer}});
38
+ return true;
39
+ };
40
+
41
+ if (!register() && typeof globalThis.addEventListener === "function") {
42
+ globalThis.addEventListener("vizcore:plugins-ready", register, { once: true });
43
+ }
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/{{plugin_name}}"
4
+
5
+ Vizcore.define do
6
+ scene :{{plugin_name}}_demo do
7
+ layer :{{plugin_name}} do
8
+ type :{{plugin_type}}
9
+ intensity 1.0
10
+ fill "#67e8f9"
11
+ blend :add
12
+ end
13
+ end
14
+ end
@@ -2,34 +2,18 @@
2
2
 
3
3
  This project was generated by `vizcore new`.
4
4
 
5
+ Template: `{{template_name}}`
6
+
5
7
  ## Start
6
8
 
7
9
  ```bash
8
- vizcore start scenes/basic.rb
10
+ vizcore start {{start_scene}}
9
11
  ```
10
12
 
11
- ## Included Scenes
12
-
13
- - `scenes/basic.rb`: Minimal wireframe starter
14
- - `scenes/intro_drop.rb`: Transition flow with beat trigger
15
- - `scenes/midi_control.rb`: MIDI note/CC mapping example
16
- - `scenes/custom_shader.rb`: Custom GLSL + post/VJ effect example
17
-
18
- ## Custom Shader
19
-
20
- `scenes/custom_shader.rb` references `shaders/custom_wave.frag`.
21
- Edit that file and save to see hot-reload updates.
22
-
23
- ## MIDI
13
+ ## Included Files
24
14
 
25
- List devices:
15
+ {{included_files}}
26
16
 
27
- ```bash
28
- vizcore devices midi
29
- ```
30
-
31
- Start MIDI example:
17
+ ## Notes
32
18
 
33
- ```bash
34
- vizcore start scenes/midi_control.rb
35
- ```
19
+ {{template_notes}}
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # {{project_name}} Ruby conference visual starter.
4
+ Vizcore.define do
5
+ scene :rubykaigi do
6
+ layer :ruby_grid do
7
+ shader :neon_grid
8
+ opacity 0.72
9
+ blend :screen
10
+ map frequency_band(:mid) => :intensity
11
+ end
12
+
13
+ layer :ruby_pulse do
14
+ type :wireframe_cube
15
+ blend :add
16
+ map amplitude => :rotation_speed, range: 0.5..3.2
17
+ map fft_spectrum => :deform
18
+ map frequency_band(:high) => :color_shift
19
+ end
20
+
21
+ layer :title do
22
+ type :text
23
+ content "{{project_name}}"
24
+ font_size 88
25
+ color "#e11d48"
26
+ glow_strength 0.35
27
+ map beat? => :flash
28
+ end
29
+ end
30
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Vizcore
4
4
  # Current gem version.
5
- VERSION = "0.1.0"
5
+ VERSION = "1.1.0"
6
6
  end
data/lib/vizcore.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "vizcore/version"
4
4
  require_relative "vizcore/errors"
5
+ require_relative "vizcore/layer_catalog"
6
+ require_relative "vizcore/shape"
5
7
  require_relative "vizcore/dsl"
6
8
  require "pathname"
7
9
 
@@ -34,4 +36,30 @@ module Vizcore
34
36
  def self.define(&block)
35
37
  DSL::Engine.define(&block)
36
38
  end
39
+
40
+ # Load a Vizcore plugin by Ruby require path.
41
+ #
42
+ # @param name [String, Symbol] require path for the plugin
43
+ # @return [true]
44
+ def self.plugin(name)
45
+ require name.to_s
46
+ end
47
+
48
+ # Register a plugin-provided layer capability for validation and docs.
49
+ #
50
+ # @param type [Symbol, String] primary layer type
51
+ # @param aliases [Array<Symbol, String>] supported aliases
52
+ # @param params [Hash] layer parameter metadata
53
+ # @param mappable_params [Array<Symbol, String>] params that can be mapped
54
+ # @param description [String, nil] human-readable docs text
55
+ # @return [Vizcore::LayerCatalog::Capability]
56
+ def self.register_layer_capability(type:, aliases: [], params: {}, mappable_params: [], description: nil)
57
+ LayerCatalog.register_layer_capability(
58
+ type: type,
59
+ aliases: aliases,
60
+ params: params,
61
+ mappable_params: mappable_params,
62
+ description: description
63
+ )
64
+ end
37
65
  end
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const options = parseArgs(process.argv.slice(2));
5
+
6
+ if (!options.url) {
7
+ console.error("Usage: node scripts/browser_capture.mjs URL --out browser-capture.png [--selector #vizcore-canvas] [--wait 1000]");
8
+ process.exit(1);
9
+ }
10
+
11
+ let chromium;
12
+ try {
13
+ ({ chromium } = await import("playwright"));
14
+ } catch {
15
+ console.error("Playwright is required for browser capture. Install it with `npm install -D playwright` in the frontend project.");
16
+ process.exit(2);
17
+ }
18
+
19
+ const browser = await chromium.launch({ headless: true });
20
+ try {
21
+ const page = await browser.newPage({ viewport: { width: options.width, height: options.height } });
22
+ await page.goto(options.url, { waitUntil: "domcontentloaded" });
23
+ if (options.wait > 0) {
24
+ await page.waitForTimeout(options.wait);
25
+ }
26
+
27
+ const element = page.locator(options.selector).first();
28
+ await element.waitFor({ state: "visible", timeout: 10000 });
29
+ await fs.mkdir(path.dirname(options.out), { recursive: true });
30
+ await element.screenshot({ path: options.out });
31
+ console.log(`Browser capture written: ${options.out}`);
32
+ } finally {
33
+ await browser.close();
34
+ }
35
+
36
+ function parseArgs(args) {
37
+ const parsed = {
38
+ url: null,
39
+ out: "browser-capture.png",
40
+ selector: "#vizcore-canvas",
41
+ wait: 1000,
42
+ width: 1280,
43
+ height: 720,
44
+ };
45
+
46
+ for (let index = 0; index < args.length; index += 1) {
47
+ const value = args[index];
48
+ if (value === "--out") {
49
+ parsed.out = String(args[index + 1] || parsed.out);
50
+ index += 1;
51
+ } else if (value === "--selector") {
52
+ parsed.selector = String(args[index + 1] || parsed.selector);
53
+ index += 1;
54
+ } else if (value === "--wait") {
55
+ parsed.wait = finiteNumber(args[index + 1], parsed.wait);
56
+ index += 1;
57
+ } else if (value === "--width") {
58
+ parsed.width = finiteNumber(args[index + 1], parsed.width);
59
+ index += 1;
60
+ } else if (value === "--height") {
61
+ parsed.height = finiteNumber(args[index + 1], parsed.height);
62
+ index += 1;
63
+ } else if (!parsed.url) {
64
+ parsed.url = value;
65
+ }
66
+ }
67
+
68
+ parsed.out = path.resolve(parsed.out);
69
+ return parsed;
70
+ }
71
+
72
+ function finiteNumber(value, fallback) {
73
+ const numeric = Number(value);
74
+ return Number.isFinite(numeric) ? numeric : fallback;
75
+ }