oximg 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +96 -6
- data/lib/oximg/binary.rb +8 -2
- data/lib/oximg/native.rb +179 -0
- data/lib/oximg/version.rb +1 -1
- data/lib/oximg.rb +242 -6
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8221098e170ea503a46a4c89169861c5368eb07f5764acbfded816f75b0eae5
|
|
4
|
+
data.tar.gz: a255bfd60744a39d76b844582730d9e85cf1b468e5c7dc56f017984ce8fdf103
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f7100d38ed24e0bc72237fb18d124abce78dd995bfbf949658a87e4fc13b96848b1ca3cc12c5574cb74455900661a038b3f149d34c28267539c9acc10145a26
|
|
7
|
+
data.tar.gz: 490bba95709fa12cced8408e0b52f4c2d45470e2e3a0b68382f5d6e8ef19b2eb1478b4dda12e8b2e3f0b0c9cd65121ce1e72b7a7d46082646d05f4635dda1b3c
|
data/README.md
CHANGED
|
@@ -18,6 +18,7 @@ gem "oximg"
|
|
|
18
18
|
```ruby
|
|
19
19
|
Oximg.resize("in.jpg", "out.jpg", width: 750)
|
|
20
20
|
Oximg.resize("in.jpg", "out.webp", width: 750, height: 500, quality: 80)
|
|
21
|
+
Oximg.strip("upload.jpg", "clean.jpg")
|
|
21
22
|
Oximg.probe("in.jpg")
|
|
22
23
|
#=> {content_type: "image/jpeg", format: :jpeg, width: 4000, height: 3000}
|
|
23
24
|
```
|
|
@@ -30,7 +31,8 @@ the name does not say, and `:gif` is not a `format:` you can ask for.
|
|
|
30
31
|
|
|
31
32
|
```ruby
|
|
32
33
|
Oximg.resize(source, destination, width: 0, height: 0,
|
|
33
|
-
quality: nil, format: nil, preset: nil
|
|
34
|
+
quality: nil, format: nil, preset: nil,
|
|
35
|
+
icc: nil, auto_rotate: nil)
|
|
34
36
|
```
|
|
35
37
|
|
|
36
38
|
The source is fitted within `width` x `height` and **never enlarged**. A
|
|
@@ -49,16 +51,104 @@ Oximg.resize("photo.jpg", "smaller.jpg", quality: 70)
|
|
|
49
51
|
| `quality` | 1–100, default 80. JPEG quality |
|
|
50
52
|
| `format` | `:jpg`, `:jpeg`, `:png`, `:webp`, `:avif`. Defaults to the destination's extension, else the source's own format |
|
|
51
53
|
| `preset` | `:jpegli` (default — maximum quality per byte), `:fast`, `:small` |
|
|
54
|
+
| `icc` | `:keep` or `:strip` the source's colour profile. Default: `OXIMG_ICC`, else keep |
|
|
55
|
+
| `auto_rotate` | `false` keeps the stored orientation, `true` applies the EXIF one. Default: `OXIMG_AUTO_ROTATE`, else true |
|
|
56
|
+
|
|
57
|
+
`icc` and `auto_rotate` apply to that call only, and win over the
|
|
58
|
+
environment, so a threaded job runner can treat one image differently
|
|
59
|
+
without touching `ENV`.
|
|
52
60
|
|
|
53
61
|
`resize` returns the destination path and raises `Oximg::ProcessingError`
|
|
54
62
|
on failure, carrying the binary's own message and exit status. Paths are
|
|
55
63
|
expanded before they reach the CLI, so an uploaded file called `-q.jpg`
|
|
56
64
|
cannot be read as a flag.
|
|
57
65
|
|
|
66
|
+
## Stripping metadata
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
Oximg.strip(source, destination, icc: nil, env: nil)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Removes EXIF (GPS, camera, timestamps), XMP, IPTC and comments from a
|
|
73
|
+
JPEG, PNG or WebP **without re-encoding**. Decoded pixels are identical,
|
|
74
|
+
and it costs a fraction of a `0 x 0` resize in time and memory. This
|
|
75
|
+
replaces `saver(strip: true)` (libvips) or `-strip` (ImageMagick). A
|
|
76
|
+
rotated photo keeps an EXIF block holding only its orientation, so it
|
|
77
|
+
still displays upright. `icc: :strip` also drops the colour profile.
|
|
78
|
+
The default is `:keep`, or `OXIMG_ICC`. Dropping a CMYK JPEG's profile
|
|
79
|
+
raises `Oximg::ProcessingError`, as do GIF and AVIF sources.
|
|
80
|
+
|
|
81
|
+
## Knobs, per process or per call
|
|
82
|
+
|
|
58
83
|
Everything the server tunes through `OXIMG_*` environment variables
|
|
59
84
|
applies here too, and is validated the same fail-closed way: a typo'd
|
|
60
85
|
knob is a startup error, never a silent default.
|
|
61
86
|
|
|
87
|
+
To set one for a single call, pass `env:` to `resize` or `strip`:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Oximg.resize("in.jpg", "out.jpg", width: 750, env: {"OXIMG_JPEG_PROGRESSIVE" => "0"})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Each call spawns its own process, and `env:` reaches that process
|
|
94
|
+
only: `ENV` is never touched, so concurrent calls in a threaded job
|
|
95
|
+
runner each get their own settings. Names must be in `Oximg::KNOBS`
|
|
96
|
+
(the pipeline knobs; server settings such as `OXIMG_KEY` are refused),
|
|
97
|
+
values are Strings or Integers, and `nil` unsets a name for that call.
|
|
98
|
+
An unknown name raises `ArgumentError` before anything runs; a bad
|
|
99
|
+
value is refused by the binary (`ProcessingError`, status 2).
|
|
100
|
+
|
|
101
|
+
## Probing
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
Oximg.probe("in.jpg") # a path
|
|
105
|
+
Oximg.probe(Pathname("in.jpg")) # also a path
|
|
106
|
+
Oximg.probe(upload.tempfile) # anything with #path
|
|
107
|
+
Oximg.probe(File.open("in.jpg", "rb")) # an open File
|
|
108
|
+
Oximg.probe(StringIO.new(bytes)) # an IO: bytes in memory
|
|
109
|
+
#=> {content_type: "image/jpeg", format: :jpeg, width: 4000, height: 3000}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`probe` reads the header only and decodes no pixels. The dimensions are
|
|
113
|
+
the stored ones, before any EXIF rotation. A String is always a path,
|
|
114
|
+
so bytes already in memory go in as a `StringIO`. An open `File` is
|
|
115
|
+
read from its start, its position untouched, and it is the file you
|
|
116
|
+
hold that is read, even if its name has since been replaced, renamed,
|
|
117
|
+
or was relative to a directory the process has left. A closed or
|
|
118
|
+
write-only `File`, which cannot be read through, is read by its name.
|
|
119
|
+
Other IO — a `StringIO`, a pipe, a socket — is read from its current
|
|
120
|
+
position through `#read(n)` and left where the read stopped.
|
|
121
|
+
|
|
122
|
+
The gem does that reading itself, in Ruby, between calls into the
|
|
123
|
+
library, so `#read` behaves as it would anywhere: whatever it raises
|
|
124
|
+
comes out of `probe` unchanged, and a `Timeout.timeout` or a
|
|
125
|
+
`Thread#kill` lands in it as usual.
|
|
126
|
+
|
|
127
|
+
With the probe library, `probe` runs in-process: oximg's own probe,
|
|
128
|
+
compiled into a small library (`ext/`) the gem calls through
|
|
129
|
+
[`ffi`](https://github.com/ffi/ffi). It reads only as much of the source
|
|
130
|
+
as the header takes, typically the first 64 KiB, and costs microseconds
|
|
131
|
+
where a spawn costs milliseconds. `resize` is not affected and still
|
|
132
|
+
runs in a child process, which keeps a full decode's memory and crashes
|
|
133
|
+
out of your process.
|
|
134
|
+
|
|
135
|
+
The platform gems bundle the library beside the executable (x86_64 and
|
|
136
|
+
aarch64 Linux, glibc and musl, and arm64 macOS), so they also depend
|
|
137
|
+
on `ffi`; beyond that it needs only libc and libgcc_s, which Ruby
|
|
138
|
+
itself needs, and not libstdc++.
|
|
139
|
+
`OXIMG_PROBE_LIB` points at another one; like `OXIMG_BIN`, one that
|
|
140
|
+
does not load raises rather than falling back. To build it from this
|
|
141
|
+
checkout, run `rake compile` here: it leaves the library under the
|
|
142
|
+
repo's `target/oximg-probe/release/`.
|
|
143
|
+
|
|
144
|
+
Without the library — the plain-Ruby gem, or a bundled one that cannot
|
|
145
|
+
load — `probe` spawns `oximg probe` instead, copying an IO to a
|
|
146
|
+
tempfile first. The answer is the same; only the cost differs.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
Oximg.in_process_probe? #=> true on a platform gem
|
|
150
|
+
```
|
|
151
|
+
|
|
62
152
|
## Where the binary comes from
|
|
63
153
|
|
|
64
154
|
`Oximg.executable` resolves, first hit wins:
|
|
@@ -71,14 +161,14 @@ knob is a startup error, never a silent default.
|
|
|
71
161
|
|
|
72
162
|
```ruby
|
|
73
163
|
Oximg.available? #=> true
|
|
74
|
-
Oximg.executable #=> "/…/gems/oximg-0.
|
|
75
|
-
Oximg.version #=> "0.
|
|
164
|
+
Oximg.executable #=> "/…/gems/oximg-0.18.1-arm64-darwin/exe/oximg"
|
|
165
|
+
Oximg.version #=> "0.18.1" # the binary's version, not the gem's
|
|
76
166
|
```
|
|
77
167
|
|
|
78
|
-
|
|
168
|
+
`resize` shells out to that binary — one process per image, which is
|
|
79
169
|
noise next to the encode itself and invisible inside the background job
|
|
80
|
-
where variants are generated anyway.
|
|
81
|
-
|
|
170
|
+
where variants are generated anyway. Only `probe`, where the spawn was
|
|
171
|
+
the whole cost, runs in-process (see [Probing](#probing)).
|
|
82
172
|
|
|
83
173
|
## Rails
|
|
84
174
|
|
data/lib/oximg/binary.rb
CHANGED
|
@@ -60,8 +60,14 @@ module Oximg
|
|
|
60
60
|
# nothing in a filename can be interpreted as syntax. Returns
|
|
61
61
|
# [stdout, stderr]; a non-zero exit raises with the binary's own
|
|
62
62
|
# stderr, which already names what it refused.
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
#
|
|
64
|
+
# +env+ is merged into the child's environment only (a nil value
|
|
65
|
+
# unsets that name there): one process per call means one call's
|
|
66
|
+
# knobs never reach another's, even across threads, and the
|
|
67
|
+
# parent's ENV is never touched. Validate it first; Oximg.resize
|
|
68
|
+
# and Oximg.strip do.
|
|
69
|
+
def run(*args, env: {})
|
|
70
|
+
out, err, status = Open3.capture3(env, path, *args)
|
|
65
71
|
unless status.success?
|
|
66
72
|
message = err.strip
|
|
67
73
|
message = "oximg exited #{status.exitstatus}" if message.empty?
|
data/lib/oximg/native.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Oximg
|
|
6
|
+
# The in-process header read: oximg's own probe, compiled as a small
|
|
7
|
+
# C library (rubygem/oximg/ext) and called through the +ffi+ gem, so
|
|
8
|
+
# Oximg.probe costs microseconds instead of a process spawn.
|
|
9
|
+
#
|
|
10
|
+
# Only the header read runs in-process. It reads a bounded prefix of
|
|
11
|
+
# the source and decodes no pixels; resize stays a child process, for
|
|
12
|
+
# the memory and crash isolation a full decode wants.
|
|
13
|
+
#
|
|
14
|
+
# Resolution order, first hit wins:
|
|
15
|
+
#
|
|
16
|
+
# 1. +OXIMG_PROBE_LIB+ — an explicit path always wins, and a wrong
|
|
17
|
+
# one raises rather than falling back, as +OXIMG_BIN+ does.
|
|
18
|
+
# 2. the library bundled in this gem, beside this file — platform
|
|
19
|
+
# gems ship one. If it cannot load (no +ffi+ gem, a missing
|
|
20
|
+
# system library), probe falls back to spawning the executable:
|
|
21
|
+
# slower, same answer.
|
|
22
|
+
#
|
|
23
|
+
# The plain-Ruby gem ships no library and always spawns.
|
|
24
|
+
module Native
|
|
25
|
+
LIBRARY = "liboximg_probe.#{RbConfig::CONFIG["SOEXT"]}"
|
|
26
|
+
|
|
27
|
+
# Populated by the platform gems at package time; absent in the
|
|
28
|
+
# plain-Ruby gem.
|
|
29
|
+
BUNDLED = File.expand_path("native/#{LIBRARY}", __dir__)
|
|
30
|
+
|
|
31
|
+
# The library's oximg_probe_abi_version this code was written for.
|
|
32
|
+
ABI_VERSION = 1
|
|
33
|
+
|
|
34
|
+
# Statuses (ext/src/lib.rs).
|
|
35
|
+
OK = 0
|
|
36
|
+
READ = 3
|
|
37
|
+
|
|
38
|
+
# The byte count READ gives for "to the end".
|
|
39
|
+
READ_ALL = 2**64 - 1
|
|
40
|
+
|
|
41
|
+
# Room for an error message; the library cuts a longer one at a
|
|
42
|
+
# character boundary.
|
|
43
|
+
MESSAGE_BYTES = 1024
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# True when the library is loaded and probe runs in-process.
|
|
47
|
+
def available?
|
|
48
|
+
!binding_module.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Header of the file at +path+ (already expanded).
|
|
52
|
+
def probe_path(path)
|
|
53
|
+
# Copied off the Ruby heap: the call runs without the GVL, while
|
|
54
|
+
# other threads may run GC.
|
|
55
|
+
bytes = FFI::MemoryPointer.from_string(path)
|
|
56
|
+
call { |dims, msg| binding_module.oximg_probe_path(bytes, path.bytesize, dims, msg, MESSAGE_BYTES) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Header of the open, readable regular +file+, read through its
|
|
60
|
+
# descriptor from the start with positioned reads: what it holds,
|
|
61
|
+
# whatever its name leads to by now, its position untouched.
|
|
62
|
+
def probe_file(file)
|
|
63
|
+
call { |dims, msg| binding_module.oximg_probe_fd(file.fileno, dims, msg, MESSAGE_BYTES) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Header of +io+, read from its current position through
|
|
67
|
+
# IO#read(n) — a pipe, a socket, a StringIO. Only what the header
|
|
68
|
+
# needs is read, and the IO is left where the read stopped.
|
|
69
|
+
#
|
|
70
|
+
# The library decides how much to read and the reading happens
|
|
71
|
+
# here, between calls, so the IO's code never runs under the
|
|
72
|
+
# library's frames: whatever it raises, a Timeout or a Thread#kill
|
|
73
|
+
# included, leaves the way it would leave any Ruby method.
|
|
74
|
+
def probe_io(io)
|
|
75
|
+
prefix = String.new(encoding: Encoding::BINARY)
|
|
76
|
+
eof = false
|
|
77
|
+
loop do
|
|
78
|
+
want = nil
|
|
79
|
+
result = call do |dims, msg|
|
|
80
|
+
status = binding_module.oximg_probe_step(prefix, prefix.bytesize, eof ? 1 : 0, dims, msg, MESSAGE_BYTES)
|
|
81
|
+
want = dims.read_uint64 if status == READ
|
|
82
|
+
status
|
|
83
|
+
end
|
|
84
|
+
return result unless want
|
|
85
|
+
|
|
86
|
+
eof = read_to(io, prefix, want)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Forgets the loaded library. For tests, and for a process that
|
|
91
|
+
# changes OXIMG_PROBE_LIB after boot.
|
|
92
|
+
def reset!
|
|
93
|
+
remove_instance_variable(:@binding_module) if instance_variable_defined?(:@binding_module)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Reads +io+ onto +prefix+ until it holds +want+ bytes; true if the
|
|
99
|
+
# source ended first. READ_ALL asks for everything, which is read
|
|
100
|
+
# a MiB at a time: a length that large would be allocated up
|
|
101
|
+
# front, and an IO need only answer #read(n).
|
|
102
|
+
def read_to(io, prefix, want)
|
|
103
|
+
while want == READ_ALL || prefix.bytesize < want
|
|
104
|
+
chunk = io.read(want == READ_ALL ? 1 << 20 : want - prefix.bytesize)
|
|
105
|
+
return true if chunk.nil? || chunk.empty?
|
|
106
|
+
|
|
107
|
+
prefix << chunk.b
|
|
108
|
+
end
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def call
|
|
113
|
+
dims = FFI::MemoryPointer.new(:uint64, 2)
|
|
114
|
+
msg = FFI::MemoryPointer.new(:char, MESSAGE_BYTES)
|
|
115
|
+
status = yield(dims, msg)
|
|
116
|
+
return if status == READ
|
|
117
|
+
|
|
118
|
+
text = msg.read_string.force_encoding(Encoding::UTF_8)
|
|
119
|
+
raise ProcessingError.new(text, 1) unless status == OK
|
|
120
|
+
|
|
121
|
+
width, height = dims.read_array_of_uint64(2)
|
|
122
|
+
{content_type: text, format: CONTENT_TYPE_FORMATS[text], width: width, height: height}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# nil when there is no library to use. Memoized either way, so a
|
|
126
|
+
# gem without one does not look again on every probe.
|
|
127
|
+
def binding_module
|
|
128
|
+
return @binding_module if instance_variable_defined?(:@binding_module)
|
|
129
|
+
|
|
130
|
+
@binding_module = load_library
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def load_library
|
|
134
|
+
configured = ENV["OXIMG_PROBE_LIB"]
|
|
135
|
+
if configured.nil? || configured.strip.empty?
|
|
136
|
+
return nil unless File.file?(BUNDLED)
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
open_library(BUNDLED)
|
|
140
|
+
rescue LoadError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
unless File.file?(configured)
|
|
145
|
+
raise LoadError, "OXIMG_PROBE_LIB is set to #{configured.inspect}, which is not a file"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Absolute, so the file loaded is the one just checked. Where
|
|
149
|
+
# dlopen looks for a bare name depends on the platform (glibc
|
|
150
|
+
# never looks in the working directory; dyld does, after other
|
|
151
|
+
# places). absolute_path, unlike expand_path, leaves "~" alone,
|
|
152
|
+
# as File.file? did.
|
|
153
|
+
open_library(File.absolute_path(configured))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def open_library(path)
|
|
158
|
+
require "ffi"
|
|
159
|
+
mod = Module.new do
|
|
160
|
+
extend FFI::Library
|
|
161
|
+
ffi_lib path
|
|
162
|
+
attach_function :oximg_probe_abi_version, [], :uint32
|
|
163
|
+
# A path or descriptor read blocks on the filesystem, so it runs
|
|
164
|
+
# without the GVL. A step only parses bytes already in memory and
|
|
165
|
+
# keeps it, which also keeps the prefix String where it is.
|
|
166
|
+
attach_function :oximg_probe_path, %i[pointer size_t pointer pointer size_t], :int, blocking: true
|
|
167
|
+
attach_function :oximg_probe_fd, %i[int pointer pointer size_t], :int, blocking: true
|
|
168
|
+
attach_function :oximg_probe_step, %i[buffer_in size_t uint8 pointer pointer size_t], :int
|
|
169
|
+
end
|
|
170
|
+
abi = mod.oximg_probe_abi_version
|
|
171
|
+
unless abi == ABI_VERSION
|
|
172
|
+
raise LoadError, "#{path} speaks probe ABI #{abi}; this gem speaks #{ABI_VERSION}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
mod
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/oximg/version.rb
CHANGED
data/lib/oximg.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fcntl"
|
|
4
|
+
require "tempfile"
|
|
3
5
|
require_relative "oximg/version"
|
|
4
6
|
require_relative "oximg/binary"
|
|
7
|
+
require_relative "oximg/native"
|
|
5
8
|
|
|
6
9
|
# Ruby bindings for oximg (https://github.com/oximg/oximg):
|
|
7
10
|
# high-performance image compression and resizing, in-process work for a
|
|
@@ -9,6 +12,7 @@ require_relative "oximg/binary"
|
|
|
9
12
|
#
|
|
10
13
|
# Oximg.resize("in.jpg", "out.jpg", width: 750)
|
|
11
14
|
# Oximg.resize("in.jpg", "out.webp", width: 750, height: 500, quality: 80)
|
|
15
|
+
# Oximg.strip("upload.jpg", "clean.jpg") # drop EXIF/GPS, XMP, IPTC; no re-encode
|
|
12
16
|
# Oximg.probe("in.jpg") #=> {content_type: "image/jpeg", format: :jpeg, ...}
|
|
13
17
|
#
|
|
14
18
|
# Rails/ActiveStorage integration, and URL building for a remote oximg
|
|
@@ -41,6 +45,52 @@ module Oximg
|
|
|
41
45
|
# mozjpeg's fast/small trade differently.
|
|
42
46
|
PRESETS = %i[jpegli fast small].freeze
|
|
43
47
|
|
|
48
|
+
# What `resize` and `strip` may do with an embedded colour profile.
|
|
49
|
+
ICC_MODES = %i[keep strip].freeze
|
|
50
|
+
|
|
51
|
+
# The OXIMG_* pipeline knobs an +env:+ may set for one call. Pinned to
|
|
52
|
+
# the binary's own inventory (KNOBS in src/config.rs) by a test there,
|
|
53
|
+
# so a knob the binary gains or drops cannot drift from this list.
|
|
54
|
+
# Server-startup settings (OXIMG_KEY, OXIMG_BIND, ...) are not here:
|
|
55
|
+
# a one-shot never reads them.
|
|
56
|
+
KNOBS = %w[
|
|
57
|
+
OXIMG_TIMING
|
|
58
|
+
OXIMG_RESIZE
|
|
59
|
+
OXIMG_RESIZE_BACKEND
|
|
60
|
+
OXIMG_AUTO_ROTATE
|
|
61
|
+
OXIMG_ICC
|
|
62
|
+
OXIMG_DCT_MARGIN
|
|
63
|
+
OXIMG_LINEAR_SHRINK
|
|
64
|
+
OXIMG_JPEG_PROGRESSIVE
|
|
65
|
+
OXIMG_JPEG_SEQUENTIAL_PIXELS
|
|
66
|
+
OXIMG_JPEG_STREAMING_PIXELS
|
|
67
|
+
OXIMG_FLATTEN_BG
|
|
68
|
+
OXIMG_PNG_EFFORT
|
|
69
|
+
OXIMG_PNG_QUANTIZE
|
|
70
|
+
OXIMG_PNG_QUANTIZE_COLORS
|
|
71
|
+
OXIMG_WEBP_QUALITY
|
|
72
|
+
OXIMG_WEBP_EFFORT
|
|
73
|
+
OXIMG_WEBP_DECODE_THREADS
|
|
74
|
+
OXIMG_AVIF_QUALITY
|
|
75
|
+
OXIMG_AVIF_ALPHA_QUALITY
|
|
76
|
+
OXIMG_AVIF_SPEED
|
|
77
|
+
OXIMG_AVIF_DECODE_THREADS
|
|
78
|
+
OXIMG_MAX_SOURCE_BYTES
|
|
79
|
+
OXIMG_MAX_SRC_PIXELS
|
|
80
|
+
OXIMG_GIF_ANIMATION
|
|
81
|
+
OXIMG_MAX_ANIM_FRAMES
|
|
82
|
+
OXIMG_MAX_ANIM_WORK
|
|
83
|
+
OXIMG_ANIM_FRAME_STEP
|
|
84
|
+
OXIMG_MAX_DECODED_BYTES
|
|
85
|
+
OXIMG_LOG_DECODED_BYTES_ABOVE
|
|
86
|
+
OXIMG_UPSTREAM_CONNECT_TIMEOUT
|
|
87
|
+
OXIMG_UPSTREAM_TIMEOUT
|
|
88
|
+
OXIMG_GCS_ENDPOINT
|
|
89
|
+
OXIMG_S3_ENDPOINT
|
|
90
|
+
OXIMG_S3_PATH_STYLE
|
|
91
|
+
OXIMG_OVERLAP
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
44
94
|
CONTENT_TYPE_FORMATS = {
|
|
45
95
|
"image/jpeg" => :jpeg,
|
|
46
96
|
"image/png" => :png,
|
|
@@ -99,25 +149,121 @@ module Oximg
|
|
|
99
149
|
# The output format follows +format:+, else +destination+'s
|
|
100
150
|
# extension, else the source's own format. Returns the destination
|
|
101
151
|
# path.
|
|
102
|
-
|
|
152
|
+
#
|
|
153
|
+
# +icc:+ (:keep or :strip) and +auto_rotate:+ (true or false)
|
|
154
|
+
# override the binary's OXIMG_ICC and OXIMG_AUTO_ROTATE for this
|
|
155
|
+
# call only; nil leaves them to the environment (default: keep the
|
|
156
|
+
# profile, apply the EXIF orientation).
|
|
157
|
+
#
|
|
158
|
+
# +env:+ sets any other pipeline knob for this call only, e.g.
|
|
159
|
+
# <tt>env: {"OXIMG_JPEG_PROGRESSIVE" => "0"}</tt>: it reaches the
|
|
160
|
+
# one child process this call spawns, never ENV, so it is safe in a
|
|
161
|
+
# threaded job runner. Names must be in KNOBS; nil unsets a name
|
|
162
|
+
# for the call. The binary validates the values, fail-closed.
|
|
163
|
+
def resize(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil,
|
|
164
|
+
icc: nil, auto_rotate: nil, env: nil)
|
|
165
|
+
output = expand(destination, "destination")
|
|
166
|
+
argv = resize_argv(source, output,
|
|
167
|
+
width: width, height: height, quality: quality, format: format, preset: preset,
|
|
168
|
+
icc: icc, auto_rotate: auto_rotate)
|
|
169
|
+
Binary.run(*argv, env: knob_env(env))
|
|
170
|
+
output
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Removes metadata — EXIF (GPS, camera, timestamps), XMP, IPTC,
|
|
174
|
+
# comments — from a JPEG, PNG or WebP without re-encoding, and
|
|
175
|
+
# writes the result to +destination+. Decoded pixels are identical;
|
|
176
|
+
# a rotated photo keeps only its EXIF orientation, so it still
|
|
177
|
+
# displays upright. +icc:+ is :keep or :strip (default: the
|
|
178
|
+
# binary's OXIMG_ICC, else keep); dropping a CMYK JPEG's profile is
|
|
179
|
+
# refused with a ProcessingError. Returns the destination path.
|
|
180
|
+
#
|
|
181
|
+
# This is what <tt>saver(strip: true)</tt> / <tt>-strip</tt> did
|
|
182
|
+
# under libvips or ImageMagick, minus the re-encode.
|
|
183
|
+
#
|
|
184
|
+
# +env:+ is per-call knobs, as for resize.
|
|
185
|
+
def strip(source, destination, icc: nil, env: nil)
|
|
103
186
|
output = expand(destination, "destination")
|
|
104
|
-
|
|
105
|
-
|
|
187
|
+
argv = strip_argv(source, output, icc: icc)
|
|
188
|
+
Binary.run(*argv, env: knob_env(env))
|
|
106
189
|
output
|
|
107
190
|
end
|
|
108
191
|
|
|
192
|
+
# The child environment an +env:+ turns into: String names and
|
|
193
|
+
# values, each name a known knob. A typo raises here rather than
|
|
194
|
+
# being passed along and ignored, which is the same fail-closed
|
|
195
|
+
# promise the binary makes about the values. Public for the same
|
|
196
|
+
# reason as resize_argv.
|
|
197
|
+
def knob_env(env)
|
|
198
|
+
return {} if env.nil?
|
|
199
|
+
raise ArgumentError, "env must be a Hash, got #{env.inspect}" unless env.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
env.each_with_object({}) do |(name, value), out|
|
|
202
|
+
key = name.to_s
|
|
203
|
+
unless KNOBS.include?(key)
|
|
204
|
+
raise ArgumentError, "unknown knob #{name.inspect} in env (one of Oximg::KNOBS)"
|
|
205
|
+
end
|
|
206
|
+
unless value.nil? || value.is_a?(String) || value.is_a?(Integer)
|
|
207
|
+
raise ArgumentError, "#{key} must be a String, an Integer or nil, got #{value.inspect}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
out[key] = value&.to_s
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The argv `strip` would run, minus the executable; public for the
|
|
215
|
+
# same reason as resize_argv.
|
|
216
|
+
def strip_argv(source, destination, icc: nil)
|
|
217
|
+
argv = ["strip", expand(source, "source"), expand(destination, "destination")]
|
|
218
|
+
argv.push("--icc", token(icc, ICC_MODES, "icc")) unless icc.nil?
|
|
219
|
+
argv
|
|
220
|
+
end
|
|
221
|
+
|
|
109
222
|
# Header-only inspection — no pixels are decoded. Returns
|
|
110
223
|
# <tt>{content_type:, format:, width:, height:}</tt>, where the
|
|
111
224
|
# dimensions are the stored ones (an EXIF rotation is not applied).
|
|
225
|
+
#
|
|
226
|
+
# +source+ is a path — a String, a Pathname, or anything with #path
|
|
227
|
+
# such as a Tempfile — an open File, or a stream read from its
|
|
228
|
+
# current position through #read(n): a StringIO, a pipe, a socket.
|
|
229
|
+
# A String is always a path, so bytes already in memory go in as
|
|
230
|
+
# <tt>StringIO.new(bytes)</tt>. An open File on a regular file is
|
|
231
|
+
# read from its start, its position untouched, and it is the file
|
|
232
|
+
# it holds that is read, whatever its name leads to by now. A closed
|
|
233
|
+
# or write-only File, which cannot be read through, is read by its
|
|
234
|
+
# name.
|
|
235
|
+
#
|
|
236
|
+
# With the probe library this runs in-process and reads only as much
|
|
237
|
+
# of the source as the header takes (see in_process_probe?). Without it,
|
|
238
|
+
# it spawns <tt>oximg probe</tt>, which reads the whole source, and an
|
|
239
|
+
# IO is copied to a tempfile first. The answer is the same.
|
|
112
240
|
def probe(source)
|
|
113
|
-
|
|
241
|
+
return probe_open_file(source) if open_regular_file?(source)
|
|
242
|
+
|
|
243
|
+
if stream?(source)
|
|
244
|
+
return Native.available? ? Native.probe_io(source) : probe_spawned_io(source)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
path = expand(source, "source")
|
|
248
|
+
return Native.probe_path(path) if Native.available?
|
|
249
|
+
|
|
250
|
+
out, = Binary.run("probe", path)
|
|
114
251
|
parse_probe(out)
|
|
115
252
|
end
|
|
116
253
|
|
|
254
|
+
# True when probe runs in-process, through the probe library (see
|
|
255
|
+
# Native); false when it spawns the executable. Either way
|
|
256
|
+
# probe answers the same, and only its cost differs — this is for a
|
|
257
|
+
# boot-time log, or a test that must know which path it took.
|
|
258
|
+
def in_process_probe?
|
|
259
|
+
Native.available?
|
|
260
|
+
end
|
|
261
|
+
|
|
117
262
|
# The argv `resize` would run, minus the executable. Public because
|
|
118
263
|
# it is the honest way to see what a call turns into — and the way
|
|
119
264
|
# to test that without a binary present.
|
|
120
|
-
def resize_argv(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil
|
|
265
|
+
def resize_argv(source, destination, width: 0, height: 0, quality: nil, format: nil, preset: nil,
|
|
266
|
+
icc: nil, auto_rotate: nil)
|
|
121
267
|
argv = [
|
|
122
268
|
"resize",
|
|
123
269
|
expand(source, "source"),
|
|
@@ -128,11 +274,87 @@ module Oximg
|
|
|
128
274
|
argv.push("-q", bounded(quality, "quality", 1, 100).to_s) unless quality.nil?
|
|
129
275
|
argv.push("-f", token(format, FORMATS, "format")) unless format.nil?
|
|
130
276
|
argv.push("--preset", token(preset, PRESETS, "preset")) unless preset.nil?
|
|
277
|
+
argv.push("--icc", token(icc, ICC_MODES, "icc")) unless icc.nil?
|
|
278
|
+
argv.push(boolean(auto_rotate, "auto_rotate") ? "--auto-rotate" : "--no-auto-rotate") unless auto_rotate.nil?
|
|
131
279
|
argv
|
|
132
280
|
end
|
|
133
281
|
|
|
134
282
|
private
|
|
135
283
|
|
|
284
|
+
def open_regular_file?(source)
|
|
285
|
+
source.is_a?(File) && !source.closed? && source.stat.file?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# An open File is read through its descriptor, not by the name it
|
|
289
|
+
# was opened under: the name may lead to another file by now
|
|
290
|
+
# (replaced, renamed, or relative to a working directory the
|
|
291
|
+
# process has since left). Positioned reads leave its position
|
|
292
|
+
# alone. A write-only File cannot be read that way, and is read by
|
|
293
|
+
# its name.
|
|
294
|
+
def probe_open_file(file)
|
|
295
|
+
readable = (file.fcntl(Fcntl::F_GETFL) & Fcntl::O_ACCMODE) != Fcntl::O_WRONLY
|
|
296
|
+
return Native.probe_file(file) if readable && Native.available?
|
|
297
|
+
|
|
298
|
+
# A File made from a descriptor (File.for_fd) has no name.
|
|
299
|
+
name = file.path.to_s.empty? ? nil : File.expand_path(file.path)
|
|
300
|
+
if name && (!readable || File.identical?(file, name))
|
|
301
|
+
return Native.probe_path(name) if Native.available?
|
|
302
|
+
|
|
303
|
+
out, = Binary.run("probe", name)
|
|
304
|
+
return parse_probe(out)
|
|
305
|
+
end
|
|
306
|
+
raise IOError, "a write-only File with no name cannot be probed" unless readable
|
|
307
|
+
|
|
308
|
+
# The executable reads names only, and this one has moved on: it
|
|
309
|
+
# gets a copy, made with positioned reads.
|
|
310
|
+
Tempfile.create("oximg-probe") do |copy|
|
|
311
|
+
copy.binmode
|
|
312
|
+
offset = 0
|
|
313
|
+
loop do
|
|
314
|
+
chunk = file.pread(1 << 20, offset)
|
|
315
|
+
copy.write(chunk)
|
|
316
|
+
offset += chunk.bytesize
|
|
317
|
+
rescue EOFError
|
|
318
|
+
break
|
|
319
|
+
end
|
|
320
|
+
copy.flush
|
|
321
|
+
out, = Binary.run("probe", copy.path)
|
|
322
|
+
parse_probe(out)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Whether probe reads +source+ through #read rather than opening it
|
|
327
|
+
# by name. Every IO answers #path since Ruby 3.2 — nil for a pipe,
|
|
328
|
+
# "" for a socket, "<STDIN>" for STDIN — so for an IO that proves
|
|
329
|
+
# nothing: an IO is a stream, except a closed File, which has
|
|
330
|
+
# nothing left to read but its name (an open one on a regular file
|
|
331
|
+
# never gets here). Anything else keeps the duck typing: #to_path
|
|
332
|
+
# or #path (a Pathname, a Tempfile, an upload) is a path.
|
|
333
|
+
def stream?(source)
|
|
334
|
+
case source
|
|
335
|
+
when String then false
|
|
336
|
+
when File then !source.closed?
|
|
337
|
+
when IO then true
|
|
338
|
+
else !source.respond_to?(:to_path) && !source.respond_to?(:path) && source.respond_to?(:read)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The executable reads paths only, so without the library an IO
|
|
343
|
+
# goes through a tempfile. It is read through #read(n) alone, as the
|
|
344
|
+
# library reads it, so probe takes the same IO either way
|
|
345
|
+
# (IO.copy_stream would also pass #read a buffer).
|
|
346
|
+
def probe_spawned_io(io)
|
|
347
|
+
Tempfile.create("oximg-probe") do |file|
|
|
348
|
+
file.binmode
|
|
349
|
+
while (chunk = io.read(1 << 20)) && !chunk.empty?
|
|
350
|
+
file.write(chunk)
|
|
351
|
+
end
|
|
352
|
+
file.flush
|
|
353
|
+
out, = Binary.run("probe", file.path)
|
|
354
|
+
parse_probe(out)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
136
358
|
# The hash `probe` builds from the line the CLI printed. Separate
|
|
137
359
|
# from `probe` so the parser can be tested against every line the
|
|
138
360
|
# CLI can print — the still form, each loop spelling — without a
|
|
@@ -153,7 +375,11 @@ module Oximg
|
|
|
153
375
|
# would otherwise reach the CLI's argument parser as a flag, and
|
|
154
376
|
# the caller rarely controls what an uploaded file is called.
|
|
155
377
|
def expand(path, name)
|
|
156
|
-
|
|
378
|
+
if path.respond_to?(:path)
|
|
379
|
+
path = path.path
|
|
380
|
+
elsif path.respond_to?(:to_path)
|
|
381
|
+
path = path.to_path
|
|
382
|
+
end
|
|
157
383
|
path = path.to_s
|
|
158
384
|
raise ArgumentError, "#{name} path is empty" if path.strip.empty?
|
|
159
385
|
|
|
@@ -178,6 +404,16 @@ module Oximg
|
|
|
178
404
|
value
|
|
179
405
|
end
|
|
180
406
|
|
|
407
|
+
# Strictly true or false: a truthy "0" or "false" read as true would
|
|
408
|
+
# rotate exactly the images the caller asked not to.
|
|
409
|
+
def boolean(value, name)
|
|
410
|
+
unless value == true || value == false
|
|
411
|
+
raise ArgumentError, "#{name} must be true or false, got #{value.inspect}"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
value
|
|
415
|
+
end
|
|
416
|
+
|
|
181
417
|
def token(value, allowed, name)
|
|
182
418
|
token = value.to_s.downcase
|
|
183
419
|
unless allowed.include?(token.to_sym)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: oximg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- oximg contributors
|
|
@@ -20,6 +20,7 @@ files:
|
|
|
20
20
|
- README.md
|
|
21
21
|
- lib/oximg.rb
|
|
22
22
|
- lib/oximg/binary.rb
|
|
23
|
+
- lib/oximg/native.rb
|
|
23
24
|
- lib/oximg/version.rb
|
|
24
25
|
homepage: https://github.com/oximg/oximg
|
|
25
26
|
licenses:
|