PaintFE Scripting API

PaintFE embeds Rhai v1.24, 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.

ℹ️
New to scripting? That's totally fine. Rhai's syntax borrows from Rust and JavaScript. If you've ever written any code before, you'll feel at home within minutes. Read the Language Basics section below before diving into the API.

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:

hello_invert.rhai
// 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.

πŸ’‘
Scripts always run on the active layer. Make sure you've selected the layer you want to edit in the Layers panel before running your script.

Language Basics (Rhai)

Rhai syntax is close to Rust and JavaScript. Here's everything you need to write useful scripts:

language_basics.rhai
// 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);
⚠️
Rhai is strictly typed. You cannot add an integer to a float directly. Use .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.

width() β†’ int

Returns the canvas width in pixels.

Example
let w = width(); print("Canvas is " + w + "px wide");
height() β†’ int

Returns the canvas height in pixels.

Example
let h = height(); print("Canvas is " + h + "px tall");
is_selected(x: int, y: int) β†’ bool

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."

Example β€” selection-aware invert
for_each_pixel(|x, y, r, g, b, a| { if is_selected(x, y) { [255 - r, 255 - g, 255 - b, a] // returning nothing leaves unselected pixels as-is } });

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.

get_pixel(x: int, y: int) β†’ array [r, g, b, a]

Returns a 4-element array containing the RGBA values (0–255) of the pixel at (x, y).

Example
let px = get_pixel(50, 100); print("Red: " + px[0]); // px[0] = r print("Green: " + px[1]); // px[1] = g print("Blue: " + px[2]); // px[2] = b print("Alpha: " + px[3]); // px[3] = a
set_pixel(x: int, y: int, r: int, g: int, b: int, a: int)

Sets the pixel at (x, y) to the given RGBA values. Values are automatically clamped to 0–255.

Example
set_pixel(50, 100, 255, 0, 0, 255); // bright red, fully opaque set_pixel(51, 100, 0, 255, 0, 128); // 50% transparent green
get_r / get_g / get_b / get_a(x: int, y: int) β†’ int
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.

Example
let red = get_r(10, 20); set_a(10, 20, 128); // set alpha to 50% without touching RGB
for_each_pixel(|x, y, r, g, b, a| { ... })

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.

Example β€” tint red pixels only
for_each_pixel(|x, y, r, g, b, a| { if r > g && r > b { // if red is dominant [clamp(r + 50, 0, 255), g, b, a] } // other pixels: returned nothing β†’ unchanged });
for_region(x: int, y: int, w: int, h: int, |x, y, r, g, b, a| { ... })

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.

Example β€” darken top-left 100Γ—100
for_region(0, 0, 100, 100, |x, y, r, g, b, a| { [r / 2, g / 2, b / 2, a] });
map_channels(|r, g, b, a| { ... })

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.

Example β€” boost contrast via S-curve (smoothstep)
map_channels(|r, g, b, a| { let curve = |v| { let n = v.to_float() / 255.0; let s = n * n * (3.0 - 2.0 * n); // smoothstep (s * 255.0).to_int() }; [curve.call(r), curve.call(g), curve.call(b), a] });

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

apply_blur(sigma: float)

Gaussian blur. sigma controls the blur radius. Higher = blurrier. Typical range: 0.5–20.0. GPU-accelerated at large values.

Example
apply_blur(3.0);
apply_box_blur(radius: int)

Fast box (averaging) blur. Radius in pixels. Less smooth than Gaussian but fast for large radii.

Example
apply_box_blur(5);
apply_motion_blur(angle: float, distance: float)

Directional blur. angle in degrees (0.0–360.0). distance in pixels.

Example
apply_motion_blur(45.0, 10.0);
apply_sharpen(amount: float)

Unsharp mask sharpening. Typical range: 0.5–5.0.

Example
apply_sharpen(1.5);
apply_reduce_noise(strength: float)

Noise reduction. Range: 0.0–1.0.

Example
apply_reduce_noise(0.5);
apply_median(radius: int)

Median filter. Removes salt-and-pepper noise without blurring edges. Radius ≥ 1.

Example
apply_median(2);

Color & Adjustment

FunctionParametersDescription
apply_invert()β€”Invert RGB channels (not alpha)
apply_desaturate()β€”Convert to grayscale (BT.601 weights)
apply_sepia()β€”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–100Hue/Saturation/Lightness shift
apply_exposure(ev)ev: float (EV stops, Β±)Exposure adjustment
apply_levels(black, white, gamma)black/white: 0–255, gamma: 0.1–10Levels input/output

Noise

apply_noise(amount: float, monochrome: bool)

Adds noise. amount: 0.0–1.0. monochrome = true adds gray grain; false adds color noise.

Example
apply_noise(0.15, true); // subtle film grain apply_noise(0.3, false); // color noise

Distort

FunctionParametersEffect
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

FunctionParametersEffect
apply_glow(radius, intensity)radius: float, intensity: float 0–1Soft bloom
apply_vignette(strength, softness)both: float 0–1Edge darkening
apply_halftone(dot_size)dot_size: floatHalftone dots

Artistic

FunctionParametersEffect
apply_ink(strength, threshold)both: float 0–1Edge-detect line art
apply_oil_painting(radius)radius: int β‰₯ 1Oil 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.

flip_horizontal()

Mirrors the active layer left↔right.

Example
flip_horizontal();
flip_vertical()

Mirrors the active layer top↔bottom.

rotate_180()

Rotates the active layer 180Β°.

Canvas Transforms

These transform all layers. Canvas rotation by 90Β° swaps width and height.

flip_canvas_horizontal()

Mirrors every layer left↔right.

flip_canvas_vertical()

Mirrors every layer top↔bottom.

rotate_canvas_90cw()

Rotates every layer 90Β° clockwise. Swaps canvas width ↔ height.

rotate_canvas_90ccw()

Rotates every layer 90Β° counter-clockwise. Swaps canvas width ↔ height.

rotate_canvas_180()

Rotates every layer 180Β°. Dimensions stay the same.

Resize Operations

resize_image(w: int, h: int, method: string)

Scales every layer to new dimensions. Methods: "nearest", "bilinear" (default), "bicubic", "lanczos". Max 32768Γ—32768.

Example β€” downscale to 50%
let w = width(); let h = height(); resize_image(w / 2, h / 2, "lanczos");
resize_canvas(w: int, h: int, anchor: string)

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".

Example β€” add 100px border
let w = width(); let h = height(); resize_canvas(w + 200, h + 200, "center");

Utility API

Output & Progress

print(message)

Prints any value to the script console. Strings, numbers, arrays, anything goes. No string conversion required.

Example
print("Hello!"); print(42); print("Width: " + width());
sleep(ms: int)

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.

Example β€” animated blur
for i in 0..10 { apply_blur(0.5); // blur a little each step sleep(100); // canvas updates here if Live is on } print("Done!");
progress(fraction: float)

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.

Example
let h = height(); for y in 0..h { // ... process row y ... progress(y.to_float() / h.to_float()); }

Random Numbers

FunctionReturnsNotes
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

FunctionSignatureDescription
clamp(v, lo, hi: int) β†’ intClamp integer to range
clamp_f(v, lo, hi: float) β†’ floatClamp float to range
lerp(a, b, t: float) β†’ floatLinear interpolation: a + (bβˆ’a)Γ—t
distance(x1, y1, x2, y2: float) β†’ floatEuclidean distance
abs_i(x: int) β†’ intAbsolute value (int)
min_i / max_i(a, b: int) β†’ intMin / max of two ints
min_f / max_f(a, b: float) β†’ floatMin / max of two floats
floor / ceil / round(x: float) β†’ floatRounding functions
sqrt(x: float) β†’ floatSquare root
pow(x, y: float) β†’ floatx raised to power y
sin / cos / tan(x: float) β†’ floatTrig (radians)
atan2(y, x: float) β†’ floatTwo-arg arctangent
PI()() β†’ floatReturns Ο€ (3.14159…)

Color Space Conversion

rgb_to_hsl(r: int, g: int, b: int) β†’ array [h, s, l]

Converts RGB (0–255) to HSL. Returns hue (0–360Β°), saturation (0–100), lightness (0–100).

Example
let hsl = rgb_to_hsl(255, 128, 0); print("Hue: " + hsl[0]); // ~30Β°
hsl_to_rgb(h: float, s: float, l: float) β†’ array [r, g, b]

Converts HSL to RGB. h: 0–360, s: 0–100, l: 0–100. Returns [r, g, b] (0–255 each).

Example
let rgb = hsl_to_rgb(200.0, 80.0, 50.0); set_pixel(0, 0, rgb[0], rgb[1], rgb[2], 255);

Types

Rhai TypeNotes
int64-bit signed integer (i64). Used for coordinates, channel values (0–255), sizes.
float64-bit float (f64). Used for effect parameters, math results.
booltrue / false
stringUTF-8 string. Concatenate with +.
arrayDynamic typed array: [1, 2, 3]. Used for pixel values [r,g,b,a], HSL, etc.
⚠️
Rhai is strict: 10 + 3.0 fails. Convert with .to_float() or .to_int(). Similarly: let n = 10; n.to_float() + 3.0 works.

Sandbox Limits

LimitValue
Max operations per run50,000,000
Max function call depth64 levels
Max expression depth64
Max string length10,000 characters
Max array size10,000 elements
Max map size1,000 entries
sleep() max10,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

bw_finish.rhai
// 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_film.rhai
// 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

selection_bw.rhai
// 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)

saturation_drain.rhai
// 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

radial_tint.rhai
// 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]
});

Ready to script?

Download PaintFE and open View β†’ Script Editor to start.

Download Free