Writing a feature

Ok, so here we are, you have read the previous sections so you know roughly your way around Siril's important files and variables. This chapter is the tutorial; for the full worker reference it leans on, see Generic workers.

It can also be worth taking a look at glib that we use a lot to handle all the machinery.

Remember that you will most probably need to write something that can handle:

  • single image or sequences

  • by command or through GUI

  • for mono or color images

  • for 16b or 32b images

but we'll get there.

Which worker do I need?

Almost every feature is built on one of the generic workers (see Generic workers). Pick before you start:

Your operation

Worker

Processes a single image's pixels

generic_image_worker (generic_img_args)

Creates or edits an image mask

generic_mask_worker (generic_mask_args); see Masks

Processes every frame of a sequence

generic_sequence_worker (generic_seq_args)

Pure query, touches no pixels (e.g. a measurement print)

Possibly none — do the work directly in the command, but read Threading first

The workers give you memory checks, threading, locking, progress, undo and idle dispatch for free. Resist the temptation to roll your own thread.

Logging

All log output in Siril goes through the functions declared in src/core/siril_log.h. Each call is thread-safe (serialised by com.mutex) and simultaneously writes to standard output (prefixed with log:), to the GUI log panel, and to the named pipe used by the CLI. Contributors should always use the semantic helpers rather than siril_log_color_message(), which is internal:

  • siril_log_message() — neutral, default colour; general informational output.

  • siril_log_info() — green; confirm that an operation succeeded.

  • siril_log_warning() — salmon; non-fatal anomalies the user should know about.

  • siril_log_error() — red; when an operation fails.

  • siril_log_bold() — bold; section headers or important milestones.

  • siril_log_status() — blue; progress updates during a long operation.

  • siril_log_debug() — debugging output.

All functions accept printf-style format strings and return a pointer to the formatted message (backed by a static buffer), which can be passed to the progress interface if needed. User-visible strings must be wrapped in _(), except for siril_log_debug().

Process a single image

We'll take as an example a feature which does a processing of some sort to an image.

The steps would be:

  • you create a new file (mynewproc.c and its header mynewproc.h) in the appropriate folder in src/.

  • the core processing function takes a gpointer to a structure holding some arguments (one of which is the image you want to process) and returns an integer (0 for success, non-zero for errors). The exact signature is up to you, as it will be called either from a generic_image_worker image hook or from a generic_sequence_worker image hook.

gpointer mynewproc(gpointer p) {
    struct mynewproc_data *args = (struct mynewproc_data*) p;
    // do anything you want to the pixels of args->fit here
    return GINT_TO_POINTER(0);
}

A few things to remember:

  • you will need to deal in most cases with 2 bitdepths. Siril handles both 16b (or lower) as ushort and 32b as float. Data for these types are stored in data (pdata for pointer access) and fdata (fpdata for pointer access) members of the fit structure, for 16b and 32b respectively:

data_type type;        // use of data or fdata is managed by this
WORD *data;            // 16-bit image data (depending on image type)
WORD *pdata[3];        // pointers on data, per layer data access (RGB)
float *fdata;          // same with float
float *fpdata[3];      // same with float

with data_type defined as:

typedef enum { DATA_USHORT, DATA_FLOAT, DATA_UNSUPPORTED } data_type;
  • you will also need to deal with mono and color images. To determine this, test fit->naxes[2] (1 is mono, 3 is color; anything else should raise an error code). You can use g_assert statements at the beginning of your function.

  • the mynewproc_data structure should be defined in mynewproc.h and contain all the inputs (and possibly outputs) required. Let's say we want a feature that multiplies all pixel values by a factor f. For 32b images, we add an optional clamp argument to clamp the output to the [0,1] range.

Warning

The first member of the struct must be a destructor, by convention called destroy_fn. This is the polymorphic pattern the workers rely on: free_generic_img_args calls destroy_any_args(args->user), which invokes the destructor stored as the first member — or, if it is NULL, simply free()s the struct. So: allocate the struct with calloc(); if it owns any heap members, define a destructor that frees them and set destroy_fn to it; if it owns nothing, leave destroy_fn NULL. See Generic workers.

