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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff53983f255f2168fd0897271270862882583dfd8094a09195a10cffda795f1c
4
- data.tar.gz: ebda0e130e1208ecb6389e90359edda86cb12860db5da3db125269cc8e380962
3
+ metadata.gz: ded00b784bc38feace65825e78c1a054c85e25deaa24c3646f0bb42413cbb678
4
+ data.tar.gz: 25e71fe884b758478af207538e4652d9c3d9186a10d7333045d8f9c7f85e9fe8
5
5
  SHA512:
6
- metadata.gz: 0b9a040916e1370d2e76eec0e8252419d83fbc16686901b032550374844341d05c502bb9d387c1e521b735099ef607389c1220e3a331883d61b4db9cade10ee2
7
- data.tar.gz: f188f38de764c5af1dc262bb7c7705086d77782ba3070e837d93f73c1362925df26848526acf26e01cac2ba8f7913d7c6405a0764d71153ef1631b7c09ce24fc
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
- ### Added
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
- - Pure Ruby PNG, PPM, and BMP editing through lazy pipelines and a command line.
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
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 ydah
3
+ Copyright (c) 2026 Yudai Takada
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,103 +1,110 @@
1
- <div align="center">
1
+ <h1 align="center">Retouch</h1>
2
2
 
3
- # Retouch
3
+ <p align="center">A small Ruby image editor for PNG, PPM, and BMP.</p>
4
4
 
