distorted 0.5.4 → 0.7.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +661 -0
  3. data/README.md +5 -140
  4. data/bin/console +14 -0
  5. data/bin/distorted +6 -0
  6. data/bin/setup +8 -0
  7. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Less_Perfect_DOS_VGA.png +0 -0
  8. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/More_Perfect_DOS_VGA.png +0 -0
  9. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/img/Perfect_DOS_VGA.png +0 -0
  10. data/font/1252/LICENSE/MoreLessPerfectDOSVGA437/less_more_perfect_dos_vga_437.html +52 -0
  11. data/font/1252/LICENSE/PerfectDOSVGA437/font-comment.php@file=perfect_dos_vga_437.html +5 -0
  12. data/font/1252/LessPerfectDOSVGA.ttf +0 -0
  13. data/font/1252/MorePerfectDOSVGA.ttf +0 -0
  14. data/font/1252/Perfect DOS VGA 437 Win.ttf +0 -0
  15. data/font/437/Perfect DOS VGA 437.ttf +0 -0
  16. data/font/437/dos437.txt +72 -0
  17. data/font/65001/Anonymous Pro B.ttf +0 -0
  18. data/font/65001/Anonymous Pro BI.ttf +0 -0
  19. data/font/65001/Anonymous Pro I.ttf +0 -0
  20. data/font/65001/Anonymous Pro.ttf +0 -0
  21. data/font/65001/LICENSE/AnonymousPro/FONTLOG.txt +45 -0
  22. data/font/65001/LICENSE/AnonymousPro/OFL-FAQ.txt +235 -0
  23. data/font/65001/LICENSE/AnonymousPro/OFL.txt +94 -0
  24. data/font/65001/LICENSE/AnonymousPro/README.txt +55 -0
  25. data/font/850/ProFont-Bold-01/LICENSE +22 -0
  26. data/font/850/ProFont-Bold-01/readme.txt +28 -0
  27. data/font/850/ProFontWindows-Bold.ttf +0 -0
  28. data/font/850/ProFontWindows.ttf +0 -0
  29. data/font/850/Profont/LICENSE +22 -0
  30. data/font/850/Profont/readme.txt +31 -0
  31. data/font/932/LICENSE/README-ttf.txt +213 -0
  32. data/font/932/mona.ttf +0 -0
  33. data/lib/distorted.rb +2 -0
  34. data/lib/distorted/checking_you_out.rb +219 -0
  35. data/lib/distorted/checking_you_out/README +4 -0
  36. data/lib/distorted/checking_you_out/application.yaml +33 -0
  37. data/lib/distorted/checking_you_out/font.yaml +29 -0
  38. data/lib/distorted/checking_you_out/image.yaml +108 -0
  39. data/lib/distorted/click_again.rb +333 -0
  40. data/lib/distorted/element_of_media.rb +2 -0
  41. data/lib/distorted/element_of_media/change.rb +119 -0
  42. data/lib/distorted/element_of_media/compound.rb +120 -0
  43. data/lib/distorted/error_code.rb +51 -0
  44. data/lib/distorted/floor.rb +17 -0
  45. data/lib/distorted/invoker.rb +97 -0
  46. data/lib/distorted/media_molecule.rb +58 -0
  47. data/lib/distorted/media_molecule/font.rb +195 -0
  48. data/lib/distorted/media_molecule/image.rb +33 -0
  49. data/lib/distorted/media_molecule/pdf.rb +44 -0
  50. data/lib/distorted/media_molecule/svg.rb +45 -0
  51. data/lib/distorted/media_molecule/text.rb +203 -0
  52. data/lib/distorted/media_molecule/video.rb +18 -0
  53. data/lib/distorted/modular_technology/gstreamer.rb +174 -0
  54. data/lib/distorted/modular_technology/pango.rb +90 -0
  55. data/lib/distorted/modular_technology/ttfunk.rb +48 -0
  56. data/lib/distorted/modular_technology/vips.rb +17 -0
  57. data/lib/distorted/modular_technology/vips/foreign.rb +489 -0
  58. data/lib/distorted/modular_technology/vips/load.rb +133 -0
  59. data/lib/distorted/modular_technology/vips/save.rb +161 -0
  60. data/lib/distorted/monkey_business/encoding.rb +317 -0
  61. data/lib/distorted/monkey_business/hash.rb +18 -0
  62. data/lib/distorted/monkey_business/set.rb +15 -0
  63. data/lib/distorted/monkey_business/string.rb +6 -0
  64. data/lib/distorted/triple_counter.rb +52 -0
  65. data/lib/distorted/version.rb +22 -0
  66. data/test/distorted_test.rb +11 -0
  67. data/test/test_helper.rb +4 -0
  68. metadata +130 -20
