Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Script plugins

Script plugins are .pf files stored in the same plugin directories as compiled plugins. They add functions under the plugin. namespace, so you call them the same way you call compiled plugin filters.

Namespace mapping

PixelFlow builds the namespace from the script plugin file path:

  • acme.pf exposes functions as plugin.acme.<function>.
  • acme/utils.pf exposes functions as plugin.acme.utils.<function>.

Namespace segments must be valid script identifiers.

Authoring rules

  • A script plugin file may contain one or more top-level fn declarations.
  • Function parameters may be plain identifiers or typed declarations like clip: Clip.
  • Type declarations may use unions like width: int | none; none() is accepted only when none appears in the union.
  • Functions may accept additional parameters after the input clip.
  • Parameters with defaults are optional at call sites and must be trailing.
  • Type annotations are checked when the script plugin function is called.
  • Each function must assign output exactly once.
  • The assigned output value becomes the function result.

Example with multiple functions in one file:

fn blur(clip) {
    output = clip.std.resize(#{ width: 1280, height: 720 })
}

fn sharpen(clip) {
    output = clip.std.crop(#{ left: 8, right: 8, top: 8, bottom: 8 })
}

Script plugin functions can call compiled plugin filters and other script plugin functions with the same plugin.<namespace>.<filter>(clip, ...) and clip.plugin.<namespace>.<filter>(...) forms that regular scripts use. Calls may target functions in the same script plugin namespace or another loaded script plugin namespace.

fn blur(clip) {
    output = clip.std.resize(#{ width: 1280, height: 720 })
}

fn preview(clip) {
    output = clip.plugin.acme.blur()
}

Example with additional options:

fn resize_to(clip, width, height) {
    output = clip.std.resize(#{ width: width, height: height })
}

fn crop_edges(clip, options) {
    output = clip.std.crop(options)
}

Example with typed parameters, unions, and defaults:

fn resize_to(clip: Clip, width: int | none = 1280, height: int = 720) {
    result = clip

    if is_none(width) {
        result = clip
    } else {
        result = clip.std.resize(#{ width: width, height: height })
    }

    output = result
}

Supported parameter types are Clip, bool, int, float, string, array, map, rational, blob, and none. Use | for unions, for example int | none.

Option map validation

Use option-map helpers when a script plugin accepts #{ ... } options. These helpers let the plugin reject typos, apply defaults, and forward only supported options.

fn resize_to(clip: Clip, options: map = #{}) {
    context = "plugin.acme.resize_to"
    allowed = ["width", "height"]

    option_reject_unknown(options, allowed, context)

    width = option_require(options, "width", "int", context)
    height = option_get_as_or(options, "height", "int", 720, context)

    output = clip.std.resize(#{ width: width, height: height })
}

Use option_pick when the plugin forwards a validated subset without changing values:

fn crop_edges(clip: Clip, options: map = #{}) {
    context = "plugin.acme.crop_edges"
    allowed = ["left", "right", "top", "bottom"]

    option_reject_unknown(options, allowed, context)

    output = clip.std.crop(option_pick(options, allowed))
}

Available helpers:

  • option_has(options, key) returns whether key is present, even when its value is none().
  • option_get(options, key) returns the value, or none() when the key is missing.
  • option_get_or(options, key, default) returns default when the key is missing or none().
  • option_get_as(options, key, expected, context) validates an optional typed value.
  • option_get_as_or(options, key, expected, default, context) validates a typed value with a default.
  • option_require(options, key, expected, context) requires a typed value.
  • option_keys(options) returns keys in stable sorted order.
  • option_unknown_keys(options, allowed) returns unsupported keys in stable sorted order.
  • option_reject_unknown(options, allowed, context) throws on the first unsupported key.
  • option_pick(options, allowed) returns a new map containing only allowed keys.

expected accepts Clip, bool, int, float, number, string, array, map, rational, blob, none, shallow arrays such as array<int>, and unions such as int|none.

For typed helpers, an explicit none() value matches only none or a union that includes none. option_get_as_or is the defaulting exception: it returns the default when the key is missing or when the value is none().

The context string appears at the start of validation errors. For example, #{ widt: 16 } with the example above reports:

plugin.acme.resize_to option 'widt' is not supported

Conditionals and loops are supported, as long as output is assigned only once:

fn conditional_resize(clip, enabled) {
    result = clip

    if enabled {
        result = clip.std.resize(#{ width: 1280, height: 720 })
    } else {
        result = clip.std.crop(#{ left: 8, right: 8, top: 8, bottom: 8 })
    }

    output = result
}

Defaults are the preferred way to make trailing parameters omittable:

fn maybe_resize(clip: Clip, width: int = 1280) {
    output = clip.std.resize(#{ width: width, height: 720 })
}

Use none() with an explicit union when a caller needs to pass an empty value:

fn maybe_resize(clip: Clip, width: int | none) {
    result = clip

    if !is_none(width) {
        result = clip.std.resize(#{ width: width, height: 720 })
    }

    output = result
}

Call forms

When a script plugin function accepts more parameters than the input clip, pass the extra values after the clip argument. In chain-style calls, the clip is still the first function argument, so you only provide the remaining values or option maps.

When trailing defaults are declared, callers may omit those arguments:

clip = source("input.mkv")
output = plugin.acme.resize_to(clip)
output = source("input.mkv").plugin.acme.resize_to(1920)

When none is in a union, callers may pass none() explicitly:

output = source("input.mkv").plugin.acme.maybe_resize(none())

Function-style call:

clip = source("input.mkv")
output = plugin.acme.blur(clip)

Chain-style call:

output = source("input.mkv").plugin.acme.blur()

Function-style call with additional options:

clip = source("input.mkv")
output = plugin.acme.resize_to(clip, 1280, 720)

Chain-style call with an options map:

output = source("input.mkv").plugin.acme.crop_edges(#{
    left: 8,
    right: 8,
    top: 16,
    bottom: 16,
})

Nested namespaces work the same way:

output = source("input.mkv").plugin.acme.utils.blur()

Conflicts and validation errors

Script plugins share the same plugin. namespace as compiled plugins. If two script plugins define the same symbol, or a script plugin conflicts with a compiled plugin, PixelFlow reports the symbol and the source file paths involved.

Invalid or unreadable script plugins are warned and skipped so PixelFlow can continue loading the rest of the plugin directory.

Name collisions are fatal. If two plugins claim the same plugin.* symbol, PixelFlow stops execution instead of letting one plugin silently override the other.

When an error happens inside a script plugin call, PixelFlow can report a script backtrace that includes the .pf file path, the original plugin source line, and the public plugin.* symbol being executed instead of the generated internal Rhai helper name.