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 |
|
Creates or edits an image mask |
|
Processes every frame of a sequence |
|
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.cand its headermynewproc.h) in the appropriate folder insrc/.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 (
0for success, non-zero for errors). The exact signature is up to you, as it will be called either from ageneric_image_workerimage hook or from ageneric_sequence_workerimage 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
ushortand 32b asfloat. Data for these types are stored indata(pdatafor pointer access) andfdata(fpdatafor 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 useg_assertstatements at the beginning of your function.the
mynewproc_datastructure should be defined inmynewproc.hand contain all the inputs (and possibly outputs) required. Let's say we want a feature that multiplies all pixel values by a factorf. For 32b images, we add an optionalclampargument 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 functionprocess_mynewproc(int nb)and most probably a static parser, likeparse_mynewproc_args(we'll see why when we write the sequence equivalent).in
command.h, declareprocess_mynewproc(int nb).in
command_def.h, define a string, saySTR_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_prerequiresenum insiril.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_argsstruct and an image-specific params struct, and pointargs->opat the operation's descriptor,launch
generic_image_worker()in the processing thread,deal with errors and return a status from the
cmd_errorsenum.
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_argsandcheck_mynewproc_argsis 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 includeg_ascii_strtoullfor integers andg_str_has_prefixfor 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()andshow_time()are handled bygeneric_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
paramsis attached togeneric_img_args->user, the whole thing frees reliably viafree_generic_img_args(which calls yourdestroy_fn). Before that hand-off, freeparamsyourself on the error paths — using yourfree_mynewproc_datadestructor, never a barefree()that would leakseqprefix.
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_hookcomputes the output sequence size (whenhas_output). IfNULL, output images are assumed the same size as input images.compute_mem_limits_hookcomputes how many images can be processed in parallel.prepare_hookhere calls a generic implementation that tidies existing sequences/images before writing an output sequence. Pass a specific implementation, orNULLif nothing is to be prepared.image_hookis the core of the process, applied to each image.save_hook(not set here, so handled generically) saves the output images.finalize_hookfinishes 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:
commandstays FALSE. GUI operations do not setcommand/command_updates_gfit; they provide anidle_functionfor 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 withstart_in_new_threadand an idle. See Display and rendering and Region of Interest Processing.ROI awareness. Point
args->fitat&gui.roi.fitwhengui.roi.active, and setfor_roi. Register an ROI callback (add_roi_callback) and declare support withroi_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.