exrviewer 0.1.0.beta1

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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "tempfile"
5
+ require "active_storage/previewer"
6
+ require "active_storage/errors"
7
+ require "image_processing/vips"
8
+ require "vips"
9
+
10
+ module ExrViewer
11
+ module ActiveStorage
12
+ class ExrPreviewer < ::ActiveStorage::Previewer
13
+ EXR_CONTENT_TYPES = Set.new([
14
+ "image/x-exr",
15
+ "image/exr",
16
+ "application/x-exr",
17
+ "application/exr"
18
+ ]).freeze
19
+
20
+ FALLBACK_CONTENT_TYPES = Set.new([
21
+ "application/octet-stream",
22
+ "binary/octet-stream"
23
+ ]).freeze
24
+
25
+ MAX_EDGE = 1600
26
+ JPEG_QUALITY = 85
27
+ GAMMA_EXPONENT = 1.0 / 2.2
28
+
29
+ class << self
30
+ def accept?(blob)
31
+ content_type = blob.content_type.to_s.downcase
32
+ return true if EXR_CONTENT_TYPES.include?(content_type)
33
+
34
+ extension = blob.filename.extension_without_delimiter.to_s.downcase
35
+ FALLBACK_CONTENT_TYPES.include?(content_type) && extension == "exr"
36
+ end
37
+ end
38
+
39
+ def preview(**options)
40
+ download_blob_to_tempfile do |input|
41
+ output = Tempfile.new([blob.filename.base.to_s, ".jpg"], binmode: true)
42
+
43
+ begin
44
+ render_preview(input.path, output.path)
45
+
46
+ File.open(output.path, "rb") do |io|
47
+ yield io: io,
48
+ filename: "#{blob.filename.base}.jpg",
49
+ content_type: "image/jpeg",
50
+ **options
51
+ end
52
+ ensure
53
+ output.close!
54
+ end
55
+ end
56
+ rescue LoadError => e
57
+ raise ::ActiveStorage::UnpreviewableError,
58
+ "EXR previewing requires image_processing and ruby-vips (#{e.message})"
59
+ rescue StandardError => e
60
+ raise e if e.is_a?(::ActiveStorage::UnpreviewableError)
61
+
62
+ raise ::ActiveStorage::UnpreviewableError,
63
+ "Unable to generate EXR preview: #{e.message}"
64
+ end
65
+
66
+ private
67
+
68
+ def render_preview(input_path, output_path)
69
+ input = ::Vips::Image.new_from_file(input_path, access: :sequential)
70
+ rgb = best_effort_rgb(input)
71
+ rgb = rgb.cast("float")
72
+ rgb = rgb.clamp(min: 0.0, max: 1.0)
73
+ rgb = rgb.pow(GAMMA_EXPONENT)
74
+
75
+ linear_png = Tempfile.new(["exr-linear", ".png"], binmode: true)
76
+ begin
77
+ rgb.pngsave(linear_png.path, compression: 3)
78
+
79
+ ImageProcessing::Vips
80
+ .source(linear_png.path)
81
+ .resize_to_limit(MAX_EDGE, MAX_EDGE)
82
+ .convert("jpg")
83
+ .saver(Q: JPEG_QUALITY, strip: true)
84
+ .call(destination: output_path)
85
+ ensure
86
+ linear_png.close!
87
+ end
88
+ end
89
+
90
+ def best_effort_rgb(image)
91
+ if image.bands >= 3
92
+ image.extract_band(0, n: 3)
93
+ elsif image.bands == 2
94
+ image.bandjoin(image.extract_band(0, n: 1))
95
+ else
96
+ image.bandjoin([image, image])
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module ExrViewer
6
+ module Helpers
7
+ COMPONENT_ATTRIBUTES = Set.new([
8
+ "tone-mapping",
9
+ "exposure",
10
+ "controls"
11
+ ]).freeze
12
+
13
+ HTML_ATTRIBUTES = Set.new([
14
+ "id",
15
+ "class",
16
+ "style",
17
+ "title",
18
+ "role",
19
+ "hidden",
20
+ "tabindex",
21
+ "lang",
22
+ "dir",
23
+ "slot",
24
+ "part"
25
+ ]).freeze
26
+
27
+ ATTRIBUTE_ALIASES = {
28
+ "tone_mapping" => "tone-mapping"
29
+ }.freeze
30
+
31
+ def exr_viewer_include_tag(optional: true, **attrs)
32
+ return "".html_safe if optional && exr_viewer_managed_by_js_pipeline?
33
+
34
+ defaults = { defer: true }
35
+ javascript_include_tag("exr-viewer-stimulus", **defaults.merge(attrs))
36
+ end
37
+
38
+ def exr_viewer_tag(src:, **attrs)
39
+ normalized = normalize_component_attrs(attrs)
40
+ component = extract_component_attributes(normalized)
41
+ data_attributes = extract_data_attributes(normalized)
42
+ merge_controller_identifier(data_attributes, "exr-viewer")
43
+
44
+ data_attributes["exr-viewer-src-value"] = resolve_exr_src(src)
45
+ data_attributes["exr-viewer-tone-mapping-value"] = component.fetch("tone-mapping", "aces")
46
+ data_attributes["exr-viewer-exposure-value"] = component.fetch("exposure", 1.0)
47
+ data_attributes["exr-viewer-controls-value"] = component.fetch("controls", false) ? true : false
48
+
49
+ normalized["data"] = data_attributes
50
+ merge_class_name(normalized, "exr-viewer")
51
+
52
+ content_tag("div", "", normalized)
53
+ end
54
+
55
+ def exr_preview_tag(source, preview: {}, **attrs)
56
+ image_tag(resolve_exr_preview_source(source, preview_options: preview), **attrs)
57
+ end
58
+
59
+ private
60
+
61
+ def resolve_exr_src(source)
62
+ return source if source.is_a?(String)
63
+
64
+ if active_storage_blob?(source) || active_storage_attachment?(source)
65
+ return url_for(source)
66
+ end
67
+
68
+ raise ArgumentError, "src must be a String, ActiveStorage::Blob, or ActiveStorage::Attachment"
69
+ end
70
+
71
+ def active_storage_blob?(source)
72
+ defined?(::ActiveStorage::Blob) && source.is_a?(::ActiveStorage::Blob)
73
+ end
74
+
75
+ def active_storage_attachment?(source)
76
+ defined?(::ActiveStorage::Attachment) && source.is_a?(::ActiveStorage::Attachment)
77
+ end
78
+
79
+ def resolve_exr_preview_source(source, preview_options:)
80
+ return source if source.is_a?(String)
81
+
82
+ if active_storage_blob?(source) || active_storage_attachment?(source)
83
+ return source.preview(**preview_options).processed
84
+ end
85
+
86
+ raise ArgumentError, "source must be a String, ActiveStorage::Blob, or ActiveStorage::Attachment"
87
+ end
88
+
89
+ def exr_viewer_managed_by_js_pipeline?
90
+ exr_viewer_importmap_setup? || exr_viewer_node_setup?
91
+ end
92
+
93
+ def exr_viewer_importmap_setup?
94
+ exr_viewer_app_file_exists?("config/importmap.rb")
95
+ end
96
+
97
+ def exr_viewer_node_setup?
98
+ exr_viewer_app_file_exists?("package.json")
99
+ end
100
+
101
+ def exr_viewer_app_file_exists?(relative_path)
102
+ return false unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
103
+
104
+ File.exist?(Rails.root.join(relative_path))
105
+ end
106
+
107
+ def extract_component_attributes(normalized)
108
+ component = {}
109
+ COMPONENT_ATTRIBUTES.each do |key|
110
+ next unless normalized.key?(key)
111
+
112
+ component[key] = normalized.delete(key)
113
+ end
114
+ component
115
+ end
116
+
117
+ def extract_data_attributes(normalized)
118
+ data_attributes = {}
119
+
120
+ data = normalized.delete("data")
121
+ if data.is_a?(Hash)
122
+ data.each do |key, value|
123
+ data_attributes[key.to_s.tr("_", "-")] = value
124
+ end
125
+ end
126
+
127
+ normalized.keys.grep(/\Adata-/).each do |key|
128
+ data_attributes[key.delete_prefix("data-")] = normalized.delete(key)
129
+ end
130
+
131
+ data_attributes
132
+ end
133
+
134
+ def merge_controller_identifier(data_attributes, identifier)
135
+ current = data_attributes.fetch("controller", "").to_s.split(/\s+/).reject(&:empty?)
136
+ current << identifier unless current.include?(identifier)
137
+ data_attributes["controller"] = current.join(" ")
138
+ end
139
+
140
+ def merge_class_name(normalized, css_class)
141
+ classes = normalized.fetch("class", "").to_s.split(/\s+/).reject(&:empty?)
142
+ classes << css_class unless classes.include?(css_class)
143
+ normalized["class"] = classes.join(" ")
144
+ end
145
+
146
+ def normalize_component_attrs(attrs)
147
+ normalized = {}
148
+
149
+ attrs.each do |raw_key, value|
150
+ key = canonical_attr_name(raw_key)
151
+
152
+ unless allowed_attr?(key)
153
+ raise ArgumentError, "Unknown exr_viewer_tag attribute: #{raw_key}"
154
+ end
155
+
156
+ normalized[key] = value
157
+ end
158
+
159
+ normalized
160
+ end
161
+
162
+ def canonical_attr_name(raw_key)
163
+ string_key = raw_key.to_s
164
+ ATTRIBUTE_ALIASES.fetch(string_key, string_key.tr("_", "-"))
165
+ end
166
+
167
+ def allowed_attr?(key)
168
+ COMPONENT_ATTRIBUTES.include?(key) ||
169
+ HTML_ATTRIBUTES.include?(key) ||
170
+ key.start_with?("data-") ||
171
+ key.start_with?("aria-") ||
172
+ key == "data" ||
173
+ key == "aria"
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/module"
5
+
6
+ module ExrViewer
7
+ class Engine < ::Rails::Engine
8
+ initializer "exr_viewer.helpers" do
9
+ ActiveSupport.on_load(:action_view) do
10
+ include ExrViewer::Helpers
11
+ end
12
+ end
13
+
14
+ initializer "exr_viewer.assets.precompile" do |app|
15
+ next unless app.config.respond_to?(:assets)
16
+ next unless app.config.assets.respond_to?(:precompile)
17
+
18
+ app.config.assets.precompile << "exr-viewer-stimulus.js"
19
+ end
20
+
21
+ initializer "exr_viewer.active_storage.previewer" do
22
+ config.after_initialize do
23
+ next unless defined?(::ActiveStorage)
24
+ next unless ::ActiveStorage.respond_to?(:previewers)
25
+
26
+ previewer = ExrViewer::ActiveStorage::ExrPreviewer
27
+ unless ::ActiveStorage.previewers.include?(previewer)
28
+ ::ActiveStorage.previewers << previewer
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExrViewer
4
+ VERSION = "0.1.0.beta1"
5
+ end
data/lib/exr_viewer.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "exr_viewer/version"
4
+ require "exr_viewer/helpers"
5
+ require "exr_viewer/active_storage/exr_previewer"
6
+ require "exr_viewer/railtie" if defined?(Rails::Railtie)
7
+
8
+ module ExrViewer
9
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rails/generators"
5
+
6
+ module ExrViewer
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ class_option :strategy,
12
+ type: :string,
13
+ default: "auto",
14
+ desc: "Install strategy: auto, bun, node, importmap, vendored"
15
+ class_option :skip_install,
16
+ type: :boolean,
17
+ default: false,
18
+ desc: "Skip package manager install commands"
19
+
20
+ THREE_VERSION = "0.164.1"
21
+ THREE_PACKAGE = "three"
22
+ STIMULUS_PACKAGE = "@hotwired/stimulus"
23
+
24
+ IMPORTMAP_STIMULUS_PIN = %(pin "@hotwired/stimulus", to: "stimulus.min.js")
25
+ IMPORTMAP_STIMULUS_LOADING_PIN = %(pin "@hotwired/stimulus-loading", to: "stimulus-loading.js")
26
+ IMPORTMAP_CONTROLLERS_PIN = %(pin_all_from "app/javascript/controllers", under: "controllers")
27
+ IMPORTMAP_THREE_PIN = %(pin "three", to: "https://ga.jspm.io/npm:three@#{THREE_VERSION}/build/three.module.js")
28
+ IMPORTMAP_EXR_LOADER_PIN = %(pin "three/examples/jsm/loaders/EXRLoader.js", to: "https://ga.jspm.io/npm:three@#{THREE_VERSION}/examples/jsm/loaders/EXRLoader.js")
29
+ IMPORTMAP_WEBGPU_RENDERER_PIN = %(pin "three/examples/jsm/renderers/webgpu/WebGPURenderer.js", to: "https://ga.jspm.io/npm:three@#{THREE_VERSION}/examples/jsm/renderers/webgpu/WebGPURenderer.js")
30
+
31
+ def install
32
+ strategy = resolve_strategy
33
+ say_status :exr_viewer, "Using #{strategy} install strategy", :blue
34
+
35
+ case strategy
36
+ when "bun"
37
+ install_bun
38
+ when "node"
39
+ install_node
40
+ when "importmap"
41
+ install_importmap
42
+ when "vendored"
43
+ install_vendored
44
+ else
45
+ raise Thor::Error, "Unsupported strategy: #{strategy}"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def resolve_strategy
52
+ selected = options[:strategy].to_s
53
+ selected = "auto" if selected.empty?
54
+
55
+ return detect_strategy if selected == "auto"
56
+
57
+ valid = %w[bun node importmap vendored]
58
+ unless valid.include?(selected)
59
+ raise Thor::Error, "Unknown --strategy=#{selected}. Use one of: auto, #{valid.join(', ')}"
60
+ end
61
+
62
+ selected
63
+ end
64
+
65
+ def detect_strategy
66
+ return "importmap" if File.exist?(app_path("config/importmap.rb"))
67
+ return "bun" if bun_project?
68
+ return "node" if File.exist?(app_path("package.json"))
69
+
70
+ "vendored"
71
+ end
72
+
73
+ def bun_project?
74
+ File.exist?(app_path("bun.lockb")) || File.exist?(app_path("bun.lock")) || File.exist?(app_path("bunfig.toml"))
75
+ end
76
+
77
+ def install_bun
78
+ ensure_package_json!
79
+
80
+ if options[:skip_install]
81
+ add_package_dependencies([THREE_PACKAGE, STIMULUS_PACKAGE])
82
+ else
83
+ run "bun add #{THREE_PACKAGE} #{STIMULUS_PACKAGE}"
84
+ end
85
+
86
+ ensure_node_stimulus_setup
87
+ copy_controller_template
88
+ end
89
+
90
+ def install_node
91
+ ensure_package_json!
92
+
93
+ if options[:skip_install]
94
+ add_package_dependencies([THREE_PACKAGE, STIMULUS_PACKAGE])
95
+ else
96
+ run "#{node_add_command} #{THREE_PACKAGE} #{STIMULUS_PACKAGE}"
97
+ end
98
+
99
+ ensure_node_stimulus_setup
100
+ copy_controller_template
101
+ end
102
+
103
+ def install_importmap
104
+ importmap_path = app_path("config/importmap.rb")
105
+ raise Thor::Error, "Missing config/importmap.rb for importmap strategy" unless File.exist?(importmap_path)
106
+
107
+ ensure_importmap_stimulus_setup(importmap_path)
108
+ copy_controller_template
109
+ end
110
+
111
+ def install_vendored
112
+ ensure_layout_include_tag
113
+ end
114
+
115
+ def ensure_package_json!
116
+ return if File.exist?(app_path("package.json"))
117
+
118
+ raise Thor::Error,
119
+ "Missing package.json. Run with --strategy=importmap or --strategy=vendored, or initialize JavaScript tooling first."
120
+ end
121
+
122
+ def add_package_dependencies(packages)
123
+ package_json_path = app_path("package.json")
124
+ package_json = JSON.parse(File.read(package_json_path))
125
+ package_json["dependencies"] ||= {}
126
+
127
+ packages.each do |package_name|
128
+ next if package_json["dependencies"].key?(package_name)
129
+
130
+ package_json["dependencies"][package_name] = package_name == THREE_PACKAGE ? "^#{THREE_VERSION}" : "^3.2.2"
131
+ end
132
+
133
+ File.write(package_json_path, "#{JSON.pretty_generate(package_json)}\n")
134
+ end
135
+
136
+ def node_add_command
137
+ return "yarn add" if File.exist?(app_path("yarn.lock"))
138
+ return "pnpm add" if File.exist?(app_path("pnpm-lock.yaml"))
139
+
140
+ "npm install"
141
+ end
142
+
143
+ def ensure_node_stimulus_setup
144
+ ensure_controllers_directory
145
+ ensure_controllers_application
146
+ ensure_node_controllers_index
147
+ ensure_application_import(%(import "./controllers";))
148
+ end
149
+
150
+ def ensure_importmap_stimulus_setup(importmap_path)
151
+ ensure_controllers_directory
152
+ ensure_controllers_application
153
+ ensure_importmap_controllers_index
154
+ ensure_application_import(%(import "controllers";))
155
+
156
+ append_line_unless_present(importmap_path, IMPORTMAP_STIMULUS_PIN)
157
+ append_line_unless_present(importmap_path, IMPORTMAP_STIMULUS_LOADING_PIN)
158
+ append_line_unless_present(importmap_path, IMPORTMAP_CONTROLLERS_PIN)
159
+ append_line_unless_present(importmap_path, IMPORTMAP_THREE_PIN)
160
+ append_line_unless_present(importmap_path, IMPORTMAP_EXR_LOADER_PIN)
161
+ append_line_unless_present(importmap_path, IMPORTMAP_WEBGPU_RENDERER_PIN)
162
+ end
163
+
164
+ def ensure_controllers_directory
165
+ empty_directory app_path("app/javascript/controllers")
166
+ end
167
+
168
+ def ensure_controllers_application
169
+ application_path = app_path("app/javascript/controllers/application.js")
170
+ return if File.exist?(application_path)
171
+
172
+ copy_file "controllers_application.js", application_path
173
+ end
174
+
175
+ def ensure_node_controllers_index
176
+ index_path = app_path("app/javascript/controllers/index.js")
177
+ unless File.exist?(index_path)
178
+ copy_file "controllers_index_for_node.js", index_path
179
+ end
180
+
181
+ unless File.read(index_path).include?("import { application }")
182
+ append_line_unless_present(index_path, %(import { application } from "./application"))
183
+ end
184
+ append_line_unless_present(index_path, %(import ExrViewerController from "./exr_viewer_controller"))
185
+ append_line_unless_present(index_path, %(application.register("exr-viewer", ExrViewerController)))
186
+ end
187
+
188
+ def ensure_importmap_controllers_index
189
+ index_path = app_path("app/javascript/controllers/index.js")
190
+ return if File.exist?(index_path)
191
+
192
+ copy_file "controllers_index_for_importmap.js", index_path
193
+ end
194
+
195
+ def copy_controller_template
196
+ controller_path = app_path("app/javascript/controllers/exr_viewer_controller.js")
197
+ return if File.exist?(controller_path)
198
+
199
+ copy_file "exr_viewer_controller.js", controller_path
200
+ end
201
+
202
+ def ensure_application_import(import_line)
203
+ application_js_path = app_path("app/javascript/application.js")
204
+ unless File.exist?(application_js_path)
205
+ empty_directory app_path("app/javascript")
206
+ File.write(application_js_path, "")
207
+ end
208
+
209
+ append_line_unless_present(application_js_path, import_line)
210
+ end
211
+
212
+ def ensure_layout_include_tag
213
+ layout_path = app_path("app/views/layouts/application.html.erb")
214
+ unless File.exist?(layout_path)
215
+ say_status :warn, "No app/views/layouts/application.html.erb found. Add <%= exr_viewer_include_tag %> to your layout.", :yellow
216
+ return
217
+ end
218
+
219
+ layout = File.read(layout_path)
220
+ return if layout.include?("<%= exr_viewer_include_tag")
221
+
222
+ if layout.include?("</head>")
223
+ updated = layout.sub("</head>", " <%= exr_viewer_include_tag %>\n</head>")
224
+ File.write(layout_path, updated)
225
+ else
226
+ File.write(layout_path, "#{layout}\n<%= exr_viewer_include_tag %>\n")
227
+ end
228
+ end
229
+
230
+ def append_line_unless_present(path, line)
231
+ contents = File.read(path)
232
+ return if contents.include?(line)
233
+
234
+ separator = contents.end_with?("\n") || contents.empty? ? "" : "\n"
235
+ File.write(path, "#{contents}#{separator}#{line}\n")
236
+ end
237
+
238
+ def app_path(relative_path)
239
+ File.expand_path(relative_path, destination_root)
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,7 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+ application.debug = false
5
+ window.Stimulus = application
6
+
7
+ export { application }
@@ -0,0 +1,4 @@
1
+ import { application } from "controllers/application"
2
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
3
+
4
+ eagerLoadControllersFrom("controllers", application)
@@ -0,0 +1 @@
1
+ import { application } from "./application"