Write your own image effects

An effect is a plain JavaScript file. No compiler, installation or registration is needed. Custom effects, like the built-in ones, require Pulpo SVG Pro.

Install and try an effect

  1. Download the code template or copy the code below. Save it as UTF-8 text named my-duotone.js, not my-duotone.js.txt.
  2. Put it directly in effects/ at the root of the folder mounted as your project. Opening a single SVG does not give the app access to its neighbouring folders. Subfolders of effects/ are not scanned.
  3. Mount the project folder in Pulpo SVG, or press Reindex in the project panel if it is already mounted. Repeat this after editing the JavaScript file.
  4. Select one raster image (such as PNG or JPEG), then open Tools → Image → Effect. Your effect appears with a From project badge. Linked SVGs are not supported.
  5. Choose Duotone, change Shadows and Highlights, and move Amount. At 0 the image is unchanged; at 100 it uses the two colours. Transparency is preserved. Cancel to discard the preview, or Apply to keep the result. Save the SVG afterwards.
my-project/
├── artwork/
│   └── drawing.svg
└── effects/
    └── my-duotone.js

The location is relative to the mounted project, not to drawing.svg.

Complete code template

Change id and name, then edit apply. The comments explain what each part does. The script works with either app language.

/*
 * Pulpo SVG effect template.
 * Save this file in your project's effects/ folder. Change id and name,
 * then edit apply. Use a plain-text editor; no compilation is needed.
 * Full instructions: https://collideworks.com/pulpo-svg/docs/effects.html
 */
effect({
  // Unique id. Reusing a built-in effect id replaces that effect in this project.
  id: 'duotone',

  // Names and labels appear as written. Use the language of your audience.
  name: 'Duotone',
  description: 'Maps brightness onto two colours.',

  // Controls appear automatically. Values arrive in p under each control id.
  //   range   -> number        { min, max, step, value }
  //   select  -> string        { options: [{ value, label }], value }
  //   toggle  -> boolean       { value }
  //   color   -> '#rrggbb'     { value }
  //   palette -> ['#rrggbb']   (no value: the user chooses a palette)
  controls: [
    { id: 'oscuro', type: 'color', label: 'Shadows', value: '#1a1c2c' },
    { id: 'claro', type: 'color', label: 'Highlights', value: '#ffcd75' },
    { id: 'mezcla', type: 'range', label: 'Amount', min: 0, max: 100, step: 1, value: 100 },
  ],

  /*
   * img.data: RGBA channels, four numbers per pixel, from 0 to 255.
   * Modify the array elements in place; do not replace it or return a result.
   * img.width / img.height: pixel dimensions; do not change them.
   * p: current control values, keyed by their ids.
   * apply must be synchronous: no async functions or promises.
   * Each pass starts from the original image, not the previous preview.
   */
  apply: function (img, p) {
    var k = p.mezcla / 100;
    var a = hex(p.oscuro);
    var b = hex(p.claro);
    var d = img.data;

    for (var i = 0; i < d.length; i += 4) {
      // Weighted brightness: green contributes more than blue to perceived lightness.
      var y = (0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]) / 255;
      d[i] += (a[0] + (b[0] - a[0]) * y - d[i]) * k;
      d[i + 1] += (a[1] + (b[1] - a[1]) * y - d[i + 1]) * k;
      d[i + 2] += (a[2] + (b[2] - a[2]) * y - d[i + 2]) * k;
      // Leave alpha (d[i + 3]) unchanged to preserve transparency.
    }

    function hex(s) {
      var n = parseInt(s.slice(1), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
  },
});

File format and pixels

Call effect({...}) once per file. Supply a unique non-empty id, a non-empty name and an apply function. description and controls are optional. Do not use import or export.

The file name does not identify the effect: id does. A project effect with the same id as a built-in one replaces it. Give independent effects different ids; if two project files share an id, the later file in name order takes precedence.

apply(img, params) must finish synchronously, without async, promises or timers. Modify the elements of img.data in place and return nothing. Do not replace the array or change img.width or img.height.

img.data is a Uint8ClampedArray: four channels per pixel in RGBA order, each from 0 to 255. Writes are automatically clamped to that range. The pixel at column x, row y starts at (y * img.width + x) * 4. Leave the fourth channel (alpha) unchanged to preserve transparency.

params contains each control's current value under its id. Every preview starts from a fresh copy of the session's original image. Avoid keeping state between calls. Previews use at most 512 pixels on the longest side; Apply processes at full resolution up to 4096 pixels on the longest side; larger images are reduced proportionally to limit memory use. Use the dimensions passed to each call.

Declare your controls

The app builds the controls from data. Use a distinct id for each control. Names, descriptions and labels are displayed as written, without automatic translation; choose the language of your users.

controls: [
  { id: 'amount', type: 'range', label: 'Amount', min: 0, max: 100, step: 1, value: 50 },
  { id: 'mode', type: 'select', label: 'Mode',
    options: [{ value: 'soft', label: 'Soft' }, { value: 'hard', label: 'Hard' }], value: 'soft' },
  { id: 'enabled', type: 'toggle', label: 'Enabled', value: true },
  { id: 'tint', type: 'color', label: 'Tint', value: '#ff0000' },
  { id: 'palette', type: 'palette', label: 'Palette' },
]
TypeValue received in paramsDeclaration
rangeNumbermin, max (greater than min), value; step defaults to 1.
selectOption value stringAt least one option, plus the initial value.
toggleBooleanInitial value: true or false.
color#rrggbb stringInitial colour in the same format.
paletteArray of #rrggbb stringsNo value field; the app resolves the chosen palette.

For example, read params.amount or params.tint in apply. A palette control uses the palettes already available in the app, including project palettes/ files. Handle an empty colour array. Invalid controls are omitted; the rest of the effect can still load. Starting values resets the controls.

Keep or discard the result

Closing the panel or cancelling discards the preview. Apply offers two destinations:

The SVG change can be undone. Undo does not delete or restore a written image file. Save the SVG to keep its new image or link.

Execution and performance

Only install scripts you wrote yourself or obtained from someone you trust. Scripts run when the project is loaded or reindexed, before you press Apply.

Your effect should only process the pixels it receives. Do not use it to open files, connect to the internet or change the app's interface. Standard JavaScript tools such as Math and typed arrays are available for your calculations.

Keep calculations that do not change from pixel to pixel outside the loop, and avoid sorting or creating new arrays for every pixel. Test on a small image first. If a script stops responding, Pulpo may stop it after a minute; reopen the effect and check your loops. Some devices cannot interrupt a stuck script, so avoid loops without a clear end.

Troubleshooting

Built-in effects

You can also choose palette mapping (palette-map), brightness, contrast and gamma (adjust), grayscale (grayscale), inverted colours (invert) or fewer tones per channel (posterize) from the effect list. Give your own script a different id to keep both effects available. If you deliberately reuse one of these ids, your project script takes its place in this project.