retouch 0.3.0 → 0.3.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/CHANGELOG.md +12 -4
- data/LICENSE +1 -1
- data/README.md +74 -67
- data/bench/retouch.rb +4 -2
- data/docs/index.html +28 -0
- data/lib/retouch/batch.rb +61 -41
- data/lib/retouch/cli.rb +178 -187
- data/lib/retouch/io.rb +5 -19
- data/lib/retouch/kernels/color.rb +18 -0
- data/lib/retouch/kernels/convolve.rb +60 -0
- data/lib/retouch/kernels/lut.rb +75 -0
- data/lib/retouch/operations/color.rb +21 -0
- data/lib/retouch/operations/draw.rb +160 -0
- data/lib/retouch/operations/filter.rb +64 -0
- data/lib/retouch/operations/multiple.rb +192 -0
- data/lib/retouch/operations.rb +31 -482
- data/lib/retouch/pipeline.rb +3 -4
- data/lib/retouch/version.rb +1 -1
- data/lib/retouch.rb +13 -5
- data/retouch.gemspec +2 -2
- data/sig/retouch.rbs +43 -11
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ded00b784bc38feace65825e78c1a054c85e25deaa24c3646f0bb42413cbb678
|
|
4
|
+
data.tar.gz: 25e71fe884b758478af207538e4652d9c3d9186a10d7333045d8f9c7f85e9fe8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fef149f89fed473a1f2e648bb0162e7957bc28a799850026743ec872777ba7bc87b1e82ae3db15b43beb927a39fa93a2dd40af297c37495f1de245591ff140d2
|
|
7
|
+
data.tar.gz: d3d5e48248efadcd180e51085a4d92cd8b4b560d21ea1a968ca4c95fe07953794db2cdd29db007109170548d680e10e1ec495fe17a061583956a259b23ee321e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Retouch are recorded here.
|
|
4
4
|
|
|
5
|
+
## [0.3.1] - 2026-09-25
|
|
6
|
+
|
|
7
|
+
- Use normalized fixed-point weights for resampling with integer interpolation.
|
|
8
|
+
- Require Flipbook 0.4.0 for GIF and APNG integration.
|
|
9
|
+
|
|
5
10
|
## [0.3.0] - 2026-09-25
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
- Add color, quantization, convolution, pixelation, compositing, annotation, and multi-image operations.
|
|
13
|
+
- Add glob-based batch transforms, output templates, and process workers.
|
|
14
|
+
- Add optional Glyphic text and Flipbook 0.3.0+ GIF reading, GIF output, and APNG output integrations.
|
|
15
|
+
- Document current format and GIF Reader limitations.
|
|
16
|
+
|
|
17
|
+
## [0.1.0] - 2026-09-25
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
- Geometry-based resampling, color adjustments, filters, drawing, compositing, and batch tools.
|
|
11
|
-
- Optional Flipbook, Glyphic, and Lookalike integrations.
|
|
19
|
+
Initial release
|
data/LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,103 +1,110 @@
|
|
|
1
|
-
<
|
|
1
|
+
<h1 align="center">Retouch</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">A small Ruby image editor for PNG, PPM, and BMP.</p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/rbgfx/retouch/actions/workflows/main.yml"><img src="https://github.com/rbgfx/retouch/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
7
|
+
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white" alt="Ruby 3.1+"></a>
|
|
8
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="MIT license"></a>
|
|
9
|
+
</p>
|
|
6
10
|
|
|
7
|
-
[
|
|
8
|
-
[](https://rubygems.org/gems/retouch)
|
|
9
|
-
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
Retouch handles everyday image cleanup without an ImageMagick install. It reads and writes PNG, PPM, and BMP through [Tessel](https://github.com/rbgfx/tessel). GIF input and GIF/APNG output, text rendering, and image diffs are available through optional [Flipbook](https://github.com/rbgfx/flipbook), [Glyphic](https://github.com/rbgfx/glyphic), and [Lookalike](https://github.com/rbgfx/lookalike) gems.
|
|
13
|
-
|
|
14
|
-
JPEG, WebP, color profiles, and very large photographic workloads are outside the current scope.
|
|
11
|
+
Retouch provides a small image-processing API and CLI backed by [Tessel](https://github.com/rbgfx/tessel). It runs in pure Ruby and is intended for screenshots, pixel art, and CI image tasks. JPEG, WebP, and color management are outside its current scope.
|
|
15
12
|
|
|
16
13
|
## Install
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
```sh
|
|
19
16
|
gem install retouch
|
|
20
|
-
|
|
17
|
+
```
|
|
21
18
|
|
|
22
19
|
Install optional integrations only when needed:
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
gem install
|
|
26
|
-
|
|
21
|
+
```sh
|
|
22
|
+
gem install glyphic # text and montage labels
|
|
23
|
+
gem install flipbook -v '>= 0.4.0' # GIF reading, GIF output, and APNG output
|
|
24
|
+
```
|
|
27
25
|
|
|
28
|
-
##
|
|
26
|
+
## Ruby API
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
retouch info screenshot.png
|
|
32
|
-
retouch screenshot.png resize 50% border 2 '#303846' -o small.png
|
|
33
|
-
retouch screenshot.png crop 800x600+120+40 text 'v1.2' --at south-east --font ./font.ttf -o crop.png
|
|
34
|
-
retouch sprite.png resize 400% --filter nearest -o sprite@4x.png
|
|
35
|
-
retouch 'shots/*.png' thumbnail 320x -o 'thumbs/{name}.png'
|
|
36
|
-
retouch 'frames/*.png' animate --fps 24 -o animation.gif
|
|
37
|
-
retouch a.png b.png c.png montage --cols 3 --gap 8 -o sheet.png
|
|
38
|
-
retouch diff expected.png actual.png -o diff.png
|
|
39
|
-
~~~
|
|
28
|
+
Pipelines are lazy: each operation returns a new pipeline, and rendering starts at `to_image` or `save`.
|
|
40
29
|
|
|
41
|
-
|
|
30
|
+
```ruby
|
|
31
|
+
require "retouch"
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
| 50% / 50%x25% | Scale relative to source size |
|
|
50
|
-
| 800x600> | Shrink only |
|
|
51
|
-
| 800x600+10+20 | Dimensions plus crop offset |
|
|
33
|
+
Retouch.open("screenshot.png")
|
|
34
|
+
.resize("640x")
|
|
35
|
+
.brightness(8)
|
|
36
|
+
.border(1, "#303846")
|
|
37
|
+
.save("small.png")
|
|
38
|
+
```
|
|
52
39
|
|
|
53
|
-
|
|
40
|
+
Available operations include `resize`, `thumbnail`, `crop`, `trim`, `pad`, `extend`, `border`, `rotate`, `flip`, `flop`, `grayscale`, `invert`, `brightness`, `contrast`, `gamma`, `saturate`, `tint`, `opacity`, `quantize`, `blur`, `sharpen`, `pixelate`, `overlay`, `watermark`, `text`, `rect`, and `arrow`.
|
|
54
41
|
|
|
55
|
-
|
|
42
|
+
```ruby
|
|
43
|
+
image = Retouch.open("screen.png")
|
|
44
|
+
.resize("800x")
|
|
45
|
+
.overlay("logo.png", gravity: :south_east, opacity: 0.8, mode: :multiply)
|
|
46
|
+
.text("Build passed", at: :north_west, size: 18, background: "#18202ddd")
|
|
47
|
+
image.save("annotated.png")
|
|
48
|
+
```
|
|
56
49
|
|
|
57
|
-
|
|
50
|
+
`brightness` adds a channel value from -255 to 255. `contrast` and `saturate` use 1 as unchanged; `gamma` also uses 1 as unchanged. `tint` takes `amount: 0..1`, and `opacity` takes a multiplier from 0 to 1. `quantize` accepts 2–256 colors and Tessel's `:none`, `:ordered`, or `:floyd_steinberg` dithering. `blur` and `sharpen` take a Gaussian sigma. `pixelate(size)` averages each size-by-size block.
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
require "retouch"
|
|
52
|
+
GIF frames can be read as composited Tessel images:
|
|
61
53
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
```ruby
|
|
55
|
+
frames = Retouch.open_gif("animation.gif")
|
|
56
|
+
first_frame = Retouch.open_gif("animation.gif", frame: :first).to_image
|
|
57
|
+
```
|
|
66
58
|
|
|
67
|
-
|
|
68
|
-
.crop("800x600+120+40")
|
|
69
|
-
.grayscale
|
|
70
|
-
.to_image
|
|
59
|
+
Drawing and composite methods accept hex colors, including alpha (`#rrggbbaa`). Blend modes are `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, and `add`. `text` and `montage` labels need Glyphic; without it, those calls explain how to install the optional gem.
|
|
71
60
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
61
|
+
## CLI
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
retouch screen.png resize 640x --filter lanczos3 -o small.png
|
|
65
|
+
retouch screen.png grayscale contrast 1.1 -o adjusted.png
|
|
66
|
+
retouch screen.png crop 320x200+40+20 text "v1.2" --at south-east --size 18 -o crop.png
|
|
67
|
+
retouch base.png overlay logo.png --gravity south-east --opacity 0.8 -o marked.png
|
|
68
|
+
retouch a.png b.png c.png montage --cols 3 --gap 8 -o sheet.png
|
|
69
|
+
retouch a.png b.png append --direction horizontal -o row.png
|
|
70
|
+
retouch 'frames/*.png' animate --fps 24 -o preview.gif
|
|
71
|
+
retouch 'frames/*.png' animate --delay 0.08 -o preview.apng
|
|
72
|
+
retouch expected.png actual.png diff -o diff.png
|
|
73
|
+
retouch info screen.png
|
|
74
|
+
```
|
|
76
75
|
|
|
77
|
-
|
|
76
|
+
Quote a glob to let Retouch expand it. One-to-one processing accepts output templates and `--jobs`:
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
```sh
|
|
79
|
+
retouch 'screens/*.png' thumbnail 320x -o 'thumbs/{name}-{index:03}.png' --jobs 4
|
|
80
|
+
```
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
Templates support `{name}`, `{ext}`, `{dir}`, `{index}`, and zero-padded `{index:03}`. Duplicate output paths and accidental overwrites are rejected; use `--force` to replace existing files. Without `Process.fork`, jobs run sequentially. `spritesheet` writes a JSON sidecar with each image's coordinates and packs with a simple maximum-rectangles heuristic.
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
Geometry accepts `WIDTHxHEIGHT`, `WIDTHx`, `xHEIGHT`, percentages, `!` to stretch, `^` to cover, and `>` to shrink only. Crop offsets may be positive or negative. Resize filters are `nearest`, `bilinear`, `bicubic`, and `lanczos3`.
|
|
84
85
|
|
|
85
|
-
##
|
|
86
|
+
## How Retouch differs from ImageMagick
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
Retouch uses ImageMagick-style geometry as a familiar shorthand, but it does not implement the full ImageMagick command language or promise pixel-for-pixel compatibility. It is a small Ruby library and CLI built on Tessel, supports a narrower set of formats and operations, and does not provide JPEG/WebP codecs or color management. Choose ImageMagick when broad format support or mature photo-processing filters matter; choose Retouch for lightweight PNG-centered Ruby and CI workflows.
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
Global options include `--dry-run`, `--verbose`, `--quiet`, `--strip`, and `--level 0..9`. `retouch help OPERATION` shows that operation's syntax.
|
|
91
|
+
|
|
92
|
+
## Formats and limits
|
|
93
|
+
|
|
94
|
+
- PNG, PPM, and BMP input and output through Tessel.
|
|
95
|
+
- GIF input and GIF/APNG output through optional Flipbook 0.4.0 or newer. `animate` selects GIF or APNG from the output extension; `--fps` and `--delay` are mutually exclusive, and delay is in seconds.
|
|
96
|
+
- Multi-image `diff` compares RGBA channels exactly by default and returns a magenta diff image plus the changed-pixel ratio in the Ruby API.
|
|
97
|
+
- Large photographic images and high-quality arbitrary-angle rotation can be slow in pure Ruby. JPEG, WebP, and color-managed workflows are not supported.
|
|
98
|
+
|
|
99
|
+
On Ruby 4.0.6 with YJIT, the local benchmark measured a 1920×1080 Gaussian blur at σ=3 in 5.836 seconds, above the 3-second target. Results depend on Ruby and hardware; use `--verbose` to measure your own workload.
|
|
90
100
|
|
|
91
101
|
## Development
|
|
92
102
|
|
|
93
|
-
|
|
103
|
+
```sh
|
|
94
104
|
bundle install
|
|
95
105
|
bundle exec rake verify
|
|
96
|
-
|
|
97
|
-
~~~
|
|
98
|
-
|
|
99
|
-
The verification task runs RuboCop, test-unit, and RBS validation. The README Ruby examples are exercised by the test suite.
|
|
106
|
+
```
|
|
100
107
|
|
|
101
108
|
## License
|
|
102
109
|
|
|
103
|
-
MIT
|
|
110
|
+
MIT. See [LICENSE](LICENSE).
|
data/bench/retouch.rb
CHANGED
|
@@ -13,8 +13,10 @@ puts "YJIT: #{RubyVM::YJIT.enabled?}"
|
|
|
13
13
|
image = Tessel::Image.new(1920, 1080, fill: [80, 120, 160, 255])
|
|
14
14
|
measure("1920x1080 -> 960x540 bilinear") { Retouch::Operations.resize(image, "960x540", filter: :bilinear) }
|
|
15
15
|
measure("1920x1080 -> 960x540 lanczos3") { Retouch::Operations.resize(image, "960x540", filter: :lanczos3) }
|
|
16
|
-
measure("1920x1080
|
|
17
|
-
measure("1920x1080 brightness") { Retouch::Operations.brightness(image,
|
|
16
|
+
measure("1920x1080 -> 960x540 nearest") { Retouch::Operations.resize(image, "960x540", filter: :nearest) }
|
|
17
|
+
measure("1920x1080 brightness") { Retouch::Operations.brightness(image, 8) }
|
|
18
|
+
measure("1920x1080 contrast") { Retouch::Operations.contrast(image, 1.1) }
|
|
19
|
+
measure("1920x1080 blur sigma=3") { Retouch::Operations.blur(image, 3) }
|
|
18
20
|
measure("256x256 -> 1024x1024 nearest") do
|
|
19
21
|
Retouch::Operations.resize(Tessel::Image.new(256, 256), "400%", filter: :nearest)
|
|
20
22
|
end
|
data/docs/index.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="theme-color" content="#111315">
|
|
7
|
+
<meta name="description" content="A small Ruby image editor for PNG, PPM, and BMP.">
|
|
8
|
+
<title>Retouch — image work in Ruby</title>
|
|
9
|
+
<style>
|
|
10
|
+
:root{color-scheme:dark;--bg:#111315;--panel:#1a1d20;--line:#2c3034;--text:#f1f1ec;--muted:#a2a7a9;--accent:#79d9c5;--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
11
|
+
*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 20% -20%,#1d3934,transparent 44%),var(--bg);color:var(--text);font:16px/1.65 var(--sans)}a{color:inherit;text-decoration:none}a:hover{color:var(--accent)}.wrap{width:min(1040px,calc(100% - 40px));margin:auto}.nav{height:76px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{font-size:20px;font-weight:750;letter-spacing:-.04em}.brand span,.label{color:var(--accent)}.links{display:flex;gap:24px;color:var(--muted);font-size:14px}.hero{padding:88px 0 70px;display:grid;grid-template-columns:1.1fr .9fr;align-items:center;gap:50px}.label{font:12px var(--mono);letter-spacing:.14em;text-transform:uppercase}h1{font-size:clamp(48px,7vw,78px);line-height:.99;letter-spacing:-.075em;margin:19px 0}.hero p{font-size:18px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:28px 0 22px}.button{padding:10px 16px;border:1px solid var(--line);border-radius:5px;font-weight:650}.primary{background:var(--accent);border-color:var(--accent);color:#10211d}.install{font:14px var(--mono);background:#090b0c;border:1px solid var(--line);padding:12px 15px;border-radius:6px;display:inline-flex;gap:16px}.install span{color:#737a7b}.art{aspect-ratio:1;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#202825,#141719);padding:28px;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:12px;transform:rotate(2deg)}.tile{border:1px solid #36433f;border-radius:7px;background:linear-gradient(145deg,#87dbc5,#36594f);position:relative;overflow:hidden}.tile:nth-child(2){background:linear-gradient(145deg,#e0a577,#704d39)}.tile:nth-child(3){background:linear-gradient(145deg,#6d92c7,#354764)}.tile:nth-child(4){background:linear-gradient(145deg,#d8d49c,#6e7150)}.tile:after{content:"";position:absolute;width:80%;height:80%;border:1px solid #ffffff70;inset:10%;border-radius:50%}.tile:nth-child(even):after{border-radius:2px;transform:rotate(24deg)}.section{border-top:1px solid var(--line);padding:42px 0 66px}.section h2{font-size:28px;letter-spacing:-.04em;margin:8px 0 10px}.section>p{max-width:650px;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:25px}.card{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:19px}.card strong{display:block;margin-bottom:7px}.card p{color:var(--muted);font-size:14px;margin:0}.code{border:1px solid var(--line);background:#090b0c;border-radius:7px;padding:18px;overflow:auto;font:14px/1.8 var(--mono);color:#d3eee5}.foot{border-top:1px solid var(--line);padding:24px 0 36px;display:flex;justify-content:space-between;gap:16px;color:var(--muted);font-size:13px}
|
|
12
|
+
@media(max-width:740px){.hero{grid-template-columns:1fr;gap:30px;padding:66px 0 54px}.art{max-width:400px}.links{gap:14px}.cards{grid-template-columns:1fr}.foot{flex-direction:column}}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body><div class="wrap">
|
|
16
|
+
<nav class="nav" aria-label="Main navigation"><a class="brand" href="#top">re<span>touch</span></a><div class="links"><a href="#pipeline">Pipeline</a><a href="https://github.com/rbgfx/retouch">GitHub</a><a href="https://rubygems.org/gems/retouch">RubyGems</a></div></nav>
|
|
17
|
+
<main id="top">
|
|
18
|
+
<section class="hero"><div><div class="label">Image tools for Ruby</div><h1>Small edits.<br>Clean pipeline.</h1><p>Resize, crop, annotate, and compose PNG-centered images from Ruby or the command line. Retouch is pure Ruby and built on Tessel.</p><div class="actions"><a class="button primary" href="https://github.com/rbgfx/retouch#ruby-api">Read the guide ↗</a><a class="button" href="https://github.com/rbgfx/retouch#cli">Explore the CLI</a></div><div class="install"><span>$</span> gem install retouch</div></div><div class="art" aria-hidden="true"><div class="tile"></div><div class="tile"></div><div class="tile"></div><div class="tile"></div></div></section>
|
|
19
|
+
<section class="section" id="pipeline"><div class="label">Lazy, composable operations</div><h2>Keep image steps readable</h2><p>Build a pipeline with chainable transformations. Processing starts only when you render or save the result.</p><pre class="code"><code>require "retouch"
|
|
20
|
+
|
|
21
|
+
Retouch.open("screenshot.png")
|
|
22
|
+
.resize("640x")
|
|
23
|
+
.brightness(8)
|
|
24
|
+
.border(1, "#303846")
|
|
25
|
+
.save("small.png")</code></pre><div class="cards"><article class="card"><strong>Ruby API</strong><p>Compose resizing, color adjustments, drawing, overlays, and image comparisons.</p></article><article class="card"><strong>Practical CLI</strong><p>Process globs in batches, create montages, and automate image checks in CI.</p></article><article class="card"><strong>Focused formats</strong><p>PNG, PPM, and BMP through Tessel, with optional GIF and APNG through Flipbook.</p></article></div></section>
|
|
26
|
+
</main><footer class="foot"><span>Retouch · Image work in Ruby</span><span><a href="https://github.com/rbgfx/retouch">Source</a> · MIT License</span></footer>
|
|
27
|
+
</div></body>
|
|
28
|
+
</html>
|
data/lib/retouch/batch.rb
CHANGED
|
@@ -1,62 +1,82 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Retouch
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
class Batch
|
|
5
|
+
def self.expand(inputs)
|
|
6
|
+
Array(inputs).flat_map do |input|
|
|
7
|
+
pattern = String(input)
|
|
8
|
+
if pattern.match?(/[?*{\[]/)
|
|
9
|
+
Dir.glob(pattern)
|
|
10
|
+
else
|
|
11
|
+
pattern
|
|
12
|
+
end
|
|
13
|
+
end.sort
|
|
14
|
+
end
|
|
6
15
|
|
|
7
|
-
def
|
|
8
|
-
|
|
16
|
+
def self.output_path(template, input, index)
|
|
17
|
+
path = String(template).gsub("{name}", File.basename(input, File.extname(input)))
|
|
18
|
+
.gsub("{ext}", File.extname(input).delete_prefix("."))
|
|
19
|
+
.gsub("{dir}", File.dirname(input))
|
|
20
|
+
.gsub(/\{index(?::(\d+))?\}/) { format("%0*d", Regexp.last_match(1).to_i, index) }
|
|
21
|
+
raise ArgumentError, "unknown output template token in #{path}" if path.match?(/\{[^}]+\}/)
|
|
9
22
|
|
|
10
|
-
|
|
11
|
-
|
|
23
|
+
path
|
|
24
|
+
end
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
def self.run(inputs, to:, jobs: 1, force: false, dry_run: false)
|
|
27
|
+
paths = expand(inputs)
|
|
28
|
+
raise Error, "no input files matched" if paths.empty?
|
|
29
|
+
raise Error, "input file does not exist: #{paths.find { |path| !File.file?(path) }}" unless paths.all? { |path| File.file?(path) }
|
|
15
30
|
|
|
16
|
-
outputs =
|
|
17
|
-
raise
|
|
18
|
-
raise Error, "refusing to overwrite #{outputs.find { |path| File.exist?(path) }}" unless force || outputs.none? { |path| File.exist?(path) }
|
|
31
|
+
outputs = paths.each_with_index.map { |path, index| output_path(to, path, index) }
|
|
32
|
+
raise Error, "output template produces duplicate paths" unless outputs.uniq.length == outputs.length
|
|
19
33
|
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
jobs = Integer(jobs)
|
|
35
|
+
raise ArgumentError, "jobs must be positive" unless jobs.positive?
|
|
36
|
+
if !force && (existing = outputs.find { |path| File.exist?(path) })
|
|
37
|
+
raise Error, "refusing to overwrite #{existing}; use --force"
|
|
38
|
+
end
|
|
39
|
+
return outputs if dry_run
|
|
22
40
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
worker_count = Process.respond_to?(:fork) ? jobs : 1
|
|
42
|
+
children = []
|
|
43
|
+
failed = false
|
|
44
|
+
paths.zip(outputs).each do |input, output|
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
|
|
46
|
+
if worker_count > 1
|
|
47
|
+
children << Process.fork do
|
|
48
|
+
yield input, output
|
|
27
49
|
exit! 0
|
|
28
|
-
rescue StandardError
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
warn("retouch: #{e.message}")
|
|
29
52
|
exit! 1
|
|
30
53
|
end
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
if children.length >= worker_count
|
|
55
|
+
_pid, status = Process.wait2(children.shift)
|
|
56
|
+
failed ||= !status.success?
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
yield input, output
|
|
36
60
|
end
|
|
37
|
-
group.map(&:last)
|
|
38
61
|
end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
template = String(template)
|
|
43
|
-
name = File.basename(path, File.extname(path))
|
|
44
|
-
values = { "name" => name, "ext" => File.extname(path).delete_prefix("."), "dir" => File.dirname(path), "index" => index.to_s }
|
|
45
|
-
template.gsub(/\{(name|ext|dir|index)(?::(\d+))?\}/) do
|
|
46
|
-
key, width = Regexp.last_match.captures
|
|
47
|
-
value = values.fetch(key)
|
|
48
|
-
key == "index" && width ? value.rjust(width.to_i, "0") : value
|
|
62
|
+
children.each do |pid|
|
|
63
|
+
_child, status = Process.wait2(pid)
|
|
64
|
+
failed ||= !status.success?
|
|
49
65
|
end
|
|
66
|
+
raise Error, "one or more batch jobs failed" if failed
|
|
67
|
+
|
|
68
|
+
outputs
|
|
50
69
|
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.batch(inputs, to:, jobs: 1, force: false, &block)
|
|
73
|
+
raise ArgumentError, "a transformation block is required" unless block
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
raise TypeError, "batch block must return a Retouch::Pipeline" unless result.is_a?(Pipeline)
|
|
75
|
+
Batch.run(inputs, to:, jobs:, force:) do |input, output|
|
|
76
|
+
pipeline = block.call(Retouch.open(input))
|
|
77
|
+
raise TypeError, "batch block must return a Retouch::Pipeline" unless pipeline.is_a?(Pipeline)
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
result.save(output, level:, strip:)
|
|
59
|
-
output
|
|
79
|
+
pipeline.save(output)
|
|
60
80
|
end
|
|
61
81
|
end
|
|
62
82
|
end
|