feat(api): enhance image module management and export functionality
- Added read-write access to the darktable SQLite catalog for module parameter edits. - Implemented new endpoints for exporting images and retrieving full-resolution images. - Introduced PATCH method for updating module parameters with optional preview. - Added comprehensive tests for new API functionalities, including module retrieval and error handling. - Updated CORS headers to support new HTTP methods. - Created detailed documentation for new API endpoints and module operations.
This commit is contained in:
@@ -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": "<base64>" -- 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
|
||||
|
||||
52
contrib/darktable-api/bruno/images/Export Image.bru
Normal file
52
contrib/darktable-api/bruno/images/Export Image.bru
Normal file
@@ -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/<id>.jpg`).
|
||||
|
||||
Poll **GET /api/v1/images/:id/full** to retrieve the image once ready.
|
||||
|
||||
Requires scope: `cache:refresh`
|
||||
}
|
||||
57
contrib/darktable-api/bruno/images/Get Full Image.bru
Normal file
57
contrib/darktable-api/bruno/images/Get Full Image.bru
Normal file
@@ -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`
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
55
contrib/darktable-api/bruno/modules/Get Module.bru
Normal file
55
contrib/darktable-api/bruno/modules/Get Module.bru
Normal file
@@ -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.
|
||||
}
|
||||
79
contrib/darktable-api/bruno/modules/List Modules.bru
Normal file
79
contrib/darktable-api/bruno/modules/List Modules.bru
Normal file
@@ -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`
|
||||
}
|
||||
89
contrib/darktable-api/bruno/modules/Patch Module.bru
Normal file
89
contrib/darktable-api/bruno/modules/Patch Module.bru
Normal file
@@ -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 |
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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/<id>.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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
502
contrib/darktable-api/modules.lua
Normal file
502
contrib/darktable-api/modules.lua
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user