You could have in mynewproc.h the following:

// arg structure definition
struct mynewproc_data {
    destructor destroy_fn;
    fits *fit;         // image to process
    sequence *seq;     // sequence to process
    gchar *seqprefix;  // prefix to add to sequence output
    float f;           // factor
    gboolean clamp;    // clamp to [0,1] for 32b images
    threading_type threads; // for multithreading purposes
};
// single-image processing declaration
gpointer mynewproc(gpointer p);
// destructor (frees heap members, then the struct itself)
void free_mynewproc_data(void *p);

Note

Dealing with sequences and multithreading is explained a bit later, but added there to introduce the concept.

The destructor is trivial but must free every heap member the struct owns:

void free_mynewproc_data(void *p) {
    struct mynewproc_data *args = (struct mynewproc_data*) p;
    if (!args)
        return;
    free(args->seqprefix);
    free(args);
}
  • it is often easier to write 2 static functions for the two bitdepths and call them from the main processing function:

static gpointer mynewproc_ushort(gpointer p) {
    // handle the ushort case here
}

static gpointer mynewproc_float(gpointer p) {
    // handle the float case here
}

gpointer mynewproc(gpointer p) {
    struct mynewproc_data *args = (struct mynewproc_data*) p;
    int retval = 1;
    if (args->fit->type == DATA_USHORT) {
        retval = GPOINTER_TO_INT(mynewproc_ushort(args));
    } else if (args->fit->type == DATA_FLOAT) {
        retval = GPOINTER_TO_INT(mynewproc_float(args));
    }
    return GINT_TO_POINTER(retval);
}

Warning

Keep in mind that your processing should honour the com.pref.force_16bit setting or notify the user that it can't (if there's a very good reason). If your processing requires floating-point maths you may need to convert to float for the calculations, but honouring com.pref.force_16bit means turning the output float data back into WORD data.

Write the command for a single image

Now that you have your processing function, it is time to write the command to test it.