@@ -0,0 +1,18 @@
1
+ require 'set'
2
+ require 'distorted/monkey_business/set'
3
+
4
+ require 'distorted/checking_you_out'
5
+
6
+ require 'distorted/modular_technology/gstreamer'
7
+
8
+
9
+ module Cooltrainer; end
10
+ module Cooltrainer::DistorteD; end
11
+ module Cooltrainer::DistorteD::Molecule; end
12
+ module Cooltrainer::DistorteD::Molecule::Video
13
+
14
+ LOWER_WORLD = CHECKING::YOU::IN('video/mp4').to_hash
15
+
16
+ include Cooltrainer::DistorteD::Technology::GStreamer
17
+
18
+ end # Video
@@ -0,0 +1,174 @@
1
+ require 'set'
2
+
3
+ require 'distorted/checking_you_out'
4
+
5
+ require 'distorted/triple_counter'
6
+ GST_MINIMUM_VER = TripleCounter.new(1, 18, 0)
7
+
8
+ begin
9
+ require 'gst'
10
+ GST_AVAILABLE_VER = TripleCounter.new(*(Gst.version))
11
+ unless GST_AVAILABLE_VER >= GST_MINIMUM_VER
12
+ raise LoadError.new(
13
+ "DistorteD needs GStreamer #{GST_MINIMUM_VER}, but the available version is '#{Gst.version_string}'"
14
+ )
15
+ end
16
+ rescue LoadError => le
17
+ raise unless le.message =~ /libgst/
18
+
19
+ # Multiple OS help
20
+ help = <<~INSTALL
21
+
22
+ Please install the GStreamer library for your system, version #{GST_MINIMUM_VER} or later.
23
+ INSTALL
24
+
25
+ # Re-raise with install message
26
+ raise $!, "#{help}\n#{$!}", $!.backtrace
27
+ end
28
+
29
+
30
+ module Cooltrainer; end
31
+ module Cooltrainer::DistorteD; end
32
+ module Cooltrainer::DistorteD::Technology; end
33
+ module Cooltrainer::DistorteD::Technology::GStreamer
34
+
35
+ OUTER_LIMITS = CHECKING::YOU::IN(Set[
36
+ 'application/dash+xml',
37
+ 'application/vnd.apple.mpegurl',
38
+ 'video/mp4',
39
+ ])
40
+
41
+
42
+ def write_video_mp4(dest_root, change)
43
+ copy_file(change.paths(dest_root).first)
44
+ end
45
+
46
+ def write_application_dash_xml(dest, *a, **k)
47
+ begin
48
+ segment_dest = File.join(File.dirname(dest), "#{basename}.dash", '/')
49
+ #segment_dest = segment_dest.sub("#{@base}/", '')
50
+ FileUtils.mkdir_p(segment_dest)
51
+ Jekyll.logger.debug(@tag_name, "Re-muxing #{path} to #{segment_dest}")
52
+
53
+ # https://gstreamer.freedesktop.org/documentation/tools/gst-launch.html?gi-language=c#pipeline-description
54
+ # TODO: Convert this from parse_launch() pipeline notation to Element objects
55
+ # TODO: Get source video duration/resolution/etc and use it to compute a
56
+ # value for `target-duration`.
57
+ # TODO: Also support urldecodebin for remote media.
58
+ pipeline, error = Gst.parse_launch("dashsink name=mux filesrc name=src ! decodebin name=demux ! audioconvert ! avenc_aac ! mux.audio_0 demux. ! videoconvert ! x264enc ! mux.video_0")
59
+
60
+ if pipeline.nil?
61
+ Jekyll.logger.error(@tag_name, "Parse error: #{error.message}")
62
+ return false
63
+ end
64
+
65
+ filesrc = pipeline.get_by_name('src')
66
+ filesrc.location = path
67
+
68
+ mux = pipeline.get_by_name('mux')
69
+ mux.mpd_filename = File.basename(dest)
70
+ mux.target_duration = 3
71
+ #mux.segment_tpl_path = "#{segment_dest}/#{basename}%05d.mp4"
72
+ mux.mpd_root_path = segment_dest
73
+ Jekyll.logger.warn('MPD ROOT PATH', mux.get_property('mpd-root-path'))
74
+
75
+ # typedef enum
76
+ # {
77
+ # GST_DASH_SINK_MUXER_TS = 0,
78
+ # GST_DASH_SINK_MUXER_MP4 = 1,
79
+ # } GstDashSinkMuxerType;
80
+ mux.muxer = 1
81
+
82
+ pipeline.play
83
+
84
+ # Play until End Of Stream
85
+ event_loop(pipeline)
86
+
87
+ pipeline.stop
88
+
89
+ rescue Gst::ParseError::NoSuchElement
90
+ raise
91
+ end
92
+ end
93
+
94
+ def write_application_vnd_apple_mpegurl(dest, *a, **k)
95
+ begin
96
+ orig_dest = dest
97
+ orig_path = path
98
+
99
+ FileUtils.mkdir_p(File.dirname(orig_dest))
100
+
101
+ hls_dest = File.join(File.dirname(orig_dest), basename + '.hls')
102
+ FileUtils.mkdir_p(hls_dest)
103
+ Jekyll.logger.debug(@tag_name, "Re-muxing #{orig_path} to #{hls_dest}.")
104
+
105
+ #FileUtils.rm(orig_dest) if File.exist?(orig_dest)
106
+ if not File.file?(orig_dest)
107
+ FileUtils.cp(orig_path, orig_dest)
108
+ end
109
+
110
+ # https://gstreamer.freedesktop.org/documentation/tools/gst-launch.html?gi-language=c#pipeline-description
111
+ # TODO: Convert this from parse_launch() pipeline notation to Element objects
112
+ # TODO: Get source video duration/resolution/etc and use it to compute a
113
+ # value for `target-duration`.
114
+ # TODO: Also support urldecodebin for remote media.
115
+ pipeline, error = Gst.parse_launch("filesrc name=src ! decodebin name=demux ! videoconvert ! x264enc ! queue2 ! h264parse ! queue2 ! mux.video hlssink2 name=mux max-files=0 playlist-length=0 target-duration=2 demux. ! audioconvert ! faac ! queue2 ! mux.audio")
116
+
117
+ if pipeline.nil?
118
+ Jekyll.logger.error(@tag_name, "Parse error: #{error.message}")
119
+ return false
120
+ end
121
+
122
+ filesrc = pipeline.get_by_name('src')
123
+ filesrc.location = orig_path
124
+
125
+ hls_playlist = "#{hls_dest}/#{basename}.m3u8"
126
+ hls = pipeline.get_by_name('mux')
127
+ hls.location = "#{hls_dest}/#{basename}%05d.ts"
128
+ hls.playlist_location = hls_playlist
129
+
130
+ # TODO: config option for absolute vs relative segment URIs in the playlist.
131
+ #hls.playlist_root = @url
132
+
133
+ # TODO: dashsink support once there is a stable GStreamer release including it:
134
+ # https://gitlab.freedesktop.org/gstreamer/gst-plugins-bad/merge_requests/704
135
+
136
+ pipeline.play
137
+
138
+ # Play until End Of Stream
139
+ event_loop(pipeline)
140
+
141
+ pipeline.stop
142
+
143
+ # HACK HACK HACK: Replace X-ALLOW-CACHE line in playlist with YES.
144
+ # This property does not seem to be exposed to the outside of hlssink:
145
+ # https://cgit.freedesktop.org/gstreamer/gst-plugins-bad/tree/ext/hls/gsthlssink.c
146
+ text = File.read(hls_playlist)
147
+ File.write(hls_playlist, text.gsub(/^#EXT-X-ALLOW-CACHE:NO$/, '#EXT-X-ALLOW-CACHE:YES'))
148
+ rescue Gst::ParseError::NoSuchElement
149
+ raise
150
+ end
151
+ end
152
+
153
+ def event_loop(pipeline)
154
+ running = true
155
+ bus = pipeline.bus
156
+
157
+ while running
158
+ message = bus.poll(Gst::MessageType::ANY, -1)
159
+
160
+ case message.type
161
+ when Gst::MessageType::EOS
162
+ running = false
163
+ when Gst::MessageType::WARNING
164
+ warning, _debug = message.parse_warning
165
+ Jekyll.logger.warning(@tag_name, warning)
166
+ when Gst::MessageType::ERROR
167
+ error, _debug = message.parse_error
168
+ Jekyll.logger.error(@tag_name, error)
169
+ running = false
170
+ end
171
+ end
172
+ end
173
+
174
+ end
@@ -0,0 +1,90 @@
1
+ module Cooltrainer
2
+ module DistorteD
3
+ module Technology
4
+ module Pango
5
+
6
+ # Escape text as necessary for Pango Markup, which is what Vips::Image.text()
7
+ # expects for its argv. This code should be in GLib but is unimplemented in Ruby's:
8
+ #
9
+ # https://ruby-gnome2.osdn.jp/hiki.cgi?Gtk%3A%3ALabel#Markup+%28styled+text%29
10
+ # "The markup passed to Gtk::Label#set_markup() must be valid; for example,
11
+ # literal </>/& characters must be escaped as &lt;, &gt;, and &amp;.
12
+ # If you pass text obtained from the user, file, or a network to
13
+ # Gtk::Label#set_markup(), you'll want to escape it
14
+ # with GLib::Markup.escape_text?(not implemented yet)."
15
+ #
16
+ # Base my own implementation on the original C version found in gmarkup:
17
+ # https://gitlab.gnome.org/GNOME/glib/-/blob/master/glib/gmarkup.c
18
+ def g_markup_escape_text(text)
19
+ text.map{ |c| g_markup_escape_char(c) }
20
+ end
21
+
22
+ # Returns a Pango-escaped Carriage Return.
23
+ # Use this for linebreaking Pango Markup output.
24
+ def cr
25
+ g_markup_escape_char(0x0D)
26
+ end
27
+
28
+ # Returns a Pango-escapped Line Feed.
29
+ # This isn't used/needed for anything with Pango
30
+ # but it felt weird to include CR and not LF lmao
31
+ def lf
32
+ g_markup_escape_char(0x0A)
33
+ end
34
+
35
+ # Returns a Pango'escaped CRLF pair.
36
+ # Also not needed for anything.
37
+ def crlf
38
+ cr << lf
39
+ end
40
+
41
+ # "Modified UTF-8" uses a normally-illegal byte sequence
42
+ # to encode the NULL character so 0x00 can exclusively
43
+ # be a string terminator.
44
+ def overlong_null
45
+ [0xC0, 0x80].pack('C*').force_encoding('UTF-8')
46
+ end
47
+
48
+ # The char-by-char actual function used by g_markup_escape_text
49
+ def g_markup_escape_char(c)
50
+ # I think a fully-working version of this function would
51
+ # be as simple as `sprintf('&#x%x;', c.ord)` ALL THE THINGS,
52
+ # but I want to copy the structure of the C implementation
53
+ # as closely as possible, which means using the named escape
54
+ # sequences for common characters and separating the
55
+ # Latin-1 Supplement range from the other
56
+ # the Unicode control characters (> 0x7f) even though three's no
57
+ # need to in Ruby.
58
+ case c.ord
59
+ when '&'.ord
60
+ '&amp;'
61
+ when '<'.ord
62
+ '&lt;'
63
+ when '>'.ord
64
+ '&gt;'
65
+ when '\''.ord
66
+ '&apos;'
67
+ when '"'.ord
68
+ '&quot;'
69
+ when 0x1..0x8, 0xb..0xc, 0xe..0x1f, 0x7f
70
+ sprintf('&#x%x;', c.ord)
71
+ when 0x80..0x84, 0x86..0x9f
72
+ # The original C implementation separates this range
73
+ # from the above range due to its need to handle the
74
+ # UTF control character bytes with gunichar:
75
+ # https://wiki.tcl-lang.org/page/UTF%2D8+bit+by+bit
76
+ # https://www.fileformat.info/info/unicode/utf8.htm
77
+ # Ruby has already done this for us here :)
78
+ sprintf('&#x%x;', c.ord)
79
+ when 0x0 # what's this…?
80
+ # Avoid a `ArgumentError: string contains null byte`
81
+ # by not printing one :)
82
+ else
83
+ c
84
+ end
85
+ end
86
+
87
+ end # Pango
88
+ end # Tech
89
+ end # DistorteD
90
+ end # Cooltrainer
@@ -0,0 +1,48 @@
1
+ require 'ttfunk'
2
+
3
+ module Cooltrainer; end
4
+ module Cooltrainer::DistorteD; end
5
+ module Cooltrainer::DistorteD::Technology; end
6
+ module Cooltrainer::DistorteD::Technology::TTFunk
7
+
8
+ def to_ttfunk
9
+ # TODO: Check that src exists, because TTFunk won't and will just
10
+ # give us an unusable object instead.
11
+ @ttfunk_file ||= TTFunk::File.open(font_path)
12
+ end
13
+
14
+ # Returns a boolean for whether or not this font is monospaced.
15
+ # true == monospace
16
+ # false == proportional
17
+ def font_spacing
18
+ # Monospace fonts will (read: should) have the same width
19
+ # for every glyph, so we can tell a monospace font by
20
+ # checking if a deduplicated widths table has size == 1:
21
+ # irb(main)> font.horizontal_metrics.widths.count
22
+ # => 256
23
+ # irb(main)> font.horizontal_metrics.widths.uniq.compact.length
24
+ # => 1
25
+ to_ttfunk.horizontal_metrics.widths.uniq.compact.length == 1 ? :monospace : :proportional
26
+ end
27
+
28
+ # Returns the Family and Subfamily as one string suitable for libvips
29
+ def font_name
30
+ "#{to_ttfunk.name.font_family.first.encode('UTF-8')} #{to_ttfunk.name.font_subfamily.first.encode('UTF-8')}"
31
+ end
32
+
33
+ # Returns the Pango-Markup-encoded UTF-8 String version + revision of the font
34
+ def font_version
35
+ g_markup_escape_text(to_ttfunk.name&.version&.first&.encode('UTF-8').to_s)
36
+ end
37
+
38
+ # Returns the Pango-Markup-encoded UTF-8 String font file description
39
+ def font_description
40
+ g_markup_escape_text(to_ttfunk.name&.description&.first&.encode('UTF-8').to_s)
41
+ end
42
+
43
+ # Returns the Pango-Markup-encoded UTF-8 String copyright information of the font
44
+ def font_copyright
45
+ g_markup_escape_text(to_ttfunk.name&.copyright&.first&.encode('UTF-8').to_s)
46
+ end
47
+
48
+ end
@@ -0,0 +1,17 @@
1
+ require 'set'
2
+
3
+ require 'distorted/checking_you_out'
4
+
5
+ require 'distorted/modular_technology/vips/load'
6
+ require 'distorted/modular_technology/vips/save'
7
+
8
+
9
+ module Cooltrainer; end
10
+ module Cooltrainer::DistorteD; end
11
+ module Cooltrainer::DistorteD::Technology; end
12
+ module Cooltrainer::DistorteD::Technology::Vips
13
+
14
+ include Cooltrainer::DistorteD::Technology::Vips::Save
15
+ include Cooltrainer::DistorteD::Technology::Vips::Load
16
+
17
+ end
@@ -0,0 +1,489 @@
1
+ require 'vips'
2
+
3
+ require 'distorted/checking_you_out'
4
+ require 'distorted/element_of_media'
5
+
6
+ # Based on https://github.com/libvips/ruby-vips/issues/186#issuecomment-433691412
7
+ module Vips
8
+ attach_function :vips_class_find, [:string, :string], :pointer
9
+ attach_function :vips_object_summary_class, [:pointer, :pointer], :void
10
+
11
+ class BufStruct < FFI::Struct
12
+ layout :base, :pointer,
13
+ :mx, :int,
14
+ :i, :int,
15
+ :full, :bool,
16
+ :lasti, :int,
17
+ :dynamic, :bool
18
+ end
19
+
20
+ end
21
+
22
+ module GObject
23
+ # Fundamental types not already defined in ruby-vips' `lib/vips.rb`
24
+ GBOXED_TYPE = g_type_from_name('GBoxed')
25
+ end
26
+
27
+
28
+ module Cooltrainer; end
29
+ module Cooltrainer::DistorteD; end
30
+ module Cooltrainer::DistorteD::Technology; end
31
+ module Cooltrainer::DistorteD::Technology::Vips
32
+
33
+
34
+ # 🄵🄸🄽🄳 🅃🄷🄴 🄲🄾🄼🄿🅄🅃🄴🅁 🅁🄾🄾🄼
35
+ # 🄵🄸🄽🄳 🅃🄷🄴 🄲🄾🄼🄿🅄🅃🄴🅁 🅁🄾🄾🄼
36
+ # 🄵🄸🄽🄳 🅃🄷🄴 🄲🄾🄼🄿🅄🅃🄴🅁 🅁🄾🄾🄼
37
+ Vips::vips_vector_set_enabled(1)
38
+
39
+
40
+ # All of the actual Loader/Saver classes we need to interact with
41
+ # will be tree children of one of these top-level class categories:
42
+ TOP_LEVEL_LOADER = :VipsForeignLoad
43
+ TOP_LEVEL_SAVER = :VipsForeignSave
44
+
45
+
46
+ # This has got to be built in to Ruby-GLib somewhere, right?
47
+ # Remove this if an FFI method is possible to get this mapping.
48
+ G_TYPE_VALUES = {
49
+ :gboolean => [false, true],
50
+ :gchararray => String,
51
+ :gdouble => Float,
52
+ :gint => Integer,
53
+ }
54
+
55
+ # Aliases we want to support for consistency and accessibility.
56
+ VIPS_ALIASES = {
57
+ :Q => Set[:Q, :quality],
58
+ :colours => Set[:colours, :colors],
59
+ :centre => Set[:centre, :center], # America; FUCK YEAH!
60
+ }
61
+
62
+ # GEnum valid values are detectable, but I don't know how to do the same
63
+ # for the numeric parameters. Specify them here manually for now.
64
+ VIPS_VALID = {
65
+ :"page-height" => (0..Vips::MAX_COORD),
66
+ :"quant-table" => (0..8),
67
+ :Q => (0..100),
68
+ :colours => (2..256),
69
+ :dither => (0.0..1.0),
70
+ :compression => (0..9),
71
+ :"alpha-q" => (0..100),
72
+ :"reduction-effort" => (0..6),
73
+ :kmin => (0..0x7FFFFFFF), # https://en.wikipedia.org/wiki/2,147,483,647
74
+ :kmax => (0..0x7FFFFFFF),
75
+ :"tile-width" => (0..0x8000), # 32768
76
+ :"tile-height" => (0..0x8000),
77
+ :xres => (0.001..1e+06),
78
+ :yres => (0.001..1e+06),
79
+ }
80
+
81
+ # Same with default values for numeric parameters.
82
+ VIPS_DEFAULT = {
83
+ :Q => 75,
84
+ :colours => 256,
85
+ :compression => 6,
86
+ :"alpha-q" => 100,
87
+ :"reduction-effort" => 4,
88
+ :kmin => 0x7FFFFFFF - 1,
89
+ :kmax => 0x7FFFFFFF,
90
+ :"tile-width" => 128,
91
+ :"tile-height" => 128,
92
+ :xres => 1,
93
+ :yres => 1,
94
+ }
95
+
96
+
97
+ # Store FFI results where possible to minimize memory churn 'n' general fragility.
98
+ @@vips_foreign_types = Hash[]
99
+ @@vips_foreign_suffixes = Hash[]
100
+ @@vips_foreign_options = Hash[]
101
+
102
+
103
+ # Returns a String libvips Loader class name most appropriate for the given filename suffix.
104
+ # This is a workaround for the fact that the built-in Vips::vips_foreign_find_load
105
+ # requires access of a real image file, and we are here talking only of hypothetical ones.
106
+ # See this method's call site in 'vips/load' for more detailed comments on this.
107
+ #
108
+ # irb(main):234:0> Vips::vips_filename_get_filename('fart.jpg')
109
+ # => #<FFI::Pointer address=0x0000561efe3d08e0>
110
+ # irb(main):235:0> Vips::p2str(Vips::vips_filename_get_filename('fart.jpg'))
111
+ # => "fart.jpg"
112
+ # irb(main):236:0> File.extname(Vips::p2str(Vips::vips_filename_get_filename('fart.jpg')))
113
+ # => ".jpg"
114
+ # irb(main):237:0> Vips::vips_foreign_find_save(File.extname(Vips::p2str(Vips::vips_filename_get_filename('fart.jpg'))))
115
+ # => "VipsForeignSaveJpegFile"
116
+ #
117
+ # Here are the available Operations I have on my laptop with libvips 8.8:
118
+ # [okeeblow@emi#okeeblow] vips -l|grep VipsForeign|grep File
119
+ # VipsForeignLoadPdfFile (pdfload), load PDF with libpoppler (.pdf), priority=0, is_a, get_flags, get_flags_filename, header, load
120
+ # VipsForeignLoadSvgFile (svgload), load SVG with rsvg (.svg, .svgz, .svg.gz), priority=0, is_a, get_flags, get_flags_filename, header, load
121
+ # VipsForeignLoadGifFile (gifload), load GIF with giflib (.gif), priority=0, is_a, get_flags, get_flags_filename, header, load
122
+ # VipsForeignLoadJpegFile (jpegload), load jpeg from file (.jpg, .jpeg, .jpe), priority=50, is_a, get_flags, header, load
123
+ # VipsForeignLoadWebpFile (webpload), load webp from file (.webp), priority=0, is_a, get_flags, get_flags_filename, header, load
124
+ # VipsForeignLoadTiffFile (tiffload), load tiff from file (.tif, .tiff), priority=50, is_a, get_flags, get_flags_filename, header, load
125
+ # VipsForeignLoadMagickFile (magickload), load file with ImageMagick, priority=-100, is_a, get_flags, get_flags_filename, header
126
+ # VipsForeignSaveRadFile (radsave), save image to Radiance file (.hdr), priority=0, rgb
127
+ # VipsForeignSaveDzFile (dzsave), save image to deepzoom file (.dz), priority=0, any
128
+ # VipsForeignSavePngFile (pngsave), save image to png file (.png), priority=0, rgba
129
+ # VipsForeignSaveJpegFile (jpegsave), save image to jpeg file (.jpg, .jpeg, .jpe), priority=0, rgb-cmyk
130
+ # VipsForeignSaveWebpFile (webpsave), save image to webp file (.webp), priority=0, rgba-only
131
+ # VipsForeignSaveTiffFile (tiffsave), save image to tiff file (.tif, .tiff), priority=0, any
132
+ # VipsForeignSaveMagickFile (magicksave), save file with ImageMagick (.gif, .bmp), priority=-100, any
133
+ #
134
+ # You can notice differences such as a `dzsave` and `radsave` but no `dzload` or `radload`.
135
+ # This is why we can't assume that HAS_SAVER == HAS_LOADER across the board.
136
+ # Other differences are invisible here, like different formats supported silently by `magickload`,
137
+ # so that Operation is the catch-all fallback if we don't have any better idea.
138
+ #
139
+ # We can try taking a MIME::Type's `sub_type`, capitalizing it, and trying to find a Loader Operation by that name.
140
+ # irb(main):254:0> MIME::Types::type_for('.heif').last.sub_type.capitalize
141
+ # => "Heif"
142
+ # irb(main):255:0> MIME::Types::type_for('.jpg').last.sub_type.capitalize
143
+ # => "Jpeg"
144
+ #
145
+ ## NOTE: I'm writing this on an old install that lacks HEIF support in its libvips 8.8 installation,
146
+ # so this failure to find 'VipsForeignLoadHeifFile' is expected and correct for me!
147
+ # It probably won't fail for you in the future, but I want to make sure to include
148
+ # some example of varying library capability and not assume capabilities based on libvips version:
149
+ #
150
+ # irb(main):257:0> GObject::g_type_from_name("VipsForeignLoad#{MIME::Types::type_for('.jpg').last.sub_type.capitalize}File")
151
+ # => 94691057380176
152
+ # irb(main):258:0> GObject::g_type_from_name("VipsForeignLoad#{MIME::Types::type_for('.heif').last.sub_type.capitalize}File")
153
+ # => 0
154
+ def self.vips_foreign_find_load_suffix(filename)
155
+ suffix = File.extname(Vips::p2str(Vips::vips_filename_get_filename('fart.jpg')))
156
+ guessed_loader = "VipsForeignLoad#{CHECKING::YOU::OUT(suffix).first.sub_type.capitalize}File"
157
+ return self::vips_foreign_valid_operation?(guessed_loader) ? guessed_loader : 'VipsForeignLoadMagickFile'.freeze
158
+ end
159
+
160
+
161
+ # Returns a Set of MIME::Types based on the "supported suffix" lists generated
162
+ # by libvips and our other functions here in this Module.
163
+ def self.vips_get_types(basename)
164
+ @@vips_foreign_types[basename.to_sym] ||= self::vips_get_suffixes(basename).each_with_object(Set[]) { |suffix, types|
165
+ types.merge(CHECKING::YOU::OUT(suffix))
166
+ }
167
+ end
168
+
169
+
170
+ # Returns a Set of String filename suffixes supported by a tree of libvips loader/saver classes.
171
+ #
172
+ # The Array returned from self::vips_get_nickname_suffixes will be overloaded
173
+ # with all duplicate suffix possibilities for each Type according to libvips.
174
+ # e.g.
175
+ # This is unrelated to MIME::Type#preferred_extension!!
176
+ def self.vips_get_suffixes(basename)
177
+ @@vips_foreign_suffixes[basename.to_sym] ||= self::vips_get_suffixes_per_nickname(basename).values.each_with_object(Set[]) {|s,n| n.merge(s)}
178
+ end
179
+
180
+
181
+ # Returns a Hash[alias] of Compound attributes supported by a given libvips Loader/Saver class.
182
+ def self.vips_get_options(nickname)
183
+ return Hash if nickname.nil?
184
+ @@vips_foreign_options[nickname.to_sym] ||= self::vips_get_nickname_options(nickname)
185
+ end
186
+
187
+
188
+ protected
189
+
190
+
191
+ # Returns a Set of local MIME::Types supported by the given class and any of its children.
192
+ def self.vips_get_types_per_nickname(basename)
193
+ self::vips_get_suffixes_per_nickname(basename).transform_values{|s| CHECKING::YOU::OUT(s)}
194
+ end
195
+
196
+ # Returns a Hash[Type] of Set[String] class nicknames supporting that Type.
197
+ def self.vips_get_nicknames_per_type(basename)
198
+ self::vips_get_nickname_types(basename).each_with_object(Hash.new { |h,k| h[k] = Set[] }) { |(nickname,type_set), memo|
199
+ type_set.each{ |t|
200
+ memo[t] << nickname
201
+ }
202
+ }
203
+ end
204
+
205
+ # Returns a Hash[String] of Set[String]s containing the
206
+ # supported MediaType filename suffixes for all child classes of
207
+ # either VipsForeignSave or VipsForeignLoad.
208
+ #
209
+ # This is very similar to the built-in Vips::get_suffixes except
210
+ # also allows us to directly inspect Loaders — including Magick!
211
+ #
212
+ # Previously we had to take the Saver suffixes and just assume each had a matching Loader.
213
+ # This was very limiting MediaType support since OpenEXR/OpenSlide/Magick-supported
214
+ # Loader types would not have a Saver suffix and would have no way to be discovered!
215
+ # This also works around Loader type support bugs, e.g. the Magick-based BMP (MS Bitmap)
216
+ # Loader was missing prior to libvips version 8.9.1.,
217
+ # so we can stop checking versions and inserting manual workarounds for those corner cases!
218
+ #
219
+ # The FFI buffer reads will leave us with an overloaded Array containing
220
+ # duplicate suffixes for every supported suffix variation of a given type,
221
+ # e.g. ['.jpg', '.jpe', '.jpeg', '.png], '.gif', '.tif', '.tiff' … ]
222
+ def self.vips_get_suffixes_per_nickname(basename)
223
+ self::vips_get_child_class_nicknames(basename).each_with_object(Hash.new) { |nickname, nickname_suffixes|
224
+ # "Search below basename, return the first class whose name or nickname matches."
225
+ # VipsForeign is a basename for savers and loaders alike.
226
+ foreign_class = Vips::vips_class_find('VipsForeign'.freeze, nickname)
227
+ next if foreign_class.null?
228
+
229
+ buf_struct = Vips::BufStruct.new
230
+ buf_struct_string = FFI::MemoryPointer.new(:char, 2048)
231
+ buf_struct[:base] = buf_struct_string
232
+ buf_struct[:mx] = 2048
233
+
234
+ # Load the human-readable class summary into a given buffer.
235
+ Vips::vips_object_summary_class(foreign_class, buf_struct.pointer)
236
+
237
+ class_summary = buf_struct_string.read_string
238
+
239
+ suffixes = class_summary.scan(/\.\w+\.?\w+/)
240
+ nickname_suffixes.update({nickname => suffixes.to_set}) unless suffixes.empty?
241
+ }
242
+ end
243
+
244
+ # Returns a Set of String class names for libvips' Loaders/Savers.
245
+ def self.vips_get_child_class_nicknames(basename)
246
+ nicknames = Set[]
247
+ generate_class = Proc.new{ |gtype|
248
+ nickname = Vips::nickname_find(gtype)
249
+ nicknames << nickname if nickname
250
+
251
+ # https://libvips.github.io/libvips/API/current/VipsObject.html#vips-type-map
252
+ # "Map over a type's children. Stop when fn returns non-nil and return that value."
253
+ Vips::vips_type_map(gtype, generate_class, nil)
254
+ }
255
+ generate_class.call(GObject::g_type_from_name(basename))
256
+ nicknames
257
+ end
258
+
259
+ # Returns a Hash[alias] of attribute Compounds for every optional attribute of a libvips Loader/Saver class.
260
+ #
261
+ # The discarded 'required' attributes are things like filenames that we will handle ourselves in DD.
262
+ # irb> Vips::Introspect.get('jpegload').required_input
263
+ # => [{:arg_name=>"filename", :flags=>19, :gtype=>64}]
264
+ # irb> Vips::Introspect.new('jpegload').required_output
265
+ # => [{:arg_name=>"out", :flags=>35, :gtype=>94062772794288}]
266
+ #
267
+ ## Example using :argument_map:
268
+ # irb> Vips::Operation.new('gifload').argument_map{|a,b,c| p "#{a[:name]} — #{a[:value_type]} — #{GObject::g_type_name(a[:value_type])}"}
269
+ # "filename — 64 — gchararray"
270
+ # "nickname — 64 — gchararray"
271
+ # "out — 94691057294304 — VipsImage"
272
+ # "description — 64 — gchararray"
273
+ # "page — 24 — gint"
274
+ # "n — 24 — gint"
275
+ # "flags — 94691059531296 — VipsForeignFlags"
276
+ # "memory — 20 — gboolean"
277
+ # "access — 94691057417952 — VipsAccess"
278
+ # "sequential — 20 — gboolean"
279
+ # "fail — 20 — gboolean"
280
+ # "disc — 20 — gboolean"
281
+ #
282
+ ## Descriptions are obtained by passing the complete pspec to g_param_get_blurb:
283
+ # Example:
284
+ # irb> Vips::Operation.new('openexrload').argument_map{|a,b,c| p GObject::g_param_spec_get_blurb(a)}
285
+ # "Filename to load from"
286
+ # "Class nickname"
287
+ # "Output image"
288
+ # "Class description"
289
+ # "Flags for this file"
290
+ # "Force open via memory"
291
+ # "Required access pattern for this file"
292
+ # "Sequential read only"
293
+ # "Fail on first error"
294
+ # "Open to disc"
295
+ def self.vips_get_nickname_options(nickname)
296
+ options = Hash[]
297
+ Vips::Operation.new(nickname).argument_map{ |param_spec, argument_class, _argument_instance|
298
+ flags = argument_class[:flags]
299
+ if (flags & Vips::ARGUMENT_INPUT) != 0 # We only want "input" arguments
300
+ # …and we also only want optional non-deprecated arguments.
301
+ if (flags & Vips::ARGUMENT_REQUIRED) == 0 && (flags & Vips::ARGUMENT_DEPRECATED) == 0
302
+ # ParameterSpec name will be a String e.g. 'Q' or 'interlace' or 'page-height'
303
+ element = param_spec[:name].to_sym
304
+
305
+ # `magicksave` takes an argument `format` to choose one of its many supported types,
306
+ # but that selection in DistorteD-land is via our MIME::Types, so this option should be dropped.
307
+ # https://github.com/libvips/libvips/blob/4de9b56725862edf872ae503a3dfb4cf05da9e77/libvips/foreign/magicksave.c#L455~L460
308
+ next if element == :format
309
+
310
+ # GObject::g_type_name will return `nil` for an invalid :value_type,
311
+ # but these are coming straight from libvips so we know they're fine.
312
+ gtype_name = GObject::g_type_name(param_spec[:value_type]).to_sym
313
+
314
+ # Support aliasing options like 'Q' into 'quality' for consistency
315
+ # and 'colours' into 'colors' for accessibility.
316
+ isotopes = VIPS_ALIASES.dig(element) || Set[element]
317
+
318
+ # Keyword arguments to splat into our Compound
319
+ attributes = {
320
+ # Some libvips drivers seem to have mixed-leading-case options,
321
+ # like ppmsave and webp save for example:
322
+ # https://github.com/libvips/libvips/blob/4de9b56725862edf872ae503a3dfb4cf05da9e77/libvips/foreign/ppmsave.c#L396~L415
323
+ # https://github.com/libvips/libvips/blob/4de9b56725862edf872ae503a3dfb4cf05da9e77/libvips/foreign/webpsave.c#L152
324
+ # TODO: Inventory all of these and submit an upstream patch to capitaqlize them consistently.
325
+ # Until them (and for old versions), fix up the first letter manually.
326
+ # Avoid using just `blurb.capitalize` as that will lowercase everything after
327
+ # the first character, which is definitely worse than what I'm trying to fix lol
328
+ :blurb => GObject::g_param_spec_get_blurb(param_spec).tap{|blurb| blurb[0] = blurb[0].capitalize},
329
+ :default => self::vips_get_option_default(param_spec[:value_type]),
330
+ }
331
+ if GObject::g_type_fundamental(param_spec[:value_type]) == GObject::GENUM_TYPE
332
+ attributes[:valid] = self::vips_get_enum_values(param_spec[:value_type])
333
+ elsif VIPS_VALID.has_key?(element)
334
+ attributes[:valid] = VIPS_VALID[element]
335
+ elsif G_TYPE_VALUES.has_key?(gtype_name)
336
+ attributes[:valid] = G_TYPE_VALUES[gtype_name]
337
+ end
338
+
339
+ # Add the Compound for every alias
340
+ compound = Cooltrainer::Compound.new(isotopes, **attributes)
341
+ isotopes.each{ |isotope|
342
+ options.store(isotope, compound)
343
+ }
344
+ end
345
+ end
346
+ }
347
+
348
+ # This isn't really a 'Saver' Option — rather an argument to a separate
349
+ # :smartcrop or :thumbnail VIPS method we can call, but I want to offer
350
+ # this option on every Type and use it to control the method we call
351
+ # to write the image.
352
+ options.store(:crop, Cooltrainer::Compound.new(:crop,
353
+ blurb: 'Visual cropping method',
354
+ valid: self::vips_get_enum_values('VipsInteresting'.freeze),
355
+ default: self::vips_get_option_default('VipsInteresting'.freeze),
356
+ )) if nickname.include?('Save'.freeze) # Only for savers!!
357
+
358
+ # All done :)
359
+ options
360
+ end
361
+
362
+
363
+ # Returns the default value for any ruby-vips GObject::GValue
364
+ # based on its fundamental GType.
365
+ def self.vips_get_option_default(gtype)
366
+ gtype_id = gtype.is_a?(String) ? GObject::g_type_from_name(gtype) : gtype
367
+ # The `enum` method will actually work for several of these types,
368
+ # e.g. returns `false` for GBool, but let's skip it to avoid the whole,
369
+ # like, FFI/allocation thing.
370
+ case GObject::g_type_fundamental(gtype_id)
371
+ when GObject::GENUM_TYPE
372
+ return self.vips_get_enum_default(gtype_id)
373
+ when GObject::GBOOL_TYPE
374
+ return false
375
+ when GObject::GDOUBLE_TYPE
376
+ return 0.0
377
+ when GObject::GINT_TYPE
378
+ return 0
379
+ when GObject::GUINT64_TYPE
380
+ return 0
381
+ when GObject::GBOXED_TYPE
382
+ return self.vips_get_boxed_default(gtype_id)
383
+ else
384
+ return nil
385
+ end
386
+ end
387
+
388
+ # Returns the default for a GEnum derivative by allocating, initializing,
389
+ # and getting the contents of a GValue.
390
+ #
391
+ ## Example:
392
+ # irb> gvp = GObject::GValue.alloc
393
+ # irb> gvp
394
+ # => #<GObject::GValue:0x00005603ba9d4c70>
395
+ # irb> gvp.init(GObject::g_type_from_name('VipsAccess'))
396
+ # => nil
397
+ # irb> GObject::g_type_from_name 'VipsAccess'
398
+ # => 94574011156416
399
+ # irb> gvp.get
400
+ # => :random
401
+ def self.vips_get_enum_default(gtype)
402
+ begin
403
+ gtype_id = gtype.is_a?(String) ? GObject::g_type_from_name(gtype) : gtype
404
+ # Deallocation is automatic when `gvp` goes out of scope.
405
+ gvp = GObject::GValue.alloc
406
+ gvp.init(gtype)
407
+ out = gvp.get
408
+ gvp.unset
409
+ return out
410
+ rescue FFI::NullPointerError => e
411
+ # This is happening for VipsArrayDouble gtype 94691056795136
412
+ # end I don't feel like debugging it rn lololol
413
+ nil
414
+ end
415
+ end
416
+
417
+
418
+ # Returns a Set[Symbol] of supported enum values for a given g_type
419
+ def self.vips_get_enum_values(gtype)
420
+ begin
421
+ gtype_id = gtype.is_a?(String) ? GObject::g_type_from_name(gtype) : gtype
422
+
423
+ # HACK HACK HACK:
424
+ # There *has* to be a better/native way to get this, but for now I'm just going to
425
+ # parse them out of the error message you can access after trying an obviously-wrong value.
426
+ #
427
+ # irb> Vips::vips_error_clear
428
+ # => nil
429
+ # irb> GObject::g_type_from_name 'VipsForeignTiffCompression'
430
+ # => 94691059614768
431
+ # irb> Vips::vips_enum_from_nick 'DistorteD', 94691059614768, 'lolol'
432
+ # => -1
433
+ # irb> Vips::vips_error_buffer
434
+ # => "DistorteD: enum 'VipsForeignTiffCompression' has no member 'lolol', should be one of: none, jpeg, deflate, packbits, ccittfax4, lzw\n"
435
+ Vips::vips_enum_from_nick('DistorteD'.freeze, gtype_id, 'lolol'.freeze)
436
+ error_buffer = Vips::vips_error_buffer
437
+ if error_buffer.include?('should be one of: '.freeze)
438
+ Vips::vips_error_clear
439
+ # Parse the error into a Set of Symbol options
440
+ discovered = error_buffer.split('should be one of: '.freeze)[1][0..-2].split(', '.freeze).map(&:to_sym).to_set
441
+ # For any Options with aliases, merge in the aliases.
442
+ (discovered & self::VIPS_ALIASES.keys.to_set).each { |aliased|
443
+ discovered.merge(self::VIPS_ALIASES[aliased])
444
+ }
445
+ # We need to give this back as an Array because callers will want to call :join on it,
446
+ # and we should give it back sorted so aliased aren't all piled up at the end.
447
+ discovered.to_a.sort
448
+ else
449
+ return Array[]
450
+ end
451
+ rescue
452
+ return Array[]
453
+ end
454
+ end
455
+
456
+
457
+ # Returns a Array of the boxed type (Int, Double, etc)
458
+ def self.vips_get_boxed_default(gtype)
459
+ gtype_id = gtype.is_a?(String) ? GObject::g_type_from_name(gtype) : gtype
460
+ gtype_name = GObject::g_type_name(gtype_id)
461
+ # It's not really correct to explicitly return three values here,
462
+ # but the use of this for `background` colors are the only use rn.
463
+ case gtype_name
464
+ when 'VipsArrayDouble'.freeze
465
+ return [0.0, 0.0, 0.0]
466
+ when 'VipsArrayInt'.freeze
467
+ return [0, 0, 0]
468
+ else
469
+ return []
470
+ end
471
+ end
472
+
473
+
474
+ # Returns boolean validity for libvips class names,
475
+ # e.g. for validating that a desired Loader/Saver class actually exists!
476
+ def self.vips_foreign_valid_operation?(otra)
477
+ # This doesn't seem to raise any Exception on invalid g_type, just returns 0.
478
+ # Use this to cast to a boolean return value:
479
+ #
480
+ # irb(main):243:0> GObject::g_type_from_name('VipsForeignSaveJpegFile')
481
+ # => 94691057381120
482
+ # irb(main):244:0> GObject::g_type_from_name('VipsForeignLoadJpegFile')
483
+ # => 94691057380176
484
+ # irb(main):245:0> GObject::g_type_from_name('VipsForeignLoadJpegFilgfgfgfe')
485
+ # => 0
486
+ GObject::g_type_from_name(otra) == 0 ? false : true
487
+ end
488
+
489
+ end