src/mcp/dt_bridge.c
Rama master · réplica de lectura del repositorio original.
/*
This file is part of darktable,
Copyright (C) 2026 darktable developers.
darktable is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
darktable is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with darktable. If not, see <http://www.gnu.org/licenses/>.
*/
#include "mcp/dt_bridge.h"
#include "common/colorspaces.h"
#include "common/darktable.h"
#include "control/conf.h"
#include "common/database.h"
#include "common/film.h"
#include "common/variables.h"
#include "common/image.h"
#include "common/image_cache.h"
#include "common/datetime.h"
#include "common/colorlabels.h"
#include "common/ratings.h"
#include "views/view.h"
#include "common/introspection.h"
#include "common/iop_order.h"
#include "common/history.h"
#include "common/styles.h"
#include "common/usermanual_url.h"
#include "develop/develop.h"
#include "develop/imageop.h"
#include "imageio/imageio_common.h"
#include "imageio/imageio_module.h"
#include <cairo/cairo.h>
#include <json-glib/json-glib.h>
#include <limits.h>
#include <sqlite3.h>
#include <stdlib.h>
#include <string.h>
// ---------------------------------------------------------------------------
// small helpers
// ---------------------------------------------------------------------------
// when set, every tool that would change the library refuses instead
static gboolean _read_only = FALSE;
void dt_bridge_set_read_only(const gboolean on)
{
_read_only = on;
}
static void _seterr(char **err, const char *fmt, ...)
{
if(!err) return;
va_list ap;
va_start(ap, fmt);
*err = g_strdup_vprintf(fmt, ap);
va_end(ap);
}
static uint8_t *_hex_to_bytes(const char *hex, size_t *outlen)
{
if(!hex) return NULL;
const size_t n = strlen(hex);
if(n % 2) return NULL;
const size_t bl = n / 2;
uint8_t *b = g_malloc(bl ? bl : 1);
for(size_t i = 0; i < bl; i++)
{
const int hi = g_ascii_xdigit_value(hex[2 * i]);
const int lo = g_ascii_xdigit_value(hex[2 * i + 1]);
if(hi < 0 || lo < 0)
{
g_free(b);
return NULL;
}
b[i] = (uint8_t)((hi << 4) | lo);
}
*outlen = bl;
return b;
}
static char *_bytes_to_hex(const uint8_t *b, size_t n)
{
char *s = g_malloc(2 * n + 1);
static const char hexd[] = "0123456789abcdef";
for(size_t i = 0; i < n; i++)
{
s[2 * i] = hexd[b[i] >> 4];
s[2 * i + 1] = hexd[b[i] & 0xf];
}
s[2 * n] = '\0';
return s;
}
static dt_iop_module_so_t *_find_so(const char *op)
{
if(!op) return NULL;
return dt_iop_get_module_so(op);
}
// full usermanual URL for a module op, or NULL (caller frees)
static char *_doc_url(const char *op)
{
const char *topic = dt_get_help_url(op); // pointer into a static table; do not free
return topic ? dt_get_manual_url(topic) : NULL;
}
static void _add_doc_url(JsonBuilder *b, const char *op)
{
char *url = _doc_url(op);
if(url)
{
json_builder_set_member_name(b, "doc_url");
json_builder_add_string_value(b, url);
g_free(url);
}
}
static const char *_type_name(dt_introspection_type_t t)
{
switch(t)
{
case DT_INTROSPECTION_TYPE_FLOAT: return "float";
case DT_INTROSPECTION_TYPE_DOUBLE: return "double";
case DT_INTROSPECTION_TYPE_INT: return "int";
case DT_INTROSPECTION_TYPE_UINT: return "uint";
case DT_INTROSPECTION_TYPE_INT8: return "int8";
case DT_INTROSPECTION_TYPE_UINT8: return "uint8";
case DT_INTROSPECTION_TYPE_SHORT: return "short";
case DT_INTROSPECTION_TYPE_USHORT: return "ushort";
case DT_INTROSPECTION_TYPE_BOOL: return "bool";
case DT_INTROSPECTION_TYPE_ENUM: return "enum";
default: return "other";
}
}
// is this a flat scalar leaf we can read/write generically?
static gboolean _is_scalar(dt_introspection_type_t t)
{
switch(t)
{
case DT_INTROSPECTION_TYPE_FLOAT:
case DT_INTROSPECTION_TYPE_DOUBLE:
case DT_INTROSPECTION_TYPE_INT:
case DT_INTROSPECTION_TYPE_UINT:
case DT_INTROSPECTION_TYPE_INT8:
case DT_INTROSPECTION_TYPE_UINT8:
case DT_INTROSPECTION_TYPE_SHORT:
case DT_INTROSPECTION_TYPE_USHORT:
case DT_INTROSPECTION_TYPE_BOOL:
case DT_INTROSPECTION_TYPE_ENUM:
return TRUE;
default:
return FALSE;
}
}
// read the scalar at p (of introspection type t) and add it to the builder
static void _add_value(JsonBuilder *b, dt_introspection_field_t *f, const void *p)
{
switch(f->header.type)
{
case DT_INTROSPECTION_TYPE_FLOAT:
json_builder_add_double_value(b, *(const float *)p);
break;
case DT_INTROSPECTION_TYPE_DOUBLE:
json_builder_add_double_value(b, *(const double *)p);
break;
case DT_INTROSPECTION_TYPE_INT:
json_builder_add_int_value(b, *(const int *)p);
break;
case DT_INTROSPECTION_TYPE_UINT:
json_builder_add_int_value(b, *(const unsigned int *)p);
break;
case DT_INTROSPECTION_TYPE_INT8:
json_builder_add_int_value(b, *(const int8_t *)p);
break;
case DT_INTROSPECTION_TYPE_UINT8:
json_builder_add_int_value(b, *(const uint8_t *)p);
break;
case DT_INTROSPECTION_TYPE_SHORT:
json_builder_add_int_value(b, *(const short *)p);
break;
case DT_INTROSPECTION_TYPE_USHORT:
json_builder_add_int_value(b, *(const unsigned short *)p);
break;
case DT_INTROSPECTION_TYPE_BOOL:
json_builder_add_boolean_value(b, (*(const gboolean *)p) != 0);
break;
case DT_INTROSPECTION_TYPE_ENUM:
{
const int v = *(const int *)p;
const char *name = NULL;
for(dt_introspection_type_enum_tuple_t *e = f->Enum.values; e && e->name; e++)
if(e->value == v) { name = e->name; break; }
if(name) json_builder_add_string_value(b, name);
else json_builder_add_int_value(b, v);
break;
}
default:
json_builder_add_null_value(b);
break;
}
}
// write default value of a scalar field into blob at p
static void _write_default(dt_introspection_field_t *f, void *p)
{
switch(f->header.type)
{
case DT_INTROSPECTION_TYPE_FLOAT: *(float *)p = f->Float.Default; break;
case DT_INTROSPECTION_TYPE_DOUBLE: *(double *)p = f->Double.Default; break;
case DT_INTROSPECTION_TYPE_INT: *(int *)p = f->Int.Default; break;
case DT_INTROSPECTION_TYPE_UINT: *(unsigned int *)p = f->UInt.Default; break;
case DT_INTROSPECTION_TYPE_INT8: *(int8_t *)p = f->Int8.Default; break;
case DT_INTROSPECTION_TYPE_UINT8: *(uint8_t *)p = f->UInt8.Default; break;
case DT_INTROSPECTION_TYPE_SHORT: *(short *)p = f->Short.Default; break;
case DT_INTROSPECTION_TYPE_USHORT: *(unsigned short *)p = f->UShort.Default; break;
case DT_INTROSPECTION_TYPE_BOOL: *(gboolean *)p = f->Bool.Default; break;
case DT_INTROSPECTION_TYPE_ENUM: *(int *)p = f->Enum.Default; break;
default: break;
}
}
// the bounds module_schema publishes. a field whose source carries no range
// comment gets its type's full range (introspection.h:77), so this only ever
// rejects what a module actually declares out of bounds
static gboolean _num_in_range(dt_introspection_field_t *f, const double num,
double *lo, double *hi)
{
*lo = 0.0;
*hi = 0.0;
switch(f->header.type)
{
case DT_INTROSPECTION_TYPE_FLOAT: *lo = f->Float.Min; *hi = f->Float.Max; break;
case DT_INTROSPECTION_TYPE_DOUBLE: *lo = f->Double.Min; *hi = f->Double.Max; break;
case DT_INTROSPECTION_TYPE_INT: *lo = f->Int.Min; *hi = f->Int.Max; break;
case DT_INTROSPECTION_TYPE_UINT: *lo = f->UInt.Min; *hi = f->UInt.Max; break;
case DT_INTROSPECTION_TYPE_INT8: *lo = f->Int8.Min; *hi = f->Int8.Max; break;
case DT_INTROSPECTION_TYPE_UINT8: *lo = f->UInt8.Min; *hi = f->UInt8.Max; break;
case DT_INTROSPECTION_TYPE_SHORT: *lo = f->Short.Min; *hi = f->Short.Max; break;
case DT_INTROSPECTION_TYPE_USHORT: *lo = f->UShort.Min; *hi = f->UShort.Max; break;
default: return TRUE; // bool and enum carry no range
}
return num >= *lo && num <= *hi;
}
static void _write_num(dt_introspection_field_t *f, void *p, double num)
{
switch(f->header.type)
{
case DT_INTROSPECTION_TYPE_FLOAT: *(float *)p = (float)num; break;
case DT_INTROSPECTION_TYPE_DOUBLE: *(double *)p = num; break;
case DT_INTROSPECTION_TYPE_INT: *(int *)p = (int)num; break;
case DT_INTROSPECTION_TYPE_UINT: *(unsigned int *)p = (unsigned int)num; break;
case DT_INTROSPECTION_TYPE_INT8: *(int8_t *)p = (int8_t)num; break;
case DT_INTROSPECTION_TYPE_UINT8: *(uint8_t *)p = (uint8_t)num; break;
case DT_INTROSPECTION_TYPE_SHORT: *(short *)p = (short)num; break;
case DT_INTROSPECTION_TYPE_USHORT: *(unsigned short *)p = (unsigned short)num; break;
case DT_INTROSPECTION_TYPE_BOOL: *(gboolean *)p = (num != 0.0); break;
case DT_INTROSPECTION_TYPE_ENUM: *(int *)p = (int)num; break;
default: break;
}
}
static char *_builder_to_string(JsonBuilder *b)
{
JsonNode *root = json_builder_get_root(b);
JsonGenerator *gen = json_generator_new();
json_generator_set_root(gen, root);
gchar *out = json_generator_to_data(gen, NULL);
g_object_unref(gen);
json_node_unref(root);
return out;
}
// ---------------------------------------------------------------------------
// public API
// ---------------------------------------------------------------------------
char *dt_bridge_list_modules_json(void)
{
JsonBuilder *b = json_builder_new();
json_builder_begin_array(b);
for(GList *m = darktable.iop; m; m = g_list_next(m))
{
dt_iop_module_so_t *so = (dt_iop_module_so_t *)m->data;
json_builder_begin_object(b);
json_builder_set_member_name(b, "operation");
json_builder_add_string_value(b, so->op);
json_builder_set_member_name(b, "version");
json_builder_add_int_value(b, so->version ? so->version() : -1);
json_builder_set_member_name(b, "have_introspection");
json_builder_add_boolean_value(b, so->have_introspection);
_add_doc_url(b, so->op);
json_builder_end_object(b);
}
json_builder_end_array(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
char *dt_bridge_module_schema_json(const char *op, char **err)
{
dt_iop_module_so_t *so = _find_so(op);
if(!so)
{
_seterr(err, "unknown module operation '%s'", op ? op : "(null)");
return NULL;
}
if(!so->have_introspection || !so->get_introspection || !so->get_introspection_linear)
{
_seterr(err, "module '%s' has no introspection", op);
return NULL;
}
dt_introspection_t *intro = so->get_introspection();
dt_introspection_field_t *lin = so->get_introspection_linear();
JsonBuilder *b = json_builder_new();
json_builder_begin_object(b);
json_builder_set_member_name(b, "operation");
json_builder_add_string_value(b, so->op);
json_builder_set_member_name(b, "params_version");
json_builder_add_int_value(b, intro->params_version);
json_builder_set_member_name(b, "params_size");
json_builder_add_int_value(b, (gint64)intro->size);
_add_doc_url(b, so->op);
json_builder_set_member_name(b, "fields");
json_builder_begin_array(b);
for(dt_introspection_field_t *f = lin;
f && f->header.type != DT_INTROSPECTION_TYPE_NONE; f++)
{
if(!_is_scalar(f->header.type)) continue; // skip root struct / arrays / unions
json_builder_begin_object(b);
json_builder_set_member_name(b, "name");
json_builder_add_string_value(b, f->header.field_name);
json_builder_set_member_name(b, "type");
json_builder_add_string_value(b, _type_name(f->header.type));
json_builder_set_member_name(b, "offset");
json_builder_add_int_value(b, (gint64)f->header.offset);
switch(f->header.type)
{
case DT_INTROSPECTION_TYPE_FLOAT:
json_builder_set_member_name(b, "min");
json_builder_add_double_value(b, f->Float.Min);
json_builder_set_member_name(b, "max");
json_builder_add_double_value(b, f->Float.Max);
json_builder_set_member_name(b, "default");
json_builder_add_double_value(b, f->Float.Default);
break;
case DT_INTROSPECTION_TYPE_INT:
json_builder_set_member_name(b, "min");
json_builder_add_int_value(b, f->Int.Min);
json_builder_set_member_name(b, "max");
json_builder_add_int_value(b, f->Int.Max);
json_builder_set_member_name(b, "default");
json_builder_add_int_value(b, f->Int.Default);
break;
case DT_INTROSPECTION_TYPE_UINT:
json_builder_set_member_name(b, "min");
json_builder_add_int_value(b, f->UInt.Min);
json_builder_set_member_name(b, "max");
json_builder_add_int_value(b, f->UInt.Max);
json_builder_set_member_name(b, "default");
json_builder_add_int_value(b, f->UInt.Default);
break;
case DT_INTROSPECTION_TYPE_BOOL:
json_builder_set_member_name(b, "default");
json_builder_add_boolean_value(b, f->Bool.Default != 0);
break;
case DT_INTROSPECTION_TYPE_ENUM:
json_builder_set_member_name(b, "default");
json_builder_add_int_value(b, f->Enum.Default);
json_builder_set_member_name(b, "values");
json_builder_begin_array(b);
for(dt_introspection_type_enum_tuple_t *e = f->Enum.values; e && e->name; e++)
{
json_builder_begin_object(b);
json_builder_set_member_name(b, "name");
json_builder_add_string_value(b, e->name);
json_builder_set_member_name(b, "value");
json_builder_add_int_value(b, e->value);
json_builder_end_object(b);
}
json_builder_end_array(b);
break;
default: break;
}
json_builder_end_object(b);
}
json_builder_end_array(b);
json_builder_end_object(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
// write a { field: value, ... } object for all scalar fields of `blob`
static void _write_fields_object(dt_iop_module_so_t *so, const void *blob,
JsonBuilder *b)
{
dt_introspection_field_t *lin = so->get_introspection_linear();
json_builder_begin_object(b);
for(dt_introspection_field_t *f = lin;
f && f->header.type != DT_INTROSPECTION_TYPE_NONE; f++)
{
if(!_is_scalar(f->header.type)) continue;
void *p = so->get_p((void *)blob, f->header.name);
if(!p) continue;
json_builder_set_member_name(b, f->header.field_name);
_add_value(b, f, p);
}
json_builder_end_object(b);
}
char *dt_bridge_decode_params_json(const char *op, const char *blob_hex, char **err)
{
dt_iop_module_so_t *so = _find_so(op);
if(!so)
{
_seterr(err, "unknown module operation '%s'", op ? op : "(null)");
return NULL;
}
if(!so->have_introspection || !so->get_introspection || !so->get_p)
{
_seterr(err, "module '%s' has no introspection", op);
return NULL;
}
size_t blen = 0;
uint8_t *blob = _hex_to_bytes(blob_hex, &blen);
if(!blob) { _seterr(err, "invalid hex blob"); return NULL; }
dt_introspection_t *intro = so->get_introspection();
if(blen != intro->size)
{
_seterr(err, "blob size %zu != module '%s' params size %zu"
" (version mismatch? pass the current version)",
blen, op, intro->size);
g_free(blob);
return NULL;
}
JsonBuilder *b = json_builder_new();
json_builder_begin_object(b);
json_builder_set_member_name(b, "operation");
json_builder_add_string_value(b, so->op);
json_builder_set_member_name(b, "version");
json_builder_add_int_value(b, intro->params_version);
json_builder_set_member_name(b, "fields");
_write_fields_object(so, blob, b);
json_builder_end_object(b);
char *out = _builder_to_string(b);
g_object_unref(b);
g_free(blob);
return out;
}
// build a params blob from `defaults`, then overwrite the named fields.
// `defaults` is NULL when there is no module instance to take them from
static uint8_t *_seed_and_apply(dt_iop_module_so_t *so, const void *defaults,
JsonObject *fields, size_t *size, char **err)
{
dt_introspection_t *intro = so->get_introspection();
dt_introspection_field_t *lin = so->get_introspection_linear();
uint8_t *blob = g_malloc0(intro->size);
if(defaults)
{
// arrays and curve nodes come across intact, so having one no longer
// rules out setting a module's scalar fields by name
memcpy(blob, defaults, intro->size);
}
else
{
// only scalar defaults are reachable here, and a non-scalar left at
// zero would be worse than refusing
for(dt_introspection_field_t *f = lin;
f && f->header.type != DT_INTROSPECTION_TYPE_NONE; f++)
if(!_is_scalar(f->header.type) && f->header.size != intro->size)
{
_seterr(err, "module '%s' has non-scalar parameters; pass a full blob_hex"
" instead of fields", so->op);
g_free(blob);
return NULL;
}
for(dt_introspection_field_t *f = lin;
f && f->header.type != DT_INTROSPECTION_TYPE_NONE; f++)
{
if(!_is_scalar(f->header.type)) continue;
void *p = so->get_p(blob, f->header.name);
if(p) _write_default(f, p);
}
}
if(fields)
{
GList *members = json_object_get_members(fields);
for(GList *it = members; it; it = g_list_next(it))
{
const char *name = (const char *)it->data;
dt_introspection_field_t *f = so->get_f(name);
if(!f || !_is_scalar(f->header.type))
{
_seterr(err, "unknown or non-scalar field '%s' for module '%s'", name, so->op);
g_list_free(members);
g_free(blob);
return NULL;
}
void *p = so->get_p(blob, f->header.name);
if(!p) continue;
JsonNode *node = json_object_get_member(fields, name);
if(f->header.type == DT_INTROSPECTION_TYPE_ENUM
&& json_node_get_value_type(node) == G_TYPE_STRING)
{
const char *sym = json_node_get_string(node);
int val = 0;
gboolean found = FALSE;
for(dt_introspection_type_enum_tuple_t *e = f->Enum.values; e && e->name; e++)
if(!g_strcmp0(e->name, sym)) { val = e->value; found = TRUE; break; }
if(!found)
{
_seterr(err, "unknown enum value '%s' for field '%s'", sym, name);
g_list_free(members);
g_free(blob);
return NULL;
}
*(int *)p = val;
}
else
{
// refuse rather than clamp: a silently corrected value would render
// fine and leave the caller believing the number they sent was used
const double num = json_node_get_double(node);
double lo = 0.0, hi = 0.0;
if(!_num_in_range(f, num, &lo, &hi))
{
_seterr(err, "field '%s' of module '%s' is %g, outside its range"
" [%g, %g] (see module_schema)", name, so->op, num, lo, hi);
g_list_free(members);
g_free(blob);
return NULL;
}
_write_num(f, p, num);
}
}
g_list_free(members);
}
*size = intro->size;
return blob;
}
char *dt_bridge_encode_params_hex(const char *op, void *fields_jsonobject, char **err)
{
dt_iop_module_so_t *so = _find_so(op);
if(!so)
{
_seterr(err, "unknown module operation '%s'", op ? op : "(null)");
return NULL;
}
if(!so->have_introspection || !so->get_introspection || !so->get_p || !so->get_f)
{
_seterr(err, "module '%s' has no introspection", op);
return NULL;
}
size_t size = 0;
uint8_t *blob = _seed_and_apply(so, NULL, (JsonObject *)fields_jsonobject, &size, err);
if(!blob) return NULL;
char *hex = _bytes_to_hex(blob, size);
g_free(blob);
return hex;
}
// ---------------------------------------------------------------------------
// import + render
// ---------------------------------------------------------------------------
// the image cache hands back a blank entry for any positive id, so only the
// database can say whether an image is real
static gboolean _image_exists(dt_imgid_t imgid)
{
sqlite3 *db = dt_database_get(darktable.db);
sqlite3_stmt *st = NULL;
gboolean found = FALSE;
if(db && sqlite3_prepare_v2(db, "SELECT 1 FROM main.images WHERE id = ?1",
-1, &st, NULL) == SQLITE_OK)
{
sqlite3_bind_int(st, 1, imgid);
found = sqlite3_step(st) == SQLITE_ROW;
sqlite3_finalize(st);
}
return found;
}
// every tool naming a single image goes through this: dt_is_valid_imgid() only
// rejects the sentinel, and the cache leaks a read lock on an unknown id
static gboolean _require_image(int imgid, const char *tool, char **err)
{
if(dt_is_valid_imgid((dt_imgid_t)imgid) && _image_exists((dt_imgid_t)imgid))
return TRUE;
_seterr(err, "%s: no image with imgid %d", tool, imgid);
return FALSE;
}
// is this film roll free of images?
static gboolean _film_is_empty(dt_filmid_t filmid)
{
sqlite3 *db = dt_database_get(darktable.db);
sqlite3_stmt *st = NULL;
gboolean empty = FALSE;
if(db && sqlite3_prepare_v2(db,
"SELECT COUNT(*) FROM main.images WHERE film_id = ?1", -1, &st, NULL)
== SQLITE_OK)
{
sqlite3_bind_int(st, 1, filmid);
if(sqlite3_step(st) == SQLITE_ROW) empty = sqlite3_column_int(st, 0) == 0;
sqlite3_finalize(st);
}
return empty;
}
// where the rows an import is about to create begin. -1 when the catalog could
// not be asked: 0 is a real answer (an empty library), and sharing the two
// would make every row in a roll look new
static dt_imgid_t _max_image_id(void)
{
sqlite3 *db = dt_database_get(darktable.db);
sqlite3_stmt *st = NULL;
dt_imgid_t high = -1;
if(db && sqlite3_prepare_v2(db, "SELECT IFNULL(MAX(id), 0) FROM main.images",
-1, &st, NULL) == SQLITE_OK)
{
if(sqlite3_step(st) == SQLITE_ROW) high = sqlite3_column_int(st, 0);
sqlite3_finalize(st);
}
return high;
}
// every row filed under `filmid` after `since`. one dt_image_import() adds more
// than the image asked for: _image_read_duplicates() (image.c:2068) files a row
// per "<name>_NN.<ext>.xmp" sidecar. db is a parameter so the tests can drive it
static GList *_film_images_since(sqlite3 *db, dt_filmid_t filmid, dt_imgid_t since)
{
// a negative baseline is _max_image_id() saying it could not answer, and
// "every row in the roll" is the one reply that must never be guessed:
// _drop_scratch() would delete the user's own images
if(since < 0) return NULL;
GList *out = NULL;
sqlite3_stmt *st = NULL;
if(db && sqlite3_prepare_v2(db,
"SELECT id FROM main.images WHERE film_id = ?1 AND id > ?2 ORDER BY id",
-1, &st, NULL) == SQLITE_OK)
{
sqlite3_bind_int(st, 1, filmid);
sqlite3_bind_int(st, 2, since);
while(sqlite3_step(st) == SQLITE_ROW)
out = g_list_prepend(out, GINT_TO_POINTER(sqlite3_column_int(st, 0)));
sqlite3_finalize(st);
}
return g_list_reverse(out);
}
// nothing filed for this image but its own row. that is the one state the first
// pipeline run only adds to, so taking back what it added restores the image
// exactly; anywhere else a rollback would lose work
static gboolean _image_is_undeveloped(sqlite3 *db, dt_imgid_t imgid)
{
sqlite3_stmt *st = NULL;
gboolean bare = FALSE;
if(db && sqlite3_prepare_v2(db,
"SELECT i.history_end, i.flags,"
" (SELECT COUNT(*) FROM main.history WHERE imgid = i.id)"
" + (SELECT COUNT(*) FROM main.masks_history WHERE imgid = i.id)"
" + (SELECT COUNT(*) FROM main.module_order WHERE imgid = i.id)"
" + (SELECT COUNT(*) FROM main.history_hash WHERE imgid = i.id)"
" FROM main.images i"
" WHERE i.id = ?1",
-1, &st, NULL) == SQLITE_OK)
{
sqlite3_bind_int(st, 1, imgid);
// a query that could not run leaves this FALSE: nothing is rolled back
// that was not first read back
if(sqlite3_step(st) == SQLITE_ROW)
bare = sqlite3_column_int(st, 0) == 0
&& !(sqlite3_column_int(st, 1) & DT_IMAGE_AUTO_PRESETS_APPLIED)
&& sqlite3_column_int(st, 2) == 0;
sqlite3_finalize(st);
}
return bare;
}
// the catalog matches on the literal folder string (image.c:2138) and
// dt_util_normalize_path() only resolves relative paths (utility.c:784), so
// "/dir/./a.raw" files a second roll and duplicates the image.
// not g_canonicalize_filename(): it is glib 2.58 and the tree caps
// GLIB_VERSION_MAX_ALLOWED at 2.56 (CMakeLists.txt:314). not grealpath.h's
// g_realpath(): it exit()s on failure, which a long-lived server cannot use
static gchar *_canonical_path(const char *path)
{
gchar *norm = dt_util_normalize_path(path); // handles file:// and relative
if(!norm) return NULL;
const char *rest = g_path_skip_root(norm);
if(!rest) return norm;
gchar *root = g_strndup(norm, rest - norm);
gchar **parts = g_strsplit_set(rest, "/" G_DIR_SEPARATOR_S, -1);
GPtrArray *keep = g_ptr_array_new(); // borrows the parts, frees none
for(gchar **p = parts; *p; p++)
{
if(!**p || !g_strcmp0(*p, ".")) continue;
if(!g_strcmp0(*p, ".."))
{
if(keep->len) g_ptr_array_remove_index(keep, keep->len - 1);
continue;
}
g_ptr_array_add(keep, *p);
}
g_ptr_array_add(keep, NULL);
gchar *tail = g_strjoinv(G_DIR_SEPARATOR_S, (gchar **)keep->pdata);
gchar *out = g_strconcat(root, tail, NULL);
g_free(tail);
g_ptr_array_free(keep, TRUE);
g_strfreev(parts);
g_free(root);
g_free(norm);
#ifndef _WIN32
char buf[PATH_MAX] = { 0 };
if(realpath(out, buf)) // NULL when the file is absent
{
g_free(out);
out = g_strdup(buf);
}
#endif
return out;
}
// the one place a file becomes an image row. import_images keeps the result;
// a render given input.path throws it away again (see _resolve_input)
static dt_imgid_t _import_file(const char *path, dt_filmid_t *created_film,
gboolean *created_image, GList **created_ids,
char **err)
{
if(created_film) *created_film = NO_FILMID;
if(created_image) *created_image = FALSE;
if(created_ids) *created_ids = NULL;
if(!path) { _seterr(err, "no input path or imgid provided"); return NO_IMGID; }
// dt_image_import() normalizes but does not canonicalize, so the film roll
// and the image row must both be keyed off the canonical string
gchar *norm = _canonical_path(path);
if(!norm)
{
_seterr(err, "could not resolve '%s'", path);
return NO_IMGID;
}
path = norm;
if(!g_file_test(path, G_FILE_TEST_IS_REGULAR))
{
_seterr(err, "'%s' is not a readable file", path);
g_free(norm);
return NO_IMGID;
}
gchar *dir = g_path_get_dirname(path);
// whether the roll already existed decides if a scratch import may remove it
const gboolean film_existed = dt_is_valid_filmid(dt_film_get_id(dir));
dt_film_t film;
dt_film_init(&film); // holds a mutex; dt_film_new() does not set it up
const dt_filmid_t filmid = dt_film_new(&film, dir);
dt_film_cleanup(&film);
g_free(dir);
if(created_film && !film_existed) *created_film = filmid;
if(!dt_is_valid_filmid(filmid))
{
_seterr(err, "could not create a film roll for '%s'", path);
g_free(norm);
return NO_IMGID;
}
// dt_image_import() hands back the existing id when the file is already in
// the library, and the caller must not then delete the user's image
const gboolean had_image = dt_is_valid_imgid(dt_image_get_id_full_path(path));
const dt_imgid_t high = _max_image_id();
const dt_imgid_t id = dt_image_import(filmid, path, TRUE, FALSE);
if(created_image) *created_image = dt_is_valid_imgid(id) && !had_image;
// sidecar duplicates are rows of their own, so the caller owns all of them.
// with no baseline it can only prove it owns the one id: a stale row left
// behind beats sweeping the roll blind and deleting pre-existing images
if(created_ids && dt_is_valid_imgid(id) && !had_image)
{
if(high >= 0)
*created_ids = _film_images_since(dt_database_get(darktable.db), filmid, high);
// the row just filed is always above the baseline, so an empty answer is
// the query failing, not the roll being bare. owning nothing would leave
// the scratch row behind and refuse every later render of that path
if(!*created_ids)
*created_ids = g_list_prepend(NULL, GINT_TO_POINTER(id));
}
if(!dt_is_valid_imgid(id))
{
_seterr(err, "could not import '%s'", path);
// the roll was created for this file alone, so a failed import would
// otherwise leave an empty one behind
if(!film_existed && _film_is_empty(filmid)) dt_film_remove(filmid);
if(created_film) *created_film = NO_FILMID;
}
g_free(norm);
return id;
}
// importing syncs a sidecar (image.c:_image_import_internal), so without muting
// this a scratch import leaves an .xmp beside the user's raw
typedef struct dt_mcp_xmp_mute_t
{
gchar *saved;
gboolean active;
} dt_mcp_xmp_mute_t;
static void _xmp_mute(dt_mcp_xmp_mute_t *m)
{
const char *cur = dt_conf_get_string_const("write_sidecar_files");
m->saved = g_strdup(cur ? cur : "on import");
m->active = TRUE;
dt_conf_set_string("write_sidecar_files", "never");
}
static void _xmp_unmute(dt_mcp_xmp_mute_t *m)
{
if(!m->active) return;
dt_conf_set_string("write_sidecar_files", m->saved);
g_free(m->saved);
m->saved = NULL;
m->active = FALSE;
}
// what one request's throwaway import added to the catalog, so _drop_scratch()
// can take back exactly that and nothing else
typedef struct dt_mcp_scratch_t
{
gboolean active; // this request imported, so it owes a cleanup
dt_filmid_t film; // only when the request created the roll itself
GList *imgids; // every row the import filed, duplicates included
dt_mcp_xmp_mute_t mute;
} dt_mcp_scratch_t;
// resolve a request's input to an imgid. a path already in the catalog is
// refused: reusing the row would make a stack's persistence invisible to the
// caller, and removing it afterwards would delete their image
static dt_imgid_t _resolve_input(const char *path, int imgid_in,
dt_mcp_scratch_t *sc, char **err)
{
sc->active = FALSE;
sc->film = NO_FILMID;
sc->imgids = NULL;
if(imgid_in > 0)
{
// the image cache allocates a blank entry for any positive id and leaks its
// read lock doing so, wedging the next writer
if(!_image_exists((dt_imgid_t)imgid_in))
{
_seterr(err, "no image with imgid %d", imgid_in);
return NO_IMGID;
}
return (dt_imgid_t)imgid_in;
}
if(!path) { _seterr(err, "no input path or imgid provided"); return NO_IMGID; }
// probe with the string _import_file() files the image under: a relative
// path, a file:// URI or a "/./" would miss this and the import would then
// hand back a second row for the caller's own image
gchar *norm = _canonical_path(path);
const dt_imgid_t existing = dt_image_get_id_full_path(norm ? norm : path);
g_free(norm);
if(dt_is_valid_imgid(existing))
{
_seterr(err, "'%s' is already in the catalog as imgid %d: use input.imgid %d"
" instead, where edits persist and the sidecar follows; add"
" history_end 0 to that call to ignore its existing edits",
path, existing, existing);
return NO_IMGID;
}
_xmp_mute(&sc->mute);
gboolean created = FALSE;
const dt_imgid_t id = _import_file(path, &sc->film, &created, &sc->imgids, err);
if(!dt_is_valid_imgid(id)) { _xmp_unmute(&sc->mute); return NO_IMGID; }
// only a row this request created may be removed again
if(!created)
{
// a roll made for this request has no image of its own to hold it
if(dt_is_valid_filmid(sc->film) && _film_is_empty(sc->film))
dt_film_remove(sc->film);
sc->film = NO_FILMID;
g_list_free(sc->imgids);
sc->imgids = NULL;
_xmp_unmute(&sc->mute);
_seterr(err, "'%s' is already in the catalog as imgid %d: use input.imgid %d"
" instead", path, id, id);
return NO_IMGID;
}
sc->active = TRUE;
return id;
}
// undo a scratch import: the rows go, and their history, tags and labels go
// with them through the foreign keys. the file on disk is never touched
static void _drop_scratch(dt_mcp_scratch_t *sc)
{
sqlite3 *db = dt_database_get(darktable.db);
for(GList *i = sc->imgids; i; i = g_list_next(i))
{
const dt_imgid_t imgid = GPOINTER_TO_INT(i->data);
// main.module_order carries no foreign key (database.c:3535), so unlike
// history and tags it does not go with the image
sqlite3_stmt *st = NULL;
if(db && sqlite3_prepare_v2(db, "DELETE FROM main.module_order WHERE imgid = ?1",
-1, &st, NULL) == SQLITE_OK)
{
sqlite3_bind_int(st, 1, imgid);
sqlite3_step(st);
sqlite3_finalize(st);
}
dt_image_remove(imgid);
}
g_list_free(sc->imgids);
sc->imgids = NULL;
// only the roll this request created, and only while empty:
// dt_film_remove_empty() sweeps every empty roll and rmdirs each folder
if(dt_is_valid_filmid(sc->film) && _film_is_empty(sc->film))
dt_film_remove(sc->film);
sc->film = NO_FILMID;
sc->active = FALSE;
_xmp_unmute(&sc->mute);
}
// under --read-only an image has to come back as it was, and the first pipeline
// run on an undeveloped one writes out the auto-applied modules
// (develop.c:2246 sets the flag, :2847 writes the history)
typedef struct dt_mcp_pristine_t
{
dt_imgid_t imgid; // NO_IMGID unless this request owes a rollback
dt_mcp_xmp_mute_t mute;
} dt_mcp_pristine_t;
// arm the rollback, if this run is one that needs it
static void _pristine_hold(dt_imgid_t imgid, dt_mcp_pristine_t *pr)
{
pr->imgid = NO_IMGID;
pr->mute.saved = NULL;
pr->mute.active = FALSE;
if(!_read_only
|| !dt_is_valid_imgid(imgid)
|| !_image_is_undeveloped(dt_database_get(darktable.db), imgid))
return;
pr->imgid = imgid;
// develop.c:2867 only reaches for the sidecar when the mode is "on import",
// so muting keeps the auto-applied history off disk in the first place
_xmp_mute(&pr->mute);
}
// take back what the pipeline auto-applied
static void _pristine_release(dt_mcp_pristine_t *pr)
{
if(dt_is_valid_imgid(pr->imgid))
{
// init_history FALSE keeps this off _remove_preset_flag()'s sidecar-writing
// cache release (history.c:57), which the mute above cannot reach: it asks
// for the write whatever the mode is. the flag it clears is cleared here
dt_history_delete_on_image_ext(pr->imgid, FALSE, FALSE);
dt_image_t *img = dt_image_cache_get(pr->imgid, 'w');
if(img)
{
img->flags &= ~DT_IMAGE_AUTO_PRESETS_APPLIED;
dt_image_cache_write_release(img, DT_IMAGE_CACHE_RELAXED);
}
pr->imgid = NO_IMGID;
}
_xmp_unmute(&pr->mute);
}
// apply one stack entry to a module instance in dev and snapshot it to history
static gboolean _apply_entry(dt_develop_t *dev, JsonObject *entry, char **err)
{
const char *op = json_object_has_member(entry, "operation")
? json_object_get_string_member(entry, "operation") : NULL;
if(!op) { _seterr(err, "stack entry missing 'operation'"); return FALSE; }
const int multi_priority = json_object_has_member(entry, "multi_priority")
? (int)json_object_get_int_member(entry, "multi_priority") : 0;
const gboolean enabled = json_object_has_member(entry, "enabled")
? json_object_get_boolean_member(entry, "enabled") : TRUE;
dt_iop_module_t *mod = dt_iop_get_module_by_op_priority(dev->iop, op, multi_priority);
if(!mod)
{
_seterr(err, "module '%s' (priority %d) not found in pipe", op, multi_priority);
return FALSE;
}
// relative placement: a raw iop_order is an opaque number a caller has no
// way to choose sensibly
if(json_object_has_member(entry, "before") && json_object_has_member(entry, "after"))
{
_seterr(err, "'%s': give 'before' or 'after', not both", op);
return FALSE;
}
for(int pass = 0; pass < 2; pass++)
{
const char *key = pass ? "after" : "before";
if(!json_object_has_member(entry, key)) continue;
const char *target_op = json_object_get_string_member(entry, key);
if(!target_op)
{
_seterr(err, "'%s' must name a module", key);
return FALSE;
}
dt_iop_module_t *target =
dt_iop_get_module_by_op_priority(dev->iop, target_op, -1);
if(!target)
{
_seterr(err, "cannot place '%s' %s '%s': no such module in the pipe",
op, key, target_op);
return FALSE;
}
// imageop.c:1025 gates the same moves: without this, fences and iop_order
// rules are bypassed and the illegal order is written as a custom one
const gboolean allowed = pass
? dt_ioppr_check_can_move_after_iop(dev->iop, mod, target)
: dt_ioppr_check_can_move_before_iop(dev->iop, mod, target);
if(!allowed)
{
_seterr(err, "pipeline rules forbid placing '%s' %s '%s'",
op, key, target_op);
return FALSE;
}
const gboolean moved = pass
? dt_ioppr_move_iop_after(dev, mod, target)
: dt_ioppr_move_iop_before(dev, mod, target);
if(!moved)
{
_seterr(err, "pipeline refuses to place '%s' %s '%s'", op, key, target_op);
return FALSE;
}
}
size_t size = 0;
uint8_t *blob = NULL;
if(json_object_has_member(entry, "blob_hex"))
{
blob = _hex_to_bytes(json_object_get_string_member(entry, "blob_hex"), &size);
if(!blob) { _seterr(err, "invalid blob_hex for '%s'", op); return FALSE; }
}
else
{
JsonObject *fields = NULL;
if(json_object_has_member(entry, "params"))
{
JsonNode *fn = json_object_get_member(entry, "params");
if(JSON_NODE_HOLDS_OBJECT(fn)) fields = json_node_get_object(fn);
}
if(!mod->so->have_introspection)
{
_seterr(err, "module '%s' has no introspection for 'params'", op);
return FALSE;
}
// the live block, so setting one field layers onto the image's current
// values instead of resetting the rest to defaults
const void *defaults = (mod->params_size == (int32_t)mod->so->get_introspection()->size)
? mod->params : NULL;
blob = _seed_and_apply(mod->so, defaults, fields, &size, err);
if(!blob) return FALSE;
}
if(size != (size_t)mod->params_size)
{
_seterr(err, "blob size %zu != module '%s' params size %d",
size, op, mod->params_size);
g_free(blob);
return FALSE;
}
memcpy(mod->params, blob, size);
g_free(blob);
mod->enabled = enabled;
dt_dev_add_history_item_ext(dev, mod, enabled, TRUE);
return TRUE;
}
// in-memory 8-bit RGB export "format" module; we render through this instead of
// dt_imageio_preview() because that helper builds its surface via a GUI-only
// cairo wrapper (dereferences darktable.gui->ppd) and crashes headless
typedef struct
{
dt_imageio_module_data_t head;
int bpp;
uint8_t *buf;
uint32_t width, height;
} _mcp_fmt_t;
static int _mcp_write(dt_imageio_module_data_t *data, const char *filename,
const void *in,
const dt_colorspaces_color_profile_type_t over_type,
const char *over_filename, void *exif, const int exif_len,
const dt_imgid_t imgid, const int num, const int total,
dt_dev_pixelpipe_t *pipe, const gboolean export_masks)
{
_mcp_fmt_t *d = (_mcp_fmt_t *)data;
if(!in) return 1;
// the export writes the real size into data before calling us; sizing this
// from the requested box breaks once a dimension is left unconstrained
const size_t bytes = sizeof(uint32_t) * (size_t)data->width * data->height;
g_free(d->buf);
d->buf = g_try_malloc(bytes);
if(!d->buf) return 1;
memcpy(d->buf, in, bytes);
d->width = data->width;
d->height = data->height;
return 0;
}
static int _mcp_bpp(dt_imageio_module_data_t *d)
{
return 8;
}
static int _mcp_levels(dt_imageio_module_data_t *d)
{
return IMAGEIO_RGB | IMAGEIO_INT8;
}
static const char *_mcp_mime(dt_imageio_module_data_t *d)
{
return "memory";
}
static void _free_cb(void *p) { g_free(p); }
// render the committed history of `imgid` to a plain cairo RGB24 surface that
// owns its pixel buffer (freed when the surface is destroyed)
static cairo_surface_t *_render_to_surface(dt_imgid_t imgid, int w, int h,
int history_end, char **err)
{
// max_width/max_height is a bounding box and 0 means "no limit", so
// defaulting an unset dimension would cap the one actually asked for
if(w < 0) w = 0;
if(h < 0) h = 0;
if(w == 0 && h == 0)
{
w = 1024;
h = 1024;
}
dt_imageio_module_format_t fmt;
memset(&fmt, 0, sizeof(fmt));
fmt.mime = _mcp_mime;
fmt.levels = _mcp_levels;
fmt.bpp = _mcp_bpp;
fmt.write_image = _mcp_write;
_mcp_fmt_t dat;
memset(&dat, 0, sizeof(dat));
dat.head.max_width = w;
dat.head.max_height = h;
dat.head.width = w;
dat.head.height = h;
dat.head.style_append = TRUE;
dat.bpp = 8;
dat.buf = NULL; // allocated by _mcp_write once the real size is known
// NB: dt_imageio_export_with_flags returns FALSE on success
const gboolean failed = dt_imageio_export_with_flags(
imgid, "memory", &fmt, (dt_imageio_module_data_t *)&dat,
TRUE, TRUE, FALSE /*hq*/, TRUE /*upscale*/, FALSE /*is_scaling*/, 1.0,
FALSE /*thumbnail*/, NULL /*filter*/, FALSE /*copy_meta*/, FALSE /*export_masks*/,
DT_COLORSPACE_DISPLAY, NULL, DT_INTENT_LAST, NULL, NULL,
1, 1, NULL /*metadata*/, history_end);
if(failed || dat.width == 0 || dat.height == 0)
{
g_free(dat.buf);
_seterr(err, "render failed (invalid image or pipeline)");
return NULL;
}
const int stride = cairo_format_stride_for_width(CAIRO_FORMAT_RGB24, dat.width);
cairo_surface_t *surf =
cairo_image_surface_create_for_data(dat.buf, CAIRO_FORMAT_RGB24, dat.width,
dat.height, stride);
if(cairo_surface_status(surf) != CAIRO_STATUS_SUCCESS)
{
cairo_surface_destroy(surf);
g_free(dat.buf);
_seterr(err, "cairo surface creation failed");
return NULL;
}
static const cairo_user_data_key_t key;
cairo_surface_set_user_data(surf, &key, dat.buf, _free_cb);
return surf;
}
// commit the requested edits to the image's history. the stack goes to the
// image itself, not a throwaway copy, so it persists
static gboolean _commit_stack(dt_imgid_t work, JsonArray *stack,
gboolean disable_tone_mappers, int *history_end,
gboolean scratch, char **err)
{
const guint n_stack = stack ? json_array_get_length(stack) : 0;
const gboolean has_edits = disable_tone_mappers || n_stack > 0;
if(has_edits)
{
// a scratch row is removed again before the request returns, so editing it
// changes nothing the caller could keep and needs no read-only refusal
if(_read_only && !scratch)
{
_seterr(err, "server is --read-only: applying a stack would modify image %d",
work);
return FALSE;
}
dt_develop_t dev;
dt_dev_init(&dev, FALSE);
dt_dev_load_image(&dev, work);
// dt_dev_load_image() reads the history but does not replay it into module
// params (the darkroom does that at develop.c:1702), so without this a
// stack layers onto stale values. truncating here also keeps the stack
if(*history_end != -1)
{
// a value past the end would be written to images.history_end and the
// sidecar as-is, and read back unclamped
const int n_items = (int)g_list_length(dev.history);
dt_dev_pop_history_items_ext(&dev, MIN(*history_end, n_items));
*history_end = -1;
}
else
dt_dev_pop_history_items_ext(&dev, dev.history_end);
if(disable_tone_mappers)
{
// the display transforms plugins/darkroom/workflow chooses between
// (modulegroups.c:1938), plus basecurve for the display-referred one
const char *tms[] = { "sigmoid", "filmicrgb", "agx", "spektrafilm",
"basecurve", NULL };
for(int i = 0; tms[i]; i++)
{
dt_iop_module_t *m = dt_iop_get_module_by_op_priority(dev.iop, tms[i], 0);
if(m && m->enabled)
{
m->enabled = FALSE;
dt_dev_add_history_item_ext(&dev, m, FALSE, TRUE);
}
}
}
for(guint i = 0; i < n_stack; i++)
{
JsonNode *en = json_array_get_element(stack, i);
if(!JSON_NODE_HOLDS_OBJECT(en)
|| !_apply_entry(&dev, json_node_get_object(en), err))
{
if(JSON_NODE_HOLDS_OBJECT(en) == FALSE)
_seterr(err, "stack[%u] is not an object", i);
dt_dev_cleanup(&dev);
return FALSE;
}
}
dt_dev_write_history_ext(&dev, work);
dt_dev_cleanup(&dev);
// direct rather than dt_image_synch_xmp(), whose queue is drained by a
// background job that a request-scoped server outlives
if(!scratch) dt_image_write_sidecar_file(work);
}
return TRUE;
}
// import/resolve, develop, render, and undo a scratch import afterwards
static cairo_surface_t *_render_surface(const char *path, int imgid_in, int width,
int height, JsonArray *stack,
gboolean disable_tone_mappers, int history_end,
char **err)
{
// -1 is the only "whole history" value; anything else negative would be
// stored verbatim by dt_dev_pop_history_items_ext() and then persisted
if(history_end < -1)
{
_seterr(err, "history_end must be -1 (all) or >= 0");
return NULL;
}
dt_mcp_scratch_t sc = { FALSE, NO_FILMID, NULL, { NULL, FALSE } };
const dt_imgid_t work = _resolve_input(path, imgid_in, &sc, err);
if(!dt_is_valid_imgid(work)) return NULL;
// a scratch row is dropped whole, so only an image the caller keeps needs
// holding. the two never mute the sidecar at the same time
dt_mcp_pristine_t pr;
_pristine_hold(sc.active ? NO_IMGID : work, &pr);
cairo_surface_t *surf = NULL;
if(_commit_stack(work, stack, disable_tone_mappers, &history_end, sc.active, err))
surf = _render_to_surface(work, width, height, history_end, err);
_pristine_release(&pr);
if(sc.active) _drop_scratch(&sc);
return surf;
}
static cairo_status_t _png_writer(void *closure, const unsigned char *data,
unsigned int length)
{
g_byte_array_append((GByteArray *)closure, data, length);
return CAIRO_STATUS_SUCCESS;
}
gboolean dt_bridge_render_png(const char *path, int imgid_in, int width, int height,
void *stack_jsonarray, gboolean disable_tone_mappers,
int history_end, uint8_t **png_out, size_t *png_len,
char **err)
{
cairo_surface_t *surf = _render_surface(path, imgid_in, width, height,
(JsonArray *)stack_jsonarray,
disable_tone_mappers,
history_end, err);
gboolean ok = FALSE;
if(surf)
{
GByteArray *buf = g_byte_array_new();
if(cairo_surface_write_to_png_stream(surf, _png_writer, buf) == CAIRO_STATUS_SUCCESS)
{
*png_len = buf->len;
*png_out = g_byte_array_free(buf, FALSE); // hand raw bytes to caller
ok = TRUE;
}
else { g_byte_array_free(buf, TRUE); _seterr(err, "PNG encoding failed"); }
cairo_surface_destroy(surf);
}
return ok;
}
char *dt_bridge_image_stats_json(const char *path, int imgid_in, int width, int height,
void *stack_jsonarray, gboolean disable_tone_mappers,
int history_end, char **err)
{
// stats only need a small render, but substituting the default per dimension
// would cap whichever one the caller did ask for
const gboolean unsized = width <= 0 && height <= 0;
cairo_surface_t *surf = _render_surface(path, imgid_in,
unsized ? 512 : width,
unsized ? 512 : height,
(JsonArray *)stack_jsonarray,
disable_tone_mappers, history_end, err);
if(!surf) return NULL;
cairo_surface_flush(surf);
const int w = cairo_image_surface_get_width(surf);
const int h = cairo_image_surface_get_height(surf);
const int stride = cairo_image_surface_get_stride(surf);
const unsigned char *data = cairo_image_surface_get_data(surf);
// per-channel 256-bin histograms (surface is 32bpp, native BGRA/RGB24)
guint64 hist[3][256];
memset(hist, 0, sizeof(hist));
guint64 total = 0;
for(int y = 0; y < h; y++)
{
const uint32_t *row = (const uint32_t *)(data + (size_t)y * stride);
for(int x = 0; x < w; x++)
{
const uint32_t px = row[x];
const int r = (px >> 16) & 0xff, g = (px >> 8) & 0xff, b = px & 0xff;
hist[0][r]++; hist[1][g]++; hist[2][b]++;
total++;
}
}
static const char *chan[3] = { "r", "g", "b" };
JsonBuilder *jb = json_builder_new();
json_builder_begin_object(jb);
json_builder_set_member_name(jb, "width");
json_builder_add_int_value(jb, w);
json_builder_set_member_name(jb, "height");
json_builder_add_int_value(jb, h);
json_builder_set_member_name(jb, "channels");
json_builder_begin_object(jb);
for(int c = 0; c < 3; c++)
{
int minv = 255, maxv = 0;
guint64 sum = 0;
int p1 = 0, p50 = 0, p99 = 0;
const guint64 t1 = total / 100, t50 = total / 2, t99 = (total * 99) / 100;
guint64 acc = 0;
gboolean g1 = FALSE, g50 = FALSE, g99 = FALSE;
for(int v = 0; v < 256; v++)
{
const guint64 n = hist[c][v];
if(n)
{
if(v < minv) minv = v;
if(v > maxv) maxv = v;
sum += (guint64)v * n;
}
acc += n;
if(!g1 && acc >= t1) { p1 = v; g1 = TRUE; }
if(!g50 && acc >= t50) { p50 = v; g50 = TRUE; }
if(!g99 && acc >= t99) { p99 = v; g99 = TRUE; }
}
json_builder_set_member_name(jb, chan[c]);
json_builder_begin_object(jb);
json_builder_set_member_name(jb, "min");
json_builder_add_int_value(jb, total ? minv : 0);
json_builder_set_member_name(jb, "max");
json_builder_add_int_value(jb, maxv);
json_builder_set_member_name(jb, "mean");
json_builder_add_double_value(jb, total ? (double)sum / total : 0.0);
json_builder_set_member_name(jb, "p1");
json_builder_add_int_value(jb, p1);
json_builder_set_member_name(jb, "p50");
json_builder_add_int_value(jb, p50);
json_builder_set_member_name(jb, "p99");
json_builder_add_int_value(jb, p99);
json_builder_set_member_name(jb, "clip_lo");
json_builder_add_int_value(jb, (gint64)hist[c][0]);
json_builder_set_member_name(jb, "clip_hi");
json_builder_add_int_value(jb, (gint64)hist[c][255]);
json_builder_end_object(jb);
}
json_builder_end_object(jb);
json_builder_end_object(jb);
char *out = _builder_to_string(jb);
g_object_unref(jb);
cairo_surface_destroy(surf);
return out;
}
// ---------------------------------------------------------------------------
// library (catalog) tools
// ---------------------------------------------------------------------------
// a walked folder is filtered by dt_supported_image(), so sidecars are skipped
// quietly; an explicitly named path is always attempted, so its failure reports
static void _collect_folder(const char *folder, gboolean recursive, GPtrArray *out)
{
GDir *d = g_dir_open(folder, 0, NULL);
if(!d) return;
const char *name;
while((name = g_dir_read_name(d)))
{
gchar *full = g_build_filename(folder, name, NULL);
if(g_file_test(full, G_FILE_TEST_IS_DIR))
{
// a symlinked directory can point at an ancestor, and the walk would
// then never terminate
if(recursive && !g_file_test(full, G_FILE_TEST_IS_SYMLINK))
_collect_folder(full, TRUE, out);
g_free(full);
}
else if(dt_supported_image(name))
g_ptr_array_add(out, full); // owned by the array
else
g_free(full); // sidecars and everything else darktable cannot read
}
g_dir_close(d);
}
char *dt_bridge_import_images_json(void *paths_jsonarray, const char *folder,
gboolean recursive, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot import into the library");
return NULL;
}
JsonArray *paths = (JsonArray *)paths_jsonarray;
const guint n_paths = paths ? json_array_get_length(paths) : 0;
if(!n_paths && !(folder && *folder))
{
_seterr(err, "import_images: need 'paths' or 'folder'");
return NULL;
}
GPtrArray *files = g_ptr_array_new_with_free_func(g_free);
for(guint i = 0; i < n_paths; i++)
{
const char *p = json_array_get_string_element(paths, i);
if(p) g_ptr_array_add(files, g_strdup(p));
}
if(folder && *folder)
{
if(!g_file_test(folder, G_FILE_TEST_IS_DIR))
{
_seterr(err, "import_images: '%s' is not a directory", folder);
g_ptr_array_free(files, TRUE);
return NULL;
}
_collect_folder(folder, recursive, files);
}
JsonBuilder *b = json_builder_new();
json_builder_begin_object(b);
int n_new = 0, n_have = 0, n_fail = 0;
json_builder_set_member_name(b, "images");
json_builder_begin_array(b);
for(guint i = 0; i < files->len; i++)
{
const char *path = g_ptr_array_index(files, i);
gchar *pnorm = _canonical_path(path);
const char *filed = pnorm ? pnorm : path;
const dt_imgid_t have = dt_image_get_id_full_path(filed);
dt_imgid_t id = have;
const char *status = "already";
char *ierr = NULL;
if(!dt_is_valid_imgid(have))
{
// the import is authoritative: a path that only matches once normalized
// would otherwise be reported as newly imported
gboolean created = FALSE;
id = _import_file(filed, NULL, &created, NULL, &ierr);
status = !dt_is_valid_imgid(id) ? "failed" : created ? "imported" : "already";
}
json_builder_begin_object(b);
// the canonical string, not the request's: without it a caller cannot tell
// which catalog entry their path resolved to
json_builder_set_member_name(b, "path");
json_builder_add_string_value(b, filed);
if(g_strcmp0(filed, path))
{
json_builder_set_member_name(b, "requested");
json_builder_add_string_value(b, path);
}
json_builder_set_member_name(b, "status");
json_builder_add_string_value(b, status);
if(dt_is_valid_imgid(id))
{
json_builder_set_member_name(b, "imgid");
json_builder_add_int_value(b, id);
}
else if(ierr)
{
json_builder_set_member_name(b, "error");
json_builder_add_string_value(b, ierr);
}
json_builder_end_object(b);
if(!g_strcmp0(status, "imported")) n_new++;
else if(!g_strcmp0(status, "already")) n_have++;
else n_fail++;
g_free(ierr);
g_free(pnorm);
}
json_builder_end_array(b);
json_builder_set_member_name(b, "imported");
json_builder_add_int_value(b, n_new);
json_builder_set_member_name(b, "already");
json_builder_add_int_value(b, n_have);
json_builder_set_member_name(b, "failed");
json_builder_add_int_value(b, n_fail);
json_builder_end_object(b);
g_ptr_array_free(files, TRUE);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
// one row per imported directory: what list_images can filter on
char *dt_bridge_list_film_rolls_json(void)
{
sqlite3 *db = dt_database_get(darktable.db);
JsonBuilder *b = json_builder_new();
json_builder_begin_array(b);
sqlite3_stmt *st = NULL;
const char *q =
"SELECT f.id, f.folder, COUNT(i.id)"
" FROM main.film_rolls f LEFT JOIN main.images i ON i.film_id = f.id"
" GROUP BY f.id, f.folder ORDER BY f.folder";
if(db && sqlite3_prepare_v2(db, q, -1, &st, NULL) == SQLITE_OK)
{
while(sqlite3_step(st) == SQLITE_ROW)
{
json_builder_begin_object(b);
json_builder_set_member_name(b, "film_roll");
json_builder_add_int_value(b, sqlite3_column_int(st, 0));
const char *fold = (const char *)sqlite3_column_text(st, 1);
// the name darktable itself shows: the last show_folder_levels components
// of the path, not the whole thing
json_builder_set_member_name(b, "name");
json_builder_add_string_value(b, fold ? dt_image_film_roll_name(fold) : "");
json_builder_set_member_name(b, "folder");
json_builder_add_string_value(b, fold);
json_builder_set_member_name(b, "images");
json_builder_add_int_value(b, sqlite3_column_int(st, 2));
json_builder_end_object(b);
}
sqlite3_finalize(st);
}
json_builder_end_array(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
// escape the LIKE metacharacters in a caller-supplied substring
static gchar *_sql_like_escape(const char *in)
{
GString *o = g_string_new(NULL);
for(const char *p = in; p && *p; p++)
{
if(*p == '%' || *p == '_' || *p == '\\') g_string_append_c(o, '\\');
g_string_append_c(o, *p);
}
return g_string_free(o, FALSE);
}
char *dt_bridge_list_images_json(int limit, const char *folder, int rating,
const char *color, gboolean rejected_only,
int film_roll, char **err)
{
sqlite3 *db = dt_database_get(darktable.db);
JsonBuilder *b = json_builder_new();
json_builder_begin_array(b);
// flags carries the star rating in its low 3 bits and DT_IMAGE_REJECTED (8)
// separately, so a rejected image still holds whatever stars it had
GString *q = g_string_new(
"SELECT i.id, f.folder, i.filename, i.flags & 7, i.flags & 8,"
" (SELECT group_concat(c.color) FROM main.color_labels c WHERE c.imgid = i.id)"
" FROM main.images i JOIN main.film_rolls f ON i.film_id = f.id"
" WHERE 1=1");
if(folder && *folder) g_string_append(q, " AND f.folder LIKE ?1 ESCAPE '\\'");
if(film_roll > 0) g_string_append(q, " AND f.id = ?4");
if(rating >= 0) g_string_append(q, " AND (i.flags & 7) >= ?2");
if(rejected_only) g_string_append(q, " AND (i.flags & 8) != 0");
if(color && *color)
g_string_append(q, " AND EXISTS (SELECT 1 FROM main.color_labels c"
" WHERE c.imgid = i.id AND c.color = ?3)");
g_string_append(q, " ORDER BY i.id");
int color_idx = -1;
for(int i = 0; color && dt_colorlabels_name[i]; i++)
if(!g_ascii_strcasecmp(color, dt_colorlabels_name[i])) { color_idx = i; break; }
// an unmatched name would bind -1 and return an empty list, which reads as
// "no images carry that label" rather than "that is not a label"
if(color && *color && color_idx < 0)
{
_seterr(err, "color must be one of red, yellow, green, blue, purple");
g_string_free(q, TRUE);
g_object_unref(b);
return NULL;
}
sqlite3_stmt *st = NULL;
if(db && sqlite3_prepare_v2(db, q->str, -1, &st, NULL) == SQLITE_OK)
{
if(folder && *folder)
{
// '_' and '%' would otherwise act as wildcards inside the caller's string
gchar *esc = _sql_like_escape(folder);
gchar *like = g_strdup_printf("%%%s%%", esc);
sqlite3_bind_text(st, 1, like, -1, SQLITE_TRANSIENT);
g_free(like);
g_free(esc);
}
if(rating >= 0) sqlite3_bind_int(st, 2, rating);
if(color && *color) sqlite3_bind_int(st, 3, color_idx);
if(film_roll > 0) sqlite3_bind_int(st, 4, film_roll);
int n = 0;
while(sqlite3_step(st) == SQLITE_ROW && (limit <= 0 || n < limit))
{
const int id = sqlite3_column_int(st, 0);
const char *fold = (const char *)sqlite3_column_text(st, 1);
const char *fn = (const char *)sqlite3_column_text(st, 2);
const int stars = sqlite3_column_int(st, 3);
const int rej = sqlite3_column_int(st, 4);
const char *labels = (const char *)sqlite3_column_text(st, 5);
gchar *path = g_build_filename(fold ? fold : "", fn ? fn : "", NULL);
json_builder_begin_object(b);
json_builder_set_member_name(b, "imgid");
json_builder_add_int_value(b, id);
json_builder_set_member_name(b, "path");
json_builder_add_string_value(b, path);
json_builder_set_member_name(b, "rating");
json_builder_add_int_value(b, stars);
if(rej)
{
json_builder_set_member_name(b, "rejected");
json_builder_add_boolean_value(b, TRUE);
}
if(labels && *labels)
{
json_builder_set_member_name(b, "color_labels");
json_builder_begin_array(b);
gchar **parts = g_strsplit(labels, ",", -1);
for(int i = 0; parts[i]; i++)
{
const int c = atoi(parts[i]);
json_builder_add_string_value(
b, (c >= 0 && c < DT_COLORLABELS_LAST) ? dt_colorlabels_name[c] : "?");
}
g_strfreev(parts);
json_builder_end_array(b);
}
json_builder_end_object(b);
g_free(path);
n++;
}
sqlite3_finalize(st);
}
g_string_free(q, TRUE);
json_builder_end_array(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
char *dt_bridge_get_history_json(int imgid, char **err)
{
// an empty array would otherwise read as "this image has no edits"
if(!_require_image(imgid, "get_history", err)) return NULL;
sqlite3 *db = dt_database_get(darktable.db);
sqlite3_stmt *st = NULL;
const char *q = "SELECT num, operation, op_params, module, enabled, multi_priority, multi_name"
" FROM main.history WHERE imgid = ?1 ORDER BY num";
if(!db || sqlite3_prepare_v2(db, q, -1, &st, NULL) != SQLITE_OK)
{
_seterr(err, "could not query history");
return NULL;
}
sqlite3_bind_int(st, 1, imgid);
// where each module runs; the history's own `num` is the order edits were
// made, a different thing entirely
GList *order_list = dt_ioppr_get_iop_order_list((dt_imgid_t)imgid, FALSE);
JsonBuilder *b = json_builder_new();
json_builder_begin_array(b);
while(sqlite3_step(st) == SQLITE_ROW)
{
const int num = sqlite3_column_int(st, 0);
const char *op = (const char *)sqlite3_column_text(st, 1);
const void *blob = sqlite3_column_blob(st, 2);
const int blen = sqlite3_column_bytes(st, 2);
const int version = sqlite3_column_int(st, 3);
const int enabled = sqlite3_column_int(st, 4);
const int mp = sqlite3_column_int(st, 5);
const char *mname = (const char *)sqlite3_column_text(st, 6);
json_builder_begin_object(b);
json_builder_set_member_name(b, "num");
json_builder_add_int_value(b, num);
json_builder_set_member_name(b, "operation");
json_builder_add_string_value(b, op ? op : "");
json_builder_set_member_name(b, "version");
json_builder_add_int_value(b, version);
json_builder_set_member_name(b, "enabled");
json_builder_add_boolean_value(b, enabled != 0);
json_builder_set_member_name(b, "iop_order");
json_builder_add_int_value(b, dt_ioppr_get_iop_order(order_list, op ? op : "", mp));
json_builder_set_member_name(b, "multi_priority");
json_builder_add_int_value(b, mp);
if(mname && *mname)
{
json_builder_set_member_name(b, "multi_name");
json_builder_add_string_value(b, mname);
}
// decode fields when the stored blob matches the module's current layout
dt_iop_module_so_t *so = op ? dt_iop_get_module_so(op) : NULL;
if(so && so->have_introspection && so->get_introspection && so->get_p)
{
dt_introspection_t *intro = so->get_introspection();
if(blob && (size_t)blen == intro->size && version == intro->params_version)
{
json_builder_set_member_name(b, "fields");
_write_fields_object(so, blob, b);
}
}
json_builder_end_object(b);
}
sqlite3_finalize(st);
dt_ioppr_iop_order_list_free(order_list);
json_builder_end_array(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
// ---------------------------------------------------------------------------
// configuration (read only)
// ---------------------------------------------------------------------------
static const char *_confgen_type_name(const dt_confgen_type_t t)
{
switch(t)
{
case DT_INT: return "int";
case DT_INT64: return "int64";
case DT_FLOAT: return "float";
case DT_BOOL: return "bool";
case DT_PATH: return "path";
case DT_ENUM: return "enum";
case DT_STRING:
default: return "string";
}
}
// one entry: the live value plus whatever darktableconfig.xml.in declares about
// it, so a caller can tell a changed setting from a default and see what a key
// is allowed to hold
static void _conf_entry(JsonBuilder *b, const char *key)
{
json_builder_begin_object(b);
json_builder_set_member_name(b, "key");
json_builder_add_string_value(b, key);
gchar *val = dt_conf_get_string(key);
json_builder_set_member_name(b, "value");
json_builder_add_string_value(b, val ? val : "");
g_free(val);
if(dt_confgen_exists(key))
{
json_builder_set_member_name(b, "type");
json_builder_add_string_value(b, _confgen_type_name(dt_confgen_type(key)));
const char *def = dt_confgen_get(key, DT_DEFAULT);
if(def)
{
json_builder_set_member_name(b, "default");
json_builder_add_string_value(b, def);
}
if(dt_confgen_value_exists(key, DT_MIN))
{
json_builder_set_member_name(b, "min");
json_builder_add_string_value(b, dt_confgen_get(key, DT_MIN));
}
if(dt_confgen_value_exists(key, DT_MAX))
{
json_builder_set_member_name(b, "max");
json_builder_add_string_value(b, dt_confgen_get(key, DT_MAX));
}
if(dt_confgen_type(key) == DT_ENUM)
{
// "[a][b][c]" as stored; split the same way the preferences dialog does
// (gui/preferences.c), so a value containing ']' fails identically there
const char *vals = dt_confgen_get(key, DT_VALUES);
json_builder_set_member_name(b, "values");
json_builder_begin_array(b);
while(vals && *vals++ == '[' && *vals)
{
const char *end = strchr(vals, ']');
if(!end) break;
gchar *item = g_strndup(vals, end - vals);
json_builder_add_string_value(b, item);
g_free(item);
vals = end + 1;
}
json_builder_end_array(b);
}
}
json_builder_end_object(b);
}
char *dt_bridge_get_metadata_json(int imgid, char **err)
{
if(!_require_image(imgid, "get_metadata", err)) return NULL;
const dt_image_t *img = dt_image_cache_get((dt_imgid_t)imgid, 'r');
if(!img)
{
_seterr(err, "get_metadata: no image %d", imgid);
return NULL;
}
JsonBuilder *b = json_builder_new();
json_builder_begin_object(b);
json_builder_set_member_name(b, "imgid");
json_builder_add_int_value(b, imgid);
json_builder_set_member_name(b, "filename");
json_builder_add_string_value(b, img->filename);
json_builder_set_member_name(b, "camera");
json_builder_begin_object(b);
json_builder_set_member_name(b, "maker");
json_builder_add_string_value(b, img->exif_maker);
json_builder_set_member_name(b, "model");
json_builder_add_string_value(b, img->exif_model);
json_builder_set_member_name(b, "lens");
json_builder_add_string_value(b, img->exif_lens);
json_builder_end_object(b);
json_builder_set_member_name(b, "exif");
json_builder_begin_object(b);
json_builder_set_member_name(b, "iso");
json_builder_add_double_value(b, img->exif_iso);
json_builder_set_member_name(b, "exposure");
json_builder_add_double_value(b, img->exif_exposure);
json_builder_set_member_name(b, "aperture");
json_builder_add_double_value(b, img->exif_aperture);
json_builder_set_member_name(b, "focal_length");
json_builder_add_double_value(b, img->exif_focal_length);
json_builder_set_member_name(b, "exposure_bias");
json_builder_add_double_value(b, img->exif_exposure_bias);
json_builder_set_member_name(b, "focus_distance");
json_builder_add_double_value(b, img->exif_focus_distance);
if(img->exif_datetime_taken)
{
char dt[DT_DATETIME_LENGTH] = { 0 };
if(dt_datetime_gtimespan_to_exif(dt, sizeof(dt), img->exif_datetime_taken))
{
json_builder_set_member_name(b, "datetime_taken");
json_builder_add_string_value(b, dt);
}
}
if(*img->exif_whitebalance)
{
json_builder_set_member_name(b, "white_balance");
json_builder_add_string_value(b, img->exif_whitebalance);
}
if(*img->exif_exposure_program)
{
json_builder_set_member_name(b, "exposure_program");
json_builder_add_string_value(b, img->exif_exposure_program);
}
if(*img->exif_metering_mode)
{
json_builder_set_member_name(b, "metering_mode");
json_builder_add_string_value(b, img->exif_metering_mode);
}
if(*img->exif_flash)
{
json_builder_set_member_name(b, "flash");
json_builder_add_string_value(b, img->exif_flash);
}
json_builder_end_object(b);
// rawprepare fills these on first load, so they are zero until the image has
// been through the pipe once. omitted rather than reported as zero, which
// would read as a real black point of 0 and a sensor with no range at all
if(img->raw_white_point > 0)
{
json_builder_set_member_name(b, "raw");
json_builder_begin_object(b);
json_builder_set_member_name(b, "black_level");
json_builder_add_int_value(b, img->raw_black_level);
json_builder_set_member_name(b, "white_point");
json_builder_add_int_value(b, img->raw_white_point);
json_builder_set_member_name(b, "black_level_separate");
json_builder_begin_array(b);
for(int i = 0; i < 4; i++)
json_builder_add_int_value(b, img->raw_black_level_separate[i]);
json_builder_end_array(b);
json_builder_end_object(b);
}
json_builder_set_member_name(b, "dimensions");
json_builder_begin_object(b);
json_builder_set_member_name(b, "width");
json_builder_add_int_value(b, img->width);
json_builder_set_member_name(b, "height");
json_builder_add_int_value(b, img->height);
json_builder_set_member_name(b, "final_width");
json_builder_add_int_value(b, img->final_width);
json_builder_set_member_name(b, "final_height");
json_builder_add_int_value(b, img->final_height);
json_builder_end_object(b);
json_builder_end_object(b);
dt_image_cache_read_release(img);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
char *dt_bridge_get_conf_json(const char *key, char **err)
{
if(!key || !*key) { _seterr(err, "get_conf: need 'key'"); return NULL; }
if(!dt_conf_key_exists(key) && !dt_confgen_exists(key))
{
_seterr(err, "get_conf: no such key '%s'", key);
return NULL;
}
JsonBuilder *b = json_builder_new();
_conf_entry(b, key);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
char *dt_bridge_list_conf_json(const char *prefix)
{
JsonBuilder *b = json_builder_new();
json_builder_begin_array(b);
// only the declared keys: darktablerc also holds window geometry and other
// runtime scribble that tells a caller nothing
if(darktable.conf && darktable.conf->x_confgen)
{
GList *keys = g_hash_table_get_keys(darktable.conf->x_confgen);
keys = g_list_sort(keys, (GCompareFunc)g_strcmp0);
for(GList *k = keys; k; k = g_list_next(k))
{
const char *name = (const char *)k->data;
if(prefix && *prefix && !g_str_has_prefix(name, prefix)) continue;
_conf_entry(b, name);
}
g_list_free(keys);
}
json_builder_end_array(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
char *dt_bridge_list_styles_json(const char *filter, int limit)
{
// dt_styles_get_list() wraps the filter in '%...%' and matches name or
// description (styles.c:1239), so this is a substring search, not a prefix
GList *styles = dt_styles_get_list(filter ? filter : "");
const guint total = g_list_length(styles);
JsonBuilder *b = json_builder_new();
json_builder_begin_object(b);
// a catalog of several hundred styles runs to tens of kilobytes, so say how
// many matched: a truncated array is otherwise indistinguishable from a
// complete one
json_builder_set_member_name(b, "total");
json_builder_add_int_value(b, total);
json_builder_set_member_name(b, "styles");
json_builder_begin_array(b);
int n = 0;
for(GList *s = styles; s; s = g_list_next(s))
{
if(limit > 0 && n++ >= limit) break;
dt_style_t *st = (dt_style_t *)s->data;
json_builder_begin_object(b);
json_builder_set_member_name(b, "name");
json_builder_add_string_value(b, st->name ? st->name : "");
json_builder_set_member_name(b, "description");
json_builder_add_string_value(b, st->description ? st->description : "");
json_builder_end_object(b);
}
g_list_free_full(styles, dt_style_free);
json_builder_end_array(b);
json_builder_end_object(b);
char *out = _builder_to_string(b);
g_object_unref(b);
return out;
}
// ---------------------------------------------------------------------------
// selection: rating and color labels
// ---------------------------------------------------------------------------
// JSON array of image ids -> GList, rejecting anything the library does not hold
static GList *_imgids_from_json(JsonArray *arr, char **err)
{
if(!arr || json_array_get_length(arr) == 0)
{
_seterr(err, "need a non-empty 'imgids' array");
return NULL;
}
GList *l = NULL;
const guint n = json_array_get_length(arr);
for(guint i = 0; i < n; i++)
{
const dt_imgid_t id = (dt_imgid_t)json_array_get_int_element(arr, i);
// must exist, not merely look plausible: the image cache hands back a blank
// entry for any positive id and leaks its read lock doing so
if(!dt_is_valid_imgid(id) || !_image_exists(id))
{
_seterr(err, "imgids[%u]: no image with imgid %d", i, id);
g_list_free(l);
return NULL;
}
l = g_list_prepend(l, GINT_TO_POINTER(id));
}
return g_list_reverse(l);
}
gboolean dt_bridge_set_rating(void *imgids_jsonarray, int rating,
gboolean reject, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot set a rating");
return FALSE;
}
if(!reject && (rating < 0 || rating > 5))
{
_seterr(err, "rating must be 0-5; pass reject:true to reject");
return FALSE;
}
GList *l = _imgids_from_json((JsonArray *)imgids_jsonarray, err);
if(!l) return FALSE;
// reject is a flag beside the stars, not a rating value: DT_VIEW_REJECT is
// what _ratings_apply_to_image() tests for, and it leaves the star count alone
const int target = reject ? DT_VIEW_REJECT : rating;
// ratings.c:_ratings_apply() toggles reject and one star back off when every
// image already holds them, a keyboard affordance no API caller asked for.
// dropping those images makes the call idempotent and stops the test passing
GList *todo = NULL;
for(GList *i = l; i; i = g_list_next(i))
if(dt_ratings_get(GPOINTER_TO_INT(i->data)) != target)
todo = g_list_prepend(todo, i->data);
g_list_free(l);
if(todo)
{
dt_ratings_apply_on_list(todo, target, FALSE);
for(GList *i = todo; i; i = g_list_next(i))
dt_image_write_sidecar_file(GPOINTER_TO_INT(i->data));
g_list_free(todo);
}
return TRUE;
}
gboolean dt_bridge_set_color_label(void *imgids_jsonarray, const char *color,
gboolean toggle, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot set a color label");
return FALSE;
}
int idx = -1;
for(int i = 0; color && dt_colorlabels_name[i]; i++)
if(!g_ascii_strcasecmp(color, dt_colorlabels_name[i])) { idx = i; break; }
if(idx < 0)
{
_seterr(err, "color must be one of red, yellow, green, blue, purple");
return FALSE;
}
GList *l = _imgids_from_json((JsonArray *)imgids_jsonarray, err);
if(!l) return FALSE;
// the two take the color differently: toggle wants the index and shifts it
// itself, set wants a ready-made bitmask
if(toggle)
dt_colorlabels_toggle_label_on_list(l, idx, FALSE);
else
dt_colorlabels_set_labels(l, 1 << idx, FALSE, FALSE);
for(GList *i = l; i; i = g_list_next(i))
dt_image_write_sidecar_file(GPOINTER_TO_INT(i->data));
g_list_free(l);
return TRUE;
}
gboolean dt_bridge_reset_history(int imgid, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot reset history");
return FALSE;
}
if(!_require_image(imgid, "reset_history", err)) return FALSE;
// an empty stack takes the no-edits path and renders unchanged, so
// clearing needs its own verb
dt_history_delete_on_image((dt_imgid_t)imgid);
dt_image_write_sidecar_file((dt_imgid_t)imgid);
return TRUE;
}
gboolean dt_bridge_apply_style(const char *name, int imgid, gboolean overwrite,
void *imgids_jsonarray, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot apply a style");
return FALSE;
}
if(!name)
{
_seterr(err, "apply_style: need 'name'");
return FALSE;
}
// _styles_apply_to_image_ext() just returns for an unknown name, so without
// this the call reports success having changed nothing
if(!dt_styles_exists(name))
{
_seterr(err, "apply_style: no style named '%s'", name);
return FALSE;
}
// one imgid or a list; dt_styles_apply_to_image is single-image either way,
// so the loop lives here rather than costing the caller a round trip each
GList *l = NULL;
if(imgids_jsonarray && json_array_get_length((JsonArray *)imgids_jsonarray) > 0)
{
l = _imgids_from_json((JsonArray *)imgids_jsonarray, err);
if(!l) return FALSE;
}
else if(dt_is_valid_imgid((dt_imgid_t)imgid))
{
if(!_require_image(imgid, "apply_style", err)) return FALSE;
l = g_list_prepend(NULL, GINT_TO_POINTER(imgid));
}
else
{
_seterr(err, "apply_style: need 'imgid' or a non-empty 'imgids'");
return FALSE;
}
for(GList *i = l; i; i = g_list_next(i))
{
const dt_imgid_t id = GPOINTER_TO_INT(i->data);
dt_styles_apply_to_image(name, FALSE, overwrite, id);
dt_image_write_sidecar_file(id);
}
g_list_free(l);
return TRUE;
}
gboolean dt_bridge_save_style(const char *name, const char *description,
int imgid, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot save a style");
return FALSE;
}
if(!name)
{
_seterr(err, "save_style: need 'name'");
return FALSE;
}
if(!_require_image(imgid, "save_style", err)) return FALSE;
if(!dt_styles_create_from_image(name, description ? description : "",
(dt_imgid_t)imgid, NULL, TRUE))
{
_seterr(err, "could not create style '%s' (already exists?)", name);
return FALSE;
}
return TRUE;
}
gboolean dt_bridge_import_style(const char *path, char **err)
{
if(_read_only)
{
_seterr(err, "server is --read-only: cannot import a style");
return FALSE;
}
if(!path) { _seterr(err, "import_style: need 'path'"); return FALSE; }
// dt_styles_import_from_file() is void and logs failures rather than
// returning them, so without these checks every call answers "ok"
if(!g_file_test(path, G_FILE_TEST_IS_REGULAR))
{
_seterr(err, "import_style: '%s' is not a readable file", path);
return FALSE;
}
GList *before = dt_styles_get_list("");
const guint n_before = g_list_length(before);
g_list_free_full(before, dt_style_free);
dt_styles_import_from_file(path);
GList *after = dt_styles_get_list("");
const guint n_after = g_list_length(after);
g_list_free_full(after, dt_style_free);
if(n_after == n_before)
{
_seterr(err, "import_style: '%s' added no style; malformed, or a style of"
" that name already exists", path);
return FALSE;
}
return TRUE;
}
// where darktable itself would put an export when no target is named:
// plugins/imageio/storage/disk/file_directory, defaulting to a
// darktable_exported/ folder beside each source file
static gchar *_default_out_path(dt_imgid_t imgid, int seq, const char *ext)
{
char full[PATH_MAX] = { 0 };
gboolean from_cache = FALSE;
dt_image_full_path(imgid, full, sizeof(full), &from_cache);
if(!*full) return NULL;
const char *conf =
dt_conf_get_string_const("plugins/imageio/storage/disk/file_directory");
gchar *pattern = g_strdup(conf && *conf
? conf
: "$(FILE_FOLDER)/darktable_exported/$(FILE_NAME)");
dt_variables_params_t *vp = NULL;
dt_variables_params_init(&vp);
vp->filename = full;
vp->jobcode = "export";
vp->imgid = imgid;
vp->sequence = seq;
gchar *base = dt_variables_expand_path(vp, pattern, TRUE);
dt_variables_params_destroy(vp);
g_free(pattern);
if(!base) return NULL;
gchar *out = g_strconcat(base, ".", ext, NULL);
g_free(base);
return out;
}
// darktable's configured conflict policy; split out so the name generation
// below stays a pure function the unit tests can drive
static int _conflict_action(void)
{
return dt_conf_get_int("plugins/imageio/storage/disk/overwrite");
}
// apply plugins/imageio/storage/disk/overwrite, default a free "_01" name
// rather than clobber (disk.c:451). NULL means no name: *skipped separates the
// policy leaving a file alone from every candidate being taken. the policy is
// about files on disk before the export, so a target `claimed` by this same
// call is never overwritten or skipped, or the batch would lose an image
static gchar *_resolve_conflict(const char *file, const int action,
GHashTable *claimed, gboolean *skipped)
{
*skipped = FALSE;
const gboolean taken = claimed && g_hash_table_contains(claimed, file);
if(!taken && !g_file_test(file, G_FILE_TEST_EXISTS)) return g_strdup(file);
if(!taken)
{
if(action == 1 || action == 2) return g_strdup(file); // overwrite
if(action == 3) { *skipped = TRUE; return NULL; } // skip
}
// split on the basename: a directory containing a dot would otherwise send
// the retry into a different, newly created folder
gchar *bn = g_path_get_basename(file);
gchar *bdot = g_strrstr(bn, ".");
const size_t stem_len = bdot ? (size_t)(strlen(file) - strlen(bdot)) : strlen(file);
gchar *stem = g_strndup(file, stem_len);
gchar *ext = g_strdup(bdot ? bdot + 1 : "");
g_free(bn);
gchar *out = NULL;
gboolean free_name = FALSE;
for(int seq = 1; seq < 10000; seq++)
{
g_free(out);
out = *ext ? g_strdup_printf("%s_%.2d.%s", stem, seq, ext)
: g_strdup_printf("%s_%.2d", stem, seq);
if(!g_file_test(out, G_FILE_TEST_EXISTS)
&& !(claimed && g_hash_table_contains(claimed, out)))
{
free_name = TRUE;
break;
}
}
g_free(stem);
g_free(ext);
// every candidate was taken: returning the last one would overwrite it
if(!free_name) { g_free(out); return NULL; }
return out;
}
// the single decision about where one exported image goes:
// out_path the caller named the file, used exactly as given
// out_dir <out_dir>/<source basename>.<ext>
// neither darktable's configured pattern
// the conflict policy applies only to a name we chose, never the caller's.
// *skipped is the path it left alone, NULL otherwise (caller frees)
static gchar *_export_target(dt_imgid_t imgid, int seq, const char *out_path,
const char *out_dir, const char *ext,
GHashTable *claimed, gchar **skipped, char **err)
{
*skipped = NULL;
if(out_path && *out_path) return g_strdup(out_path);
gchar *candidate = NULL;
if(out_dir && *out_dir)
{
char full[PATH_MAX] = { 0 };
gboolean from_cache = FALSE;
dt_image_full_path(imgid, full, sizeof(full), &from_cache);
gchar *base = g_path_get_basename(full);
gchar *dot = g_strrstr(base, ".");
if(dot) *dot = '\0';
gchar *name = g_strconcat(base, ".", ext, NULL);
candidate = g_build_filename(out_dir, name, NULL);
g_free(name);
g_free(base);
}
else
candidate = _default_out_path(imgid, seq, ext);
if(!candidate)
{
_seterr(err, "export_images: could not work out a target for image %d", imgid);
return NULL;
}
gboolean skip = FALSE;
gchar *final = _resolve_conflict(candidate, _conflict_action(), claimed, &skip);
if(skip)
*skipped = g_strdup(candidate);
else if(!final)
_seterr(err, "export_images: no free filename for '%s'", candidate);
g_free(candidate);
return final;
}
// create the directory an export is about to be written into
static gboolean _ensure_parent_dir(const char *file, char **err)
{
gchar *dir = g_path_get_dirname(file);
const gboolean ok = (g_mkdir_with_parents(dir, 0755) == 0);
if(!ok) _seterr(err, "export_images: could not create directory '%s'", dir);
g_free(dir);
return ok;
}
// write one developed image through a real format module, so every format
// darktable can write is available rather than cairo's PNG alone
static gboolean _write_export(dt_imgid_t imgid, const char *filename,
dt_imageio_module_format_t *fmt, int quality,
int width, int height, int history_end,
gboolean upscale, gboolean high_quality, char **err)
{
// format settings live in the module's own conf keys; quality is the one a
// caller reasonably varies per request, so set it around get_params
gchar *qkey = NULL;
int qsaved = 0;
const gboolean set_q = quality > 0;
if(set_q)
{
qkey = g_strdup_printf("plugins/imageio/format/%s/quality", fmt->plugin_name);
if(dt_conf_key_exists(qkey))
{
qsaved = dt_conf_get_int(qkey);
dt_conf_set_int(qkey, quality);
}
else
{
g_free(qkey);
qkey = NULL;
}
}
dt_imageio_module_data_t *fdata = fmt->get_params(fmt);
if(qkey) { dt_conf_set_int(qkey, qsaved); g_free(qkey); }
if(!fdata)
{
_seterr(err, "export_images: '%s' returned no format parameters",
fmt->plugin_name);
return FALSE;
}
fdata->max_width = width;
fdata->max_height = height;
fdata->style[0] = '\0';
fdata->style_append = TRUE;
if(!_ensure_parent_dir(filename, err))
{
fmt->free_params(fmt, fdata);
return FALSE;
}
// the same output profile settings darktable's own export reads
// (libs/export.c:417); -1 on either means the image's own choice stands
const dt_colorspaces_color_profile_type_t icc_type =
dt_conf_get_int("plugins/lighttable/export/icctype");
gchar *icc_filename = dt_conf_get_string("plugins/lighttable/export/iccprofile");
const dt_iop_color_intent_t icc_intent =
dt_conf_get_int("plugins/lighttable/export/iccintent");
// NB: returns FALSE on success
const gboolean failed = dt_imageio_export_with_flags(
imgid, filename, fmt, fdata,
FALSE /*ignore_exif*/, FALSE /*display_byteorder*/, high_quality, upscale,
FALSE /*is_scaling*/, 1.0, FALSE /*thumbnail*/, NULL /*filter*/,
TRUE /*copy_metadata*/, FALSE /*export_masks*/,
icc_type, icc_filename, icc_intent, NULL, NULL,
1, 1, NULL /*metadata*/, history_end);
g_free(icc_filename);
fmt->free_params(fmt, fdata);
if(failed) _seterr(err, "export_images: could not write '%s'", filename);
return !failed;
}
// pick the format module: an explicit name, else the extension the caller put
// on out_path, else darktable's configured export format
static dt_imageio_module_format_t *_pick_format(const char *format_name,
const char *out_path, char **err)
{
gchar *guess = NULL;
const char *want = format_name;
if(!want && out_path)
{
const char *dot = strrchr(out_path, '.');
if(dot && dot[1]) want = guess = g_ascii_strdown(dot + 1, -1);
}
if(!want)
{
const char *conf = dt_conf_get_string_const("plugins/lighttable/export/format_name");
want = (conf && *conf) ? conf : "jpeg";
}
// darktable knows jpeg by that name, not by the common abbreviation
if(!g_strcmp0(want, "jpg")) want = "jpeg";
if(!g_strcmp0(want, "tif")) want = "tiff";
dt_imageio_module_format_t *fmt = dt_imageio_get_format_by_name(want);
if(!fmt) _seterr(err, "export_images: unknown format '%s'", want);
g_free(guess);
return fmt;
}
gboolean dt_bridge_export_images(const char *in_path, int imgid_in,
int width, int height,
int history_end, const char *out_path,
void *imgids_jsonarray, const char *out_dir,
const char *format_name, int quality,
gboolean upscale, gboolean high_quality,
void *written_paths, void *skipped_paths,
char **err)
{
GPtrArray *written = (GPtrArray *)written_paths;
GPtrArray *skipped_out = (GPtrArray *)skipped_paths;
JsonArray *ids = (JsonArray *)imgids_jsonarray;
const gboolean batch = ids && json_array_get_length(ids) > 0;
if(history_end < -1)
{
_seterr(err, "history_end must be -1 (all) or >= 0");
return FALSE;
}
if(out_path && out_dir)
{
_seterr(err, "export_images: give 'out_path' or 'out_dir', not both");
return FALSE;
}
// out_path names one file, so it cannot describe a batch
if(batch && out_path)
{
_seterr(err, "export_images: 'out_path' names a single file; use 'out_dir'"
" with 'imgids'");
return FALSE;
}
if(!batch && !in_path && imgid_in <= 0)
{
_seterr(err, "export_images: need input.path/imgid, or 'imgids'");
return FALSE;
}
dt_imageio_module_format_t *fmt = _pick_format(format_name, out_path, err);
if(!fmt) return FALSE;
// the extension the module itself appends, so the name matches the bytes
dt_imageio_module_data_t *probe = fmt->get_params(fmt);
gchar *ext = g_strdup(probe ? fmt->extension(probe) : "png");
if(probe) fmt->free_params(fmt, probe);
// resolve the input up front so a path-addressed image has a real imgid
// before its target is worked out: it then lands where an imgid-addressed
// one would, rather than somewhere of its own
GList *l = NULL;
dt_mcp_scratch_t sc = { FALSE, NO_FILMID, NULL, { NULL, FALSE } };
if(batch)
l = _imgids_from_json(ids, err);
else
{
const dt_imgid_t work = _resolve_input(in_path, imgid_in, &sc, err);
if(dt_is_valid_imgid(work)) l = g_list_prepend(NULL, GINT_TO_POINTER(work));
}
if(!l) { g_free(ext); return FALSE; }
// the targets handed out so far: _resolve_conflict() can only see the
// filesystem, and a file is not there yet when the next image of the batch
// resolves its own name against it
GHashTable *claimed = g_hash_table_new_full(g_str_hash, g_str_equal,
g_free, NULL);
gboolean all_ok = TRUE;
int seq = 1;
GList *untried = NULL;
for(GList *i = l; i; i = g_list_next(i), seq++)
{
const dt_imgid_t id = GPOINTER_TO_INT(i->data);
gchar *skipped = NULL;
gchar *target = _export_target(id, seq, out_path, out_dir, ext, claimed,
&skipped, err);
// the conflict policy says leave it: report it, or an export that wrote
// nothing is indistinguishable from one that had nothing to do
if(skipped)
{
if(skipped_out) g_ptr_array_add(skipped_out, skipped);
else g_free(skipped);
continue;
}
if(!target) { all_ok = FALSE; untried = g_list_next(i); break; }
g_hash_table_add(claimed, g_strdup(target));
// as in _render_surface(): --read-only must not develop an image on its way
// out to a JPEG
dt_mcp_pristine_t pr;
_pristine_hold(sc.active ? NO_IMGID : id, &pr);
all_ok = _write_export(id, target, fmt, quality, width, height, history_end,
upscale, high_quality, err);
_pristine_release(&pr);
if(!all_ok) { untried = g_list_next(i); g_free(target); break; }
if(written) g_ptr_array_add(written, g_strdup(target));
g_free(target);
}
// the batch stops at the first failure, and the caller is told only which
// files were written; without this the images after it go unmentioned and
// read as if they had been exported
if(!all_ok && untried && err && *err)
{
GString *m = g_string_new(*err);
g_string_append(m, "; not attempted:");
for(GList *j = untried; j; j = g_list_next(j))
g_string_append_printf(m, " %d", GPOINTER_TO_INT(j->data));
g_free(*err);
*err = g_string_free(m, FALSE);
}
if(sc.active) _drop_scratch(&sc);
g_hash_table_destroy(claimed);
g_list_free(l);
g_free(ext);
return all_ok;
}