You will need to modify 4 files:

  • in command.c, add a function process_mynewproc(int nb) and most probably a static parser, like parse_mynewproc_args (we'll see why when we write the sequence equivalent).

  • in command.h, declare process_mynewproc(int nb).

  • in command_def.h, define a string, say STR_MYNEWPROC, that will be displayed as the command tooltip. Formatting guidelines are given there.

  • in command_list.h, add your function together with its metadata:

{"mynewproc", 1, "mynewproc factor [-clamp]", process_mynewproc, STR_MYNEWPROC, TRUE, REQ_CMD_SINGLE_IMAGE}

The arguments are:

  • the name of the command,

  • the minimum number of arguments that must be passed (here, at least the factor),

  • the command syntax. Mandatory arguments follow the command name; optional arguments go between []; choices are enclosed in {} with | separating values.

  • the name of the function to call,

  • the name of its tooltip string from command_def.h,

  • whether the command can be used in a script (in most cases, yes),

  • the prerequisites for evaluation, from the cmd_prerequires enum in siril.h (combine with bitwise operators). Here it means a single image must be loaded, otherwise the preprocessor won't even try to evaluate it.

Warning

In all these files, please add your functions respecting alphabetical order!

Now what should process_mynewproc do:

  • parse the arguments,

  • check their consistency,

  • create a generic_img_args struct and an image-specific params struct, and point args->op at the operation's descriptor,

  • launch generic_image_worker() in the processing thread,

  • deal with errors and return a status from the cmd_errors enum.

Arguments are passed by means of a static null-terminated array word (of size 50 — not an invitation to write a 49-argument command!). word[0] is the command name; the following entries are the arguments to parse until the null string.

Writing a parser may seem like overkill here, but the point is to share parsing between the image and sequence commands, avoiding code duplication.

Here's the code to add in command.c:

int mynewproc_image_hook(struct generic_img_args *args, fits *fit, int nb_threads) {
    // Wrapper to the real processing function: the generic_image_worker
    // image hook has a different signature from the generic_sequence_worker
    // image hook, and each calls the actual processing function.
    struct mynewproc_data *params = args->user;
    params->fit = fit;              // operate on the fit the worker handed us
    params->threads = nb_threads;   // honour the worker's thread budget
    return GPOINTER_TO_INT(mynewproc(params));
}

/* The operation descriptor: the single source of truth for this op's hook,
 * log hook, progress label and memory ratio. Defined next to its hook and
 * referenced at the construction site (args->op = &op_desc_mynewproc). */
const op_descriptor op_desc_mynewproc = {
    .id          = "filters.mynewproc",   // stable "area.op" identity, never reused
    .version     = 1,
    .image_hook  = mynewproc_image_hook,
    .log_hook    = mynewproc_log_hook,    // declared in mynewproc.h
    .description = N_("My new process"),  // N_(): the worker translates it
    .mem_ratio   = 1.0f,                  // peak memory as a multiple of image size
    .flags       = OP_MASK_CAPABLE,       // this op respects an active mask
};

static cmd_errors parse_mynewproc_args(int start, int nb, struct mynewproc_data *args) {
    for (int i = start; i < nb; i++) {
        if (i == start) { // first positional argument: factor
            gchar *end;
            args->f = g_ascii_strtod(word[i], &end);
            if (end == word[i]) {
                siril_log_message(_("Invalid argument %s, aborting.\n"), word[i]);
                return CMD_ARG_ERROR;
            }
        } else if (!g_strcmp0(word[i], "-clamp")) { // optional argument clamp
            args->clamp = TRUE;
        } else {
            siril_log_error(_("Unknown parameter %s, aborting.\n"), word[i]);
            return CMD_ARG_ERROR;
        }
    }
    return CMD_OK;
}

int process_mynewproc(int nb) {
    struct mynewproc_data *params = calloc(1, sizeof(struct mynewproc_data));
    if (!params) {
        PRINT_ALLOC_ERR;
        return CMD_ALLOC_ERROR;
    }
    // initialise defaults
    params->destroy_fn = free_mynewproc_data;
    params->f = 1.f;
    params->clamp = FALSE;
    params->threads = MULTI_THREADED;
    params->fit = gfit;
    params->seq = NULL;
    params->seqprefix = NULL;

    cmd_errors retval = parse_mynewproc_args(1, nb, params);
    if (retval) {                 // CMD_OK == 0, so a non-zero retval is an error
        free_mynewproc_data(params);
        return retval;
    }

    retval = check_mynewproc_args(params);
    if (retval) {
        free_mynewproc_data(params);
        return retval;
    }

    // Allocate worker args
    struct generic_img_args *args = calloc(1, sizeof(struct generic_img_args));
    if (!args) {
        PRINT_ALLOC_ERR;
        free_mynewproc_data(params);
        return CMD_ALLOC_ERROR;
    }

    args->fit = gfit;                    // on master gfit is a fits* (see below)
    args->op = &op_desc_mynewproc;       // descriptor supplies image_hook,
                                         // log_hook, description and mem_ratio
    args->idle_function = NULL;          // NULL for commands; the GUI path sets one
    args->verbose = TRUE;
    args->user = params;                 // your params struct, freed via destroy_fn
    args->max_threads = com.max_thread;  // single-image ops usually allow full parallelism
    args->mask_aware = TRUE;             // respect an active mask (worker blends the result)
    args->command = TRUE;                // distinguishes command from GUI operations
    args->command_updates_gfit = TRUE;   // this command modifies gfit (most do)

    if (!start_in_new_thread(generic_image_worker, args)) {
        free_generic_img_args(args); // frees args AND params (via destroy_fn)
        return CMD_GENERIC_ERROR;
    }

    return CMD_OK;
}

Important

Added in version 1.5.

The construction site sets args->op rather than image_hook, log_hook, description and mem_ratio individually — the worker fills those from the descriptor. One last step: register the descriptor by adding a single line to src/core/op_descriptors.def:

OP_DESC(op_desc_mynewproc)    /* filters.mynewproc */

That list is the single source that generates both the extern declarations (src/core/op_descriptors.h) and the registry array, so forgetting it is a build error, not a silent omission. A site may still pre-set description or mem_ratio after args->op to override the descriptor's default (variant labels, a computed ratio). See Operation descriptors.

Changed in version 1.5: args->fit = gfit; and params->fit = gfit; are correct because on master gfit is a pointer (extern fits *gfit). In 1.4 gfit was a value, so the same lines read args->fit = &gfit;. Audit any old patch for &gfit. See gfit.

Note

generic_img_args has no mask_hook member — masking for a single-image op is controlled by mask_aware (the worker blends the hook output through the fit's active mask). Mask creation is a separate worker; see Masks. For the full field-by-field meaning of mem_ratio, for_preview, for_roi, skip_generic_undo, log_hook and max_threads, and how op supplies them, see generic_image_worker.

and in mynewproc.c:

/* argument sanity check function */
cmd_errors check_mynewproc_args(struct mynewproc_data *args) {
    if (args->clamp && com.pref.force_16bit)
        siril_log_message(_("The -clamp option has no effect on 16b output, ignoring.\n"));
    // more checks to come when we deal with sequences
    return CMD_OK;
}

gchar *mynewproc_log_hook(gpointer p, log_hook_detail detail) {
    // Returns a gchar* describing what was done. The params are passed as p so
    // parameters can be included. detail == SUMMARY feeds the undo label;
    // detail == DETAILED is logged and written as a FITS HISTORY card.
    struct mynewproc_data *params = (struct mynewproc_data *) p;
    if (detail == SUMMARY)
        return g_strdup_printf(_("Multiply by %.3f"), params->f);
    return g_strdup_printf(_("Multiplied all pixels by factor %.3f%s"),
                           params->f, params->clamp ? _(", clamped to [0,1]") : "");
}

Many things to say, so we'll go through the new stuff step by step:

  • Having both parse_mynewproc_args and check_mynewproc_args is overkill in this very simple example, but the split scales well.

  • The philosophy is:

    • parse the arguments (check types and ranges for numerical values) in the parser,

    • check their mutual compatibility in the checker. This keeps the parser from becoming a mess to write, test and review. It is up to you whether a failed check aborts the command or just warns.

  • There are many examples of argument handling in command.c. Useful functions include g_ascii_strtoull for integers and g_str_has_prefix for options of the form -opt=....

  • The siril_log_* functions are thread-safe (see the Logging section). Wrap user-visible strings in _(). Be kind to translators: keep messages generic and, where several arguments share a message, factor it:

siril_log_error(_("Mynewproc error: %s should be in the range %s, aborting\n"), "val1", "[-1,1]");
siril_log_error(_("Mynewproc error: %s should be in the range %s, aborting\n"), "val2", "[0,100]");
  • For debug-only output use siril_log_debug (not translatable).

  • set_cursor_waiting() and show_time() are handled by generic_image_worker — do not call them in your processing function or command handler, or you will get duplicate messages.

  • Siril is written in C, so free along the way to avoid leaks. Once params is attached to generic_img_args->user, the whole thing frees reliably via free_generic_img_args (which calls your destroy_fn). Before that hand-off, free params yourself on the error paths — using your free_mynewproc_data destructor, never a bare free() that would leak seqprefix.

Warning

Any change to a graphical display, UI element or label — no matter how tiny — must be done in an idle function. See Threading.

Write the command for a sequence

Once your processing behaves on a single image, it is time to handle a whole sequence, using the machinery in core/processing.c (full reference: generic_sequence_worker).

First add this function to mynewproc.c:

void apply_mynewproc_to_sequence(struct mynewproc_data *mynewproc_args) {
    struct generic_seq_args *args = create_default_seqargs(mynewproc_args->seq);
    args->filtering_criterion = seq_filter_included;
    args->nb_filtered_images = args->seq->selnum;
    args->prepare_hook = seq_prepare_hook;
    args->image_hook = mynewproc_image_hook;
    args->stop_on_error = FALSE;
    args->description = _("Mynewproc");
    args->has_output = TRUE;
    args->parallel = TRUE;
    args->output_type = get_data_type(args->seq->bitpix);
    args->new_seq_prefix = strdup(mynewproc_args->seqprefix);
    args->load_new_sequence = TRUE;
    args->user = mynewproc_args;
    mynewproc_args->fit = NULL;
    start_in_new_thread(generic_sequence_worker, args);
}

Note

args->new_seq_prefix is duplicated with strdup because free_generic_seq_args frees it, while mynewproc_args->seqprefix is owned (and freed) by your destroy_fn — sharing one pointer would double free.

The generic sequence processor can be fine-tuned with several hooks (see generic_sequence_worker for the full call order):

  • compute_size_hook computes the output sequence size (when has_output). If NULL, output images are assumed the same size as input images.

  • compute_mem_limits_hook computes how many images can be processed in parallel.

  • prepare_hook here calls a generic implementation that tidies existing sequences/images before writing an output sequence. Pass a specific implementation, or NULL if nothing is to be prepared.

  • image_hook is the core of the process, applied to each image.

  • save_hook (not set here, so handled generically) saves the output images.

  • finalize_hook finishes up (aggregating and saving results).

  • idle_function (not set here) handles special idle-phase cases.

Name all your hooks mynewproc_hookname and the sequence-apply function apply_mynewproc_to_sequence for consistency.

Then write the related command in command.c, extending the shared parser with the sequence-only -prefix= option:

static cmd_errors parse_mynewproc_args(int start, int nb, struct mynewproc_data *args) {
    for (int i = start; i < nb; i++) {
        if (i == start) { // first positional argument: factor
            gchar *end;
            args->f = g_ascii_strtod(word[i], &end);
            if (end == word[i]) {
                siril_log_message(_("Invalid argument %s, aborting.\n"), word[i]);
                return CMD_ARG_ERROR;
            }
        } else if (!g_strcmp0(word[i], "-clamp")) { // optional argument clamp
            args->clamp = TRUE;
        } else if (g_str_has_prefix(word[i], "-prefix=")) { // sequence output prefix
            const char *value = word[i] + strlen("-prefix=");
            if (value[0] == '\0') {
                siril_log_message(_("Missing argument to %s, aborting.\n"), word[i]);
                return CMD_ARG_ERROR;
            }
            free(args->seqprefix);           // free the default before replacing
            args->seqprefix = strdup(value); // heap copy, freed by destroy_fn
        } else {
            siril_log_error(_("Unknown parameter %s, aborting.\n"), word[i]);
            return CMD_ARG_ERROR;
        }
    }
    return CMD_OK;
}

int process_seq_mynewproc(int nb) {
    // check that we can load the sequence
    sequence *seq = load_sequence(word[1], NULL);
    if (!seq)
        return CMD_SEQUENCE_NOT_FOUND;
    // if the sequence is the one already loaded in the GUI, use com.seq
    // (no effect in CLI mode)
    if (check_seq_is_comseq(seq)) {
        free_sequence(seq, TRUE);
        seq = &com.seq;
    }
    struct mynewproc_data *args = calloc(1, sizeof(struct mynewproc_data));
    if (!args) {
        PRINT_ALLOC_ERR;
        return CMD_ALLOC_ERROR;
    }
    // initialise defaults
    args->destroy_fn = free_mynewproc_data;
    args->f = 1.f;
    args->clamp = FALSE;
    args->threads = com.max_thread;
    args->fit = NULL;
    args->seq = seq;
    args->seqprefix = strdup("mnp_"); // "mnp" short for MyNewProcess; heap-allocated

    cmd_errors retval = parse_mynewproc_args(2, nb, args); // word[1] is the sequence name
    if (retval) {
        free_mynewproc_data(args);
        return retval;
    }

    retval = check_mynewproc_args(args);
    if (retval) {
        free_mynewproc_data(args);
        return retval;
    }

    apply_mynewproc_to_sequence(args);
    return CMD_OK;
}

Update the sanity-check function in mynewproc.c (still not instrumental):

/* argument sanity check function */
cmd_errors check_mynewproc_args(struct mynewproc_data *args) {
    if (args->clamp && com.pref.force_16bit)
        siril_log_message(_("The -clamp option has no effect on 16b output, ignoring.\n"));
    if (args->fit && args->seqprefix)
        siril_log_message(_("The -prefix= option has no effect on a single image, ignoring.\n"));
    return CMD_OK;
}

And add the definition in command_list.h (and a STR_SEQ_MYNEWPROC in command_def.h):

{"seqmynewproc", 2, "seqmynewproc seqname factor [-clamp] [-prefix=]", process_seq_mynewproc, STR_SEQ_MYNEWPROC, TRUE, REQ_CMD_NONE}

Add the GUI version

The command works; now expose it in the GUI. The mechanical steps to create and register the dialog itself — the .ui file, ui_files.h, the resource manifest and the dialogs.c registry — are documented once in Adding a new dialog. This section covers the part specific to a processing feature: wiring the dialog's Apply button to the same worker and params struct you already wrote. A small, clean exemplar to read alongside this is src/gui-gtk4/asinh.c.

Where the code lives

The GUI callbacks for your feature live in src/gui-gtk4/mynewproc.c (same base name as the core file). The prevailing convention is to cache the dialog's widget pointers in file-static variables, filled once by a guarded mynewproc_dialog_init_statics() function called when the dialog first opens (see asinh_dialog_init_statics in the exemplar); lookup_widget is fine for one-off lookups.

The apply callback

Reuse the same params struct and the same image_hook; only the framing differs from the command path:

static int mynewproc_process(gboolean for_preview) {
    struct mynewproc_data *params = calloc(1, sizeof(struct mynewproc_data));
    if (!params) { PRINT_ALLOC_ERR; return 1; }
    params->destroy_fn = free_mynewproc_data;
    // read parameters from the widgets (lookup_widget by id + getters)
    params->f = gtk_spin_button_get_value(GTK_SPIN_BUTTON(lookup_widget("mynewproc_factor")));

    struct generic_img_args *args = calloc(1, sizeof(struct generic_img_args));
    if (!args) { PRINT_ALLOC_ERR; free_mynewproc_data(params); return 1; }

    args->fit = gui.roi.active ? &gui.roi.fit : gfit;  // ROI-aware
    args->mem_ratio = 1.0f;
    args->image_hook = mynewproc_image_hook;           // reused from command.c
    args->log_hook = mynewproc_log_hook;
    args->description = _("My new process");
    args->user = params;
    args->mask_aware = TRUE;
    args->max_threads = com.max_thread;
    args->for_preview = for_preview;
    args->for_roi = gui.roi.active;
    // command stays FALSE for GUI ops; use a completion idle for the apply
    args->idle_function = for_preview ? NULL : mynewproc_apply_idle;

    if (for_preview)
        generic_image_worker(args);                    // synchronous, on this thread
    else
        start_in_new_thread(generic_image_worker, args); // queued on the worker thread
    return 0;
}

Key differences from the command path:

  • command stays FALSE. GUI operations do not set command / command_updates_gfit; they provide an idle_function for the final apply so GUI updates happen on the main thread.

  • Preview runs synchronously. For a live preview, call generic_image_worker(args) directly (for_preview = TRUE, idle_function = NULL) — this is safe because your update function is invoked by the preview machinery (notify_update), which dispatches it on the processing thread, not from the widget callback. For the committed apply, submit it with start_in_new_thread and an idle. See Display and rendering and Region of Interest Processing.

  • ROI awareness. Point args->fit at &gui.roi.fit when gui.roi.active, and set for_roi. Register an ROI callback (add_roi_callback) and declare support with roi_supported(TRUE) at dialog startup.

Preview, backup and undo

The preview machinery works against a backup of gfit: take a snapshot with copy_gfit_to_backup() when the dialog opens, restore it with copy_backup_to_gfit() before each new preview and on cancel, and drop it with clear_backup() on close (details in Display and rendering). You do not save undo yourself: on the committed (non-preview) apply, generic_image_worker creates the undo state on the swap path using your log_hook SUMMARY string. Preview runs never save undo (for_preview suppresses it).

Headless guards

GUI code never runs headless, but core code your dialog shares might. Guard any GUI-only path with com.headless / com.script where relevant, and route every GUI effect from a worker thread through gui_iface — never call GTK directly from the worker. See The separation rules.