diff --git a/contrib/darktable-api/api.lua b/contrib/darktable-api/api.lua index 7b5c82a..b1be056 100644 --- a/contrib/darktable-api/api.lua +++ b/contrib/darktable-api/api.lua @@ -5,6 +5,8 @@ local auth = require "auth" local db = require "db" local preview = require "preview" local config = require "config" +local modules = require "modules" +local socket = require "socket" -- ── Refresh queue (IPC with the darktable bridge plugin) ────────────────────── -- The bridge plugin running inside darktable polls this file and calls @@ -26,6 +28,21 @@ local function enqueue_refresh(image_id) return true end +local function enqueue_export(image_id) + local qf = config.export_queue + local f, ferr = io.open(qf, "a") + if not f then + return false, "cannot open export queue " .. qf .. ": " .. (ferr or "?") + end + f:write(tostring(image_id) .. "\n") + f:close() + return true +end + +local function export_path(image_id) + return config.export_dir .. "/" .. tostring(image_id) .. ".jpg" +end + local M = {} -- ── Helpers ─────────────────────────────────────────────────────────────────── @@ -218,11 +235,10 @@ end -- ── Cache refresh ───────────────────────────────────────────────────────────── -- POST /api/v1/images/:id/refresh --- Enqueues an image ID for cache regeneration by the darktable bridge plugin. --- The bridge plugin (darktable-api-bridge.lua) running inside a live darktable --- instance calls image:drop_cache() + image:generate_cache() when it drains --- the queue. Returns 202 Accepted immediately; the actual regeneration happens --- asynchronously inside darktable. +-- Enqueues the image for thumbnail regeneration via the darktable bridge. +-- The bridge calls image:drop_cache() + image:generate_cache(true, …), which +-- re-renders through darktable's full processing pipeline so that development +-- settings (tone curves, exposure, colour grading, etc.) are reflected. -- Requires scope: cache:refresh function M.refresh_cache(req) local payload, s, b, ct = require_auth(req, "cache:refresh") @@ -231,7 +247,6 @@ function M.refresh_cache(req) local id = tonumber(req.path_params.id) if not id then return err(400, "invalid_id", "id must be an integer") end - -- Verify the image exists in the catalog before queuing local img = db.get_image(id) if not img then return err(404, "not_found", "image " .. id .. " not found") end @@ -245,8 +260,219 @@ function M.refresh_cache(req) image_id = id, filename = img.filename, queue = config.refresh_queue, - message = "cache refresh enqueued; darktable bridge will process it shortly", + message = "cache refresh enqueued; darktable bridge will re-render thumbnails shortly", }), "application/json; charset=utf-8" end +-- ── Development modules ─────────────────────────────────────────────────────── +-- GET /api/v1/images/:id/modules +-- Returns every operation in the history stack (active entry per operation). +-- Response includes decoded params for known modules; base64 blob for others. +-- Requires scope: images:read +function M.list_modules(req) + local payload, s, b, ct = require_auth(req, "images:read") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local mods = db.list_modules(id) + return ok({ image_id = id, items = mods }) +end + +-- GET /api/v1/images/:id/modules/:op +-- Returns the active history entry for one operation. +-- Requires scope: images:read +function M.get_module(req) + local payload, s, b, ct = require_auth(req, "images:read") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + local op = req.path_params.op + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local mod = db.get_module(id, op) + if not mod then + return err(404, "module_not_found", + "operation '" .. op .. "' not in history for image " .. id) + end + return ok(mod) +end + +-- PATCH /api/v1/images/:id/modules/:op +-- Body (JSON): +-- { +-- "enabled": true|false, -- optional: change enabled state +-- "params": { field: value, … } -- optional: named params for known modules +-- "params_b64": "" -- alternative: raw binary for unknown modules +-- } +-- Query param: ?preview=thumb|small|medium|large|xlarge +-- If given, waits up to 30 s for the thumbnail to update and returns the JPEG. +-- Otherwise returns 204 No Content. +-- Requires scope: cache:refresh +function M.patch_module(req) + local payload, s, b, ct = require_auth(req, "cache:refresh") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + local op = req.path_params.op + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local mod = db.get_module(id, op) + if not mod then + return err(404, "module_not_found", + "operation '" .. op .. "' not in history for image " .. id) + end + + -- Parse request body + local body = parse_body(req) + if not body then + return err(400, "invalid_body", "could not parse JSON body") + end + + local new_enabled = body.enabled -- may be nil (leave unchanged) + local new_blob = nil + + if body.params_b64 then + -- Caller provided raw base64 params (for unknown modules) + local ok_dec, decoded = pcall(require("base64").decode, body.params_b64) + if not ok_dec then + return err(400, "invalid_params_b64", "base64 decode failed") + end + new_blob = decoded + elseif body.params then + if mod.params then + local existing_blob = db.get_module_blob(id, op) + if not existing_blob then + return err(500, "db_error", "could not read existing params blob") + end + new_blob = modules.encode(op, mod.module_version, existing_blob, body.params) + if not new_blob then + return err(400, "unsupported_module", + "named-param editing is not supported for operation '" .. op .. + "'; use params_b64 with a raw binary blob instead") + end + else + -- Unknown module, caller must supply params_b64 + return err(400, "params_required", + "operation '" .. op .. "' requires params_b64 (binary blob) for editing") + end + end + + -- Persist to history table + local ok_write, werr = db.set_module_params(id, op, new_enabled, new_blob) + if not ok_write then + return err(500, "db_write_error", werr) + end + + -- Enqueue a thumbnail refresh so the bridge re-renders with the new params. + enqueue_refresh(id) + + -- Optional synchronous preview response + local preview_size = req.params.preview + if preview_size and preview_size ~= "" then + -- Read the thumbnail before the refresh so we can detect when it changes. + local old_data = preview.get(id, img.path, img.filename, preview_size) + local deadline = os.time() + 30 + local new_data + while os.time() < deadline do + socket.select(nil, nil, 0.5) + local candidate = preview.get(id, img.path, img.filename, preview_size) + if candidate and candidate ~= old_data then + new_data = candidate + break + end + end + if new_data then + return 200, new_data, "image/jpeg" + end + -- Timeout — return what we have (may be stale) with a warning header + local fallback = preview.get(id, img.path, img.filename, preview_size) + if fallback then + return 200, fallback, "image/jpeg", + { ["X-Preview-Warning"] = "regeneration-timeout; thumbnail may be stale" } + end + return err(504, "preview_timeout", + "thumbnail regeneration timed out after 30 s; retry the preview endpoint") + end + + return 204, "", nil +end + +-- ── Full-resolution export ──────────────────────────────────────────────────── +-- POST /api/v1/images/:id/export +-- Enqueues a full-resolution JPEG export via the darktable bridge. +-- The bridge calls dt.new_format("jpeg"):write_image() which runs the complete +-- development pipeline. Returns 202 immediately; poll GET /full to check when ready. +-- Requires scope: cache:refresh +function M.trigger_export(req) + local payload, s, b, ct = require_auth(req, "cache:refresh") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local queued, qerr = enqueue_export(id) + if not queued then + return err(500, "queue_error", qerr) + end + + return 202, json.encode({ + status = "accepted", + image_id = id, + filename = img.filename, + export = export_path(id), + message = "full-resolution export enqueued; poll GET /api/v1/images/" .. id .. "/full when ready", + }), "application/json; charset=utf-8" +end + +-- GET /api/v1/images/:id/full +-- Serves the full-resolution JPEG previously exported via POST /export. +-- If no export exists yet, enqueues one and returns 202 so the client can retry. +-- Requires scope: previews:read +function M.get_full(req) + local payload, s, b, ct = require_auth(req, "previews:read") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local path = export_path(id) + local f = io.open(path, "rb") + if f then + local data = f:read("*a") + f:close() + return 200, data, "image/jpeg" + end + + -- Not yet exported — enqueue and tell the client to retry. + local queued, qerr = enqueue_export(id) + if not queued then + return err(500, "queue_error", qerr) + end + + return 202, json.encode({ + status = "queued", + image_id = id, + filename = img.filename, + retry_after = 5, + message = "export enqueued; retry in a few seconds", + }), "application/json; charset=utf-8", { ["Retry-After"] = "5" } +end + return M diff --git a/contrib/darktable-api/bruno/images/Export Image.bru b/contrib/darktable-api/bruno/images/Export Image.bru new file mode 100644 index 0000000..37f3219 --- /dev/null +++ b/contrib/darktable-api/bruno/images/Export Image.bru @@ -0,0 +1,52 @@ +meta { + name: Export Image + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/v1/images/{{imageId}}/export + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 202 Accepted", function() { + expect(res.status).to.equal(202); + }); + + test("status is accepted", function() { + expect(res.body.status).to.equal("accepted"); + }); + + test("image_id matches request", function() { + expect(res.body.image_id).to.equal(Number(bru.getVar("imageId"))); + }); + + test("has filename", function() { + expect(res.body.filename).to.be.a("string"); + }); + + test("has export path", function() { + expect(res.body.export).to.be.a("string"); + }); +} + +docs { + ## Export Image (Full Resolution) + + Enqueues a full-resolution JPEG export processed through darktable's complete + development pipeline — tone curves, exposure, colour grading, noise reduction, + and all other active modules are applied. + + Returns **202 Accepted** immediately. The darktable bridge plugin writes the + result to the path shown in `export` (default: `/tmp/dt-api-exports/.jpg`). + + Poll **GET /api/v1/images/:id/full** to retrieve the image once ready. + + Requires scope: `cache:refresh` +} diff --git a/contrib/darktable-api/bruno/images/Get Full Image.bru b/contrib/darktable-api/bruno/images/Get Full Image.bru new file mode 100644 index 0000000..11b521a --- /dev/null +++ b/contrib/darktable-api/bruno/images/Get Full Image.bru @@ -0,0 +1,57 @@ +meta { + name: Get Full Image + type: http + seq: 7 +} + +get { + url: {{baseUrl}}/api/v1/images/{{imageId}}/full + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 200 or 202", function() { + expect([200, 202]).to.include(res.status); + }); + + test("if 200, content-type is image/jpeg", function() { + if (res.status === 200) { + expect(res.headers["content-type"]).to.equal("image/jpeg"); + } + }); + + test("if 200, body is non-empty", function() { + if (res.status === 200) { + expect(res.body.length).to.be.above(0); + } + }); + + test("if 202, export was queued", function() { + if (res.status === 202) { + expect(res.body.status).to.equal("queued"); + expect(res.body.retry_after).to.be.a("number"); + } + }); +} + +docs { + ## Get Full-Resolution Image + + Returns the full-resolution JPEG developed through darktable's complete + processing pipeline (all active development modules applied). + + ### Workflow + 1. POST `/api/v1/images/:id/export` — enqueue the export (returns 202) + 2. Wait ~2–10 s (depending on complexity and hardware) + 3. GET `/api/v1/images/:id/full` — download the result (200 with JPEG body) + + If no export exists yet this endpoint auto-enqueues one and returns **202** + with a `Retry-After: 5` header. Keep retrying until you receive **200**. + + Requires scope: `previews:read` +} diff --git a/contrib/darktable-api/bruno/modules/Get Module (Not Found).bru b/contrib/darktable-api/bruno/modules/Get Module (Not Found).bru new file mode 100644 index 0000000..76a9b94 --- /dev/null +++ b/contrib/darktable-api/bruno/modules/Get Module (Not Found).bru @@ -0,0 +1,31 @@ +meta { + name: Get Module (Not Found) + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/nonexistent_module + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 404", function() { + expect(res.status).to.equal(404); + }); + + test("error is module_not_found", function() { + expect(res.body.error).to.equal("module_not_found"); + }); +} + +docs { + ## Get Module — Not Found + + Verifies 404 is returned for an operation that does not exist in the image history. +} diff --git a/contrib/darktable-api/bruno/modules/Get Module.bru b/contrib/darktable-api/bruno/modules/Get Module.bru new file mode 100644 index 0000000..04b09e7 --- /dev/null +++ b/contrib/darktable-api/bruno/modules/Get Module.bru @@ -0,0 +1,55 @@ +meta { + name: Get Module + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("operation is filmicrgb", function() { + expect(res.body.operation).to.equal("filmicrgb"); + }); + + test("has enabled flag", function() { + expect(res.body.enabled).to.be.a("boolean"); + }); + + test("has decoded params", function() { + expect(res.body.params).to.be.an("object"); + expect(res.body.params.grey_point_source).to.be.a("number"); + expect(res.body.params.black_point_source).to.be.a("number"); + expect(res.body.params.white_point_source).to.be.a("number"); + expect(res.body.params.contrast).to.be.a("number"); + expect(res.body.params.saturation).to.be.a("number"); + }); + + // Save for the Patch Module test + bru.setVar("filmicGreyPointSource", res.body.params.grey_point_source); +} + +docs { + ## Get Module + + Returns the active history entry for the `filmicrgb` module. + + Change the URL path segment to inspect a different module, e.g.: + - `.../modules/exposure` + - `.../modules/temperature` + - `.../modules/highlights` + - `.../modules/channelmixerrgb` + + Returns 404 if the operation has never been added to this image's history. +} diff --git a/contrib/darktable-api/bruno/modules/List Modules.bru b/contrib/darktable-api/bruno/modules/List Modules.bru new file mode 100644 index 0000000..d0249d4 --- /dev/null +++ b/contrib/darktable-api/bruno/modules/List Modules.bru @@ -0,0 +1,79 @@ +meta { + name: List Modules + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/images/{{imageId}}/modules + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("has image_id", function() { + expect(res.body.image_id).to.equal(Number(bru.getVar("imageId"))); + }); + + test("has items array", function() { + expect(res.body.items).to.be.an("array"); + expect(res.body.items.length).to.be.above(0); + }); + + test("each item has required fields", function() { + res.body.items.forEach(function(m) { + expect(m.operation).to.be.a("string"); + expect(m.enabled).to.be.a("boolean"); + expect(m.module_version).to.be.a("number"); + expect(m.history_num).to.be.a("number"); + }); + }); + + test("known modules have decoded params", function() { + var known = res.body.items.filter(function(m) { + return ["exposure","temperature","filmicrgb","highlights", + "channelmixerrgb","flip","gamma","rawprepare","demosaic"].indexOf(m.operation) >= 0; + }); + known.forEach(function(m) { + expect(m.params).to.not.be.null; + expect(typeof m.params).to.equal("object"); + }); + }); + + test("unknown modules have params_b64", function() { + var unknown = res.body.items.filter(function(m) { return m.params === undefined || m.params === null; }); + unknown.forEach(function(m) { + expect(m.params_b64).to.be.a("string"); + }); + }); + + // Save the first module op for subsequent tests + bru.setVar("moduleOp", res.body.items[0].operation); +} + +docs { + ## List Modules + + Returns all active development modules (history stack entries) for the image. + + Each item includes: + - `operation` – module name (e.g. `"exposure"`, `"filmicrgb"`) + - `enabled` – whether the module is currently active in the pipeline + - `module_version` – params struct version stored in the database + - `multi_name` – user label for multi-instance modules (empty for single-instance) + - `history_num` – position in the history stack (higher = more recent) + - `params` – decoded named parameters (for supported modules) + - `params_b64` – base64-encoded raw binary blob (for unsupported modules) + + ### Supported modules (params decoded) + `exposure`, `temperature`, `filmicrgb`, `channelmixerrgb`, `highlights`, + `flip`, `gamma`, `rawprepare`, `demosaic` +} diff --git a/contrib/darktable-api/bruno/modules/Patch Module.bru b/contrib/darktable-api/bruno/modules/Patch Module.bru new file mode 100644 index 0000000..9c0245c --- /dev/null +++ b/contrib/darktable-api/bruno/modules/Patch Module.bru @@ -0,0 +1,89 @@ +meta { + name: Patch Module + type: http + seq: 3 +} + +patch { + url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb + body: json + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +params:query { + ~preview: medium +} + +body:json { + { + "enabled": true, + "params": { + "grey_point_source": 18.45, + "white_point_source": 4.0, + "black_point_source": -8.0, + "contrast": 1.0 + } + } +} + +tests { + test("returns 204 (no preview) or 200 (with preview)", function() { + expect([200, 204]).to.include(res.status); + }); + + test("if 200, content-type is image/jpeg", function() { + if (res.status === 200) { + expect(res.headers["content-type"]).to.equal("image/jpeg"); + } + }); +} + +docs { + ## Patch Module + + Update one or more parameters on a development module and optionally get a + preview image back showing the result. + + ### Body fields + - `enabled` (boolean, optional) – enable or disable the module + - `params` (object, optional) – named parameter overrides for known modules + - `params_b64` (string, optional) – raw base64 binary blob for unknown modules + (mutually exclusive with `params`) + + Only fields present in `params` are changed; all others keep their current values. + + ### Preview query param + Add `?preview=small|medium|large|xlarge` to receive a JPEG thumbnail (200) + instead of empty 204. The server waits up to 30 s for the thumbnail to + regenerate before returning. Without this param the endpoint returns + **204 No Content** immediately (the bridge regenerates async in ~1-3 s). + + ### filmicrgb key parameters + | Field | Description | Default | + |---|---|---| + | `grey_point_source` | middle-grey exposure % | 18.45 | + | `black_point_source` | black relative exposure EV | -8.0 | + | `white_point_source` | white relative exposure EV | 4.0 | + | `contrast` | S-curve contrast | 1.0 | + | `saturation` | extreme-luminance saturation | 0.0 | + | `balance` | shadows ↔ highlights balance | 0.0 | + | `auto_hardness` | auto-adjust curve hardness | true | + + ### exposure key parameters + | Field | Description | + |---|---| + | `exposure` | exposure compensation in EV | + | `black` | black level correction | + | `mode` | 0=manual, 1=deflicker | + + ### temperature key parameters + | Field | Description | + |---|---| + | `red` | red channel multiplier | + | `green` | green channel multiplier | + | `blue` | blue channel multiplier | +} diff --git a/contrib/darktable-api/config.lua b/contrib/darktable-api/config.lua index 0bec1b5..b72c67e 100644 --- a/contrib/darktable-api/config.lua +++ b/contrib/darktable-api/config.lua @@ -28,4 +28,11 @@ M.clients[default_id] = { -- Queue file the bridge plugin watches for cache-refresh requests. M.refresh_queue = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue" +-- Queue file the bridge plugin watches for full-resolution export requests. +M.export_queue = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue" + +-- Directory where the bridge writes full-resolution JPEG exports. +-- Served by GET /api/v1/images/:id/full. +M.export_dir = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports" + return M diff --git a/contrib/darktable-api/darktable-api-bridge.lua b/contrib/darktable-api/darktable-api-bridge.lua index ac85d03..081edf2 100644 --- a/contrib/darktable-api/darktable-api-bridge.lua +++ b/contrib/darktable-api/darktable-api-bridge.lua @@ -2,21 +2,23 @@ darktable-api-bridge.lua Companion plugin for the darktable REST API server. - Runs inside a live darktable instance and watches a queue file written by the - standalone LuaJIT server. When image IDs appear in the queue this plugin - calls image:drop_cache() and image:generate_cache() so that darktable's - thumbnail panel reflects the latest on-disk state. + Runs inside a live darktable instance and watches two queue files written by + the standalone LuaJIT server. + + Refresh queue (DT_REFRESH_QUEUE, default /tmp/dt-api-refresh.queue) + One image ID per line. The bridge calls image:drop_cache() + + image:generate_cache(true, …) so that darktable re-renders thumbnails + through its full processing pipeline, reflecting tone curves, exposure, + colour grading, and all other development settings. + + Export queue (DT_EXPORT_QUEUE, default /tmp/dt-api-export.queue) + One image ID per line. The bridge exports a full-resolution JPEG via + darktable's rendering pipeline to DT_EXPORT_DIR/.jpg. Install: add the following line to ~/.config/darktable/luarc require "contrib/darktable-api/darktable-api-bridge" Or let the startup script (start-with-api.sh) do it automatically. - - Queue file format (written by the server's /api/v1/images/:id/refresh): - One image ID (integer) per line, e.g.: - 42 - 7 - 100 --]] local dt = require "darktable" @@ -28,13 +30,16 @@ local MODULE = "darktable-api-bridge" -- ── Configuration ───────────────────────────────────────────────────────────── --- Must match config.refresh_queue in config.lua (or the DT_REFRESH_QUEUE env var). -local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue" -local POLL_MS = 1000 -- poll interval in milliseconds --- Mipmap range to regenerate: 0 = smallest thumbnail, 8 = full resolution. --- Levels 0-4 cover all GUI thumbnail sizes (~67 px … ~1080 px). -local MIP_MIN = 0 -local MIP_MAX = 4 +-- Must match config.refresh_queue / export_queue / export_dir in config.lua. +local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue" +local EXPORT_QUEUE = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue" +local EXPORT_DIR = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports" +local POLL_MS = 1000 -- poll interval in milliseconds +-- Mipmap range to regenerate: 0 = smallest thumbnail, 4 ≈ 1080 px. +local MIP_MIN = 0 +local MIP_MAX = 4 +-- JPEG quality used for full-resolution exports. +local EXPORT_QUALITY = tonumber(os.getenv("DT_EXPORT_QUALITY")) or 95 -- ── Logging ─────────────────────────────────────────────────────────────────── @@ -93,25 +98,58 @@ local function refresh_image(img) local label = string.format("id=%d %s/%s", img.id, img.path, img.filename) log("refreshing " .. label) - -- Drop the current cached thumbnails so darktable knows they're stale. img:drop_cache() - -- Regenerate thumbnails for mip levels MIP_MIN..MIP_MAX. - -- full=false uses embedded JPEG thumbnails when available (fast); - -- set full=true to force a complete RAW render (accurate but slow). - local full = false - img:generate_cache(full, MIP_MIN, MIP_MAX) + -- full=true forces darktable to re-process through its complete rendering + -- pipeline (tone curves, exposure, colour grading, etc.) instead of using + -- the stale embedded EXIF thumbnail from the source file. + img:generate_cache(true, MIP_MIN, MIP_MAX) log("done " .. label) end --- Process all pending IDs in one pass. +local function export_image(img, dest_path) + local label = string.format("id=%d → %s", img.id, dest_path) + log("exporting " .. label) + + local fmt = dt.new_format("jpeg") + fmt.quality = EXPORT_QUALITY + -- max_width/max_height = 0 means no scaling: full resolution. + fmt.max_width = 0 + fmt.max_height = 0 + + local export_err = fmt:write_image(img, dest_path) + if export_err then + error("export failed: " .. tostring(export_err)) + end + + log("export done " .. label) +end + +-- Atomically claim the export queue and return a set of unique image IDs. +local function drain_export_queue() + local processing = EXPORT_QUEUE .. ".processing" + local ok = os.rename(EXPORT_QUEUE, processing) + if not ok then return {} end + + local ids = {} + local f = io.open(processing, "r") + if f then + for line in f:lines() do + local id = tonumber(line) + if id then ids[id] = true end + end + f:close() + os.remove(processing) + end + return ids +end + +-- Process all pending refresh IDs in one pass. local function process_queue() local ids = drain_queue() local count = 0 - for id, _ in pairs(ids) do - count = count + 1 - end + for id, _ in pairs(ids) do count = count + 1 end if count == 0 then return end log(string.format("processing %d queued refresh request(s)", count)) @@ -129,11 +167,37 @@ local function process_queue() end end - -- Nudge the lighttable to redraw so the user sees updated thumbnails - -- without having to scroll away and back. + -- Nudge the lighttable to redraw so updated thumbnails appear immediately. dt.gui.views.lighttable.is_culling_mode = dt.gui.views.lighttable.is_culling_mode end +-- Process all pending export IDs in one pass. +local function process_export_queue() + local ids = drain_export_queue() + local count = 0 + for id, _ in pairs(ids) do count = count + 1 end + if count == 0 then return end + + -- Ensure export directory exists. + os.execute('mkdir -p "' .. EXPORT_DIR .. '"') + + log(string.format("processing %d queued export request(s)", count)) + local id_map = build_id_map() + + for id, _ in pairs(ids) do + local img = id_map[id] + if img then + local dest = EXPORT_DIR .. "/" .. tostring(id) .. ".jpg" + local ok, err = pcall(export_image, img, dest) + if not ok then + log("error exporting id=" .. id .. ": " .. tostring(err)) + end + else + log("export: image id=" .. id .. " not found in database") + end + end +end + -- ── Background polling loop ─────────────────────────────────────────────────── -- dt.control.dispatch runs the function asynchronously in darktable's worker @@ -143,13 +207,16 @@ local running = true local function poll_loop() notify(string.format( - "started; polling %s every %d ms", QUEUE_FILE, POLL_MS)) + "started; polling queues every %d ms\n refresh: %s\n export: %s → %s", + POLL_MS, QUEUE_FILE, EXPORT_QUEUE, EXPORT_DIR)) while running do local ok, err = pcall(process_queue) - if not ok then - log("process_queue error: " .. tostring(err)) - end + if not ok then log("process_queue error: " .. tostring(err)) end + + local ok2, err2 = pcall(process_export_queue) + if not ok2 then log("process_export_queue error: " .. tostring(err2)) end + dt.control.sleep(POLL_MS) end @@ -182,6 +249,8 @@ dt.register_event(MODULE, "shortcut", log("manual poll triggered via shortcut") local ok, err = pcall(process_queue) if not ok then notify("error during manual poll: " .. tostring(err)) end + local ok2, err2 = pcall(process_export_queue) + if not ok2 then notify("export error during manual poll: " .. tostring(err2)) end end, "poll refresh queue now" ) diff --git a/contrib/darktable-api/db.lua b/contrib/darktable-api/db.lua index 156b445..3cf398b 100644 --- a/contrib/darktable-api/db.lua +++ b/contrib/darktable-api/db.lua @@ -1,4 +1,5 @@ --- Read-only access to the darktable SQLite catalog. +-- Darktable SQLite catalog access (read-only for image queries; read-write for +-- module-parameter edits via the history table). -- Schema inspected from an actual darktable library (recent version): -- -- images(id, group_id, film_id, filename, width, height, aspect_ratio, @@ -27,9 +28,10 @@ local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility local M = {} --- ── Connection (single, lazy, read-only) ───────────────────────────────────── +-- ── Connections ─────────────────────────────────────────────────────────────── -local _db +local _db -- read-only, lazy +local _db_rw -- read-write, opened on first write local function get_db() if _db then return _db end @@ -37,12 +39,22 @@ local function get_db() if not _db then error("Cannot open darktable library: " .. config.library_db) end - -- Attach data.db for the canonical tags table (darktable ≥ 2.4) local data_db = config.data_db:gsub("'", "''") _db:exec(string.format("ATTACH DATABASE '%s' AS data", data_db)) return _db end +local function get_db_rw() + if _db_rw then return _db_rw end + _db_rw = sqlite3.open(config.library_db) -- read-write + if not _db_rw then + error("Cannot open darktable library for writing: " .. config.library_db) + end + -- WAL mode: allow concurrent reads from the darktable process. + _db_rw:exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") + return _db_rw +end + -- ── Query helpers ───────────────────────────────────────────────────────────── local function query(sql, params) @@ -384,4 +396,129 @@ function M.list_films(filters) return films end +-- ── Module history ──────────────────────────────────────────────────────────── +-- The history table stores one or more entries per (imgid, operation). +-- Only the entry with the highest `num` is the active one. +-- `module` column = the params-struct version number for that row. + +local modules_mod -- lazy-loaded to avoid circular requires + +local function get_modules_mod() + if not modules_mod then modules_mod = require "modules" end + return modules_mod +end + +local function history_row_to_module(row) + local mods = get_modules_mod() + local blob = row.op_params or "" + local ver = row.module or 0 + local params = mods.decode(row.operation, ver, blob) + return { + operation = row.operation, + enabled = row.enabled ~= 0, + module_version = ver, + multi_name = row.multi_name or "", + multi_priority = row.multi_priority or 0, + history_num = row.num, + params = params, -- nil for unknown modules + params_b64 = params and nil or require("base64").encode(blob), + } +end + +-- Return the active (highest-num) history entry for every operation on imgid. +function M.list_modules(imgid) + local rows = query([[ + SELECT h.operation, h.module, h.enabled, h.op_params, + h.multi_name, h.multi_priority, h.num + FROM history h + INNER JOIN ( + SELECT operation, MAX(num) AS max_num + FROM history WHERE imgid = ? + GROUP BY operation + ) latest ON h.operation = latest.operation AND h.num = latest.max_num + WHERE h.imgid = ? + ORDER BY h.operation + ]], { tonumber(imgid), tonumber(imgid) }) + + local result = {} + for _, row in ipairs(rows) do + result[#result+1] = history_row_to_module(row) + end + return result +end + +-- Return the raw op_params binary blob for one operation (latest history entry). +function M.get_module_blob(imgid, operation) + local row = query_one([[ + SELECT op_params FROM history + WHERE imgid = ? AND operation = ? + ORDER BY num DESC LIMIT 1 + ]], { tonumber(imgid), operation }) + return row and row.op_params or nil +end + +-- Return the single active history entry for one operation. + local row = query_one([[ + SELECT h.operation, h.module, h.enabled, h.op_params, + h.multi_name, h.multi_priority, h.num + FROM history h + WHERE h.imgid = ? AND h.operation = ? + ORDER BY h.num DESC LIMIT 1 + ]], { tonumber(imgid), operation }) + if not row then return nil end + return history_row_to_module(row) +end + +-- Update the active history entry for an operation. +-- `new_blob` (string|nil) – new binary op_params; nil to leave unchanged +-- `new_enabled` (bool|nil) – new enabled flag; nil to leave unchanged +-- Returns true on success, or false + error message. +function M.set_module_params(imgid, operation, new_enabled, new_blob) + -- Find the current latest entry and its num. + local row = query_one([[ + SELECT num, module, op_params, enabled, blendop_params, blendop_version, + multi_priority, multi_name, multi_name_hand_edited + FROM history + WHERE imgid = ? AND operation = ? + ORDER BY num DESC LIMIT 1 + ]], { tonumber(imgid), operation }) + + if not row then + return false, "module '" .. operation .. "' not found in history for image " .. imgid + end + + local d = get_db_rw() + + -- Find the global max num for this image so we can insert after it. + local max_row = query_one("SELECT MAX(num) AS m FROM history WHERE imgid=?", {tonumber(imgid)}) + local new_num = (max_row and max_row.m or 0) + 1 + + local enabled_val = (new_enabled == nil) and row.enabled or (new_enabled and 1 or 0) + local blob_val = new_blob or row.op_params + + -- Insert a new history entry (preserves darktable's undo history). + local sql = [[ + INSERT INTO history + (imgid, num, module, operation, op_params, enabled, + blendop_params, blendop_version, multi_priority, multi_name, multi_name_hand_edited) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]] + local stmt = d:prepare(sql) + if not stmt then + return false, "SQL prepare failed: " .. (d:errmsg() or "?") + end + stmt:bind_values( + tonumber(imgid), new_num, row.module, operation, blob_val, enabled_val, + row.blendop_params, row.blendop_version, + row.multi_priority, row.multi_name, row.multi_name_hand_edited + ) + local rc = stmt:step() + stmt:finalize() + + if rc ~= sqlite3.DONE then + return false, "INSERT failed (rc=" .. tostring(rc) .. "): " .. (d:errmsg() or "?") + end + return true +end + return M diff --git a/contrib/darktable-api/modules.lua b/contrib/darktable-api/modules.lua new file mode 100644 index 0000000..0270b1a --- /dev/null +++ b/contrib/darktable-api/modules.lua @@ -0,0 +1,502 @@ +-- Module parameter decoder / encoder for the darktable REST API. +-- Runs in the LuaJIT server process. Uses FFI to unpack and repack the +-- binary op_params blobs stored in the library.db history table. +-- +-- Each struct definition mirrors the darktable 4.6 iop C struct exactly so +-- that sizeof() matches the byte count in the database. The `module` column +-- in the history table carries the version number stored for that row. +-- +-- Unknown modules (or unsupported versions) pass through as a base64 string. + +local ffi = require "ffi" +local base64 = require "base64" + +-- ── C struct definitions ─────────────────────────────────────────────────────── + +ffi.cdef[[ +/* --- flip (ver=2, 4 bytes) --- */ +typedef struct { int32_t orientation; } dt_flip_v2_t; + +/* --- gamma / display encoding (ver=1, 8 bytes) --- */ +typedef struct { float gamma; float linear; } dt_gamma_v1_t; + +/* --- temperature / white balance (ver=3, 16 bytes) --- */ +typedef struct { float red; float green; float blue; float various; } dt_temperature_v3_t; + +/* --- exposure (ver=7, 28 bytes) --- */ +typedef struct { + int32_t mode; + float black; + float exposure; + float deflicker_percentile; + float deflicker_target_level; + int32_t compensate_exposure_bias; + int32_t compensate_hilite_pres; +} dt_exposure_v7_t; + +/* --- rawprepare (ver=1, 28 bytes) --- */ +typedef struct { + int32_t left; int32_t top; int32_t right; int32_t bottom; + uint16_t raw_black_level_separate[4]; + uint16_t raw_white_point; + uint16_t _pad1; +} dt_rawprepare_v1_t; + +/* --- rawprepare (ver=2, 32 bytes) --- */ +typedef struct { + int32_t left; int32_t top; int32_t right; int32_t bottom; + uint16_t raw_black_level_separate[4]; + uint16_t raw_white_point; + uint16_t _pad1; + int32_t flat_field; +} dt_rawprepare_v2_t; + +/* --- demosaic (ver=3, 20 bytes) --- */ +typedef struct { + int32_t green_eq; + float median_thrs; + int32_t color_smoothing; + int32_t demosaicing_method; + int32_t lmmse_refine; +} dt_demosaic_v3_t; + +/* --- highlights (ver=4, 48 bytes) --- */ +typedef struct { + int32_t mode; + float blendL; + float blendC; + float strength; + float clip; + float noise_level; + int32_t iterations; + int32_t scales; + float candidating; + float combine; + int32_t recovery; + float solid_color; +} dt_highlights_v4_t; + +/* --- channelmixerrgb (ver=3, 160 bytes) --- */ +typedef struct { + float red[4]; + float green[4]; + float blue[4]; + float saturation[4]; + float lightness[4]; + float grey[4]; + int32_t normalize_R; + int32_t normalize_G; + int32_t normalize_B; + int32_t normalize_sat; + int32_t normalize_light; + int32_t normalize_grey; + int32_t illuminant; + int32_t illum_fluo; + int32_t illum_led; + int32_t adaptation; + float x; + float y; + float temperature; + float gamut; + int32_t clip; + int32_t version; +} dt_channelmixerrgb_v3_t; + +/* --- filmicrgb (ver=6, 116 bytes) --- */ +typedef struct { + float grey_point_source; + float black_point_source; + float white_point_source; + float reconstruct_threshold; + float reconstruct_feather; + float reconstruct_bloom_vs_details; + float reconstruct_grey_vs_color; + float reconstruct_structure_vs_texture; + float security_factor; + float grey_point_target; + float black_point_target; + float white_point_target; + float output_power; + float latitude; + float contrast; + float saturation; + float balance; + float noise_level; + int32_t preserve_color; + int32_t version; + int32_t auto_hardness; + int32_t custom_grey; + int32_t high_quality_reconstruction; + int32_t noise_distribution; + int32_t shadows; + int32_t highlights; + int32_t compensate_icc_black; + int32_t spline_version; + int32_t enable_highlight_reconstruction; +} dt_filmicrgb_v6_t; +]] + +-- ── Internal helpers ────────────────────────────────────────────────────────── + +local function bool(v) return v ~= 0 end + +-- Cast a Lua binary string into a pointer to ctype. +local function cast_blob(ctype, blob) + local buf = ffi.new("uint8_t[?]", #blob) + ffi.copy(buf, blob, #blob) + return ffi.cast(ctype, buf), buf -- return buf too so GC doesn't collect it +end + +-- Read a 4-float array field from a cdata array into a Lua table. +local function farr(p, n) + n = n or 4 + local t = {} + for i = 0, n-1 do t[i+1] = p[i] end + return t +end + +-- Write a Lua table back into a cdata 4-float array. +local function set_farr(p, t, n) + n = n or 4 + for i = 0, n-1 do + if t[i+1] ~= nil then p[i] = t[i+1] end + end +end + +-- Apply a field-level patch: if `src[k]` exists, set `dst.k = src[k]`. +local function patch_fields(dst, src, ...) + for _, k in ipairs({...}) do + if src[k] ~= nil then dst[k] = src[k] end + end +end + +-- Build a fresh binary blob by copying the existing one and applying changes. +local function make_blob(ctype, size, existing, apply_fn) + local buf = ffi.new("uint8_t[?]", size) + if existing then ffi.copy(buf, existing, size) end + local p = ffi.cast(ctype, buf) + apply_fn(p) + return ffi.string(buf, size) +end + +-- ── Per-module decode / encode ──────────────────────────────────────────────── + +local function decode_flip(blob, ver) + if ver ~= 2 or #blob ~= 4 then return nil end + local p, _ = cast_blob("dt_flip_v2_t *", blob) + return { orientation = tonumber(p.orientation) } +end + +local function encode_flip(blob, changes) + return make_blob("dt_flip_v2_t *", 4, blob, function(p) + if changes.orientation ~= nil then p.orientation = changes.orientation end + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_gamma(blob, ver) + if ver ~= 1 or #blob ~= 8 then return nil end + local p, _ = cast_blob("dt_gamma_v1_t *", blob) + return { gamma = tonumber(p.gamma), linear = tonumber(p.linear) } +end + +local function encode_gamma(blob, changes) + return make_blob("dt_gamma_v1_t *", 8, blob, function(p) + patch_fields(p, changes, "gamma", "linear") + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_temperature(blob, ver) + if ver ~= 3 or #blob ~= 16 then return nil end + local p, _ = cast_blob("dt_temperature_v3_t *", blob) + return { + red = tonumber(p.red), + green = tonumber(p.green), + blue = tonumber(p.blue), + various = tonumber(p.various), + } +end + +local function encode_temperature(blob, changes) + return make_blob("dt_temperature_v3_t *", 16, blob, function(p) + patch_fields(p, changes, "red", "green", "blue", "various") + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_exposure(blob, ver) + if ver ~= 7 or #blob ~= 28 then return nil end + local p, _ = cast_blob("dt_exposure_v7_t *", blob) + return { + mode = tonumber(p.mode), + black = tonumber(p.black), + exposure = tonumber(p.exposure), + deflicker_percentile = tonumber(p.deflicker_percentile), + deflicker_target_level = tonumber(p.deflicker_target_level), + compensate_exposure_bias = bool(p.compensate_exposure_bias), + compensate_hilite_pres = bool(p.compensate_hilite_pres), + } +end + +local function encode_exposure(blob, changes) + return make_blob("dt_exposure_v7_t *", 28, blob, function(p) + patch_fields(p, changes, "mode", "black", "exposure", + "deflicker_percentile", "deflicker_target_level") + if changes.compensate_exposure_bias ~= nil then + p.compensate_exposure_bias = changes.compensate_exposure_bias and 1 or 0 + end + if changes.compensate_hilite_pres ~= nil then + p.compensate_hilite_pres = changes.compensate_hilite_pres and 1 or 0 + end + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_rawprepare(blob, ver) + if ver == 1 and #blob == 28 then + local p, _ = cast_blob("dt_rawprepare_v1_t *", blob) + return { + left = tonumber(p.left), top = tonumber(p.top), + right = tonumber(p.right), bottom = tonumber(p.bottom), + raw_black_level_separate = farr(p.raw_black_level_separate, 4), + raw_white_point = tonumber(p.raw_white_point), + } + elseif ver == 2 and #blob == 32 then + local p, _ = cast_blob("dt_rawprepare_v2_t *", blob) + return { + left = tonumber(p.left), top = tonumber(p.top), + right = tonumber(p.right), bottom = tonumber(p.bottom), + raw_black_level_separate = farr(p.raw_black_level_separate, 4), + raw_white_point = tonumber(p.raw_white_point), + flat_field = tonumber(p.flat_field), + } + end + return nil +end + +local function encode_rawprepare(blob, changes, ver) + if ver == 1 then + return make_blob("dt_rawprepare_v1_t *", 28, blob, function(p) + patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point") + if changes.raw_black_level_separate then + set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4) + end + end) + else + return make_blob("dt_rawprepare_v2_t *", 32, blob, function(p) + patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point", "flat_field") + if changes.raw_black_level_separate then + set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4) + end + end) + end +end + +-- --------------------------------------------------------------------------- + +local function decode_demosaic(blob, ver) + if ver ~= 3 or #blob ~= 20 then return nil end + local p, _ = cast_blob("dt_demosaic_v3_t *", blob) + return { + green_eq = tonumber(p.green_eq), + median_thrs = tonumber(p.median_thrs), + color_smoothing = tonumber(p.color_smoothing), + demosaicing_method = tonumber(p.demosaicing_method), + lmmse_refine = tonumber(p.lmmse_refine), + } +end + +local function encode_demosaic(blob, changes) + return make_blob("dt_demosaic_v3_t *", 20, blob, function(p) + patch_fields(p, changes, "green_eq", "median_thrs", "color_smoothing", + "demosaicing_method", "lmmse_refine") + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_highlights(blob, ver) + if ver ~= 4 or #blob ~= 48 then return nil end + local p, _ = cast_blob("dt_highlights_v4_t *", blob) + return { + mode = tonumber(p.mode), + blendL = tonumber(p.blendL), + blendC = tonumber(p.blendC), + strength = tonumber(p.strength), + clip = tonumber(p.clip), + noise_level = tonumber(p.noise_level), + iterations = tonumber(p.iterations), + scales = tonumber(p.scales), + candidating = tonumber(p.candidating), + combine = tonumber(p.combine), + recovery = tonumber(p.recovery), + solid_color = tonumber(p.solid_color), + } +end + +local function encode_highlights(blob, changes) + return make_blob("dt_highlights_v4_t *", 48, blob, function(p) + patch_fields(p, changes, "mode", "blendL", "blendC", "strength", "clip", + "noise_level", "iterations", "scales", "candidating", + "combine", "recovery", "solid_color") + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_channelmixerrgb(blob, ver) + if ver ~= 3 or #blob ~= 160 then return nil end + local p, _ = cast_blob("dt_channelmixerrgb_v3_t *", blob) + return { + red = farr(p.red), + green = farr(p.green), + blue = farr(p.blue), + saturation = farr(p.saturation), + lightness = farr(p.lightness), + grey = farr(p.grey), + normalize_R = bool(p.normalize_R), + normalize_G = bool(p.normalize_G), + normalize_B = bool(p.normalize_B), + normalize_sat = bool(p.normalize_sat), + normalize_light = bool(p.normalize_light), + normalize_grey = bool(p.normalize_grey), + illuminant = tonumber(p.illuminant), + illum_fluo = tonumber(p.illum_fluo), + illum_led = tonumber(p.illum_led), + adaptation = tonumber(p.adaptation), + x = tonumber(p.x), + y = tonumber(p.y), + temperature = tonumber(p.temperature), + gamut = tonumber(p.gamut), + clip = bool(p.clip), + version = tonumber(p.version), + } +end + +local function encode_channelmixerrgb(blob, changes) + return make_blob("dt_channelmixerrgb_v3_t *", 160, blob, function(p) + for _, k in ipairs({"red","green","blue","saturation","lightness","grey"}) do + if changes[k] then set_farr(p[k], changes[k], 4) end + end + for _, k in ipairs({"normalize_R","normalize_G","normalize_B", + "normalize_sat","normalize_light","normalize_grey","clip"}) do + if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end + end + patch_fields(p, changes, "illuminant","illum_fluo","illum_led","adaptation", + "x","y","temperature","gamut","version") + end) +end + +-- --------------------------------------------------------------------------- + +local function decode_filmicrgb(blob, ver) + if ver ~= 6 or #blob ~= 116 then return nil end + local p, _ = cast_blob("dt_filmicrgb_v6_t *", blob) + return { + grey_point_source = tonumber(p.grey_point_source), + black_point_source = tonumber(p.black_point_source), + white_point_source = tonumber(p.white_point_source), + reconstruct_threshold = tonumber(p.reconstruct_threshold), + reconstruct_feather = tonumber(p.reconstruct_feather), + reconstruct_bloom_vs_details = tonumber(p.reconstruct_bloom_vs_details), + reconstruct_grey_vs_color = tonumber(p.reconstruct_grey_vs_color), + reconstruct_structure_vs_texture = tonumber(p.reconstruct_structure_vs_texture), + security_factor = tonumber(p.security_factor), + grey_point_target = tonumber(p.grey_point_target), + black_point_target = tonumber(p.black_point_target), + white_point_target = tonumber(p.white_point_target), + output_power = tonumber(p.output_power), + latitude = tonumber(p.latitude), + contrast = tonumber(p.contrast), + saturation = tonumber(p.saturation), + balance = tonumber(p.balance), + noise_level = tonumber(p.noise_level), + preserve_color = tonumber(p.preserve_color), + version = tonumber(p.version), + auto_hardness = bool(p.auto_hardness), + custom_grey = bool(p.custom_grey), + high_quality_reconstruction = tonumber(p.high_quality_reconstruction), + noise_distribution = tonumber(p.noise_distribution), + shadows = tonumber(p.shadows), + highlights = tonumber(p.highlights), + compensate_icc_black = bool(p.compensate_icc_black), + spline_version = tonumber(p.spline_version), + enable_highlight_reconstruction = bool(p.enable_highlight_reconstruction), + } +end + +local function encode_filmicrgb(blob, changes) + return make_blob("dt_filmicrgb_v6_t *", 116, blob, function(p) + patch_fields(p, changes, + "grey_point_source","black_point_source","white_point_source", + "reconstruct_threshold","reconstruct_feather", + "reconstruct_bloom_vs_details","reconstruct_grey_vs_color", + "reconstruct_structure_vs_texture","security_factor", + "grey_point_target","black_point_target","white_point_target", + "output_power","latitude","contrast","saturation","balance","noise_level", + "preserve_color","version","high_quality_reconstruction", + "noise_distribution","shadows","highlights","spline_version") + for _, k in ipairs({"auto_hardness","custom_grey","compensate_icc_black", + "enable_highlight_reconstruction"}) do + if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end + end + end) +end + +-- ── Dispatch tables ─────────────────────────────────────────────────────────── + +local DECODERS = { + flip = decode_flip, + gamma = decode_gamma, + temperature = decode_temperature, + exposure = decode_exposure, + rawprepare = decode_rawprepare, + demosaic = decode_demosaic, + highlights = decode_highlights, + channelmixerrgb = decode_channelmixerrgb, + filmicrgb = decode_filmicrgb, +} + +local ENCODERS = { + flip = encode_flip, + gamma = encode_gamma, + temperature = encode_temperature, + exposure = encode_exposure, + rawprepare = function(b, c, v) return encode_rawprepare(b, c, v) end, + demosaic = encode_demosaic, + highlights = encode_highlights, + channelmixerrgb = encode_channelmixerrgb, + filmicrgb = encode_filmicrgb, +} + +-- ── Public API ──────────────────────────────────────────────────────────────── + +local M = {} + +-- Decode a binary op_params blob for `operation` (ver = history.module column). +-- Returns a Lua table of named fields, or nil if the operation/version is unknown. +function M.decode(operation, ver, blob) + local fn = DECODERS[operation] + if not fn then return nil end + local ok, result = pcall(fn, blob, ver) + if ok then return result end + return nil +end + +-- Apply `changes` (a table of field overrides) to `blob` and return the +-- modified binary string. Returns nil if the operation is not known. +-- For unknown modules, the caller should pass params_b64 directly. +function M.encode(operation, ver, blob, changes) + local fn = ENCODERS[operation] + if not fn then return nil end + local ok, result = pcall(fn, blob, changes, ver) + if ok then return result end + return nil +end + +return M diff --git a/contrib/darktable-api/router.lua b/contrib/darktable-api/router.lua index 03700fc..988a641 100644 --- a/contrib/darktable-api/router.lua +++ b/contrib/darktable-api/router.lua @@ -29,9 +29,10 @@ local function register(method, pattern, handler) routes[#routes+1] = { method = method:upper(), parts = compile(pattern), handler = handler } end -function M.get(pattern, handler) register("GET", pattern, handler) end -function M.post(pattern, handler) register("POST", pattern, handler) end -function M.put(pattern, handler) register("PUT", pattern, handler) end +function M.get(pattern, handler) register("GET", pattern, handler) end +function M.post(pattern, handler) register("POST", pattern, handler) end +function M.put(pattern, handler) register("PUT", pattern, handler) end +function M.patch(pattern, handler) register("PATCH", pattern, handler) end function M.delete(pattern, handler) register("DELETE", pattern, handler) end -- Match a request against registered routes. diff --git a/contrib/darktable-api/server.lua b/contrib/darktable-api/server.lua index d0bca7b..f579758 100644 --- a/contrib/darktable-api/server.lua +++ b/contrib/darktable-api/server.lua @@ -38,8 +38,13 @@ router.get("/health", api.health) router.get("/api/v1/images", api.list_images) router.get("/api/v1/images/:id", api.get_image) -router.get("/api/v1/images/:id/preview", api.get_preview) -router.post("/api/v1/images/:id/refresh", api.refresh_cache) +router.get("/api/v1/images/:id/preview", api.get_preview) +router.get("/api/v1/images/:id/full", api.get_full) +router.post("/api/v1/images/:id/refresh", api.refresh_cache) +router.post("/api/v1/images/:id/export", api.trigger_export) +router.get("/api/v1/images/:id/modules", api.list_modules) +router.get("/api/v1/images/:id/modules/:op", api.get_module) +router.patch("/api/v1/images/:id/modules/:op", api.patch_module) router.get("/api/v1/tags", api.list_tags) router.get("/api/v1/films", api.list_films) @@ -47,11 +52,11 @@ router.get("/api/v1/films", api.list_films) -- ── HTTP helpers ────────────────────────────────────────────────────────────── local STATUS_TEXT = { - [200] = "OK", [201] = "Created", [204] = "No Content", + [200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content", [301] = "Moved Permanently", [400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden", [404] = "Not Found", [405] = "Method Not Allowed", - [500] = "Internal Server Error", + [500] = "Internal Server Error", [504] = "Gateway Timeout", } local function url_decode(s) @@ -110,7 +115,7 @@ end local CORS_HEADERS = { ["Access-Control-Allow-Origin"] = "*", - ["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS", + ["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS", ["Access-Control-Allow-Headers"] = "Authorization, Content-Type", ["Access-Control-Max-Age"] = "86400", }