Loomy ๐งถ
The friendly pixel-weaver for Ruby.
Loomy is a modern, high-performance image processing engine for Ruby. Think of it as a master weaver for your images: it takes raw layers (the threads) and weaves them into complex compositions using a smart, declarative DSL.
Built on top of libvips, Loomy resolves your whole composition โ sizes, positions, load resolutions โ before it touches a pixel, so every source is decoded exactly once, at exactly the size it ends up.
๐ Key Features
-
Batch composition: every layer is flattened in a single
libvipscomposite call, not one composite per layer. - Layout before pixels: a measure/arrange pass resolves geometry up front, so each source is loaded straight at its final size instead of being decoded and then resized.
- Hierarchical Groups: nest layers, groups and stacks to build complex layouts with shared effects.
- Intelligent DSL: declarative, block-based syntax. Each node kind accepts only what it can actually use, so typos and misplaced properties fail with a message instead of being ignored.
- Extensible Effects: register your own processors; custom effects take part in optimisation like the built-ins do.
How a render runs
DSL โ AST โ Pruner โ Layout (measure โข arrange) โ Renderer โ Vips::Image
The AST is immutable, so a tree can be rendered more than once and always produces the same image. Layout writes its results to a side table of frames rather than back into the tree.
๐ฆ Installation
Add this line to your application's Gemfile:
gem 'loomy'And then execute:
bundle install๐ Usage
1. Generating Images
Loomy offers two ways to get your results: render (write to file) and generate (get a Vips::Image object).
Render to file
Loomy.render("output.png", size: [1200, 630]) do
layer "background.jpg"
endGenerate in-memory (Web Servers / Testing)
# Returns a Vips::Image object
image = Loomy.generate(size: [1200, 630]) do
layer "background.jpg"
end
# Get binary buffer for HTTP response or S3
buffer = image.write_to_buffer(".png")Canvas options
These describe the canvas itself. Every other option is forwarded to libvips as a write option (quality:, compression:, โฆ), and all three entry points split them on the same list, so what generate honours render and to_blob honour too.
| Option | Default | What it does |
|---|---|---|
size: |
auto |
[width, height] in pixels. Omitted, the canvas is sized from the extent of its children. |
dpi: |
none | Resolution written into the output's metadata. |
premultiplied: |
false |
Tells libvips the images being composited already carry premultiplied alpha. |
premultiplied: exists to reproduce a pipeline that makes that claim โ it is not a description of Loomy's own layers, which are straight-alpha. It changes the arithmetic of every composite in the render, nested groups and stacks included, and it is a no-op wherever alpha is 255, so it only shows up when something translucent is blended. See Known limits for what it does to the output.
2. Hierarchical Groups
Group layers to apply effects or positioning to a set of nodes collectively.
Loomy.render("banner.png", size: [800, 400]) do
group x: 50, y: 50 do
layer "icon.png", width: 50
layer "text.png", x: 60
# Apply blur to the entire group
blur radius: 2
end
end3. The Smart DSL
Forget about complex argument lists. Describe your image layout naturally:
require 'loomy'
Loomy.render("output.png", size: [1200, 630]) do
# Background
layer "background.jpg" do
fit :cover
blur radius: 10
end
# Overlay
layer "avatar.png" do
x :center
y :center
width "20%"
trim true # Crop away the transparent border
end
end4. Reusable Styles
Define common looks and apply them anywhere.
# Define a style
Loomy.define_style :hero_layer do
x 50
y 100
blend :overlay
end
Loomy.render("post.png", size: [1000, 1000]) do
layer "texture.png" do
use :hero_layer # Apply the style
width 500 # Override or extend
end
end5. Stacks
Lay children out along an axis. align/valign set cross-axis alignment (whichever axis the children are not stacked along); distribute sets main-axis distribution.
Loomy.render("card.png", size: [400, 600]) do
vstack spacing: 16, align: :center, distribute: :space_between do
layer "logo.png", width: 120
layer text: "Sold out", size: 32, color: "#111"
layer "footer.png", width: :fill
end
end6. Opacity
opacity: takes a share of full opacity, from 0.0 (invisible) to 1.0 (unchanged). It works on layers, groups and stacks:
layer "watermark.png", opacity: 0.35
group opacity: 0.5 do
layer "badge.png"
layer text: "50% off", size: 24, color: "#fff"
endFading a group is not the same as fading each of its children. A group composites its children first and the fade applies to that result, so where children overlap they stay at their own relative opacities. Fading them one by one lets each fill in part of what the last left behind.
Two things worth knowing:
-
Effects run first, then opacity. A layer's
blursees the layer at full alpha; the fade happens where the finished layer meets its parent. Reversed, a blur would smear the reduced alpha into its neighbours. -
opacity: 0is invisible, not absent. It keeps its slot in a stack and its place in the layout, soopacity: visible ? 1 : 0does what it reads like. Note that under the operator blend modes (:source,:in,:out,:clear,:xor) an alpha of zero is not "no contribution" โ it still clears what is beneath it. That is libvips' Porter-Duff semantics, not a Loomy choice.
7. Sizing and fit
width: and height: take pixels, a percentage of the parent box, or :fill:
layer "art.png", width: 200 # pixels
layer "art.png", width: "50%" # half the parent box
layer "art.png", width: :fill # the whole parent box on that axis"50%" and :fill are both relative to the parent, so both need a parent with a resolvable box on that axis. Without one โ a layer or group directly under a canvas that was given no size: โ they raise Loomy::LayoutError rather than guess. :fill works on groups and stacks too, not just layers.
fit: says how a source reaches the box it was given:
fit: |
|
|---|---|
:contain |
scale to fit inside the box, preserving aspect ratio (the default) |
:cover |
scale until it covers the box and crop the overflow |
:stretch |
scale each axis independently to hit the box exactly |
width: :fill implies :stretch on its own: it names a box, and the layer has to reach it. fit: :cover still wins over that, because cropping is what you asked for.
trim: crops a source to its content before any of this happens, so the layer measures at the size of what is actually in the file rather than the size of the file:
layer "art.png", trim: true # by alpha if the source has one, by colour if not
layer "art.png", trim: :auto # the same thing, spelled out
layer "art.png", trim: :alpha # the extent of the pixels that are not fully transparent
layer "art.png", trim: :color # the extent of the pixels that differ from the background colour:auto is the default and what true means, so writing it out says the same as leaving it off. The two modes it picks between answer different questions, and neither is the other's approximation:
-
:alphaignores colour entirely and reads the alpha channel. It is exact โ a single pixel of content counts โ and nothing about the colourspace can change the answer. A source with no alpha channel has no transparent border to find, so it is left whole. -
:coloris libvips'find_trim, which measures distance from a background colour that defaults to white. It is the only mode that can trim opaque artwork with a uniform border, and the only one that can miss: a white subject on transparency is that background once flattened, so the whole image reads as border and nothing is cropped. Greyscale and CMYK sources miss the same way.
A source with nothing to find โ fully transparent, or entirely the background colour โ keeps its full size.
bounds_of measures the same thing without cropping, for positioning against artwork whose content does not fill its canvas. It takes the same modes, and has to be asked in the mode you crop in:
bounds = bounds_of "art.png", :alpha # => Loomy::Bounds(x:, y:, width:, height:)Trimming is a full pixel scan, paid once per source per mode. It is also why a trimmed source cannot be streamed โ see Performance.
8. Built-in Effects
Every effect below is declared inside a layer, group or stack block, and applies in declaration order.
| Effect | Neutral | |
|---|---|---|
blur radius: 10 |
0 |
Gaussian blur. |
grayscale |
โ | Desaturates the colour bands; alpha is untouched. |
adjust_color brightness: 1.2, contrast: 0.9 |
1.0 each |
contrast expands around mid-grey, which is the one value it cannot move; brightness multiplies what comes out of that. |
displace map: "m.png", scale: 20 |
scale: 0 |
Warps the image by up to scale pixels. A mid-grey map pixel means no displacement; the map's first band drives x and its second y. |
relight map: "m.png", type: :soft, strength: 1.0 |
strength: 0 |
Washes the map over the image as light. type: is :soft or :hard. |
relight's strength scales the map around mid-grey, which both blends leave alone: below 1 softens the light, above 1 hardens it, a negative value inverts it โ highlight becomes shadow โ and exactly 0 drops the effect.
An effect that cannot change a pixel is dropped before any pixel work happens, so blur radius: 0 costs nothing.
9. Custom Effects & Registry
Define an effect node and register a processor for it:
class Vignette < Loomy::AST::Effect
def strength = properties[:strength] || 1.0
# Optional: lets the pruner drop the effect when it cannot change a pixel.
def no_op? = strength.zero?
end
Loomy.register_effect(Vignette, ->(image, effect, loader) {
image.my_vips_operation(effect.strength)
})The third argument is the render's source loader. An effect that reads a file from disk โ a displacement or lighting map, say โ should ask it rather than opening the file itself, so the read is cached for the render and any orientation tag is applied:
Loomy.register_effect(Overlay, ->(image, effect, loader) {
target = Loomy::Render::Target.new(width: image.width, height: image.height, fit: :cover)
image.composite(loader.load_map(effect.map, target), :over)
})โก Performance
Measured with bundle exec ruby bench.rb on an Apple Silicon Mac, Ruby 3.3.6, libvips 8.18.5. Composition figures materialise the result to memory, because libvips is demand-driven and Loomy.generate on its own computes no pixels; the end-to-end figures include PNG encoding, which dominates at large sizes.
Reproduce them yourself โ the benchmark is in the repository, and the numbers below are only as good as the machine they came from.
Composition (1024ร1024 canvas, no encoding)
| Scenario | Throughput |
|---|---|
| 2 layers | 430 img/s |
| 5 layers, no effects | 335 img/s |
| 5 layers, with blur + grayscale | 204 img/s |
| 4200ร4800 source scaled to 200px inside a group | 18 img/s |
End to end (render to PNG on disk)
| Scenario | Throughput |
|---|---|
| 2 layers, 1024ร1024 | 91 img/s |
| 3 layers, 4200ร4800 | 6.5 img/s |
| Trim a 2000px source to its 500px of content | 6.6 img/s |
An earlier version of this table reported "~70 img/s simple" and "~60 img/s complex (5+ layers + effects)". The benchmark behind those numbers passed
blur:andgrayscale:as keyword arguments, which are not how effects are declared โ they landed in the property hash and were ignored, so the "with effects" figure was measured with no effects applied.
Peak memory
render and to_blob write the image once and drop it, so a source the tree
reads exactly once can be streamed rather than decoded whole. Two layers over a
4200ร4800 source grow RSS by +69 MB across five renders in one process,
against +142 MB for the same image built with generate and written by
hand. It costs nothing in throughput, which is why the table above cannot see
it โ bundle exec ruby bench.rb reports both.
A source keeps random access when it is read more than once โ behind two layers,
or behind a layer and an effect map โ or when it carries trim: or sits under a
displace. generate hands the image back for you to read whenever and however
often you like, so it streams nothing.
๐บ Roadmap
- Percentage geometry (
width: "50%"), resolved against the parent box - Main-axis distribution on stacks (
distribute:) - Viewport units (
vh,vw) - Rounded corners and masking
- SVG support as layers
- Smart Saliency Masking (Background Removal)
Known limits
-
SourceLoader::NO_LIMITcaps the unconstrained axis at 10,000px, so a source taller than that on its free axis is scaled down to fit the cap. -
Loomy.stylesandLoomy.effectsare process-global; registration is not thread-safe, so register at boot rather than per-request. -
Loomy.generatehands back an unevaluated image, so a source that fails to decode fails inside your ownwrite_to_filewith no Loomy frame on the stack. Graph-building failures are still translated, because those happen beforegeneratereturns. Userender/to_blobif you want the decode translated too. -
premultiplied: trueleaves the result premultiplied; Loomy does not undo it on the way out, because undoing it would destroy the reproducibility the option exists for. It only matters where the output has partial alpha: when the bottom layer is opaque and covers the canvas โ a product photo under a multiply filter, say โ the output alpha is 255 everywhere, the two representations coincide, and only the blend arithmetic changed. Effects on a group under this flag also operate on premultiplied colour, sinceadjust_colorandrelightsplit alpha off assuming straight colour. -
compositing_space:is deliberately not exposed. libvips supports it, but the only useful value returns a float scRGB image in 0..1, which changes the typegeneratehands back and the mid-grey pivot every effect is written around. - Effect processors are registered per exact class, so a subclass of a registered
effect needs its own registration โ it raises
Loomy::UnknownEffectrather than inheriting the parent's processor. -
Loomy.renderis not atomic. libvips opens the destination before it pulls any pixels, so an error raised part-way through the write can leave a partial file behind โ whether it does is the saver's business and varies by libvips version. Write to a temporary path and rename it yourself if you need the destination to be all-or-nothing. -
rescue Loomy::Erroris not a total firewall.AST::VisitorandDSL::NodeBuilderraiseNotImplementedErrorat subclass authors, deliberately outside the hierarchy โ and sinceNotImplementedError < ScriptError, notStandardError, a barerescue StandardErrorwill not catch it either.
๐จ Errors
Everything Loomy raises descends from Loomy::Error, so one rescue covers it. Under that sit exactly two categories, and every concrete error is in one of them:
| Category | Means | Whose fault |
|---|---|---|
Loomy::DeclarationError |
The declaration could not be honoured as written | The caller's |
Loomy::ProcessingError |
The declaration was fine and carrying it out failed | Ours, or libvips' |
Branch on the category, never on the leaf. A leaf added in a later version lands in the right category on its own, so routing written once keeps working:
begin
Loomy.to_blob(".png", size: [1200, 630]) { layer params[:image] }
rescue Loomy::DeclarationError => e # the request asked for something impossible
render_error(422, code: e.code, message: e.message)
rescue Loomy::ProcessingError => e # we could not carry it out
render_error(500, code: e.code, message: e.message)
endEvery error also answers #code with a stable symbol โ :invalid_source, :encode_error โ for putting on a wire. The category and the code are the stable parts; leaf class names are not. There is deliberately no status_code: whether a missing source is a 404, a 400 or a 422 depends on where the path came from, and only the caller knows that.
The declaration is wrong โ Loomy::DeclarationError
| Class | #code |
Raised when |
|---|---|---|
SourceNotFound |
:source_not_found |
the file does not exist or cannot be read |
InvalidSource |
:invalid_source |
the file opens but holds no image libvips can decode |
InvalidColor |
:invalid_color |
a colour value could not be parsed |
UnknownStyle |
:unknown_style |
use :name names a style never defined |
UnknownProperty |
:unknown_property |
the node has no such property |
InvalidValue |
:invalid_value |
the property exists, the value is outside its vocabulary |
UnknownEffect |
:unknown_effect |
an effect was declared with no processor registered for it |
LayoutError |
:layout_error |
geometry cannot be resolved โ a "50%" or a :fill with no parent box to be relative to |
Both halves of a declaration are checked: the property name, and its value.
layer "art.png" do
aling :center # Loomy::UnknownProperty โ no such property (lists what is available)
align :top # Loomy::InvalidValue โ :top belongs to the vertical axis
width :fil # Loomy::InvalidValue โ not a size, so it would have read as undeclared
endalign, valign, anchor, fit, trim, distribute, blend, a canvas's premultiplied, a stack's direction, a gradient's direction and relight's type all have closed vocabularies and say what they expected. width, height and opacity are checked too, against a rule rather than a list: anything else would have reached the render as a value it could not use. A width outside the three forms in Sizing and fit reads as no size at all and the node takes the parent box; an opacity outside 0.0โ1.0 is refused rather than clamped, because opacity: 50 โ a percentage, written the way percentages are written โ would clamp to a fully opaque layer and look like it worked.
blend: is the one vocabulary Loomy does not own, so libvips is asked rather than copied โ a one-pixel composite, once per mode per process. No list here to drift from the installed version, and the error still names every mode libvips would have taken.
Carrying it out failed โ Loomy::ProcessingError
| Class | #code |
Raised when |
|---|---|---|
BackendError |
:backend_error |
libvips refused an operation the composition asked for |
EncodeError |
:encode_error |
the finished image could not be written โ unknown format, unknown write option, unwritable destination |
InternalError |
:internal_error |
Loomy reached a state it does not handle. Always a bug in Loomy โ please report it |
All three wrap the original exception: #cause is the Vips::Error (or, for a write option of the wrong type, the TypeError from ruby-vips), and libvips' own message is quoted under ours. That text is there to be read, never to be matched on โ it names the operation that refused and lists what it would have accepted, and it moves between libvips releases.
Loomy also asks libvips to refuse incomplete files, so a partial upload raises InvalidSource rather than rendering half an image against whatever the decoder had in its buffer.
๐งช Development
bundle exec rake testbundle exec rubocopCode smells, via RubyCritic (Reek + Flay + Flog + churn). rake critic opens an HTML report; rake critic:console prints to the terminal and is what CI runs. Both fail below a score floor set in the Rakefile โ treat it as a ratchet: raise it when the score rises, never lower it to make a build pass.
bundle exec rake criticReek's defaults assume object-heavy application code, and Loomy is a pipeline of passes plus small value objects. .reek.yml turns off the detectors that only ever fire on that shape (UtilityFunction, FeatureEnvy, DuplicateMethodCall, NilCheck, IrresponsibleModule) and says why, in the file. Everything that pointed at something real is still on.
The analysis tools live in their own bundle group, so the test matrix does not install them:
BUNDLE_WITHOUT=lint bundle installGolden reference images are only exactly reproducible on the libvips build they were rendered with (recorded in test/test_helper.rb). To regenerate them deliberately:
bundle exec rake test:baselineKnown rough edge
Layout::Engine carries all of the geometry and RubyCritic flags it for size โ 24 methods, and a [node, box] parameter pair threaded through eight of them. Splitting measure from arrange is awkward because they are mutually recursive (measuring a container arranges its children), so it wants its own change rather than a drive-by.
๐ License
The gem is available as open source under the terms of the MIT License.