5
- Small Ruby image edits, from the terminal or a Ruby pipeline.
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&amp;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
- [![CI](https://github.com/rbgfx/retouch/actions/workflows/main.yml/badge.svg)](https://github.com/rbgfx/retouch/actions/workflows/main.yml)
8
- [![Gem](https://img.shields.io/gem/v/retouch)](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
- ~~~sh
15
+ ```sh
19
16
  gem install retouch
20
- ~~~
17
+ ```
21
18
 
22
19
  Install optional integrations only when needed:
23
20
 
24
- ~~~sh
25
- gem install flipbook glyphic lookalike
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
- ## CLI
26
+ ## Ruby API
29
27
 
30
- ~~~sh
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
- Resize geometry follows familiar ImageMagick-style notation, with intentionally limited semantics:
30
+ ```ruby
31
+ require "retouch"
42
32
 
43
- | Geometry | Result |
44
- | --- | --- |
45
- | 800x600 | Fit inside while preserving aspect ratio |
46
- | 800x600! | Stretch to the exact dimensions |
47
- | 800x600^ | Scale to cover, then crop |
48
- | 800x / x600 | Set one dimension and preserve aspect ratio |
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
- Supported resize filters are nearest, bilinear, bicubic, and lanczos3 (default). A crop without offsets starts at the top left; --gravity selects one of nine anchors for crop, cover, overlay, and text.
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
- Use --dry-run, --verbose, --quiet, --force, --strip, --level 0..9, and --jobs N to control batch runs. Batch templates accept {name}, {ext}, {dir}, {index}, and {index:03}. Existing output files are preserved unless --force is used; conflicting templates fail before processing.
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
- ## Ruby
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
- ~~~ruby
60
- require "retouch"
52
+ GIF frames can be read as composited Tessel images:
61
53
 
62
- Retouch.open("screenshot.png")
63
- .resize("50%")
64
- .border(2, "#303846")
65
- .save("small.png")
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
- image = Retouch.open("screenshot.png")
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
- Retouch.batch("shots/*.png", to: "thumbs/{name}.png") do |image|
73
- image.thumbnail("320x")
74
- end
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
- Pipelines defer work until to_image or save and never mutate the source image. Available operations are resize/thumbnail/crop, flip/flop/rotate, trim/pad/extend/border, grayscale/invert/brightness/contrast/gamma/saturate/tint/opacity/quantize, blur/sharpen/pixelate, overlay/watermark/text/rect/arrow, and multi-image montage/append/spritesheet/animate/diff.
76
+ Quote a glob to let Retouch expand it. One-to-one processing accepts output templates and `--jobs`:
78
77
 
79
- Text requires a BDF or TrueType font path (font: or RETOUCH_FONT). Without Glyphic, only text reports a missing optional dependency. GIF/APNG and perceptual diff operations similarly report their optional gem when invoked.
78
+ ```sh
79
+ retouch 'screens/*.png' thumbnail 320x -o 'thumbs/{name}-{index:03}.png' --jobs 4
80
+ ```
80
81
 
81
- Pure Ruby pixel processing trades speed for easy installation. Nearest-neighbor scaling is suitable for pixel art; Lanczos, blur, and arbitrary-angle rotation cost more as image dimensions grow. Retouch does not claim ImageMagick pixel-for-pixel compatibility.
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
- On Ruby 4.0.6 with YJIT, a local run on a solid 1920×1080 image measured bilinear resize to 960×540 at 1.236s, Lanczos3 at 2.238s, Gaussian blur at σ=3 at 13.423s, and brightness at 0.184s. Nearest-neighbor enlargement from 256×256 to 1024×1024 took 0.071s. Treat these as reference measurements, not guarantees; Gaussian blur is currently the slow path and exceeds the design target of 3 seconds.
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
- ## API contracts
86
+ ## How Retouch differs from ImageMagick
86
87
 
87
- Inputs are file paths or Tessel::Image values with RGBA8 pixels. Transformations return a new image and leave the input untouched. Geometry, option, and frame errors raise ArgumentError; values of the wrong type raise TypeError; I/O and unavailable optional integrations raise Retouch::Error. Pipeline#save overwrites its destination, while the CLI and Retouch.batch refuse existing outputs unless --force / force: true is supplied.
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
- Retouch supports PNG, PPM, and BMP input/output. GIF input and GIF/APNG output need Flipbook; text and montage labels need Glyphic and a BDF/TrueType font; diff needs Lookalike. JPEG, WebP, color management, and APNG input are unsupported. Pipelines are safe to reuse from independent calls; Tessel image mutability follows Tessel's contract, and --jobs uses processes where fork exists.
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
- ~~~sh
103
+ ```sh
94
104
  bundle install
95
105
  bundle exec rake verify
96
- COVERAGE=1 COVERAGE_MIN=85 bundle exec rake test
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 blur sigma=3") { Retouch::Operations.blur(image, sigma: 3) }
17
- measure("1920x1080 brightness") { Retouch::Operations.brightness(image, 0.1) }
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
- module Batch
5
- module_function
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 run(pattern, to:, force: false, jobs: 1, level: 6, strip: false, &operation)
8
- raise ArgumentError, "a block is required" unless operation
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
- files = Array(pattern).flat_map { |item| File.file?(item) ? [item] : Dir.glob(item) }.uniq.sort
11
- raise Error, "no input files matched" if files.empty?
23
+ path
24
+ end
12
25
 
13
- jobs = Integer(jobs)
14
- raise ArgumentError, "jobs must be positive" unless jobs.positive?
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 = files.each_with_index.map { |file, index| expand(to, file, index) }
17
- raise ArgumentError, "output template maps multiple inputs to the same file" unless outputs.uniq.length == outputs.length
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
- work = files.zip(outputs).map { |input, output| [input, output] }
21
- return work.map { |input, output| process(input, output, level:, strip:, &operation) } if jobs == 1 || !Process.respond_to?(:fork)
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
- work.each_slice(jobs).flat_map do |group|
24
- children = group.map do |input, output|
25
- pid = Process.fork do
26
- process(input, output, level:, strip:, &operation)
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
- [pid, output]
32
- end
33
- children.each do |pid, output|
34
- _, status = Process.wait2(pid)
35
- raise Error, "batch worker failed for #{output}" unless status.success?
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
- end
40
-
41
- def expand(template, path, index)
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
- def process(input, output, level:, strip:)
53
- result = yield Retouch.open(input)
54
- result = Retouch.from_image(result) if result.is_a?(Tessel::Image)
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
- FileUtils.mkdir_p(File.dirname(output)) unless File.dirname(output) == "."
58
- result.save(output, level:, strip:)
59
- output
79
+ pipeline.save(output)
60
80
  end
61
81
  end
62
82
  end