Scripting / repeatable image work.
PaintFE embeds Rhai v1.25, a fast, safe, sandboxed scripting language. Scripts run on the active layer's pixels and can call built-in effects, manipulate individual pixels, perform math, and watch their progress live on the canvas.
Opening the Script Editor
Go to View → Script Editor in the menu bar. The editor opens as a floating panel. You can move, resize, or collapse it independently of the canvas.
The editor toolbar has four buttons:
- ▶ Run: Execute the current script against the active layer.
- ■ Stop: Cancel a running script at any time.
- Live: Toggle live canvas updates during
sleep()calls. - Save / Load: Manage saved scripts. Scripts persist to disk between sessions.
Your First Script
Let's write a script that inverts every pixel in the image:
// Invert all pixels
map_channels(|r, g, b, a| {
[255 - r, 255 - g, 255 - b, a]
});
print("Done! Canvas inverted.");
Click ▶ Run and your canvas flips to its negative. Click Ctrl+Z to undo.
Language Basics (Rhai)
Rhai syntax is close to Rust and JavaScript. Here's everything you need to write useful scripts:
// Variables: use `let`
let x = 42; // integer (i64)
let f = 3.14; // float (f64)
let name = "hello"; // string
let flag = true; // boolean
// Arithmetic
let sum = x + 10; // 52
let half = f / 2.0; // 1.57
// Type conversion (Rhai is strict!)
let as_float = x.to_float(); // 42.0
let as_int = f.to_int(); // 3 (truncates)
// if / else
if x > 10 {
print("big");
} else {
print("small");
}
// while loop
let i = 0;
while i < 5 {
print(i);
i += 1;
}
// for loop (range)
for j in 0..10 { // 0, 1, 2 ... 9
print(j);
}
// Functions
fn double(v) {
v * 2 // last expression = return value
}
print(double(7)); // 14
// Arrays
let arr = [10, 20, 30];
print(arr[0]); // 10
print(arr.len()); // 3
// Closures (used by bulk iteration functions)
let add_ten = |v| v + 10;
// Stored closures must use .call():
print(add_ten.call(5)); // 15
// String concatenation
let msg = "Width: " + width();
print(msg);
.to_float() or .to_int() to convert.
This is the most common beginner stumbling block.
Canvas API
Read-only information about the canvas. These functions never modify the image.
Returns the canvas width in pixels.
Returns the canvas height in pixels.
Returns true if pixel (x, y) falls within the current selection mask.
If there is no active selection, returns true for all pixels, meaning "the entire canvas is selected."
Pixel API
Direct read/write access to individual pixels. Coordinates are zero-based. Out-of-bounds reads return [0,0,0,0]; out-of-bounds writes are silently ignored.
Returns a 4-element array containing the RGBA values (0–255) of the pixel at (x, y).
Sets the pixel at (x, y) to the given RGBA values. Values are automatically clamped to 0–255.
set_r / set_g / set_b / set_a(x: int, y: int, v: int)
Fast single-channel read/write. When you only need one channel, these avoid the overhead of creating a full pixel array.
Iterates over every pixel. If the closure returns an array [r, g, b, a], that pixel is updated.
If the closure returns nothing (empty), the pixel is unchanged. This makes it easy to apply conditional per-pixel logic.
Same as for_each_pixel but restricted to a rectangular sub-region starting at (x, y) of size w × h. Much faster than iterating the whole canvas when you only need to affect part of it.
Fastest bulk path. Like for_each_pixel but without x/y coordinates. The closure receives only the channel values and must return [r, g, b, a].
Use this whenever you don't need to know a pixel's position.
Effect API
These functions call PaintFE's built-in image processing kernels. They operate on the entire active layer and are significantly faster than implementing the same effect via per-pixel scripting.
Blur & Denoise
Gaussian blur. sigma controls the blur radius. Higher = blurrier. Typical range: 0.5–20.0. GPU-accelerated at large values.
Fast box (averaging) blur. Radius in pixels. Less smooth than Gaussian but fast for large radii.
Directional blur. angle in degrees (0.0–360.0). distance in pixels.
Unsharp mask sharpening. Typical range: 0.5–5.0.
Noise reduction. Range: 0.0–1.0.
Median filter. Removes salt-and-pepper noise without blurring edges. Radius ≥ 1.
Color & Adjustment
| Function | Parameters | Description |
|---|---|---|
apply_invert() | None | Invert RGB channels (not alpha) |
apply_desaturate() | None | Convert to grayscale (BT.601 weights) |
apply_sepia() | None | Classic sepia tone |
apply_brightness_contrast(b, c) | b, c: float (−255 to 255) | Brightness + contrast adjustment |
apply_hsl(h, s, l) | h: −180–180°, s/l: −100–100 | Hue/Saturation/Lightness shift |
apply_exposure(ev) | ev: float (EV stops, ±) | Exposure adjustment |
apply_levels(black, white, gamma) | black/white: 0–255, gamma: 0.1–10 | Levels input/output |
Noise
Adds noise. amount: 0.0–1.0. monochrome = true adds gray grain; false adds color noise.
Distort
| Function | Parameters | Effect |
|---|---|---|
apply_pixelate(size) | size: int (block px) | Mosaic / pixelation |
apply_crystallize(size) | size: int (cell px) | Voronoi crystal shards |
apply_bulge(amount) | amount: float (neg = pinch) | Spherical distortion |
apply_twist(angle) | angle: float (degrees) | Swirl / twist distortion |
Stylize
| Function | Parameters | Effect |
|---|---|---|
apply_glow(radius, intensity) | radius: float, intensity: float 0–1 | Soft bloom |
apply_vignette(strength, softness) | both: float 0–1 | Edge darkening |
apply_halftone(dot_size) | dot_size: float | Halftone dots |
Artistic
| Function | Parameters | Effect |
|---|---|---|
apply_ink(strength, threshold) | both: float 0–1 | Edge-detect line art |
apply_oil_painting(radius) | radius: int ≥ 1 | Oil painting simulation |
Transform API
Geometric transforms for the active layer or the entire canvas. Layer-only functions transform just the active layer. Canvas-wide functions transform every layer and may change overall canvas dimensions.
Layer Transforms
These affect only the active layer. No undo overhead beyond the standard single-layer snapshot.
Mirrors the active layer left↔right.
Mirrors the active layer top↔bottom.
Rotates the active layer 180°.
Canvas Transforms
These transform all layers. Canvas rotation by 90° swaps width and height.
Mirrors every layer left↔right.
Mirrors every layer top↔bottom.
Rotates every layer 90° clockwise. Swaps canvas width ↔ height.
Rotates every layer 90° counter-clockwise. Swaps canvas width ↔ height.
Rotates every layer 180°. Dimensions stay the same.
Resize Operations
Crops every layer to a rectangle in pixels. Coordinates start at the top-left corner: x is the distance from the left and y is the distance from the top. Width and height must be positive, and the whole rectangle must fit inside the current canvas. Invalid rectangles fail the script.
Layer masks, the selection and editable text positions follow the crop. In the editor, the whole script is recorded as one undo step. Use resize_image to scale an image; use crop_canvas to keep a specific region without scaling it.
To save the result under a new name or crop a folder of images, see Crop and Batch Processing below.
Scales every layer to new dimensions. Methods: "nearest", "bilinear" (default), "bicubic", "lanczos". Max 32768×32768.
Extends or crops the canvas. Content stays at the given anchor position.
Anchors: "top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right".
Short aliases: "tl", "tc", "tr", "cl", "c", "cr", "bl", "bc", "br".
Crop and Batch Processing
Available in native PaintFE 1.4.2 and later. Run the crop in View > Script Editor for the open document, or use the command line to load files, run a script and save the results. The command-line examples below are for the installed desktop app.
Save this as crop.rhai. Each input image must be at least 500 pixels wide and 350 pixels high for this rectangle to fit.
// Keep 400 x 300 pixels, starting at x=100, y=50.
crop_canvas(100, 50, 400, 300);
Save to a New File
Run this command from the folder containing PaintFE, the script and the input image. It writes cropped.png and keeps original.png unchanged. You can also supply full paths.
.\PaintFE.exe --input original.png --script crop.rhai --output cropped.png
Process Multiple Images
Quote the input pattern so PaintFE expands it. This crops each JPEG in photos and saves a PNG with the same base name in processed, for example photos/holiday.jpg becomes processed/holiday.png.
.\PaintFE.exe --input "photos/*.jpg" --script crop.rhai --output-dir processed --format png
Linux and macOS use the same options. Replace .\PaintFE.exe with the path to your executable, such as ./PaintFE. Quote paths that contain spaces.
load() or save() functions. Scripts remain sandboxed with no filesystem or network access.- Existing output files are protected unless you add
--overwrite. - Input files cannot be overwritten, even with
--overwrite. Choose a different output name or directory. - Duplicate output names abort the batch before any files are written. Use separate output directories if inputs share a base name.
- Use
--format pfeto preserve editable layers instead of exporting a raster image.
Utility API
Output & Progress
Prints any value to the script console. Strings, numbers, arrays, anything goes. No string conversion required.
Pauses execution for ms milliseconds (max 10,000ms). When the Live button is active, each sleep() sends the current pixel state to the canvas. You can watch your script work in real time.
Sets the script progress bar. fraction from 0.0 to 1.0. Call this in loops to show users how far along the script is.
Random Numbers
| Function | Returns | Notes |
|---|---|---|
rand_int(min, max) | int in [min, max) | Upper bound is exclusive |
rand_float(min, max) | float in [min, max) | Upper bound is exclusive |
Math Functions
| Function | Signature | Description |
|---|---|---|
clamp | (v, lo, hi: int) → int | Clamp integer to range |
clamp_f | (v, lo, hi: float) → float | Clamp float to range |
lerp | (a, b, t: float) → float | Linear interpolation: a + (b−a)×t |
distance | (x1, y1, x2, y2: float) → float | Euclidean distance |
abs_i | (x: int) → int | Absolute value (int) |
min_i / max_i | (a, b: int) → int | Min / max of two ints |
min_f / max_f | (a, b: float) → float | Min / max of two floats |
floor / ceil / round | (x: float) → float | Rounding functions |
sqrt | (x: float) → float | Square root |
pow | (x, y: float) → float | x raised to power y |
sin / cos / tan | (x: float) → float | Trig (radians) |
atan2 | (y, x: float) → float | Two-arg arctangent |
PI() | () → float | Returns π (3.14159…) |
Color Space Conversion
Converts RGB (0–255) to HSL. Returns hue (0–360°), saturation (0–100), lightness (0–100).
Converts HSL to RGB. h: 0–360, s: 0–100, l: 0–100. Returns [r, g, b] (0–255 each).
Types
| Rhai Type | Notes |
|---|---|
int | 64-bit signed integer (i64). Used for coordinates, channel values (0–255), sizes. |
float | 64-bit float (f64). Used for effect parameters, math results. |
bool | true / false |
string | UTF-8 string. Concatenate with +. |
array | Dynamic typed array: [1, 2, 3]. Used for pixel values [r,g,b,a], HSL, etc. |
10 + 3.0 fails. Convert with .to_float() or .to_int(). Similarly: let n = 10; n.to_float() + 3.0 works.Sandbox Limits
| Limit | Value |
|---|---|
| Max operations per run | 50,000,000 |
| Max function call depth | 64 levels |
| Max expression depth | 64 |
| Max string length | 10,000 characters |
| Max array size | 10,000 elements |
| Max map size | 1,000 entries |
| sleep() max | 10,000 ms per call |
Scripts can be cancelled at any time using the ■ Stop button, even during infinite loops.
Full Script Examples
1. Dramatic B&W Photo Finish
// Classic B&W with contrast boost and vignette
apply_desaturate();
apply_brightness_contrast(10.0, 40.0);
apply_vignette(0.55, 0.3);
print("Done: dramatic B&W applied.");
2. Warm Film Look
// Warm hue shift + grain + vignette
apply_hsl(12.0, 15.0, 0.0); // warm tone
apply_noise(0.06, true); // subtle grain
apply_vignette(0.35, 0.5); // soft edges
print("Warm film look applied.");
3. Freeze Color: Selection-Aware
// Desaturate everything OUTSIDE the selection
// → keeps selected area in color, rest goes grey
for_each_pixel(|x, y, r, g, b, a| {
if !is_selected(x, y) {
let grey = (0.299 * r.to_float()
+ 0.587 * g.to_float()
+ 0.114 * b.to_float()).to_int();
[grey, grey, grey, a]
}
});
4. Animated Saturation Drain (Live Preview)
// Enable Live in the toolbar to watch this run!
let steps = 20;
let i = 0;
while i < steps {
apply_hsl(0.0, -5.0, 0.0); // drain 5% sat
progress(i.to_float() / steps.to_float());
sleep(200); // canvas updates live
i += 1;
}
print("Colour drain complete!");
5. Radial Distance Tint
// Tint pixels based on distance from center
let cx = (width() / 2).to_float();
let cy = (height() / 2).to_float();
let max_d = distance(0.0, 0.0, cx, cy);
for_each_pixel(|x, y, r, g, b, a| {
let d = distance(x.to_float(), y.to_float(), cx, cy);
let t = clamp_f(d / max_d, 0.0, 1.0);
// interpolate from original colour to purple tint
let new_r = lerp(r.to_float(), 80.0, t).to_int();
let new_b = lerp(b.to_float(), 180.0, t).to_int();
[new_r, g, new_b, a]
});