Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d109612045 | ||
|
|
0f47f46b35 | ||
|
|
1a44df7d3c | ||
|
|
aa62473cbf | ||
|
|
f7faad0962 | ||
|
|
53549f16b5 | ||
|
|
7531a29235 | ||
|
|
a4dce23ef8 | ||
|
|
8abc350d68 | ||
|
|
078011d6b4 | ||
|
|
2a16599f91 | ||
|
|
14646cf8e6 | ||
|
|
5823d015ac | ||
|
|
30e6b9459a | ||
|
|
d3e15065d2 | ||
|
|
fe722f8409 | ||
|
|
b91baebdeb |
@@ -1,486 +0,0 @@
|
|||||||
-- Route handlers. Each function receives a request table and returns:
|
|
||||||
-- status (int), body (string), content_type (string|nil), extra_headers (table|nil)
|
|
||||||
local json = require "json"
|
|
||||||
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
|
|
||||||
-- image:drop_cache() + image:generate_cache() for each queued image ID.
|
|
||||||
--
|
|
||||||
-- We append atomically via a .tmp + rename idiom to avoid partial reads:
|
|
||||||
-- 1. Write IDs to <queue>.tmp.<pid>
|
|
||||||
-- 2. Append that file's contents to the main queue (or rename if queue absent)
|
|
||||||
-- In practice, for low-frequency refresh calls a simple append is fine because
|
|
||||||
-- the bridge reads and renames the queue file atomically.
|
|
||||||
local function enqueue_refresh(image_id)
|
|
||||||
local qf = config.refresh_queue
|
|
||||||
local f, ferr = io.open(qf, "a")
|
|
||||||
if not f then
|
|
||||||
return false, "cannot open refresh queue " .. qf .. ": " .. (ferr or "?")
|
|
||||||
end
|
|
||||||
f:write(tostring(image_id) .. "\n")
|
|
||||||
f:close()
|
|
||||||
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 ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local function ok(data, ct)
|
|
||||||
return 200, json.encode(data), ct or "application/json; charset=utf-8"
|
|
||||||
end
|
|
||||||
|
|
||||||
local function err(status, code, detail)
|
|
||||||
local body = json.encode({ error = code, detail = detail })
|
|
||||||
return status, body, "application/json; charset=utf-8"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Parse application/x-www-form-urlencoded or application/json body.
|
|
||||||
local function parse_body(req)
|
|
||||||
local ct = (req.headers["content-type"] or ""):lower()
|
|
||||||
if ct:find("application/json", 1, true) then
|
|
||||||
return json.decode(req.body or "{}")
|
|
||||||
end
|
|
||||||
-- form-encoded
|
|
||||||
local fields = {}
|
|
||||||
for k, v in (req.body or ""):gmatch("([^&=]+)=([^&]*)") do
|
|
||||||
k = k:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
|
|
||||||
v = v:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
|
|
||||||
fields[k] = v
|
|
||||||
end
|
|
||||||
return fields
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Authenticate the request and check an optional scope.
|
|
||||||
-- Returns: payload on success.
|
|
||||||
-- Returns: nil, status, body, ct on failure (caller does "return s,b,ct").
|
|
||||||
local function require_auth(req, scope)
|
|
||||||
local payload, aerr = auth.authenticate(req)
|
|
||||||
if not payload then
|
|
||||||
return nil, err(401, "unauthorized", aerr)
|
|
||||||
end
|
|
||||||
if scope and not auth.require_scope(payload, scope) then
|
|
||||||
return nil, err(403, "forbidden", "scope '" .. scope .. "' required")
|
|
||||||
end
|
|
||||||
return payload
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Public endpoints ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- GET /health — liveness check, no auth required
|
|
||||||
function M.health(req)
|
|
||||||
return ok({ status = "ok", timestamp = os.time() })
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── OAuth 2.0 ─────────────────────────────────────────────────────────────────
|
|
||||||
-- POST /oauth/token
|
|
||||||
-- Accepts: application/x-www-form-urlencoded or application/json
|
|
||||||
-- Required fields: grant_type=client_credentials, client_id, client_secret
|
|
||||||
-- Optional: scope (space-separated, must be subset of client's scopes)
|
|
||||||
--
|
|
||||||
-- Returns: { access_token, token_type, expires_in, scope }
|
|
||||||
function M.oauth_token(req)
|
|
||||||
local fields = parse_body(req)
|
|
||||||
if not fields then
|
|
||||||
return err(400, "invalid_request", "could not parse request body")
|
|
||||||
end
|
|
||||||
|
|
||||||
if fields.grant_type ~= "client_credentials" then
|
|
||||||
return err(400, "unsupported_grant_type",
|
|
||||||
"only 'client_credentials' is supported")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Also accept Basic Auth header for client credentials
|
|
||||||
local client_id, client_secret = fields.client_id, fields.client_secret
|
|
||||||
if not client_id then
|
|
||||||
local basic = (req.headers["authorization"] or ""):match("^[Bb]asic%s+(.+)$")
|
|
||||||
if basic then
|
|
||||||
local decoded = require("base64").decode(basic)
|
|
||||||
client_id, client_secret = decoded:match("^([^:]+):(.+)$")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not client_id or not client_secret then
|
|
||||||
return err(400, "invalid_request", "client_id and client_secret are required")
|
|
||||||
end
|
|
||||||
|
|
||||||
local ok_flag, client_scopes = auth.validate_client(client_id, client_secret)
|
|
||||||
if not ok_flag then
|
|
||||||
return err(401, "invalid_client", client_scopes)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Restrict to requested scopes if provided
|
|
||||||
local granted = client_scopes
|
|
||||||
if fields.scope and fields.scope ~= "" then
|
|
||||||
local requested = {}
|
|
||||||
for s in fields.scope:gmatch("%S+") do requested[s] = true end
|
|
||||||
granted = {}
|
|
||||||
for _, s in ipairs(client_scopes) do
|
|
||||||
if requested[s] then granted[#granted+1] = s end
|
|
||||||
end
|
|
||||||
if #granted == 0 then
|
|
||||||
return err(400, "invalid_scope", "none of the requested scopes are available")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local token = auth.issue_token(client_id, granted)
|
|
||||||
return ok({
|
|
||||||
access_token = token,
|
|
||||||
token_type = "Bearer",
|
|
||||||
expires_in = config.token_ttl,
|
|
||||||
scope = table.concat(granted, " "),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Images ────────────────────────────────────────────────────────────────────
|
|
||||||
-- GET /api/v1/images
|
|
||||||
-- Query params: rating_min, rating_max, rating, film_id, from, to,
|
|
||||||
-- color_label, is_raw, is_hdr, search, tag,
|
|
||||||
-- limit (1-500, default 50), offset, sort_by, sort_dir
|
|
||||||
function M.list_images(req)
|
|
||||||
local payload, s, b, ct = require_auth(req, "images:read")
|
|
||||||
if not payload then return s, b, ct end
|
|
||||||
|
|
||||||
local result = db.list_images(req.params)
|
|
||||||
return ok(result)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- GET /api/v1/images/:id
|
|
||||||
function M.get_image(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
|
|
||||||
|
|
||||||
return ok(img)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Previews ──────────────────────────────────────────────────────────────────
|
|
||||||
-- GET /api/v1/images/:id/preview[?size=thumb|small|medium|large|xlarge|0-5]
|
|
||||||
-- Returns JPEG data directly (Content-Type: image/jpeg).
|
|
||||||
function M.get_preview(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 data, ct, redirect = preview.get(id, img.path, img.filename, req.params.size)
|
|
||||||
|
|
||||||
if data then
|
|
||||||
return 200, data, ct
|
|
||||||
end
|
|
||||||
|
|
||||||
if redirect then
|
|
||||||
-- Attempt to serve the local file directly rather than issuing a redirect,
|
|
||||||
-- since file:// URLs are not useful to HTTP clients.
|
|
||||||
local file_data = preview.read_local(redirect)
|
|
||||||
if file_data then
|
|
||||||
local ext = (img.filename or ""):match("%.([^%.]+)$") or ""
|
|
||||||
local mime = ext:lower():match("^png$") and "image/png" or "image/jpeg"
|
|
||||||
return 200, file_data, mime
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- No cached preview exists. Enqueue a full-pipeline thumbnail generation
|
|
||||||
-- so the bridge renders one; the client should retry after a few seconds.
|
|
||||||
enqueue_refresh(id)
|
|
||||||
return 202, json.encode({
|
|
||||||
status = "generating",
|
|
||||||
image_id = id,
|
|
||||||
retry_after = 4,
|
|
||||||
message = "thumbnail is being generated; retry in a few seconds",
|
|
||||||
}), "application/json; charset=utf-8", { ["Retry-After"] = "4" }
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Tags ──────────────────────────────────────────────────────────────────────
|
|
||||||
-- GET /api/v1/tags[?search=&limit=&offset=]
|
|
||||||
function M.list_tags(req)
|
|
||||||
local payload, s, b, ct = require_auth(req, "tags:read")
|
|
||||||
if not payload then return s, b, ct end
|
|
||||||
|
|
||||||
local tags = db.list_tags(req.params)
|
|
||||||
return ok({ items = tags })
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Film rolls ────────────────────────────────────────────────────────────────
|
|
||||||
-- GET /api/v1/films[?search=&limit=&offset=]
|
|
||||||
function M.list_films(req)
|
|
||||||
local payload, s, b, ct = require_auth(req, "films:read")
|
|
||||||
if not payload then return s, b, ct end
|
|
||||||
|
|
||||||
local films = db.list_films(req.params)
|
|
||||||
return ok({ items = films })
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Cache refresh ─────────────────────────────────────────────────────────────
|
|
||||||
-- POST /api/v1/images/:id/refresh
|
|
||||||
-- 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")
|
|
||||||
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_refresh(id)
|
|
||||||
if not queued then
|
|
||||||
return err(500, "queue_error", qerr)
|
|
||||||
end
|
|
||||||
|
|
||||||
return 202, json.encode({
|
|
||||||
status = "accepted",
|
|
||||||
image_id = id,
|
|
||||||
filename = img.filename,
|
|
||||||
queue = config.refresh_queue,
|
|
||||||
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
|
|
||||||
-- Removes any stale export file, enqueues the bridge job, returns 202.
|
|
||||||
-- The client should poll GET /full until it receives a 200.
|
|
||||||
-- 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
|
|
||||||
|
|
||||||
-- Remove stale export so the client can detect when the fresh one lands.
|
|
||||||
os.remove(export_path(id))
|
|
||||||
|
|
||||||
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,
|
|
||||||
message = "export enqueued; poll GET /api/v1/images/" .. id .. "/full",
|
|
||||||
}), "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()
|
|
||||||
local stem = (img.filename or "export"):match("^(.+)%.[^.]+$") or (img.filename or "export")
|
|
||||||
local disposition = string.format('attachment; filename="%s.jpg"', stem)
|
|
||||||
return 200, data, "image/jpeg", { ["Content-Disposition"] = disposition }
|
|
||||||
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
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
-- OAuth 2.0 Client Credentials grant + HS256 JWT
|
|
||||||
-- Requires: LuaJIT FFI with libcrypto (OpenSSL)
|
|
||||||
local ffi = require "ffi"
|
|
||||||
local base64 = require "base64"
|
|
||||||
local json = require "json"
|
|
||||||
local config = require "config"
|
|
||||||
|
|
||||||
-- ── OpenSSL HMAC-SHA256 via FFI ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
ffi.cdef[[
|
|
||||||
void *EVP_sha256(void);
|
|
||||||
unsigned char *HMAC(const void *evp_md, const void *key, int key_len,
|
|
||||||
const unsigned char *data, size_t data_len,
|
|
||||||
unsigned char *md, unsigned int *md_len);
|
|
||||||
]]
|
|
||||||
|
|
||||||
-- libcrypto ships as part of OpenSSL; path varies by distro but the soname is standard
|
|
||||||
local crypto = ffi.load("crypto")
|
|
||||||
|
|
||||||
local function hmac_sha256(key, data)
|
|
||||||
local digest = ffi.new("unsigned char[32]")
|
|
||||||
local dlen = ffi.new("unsigned int[1]")
|
|
||||||
crypto.HMAC(crypto.EVP_sha256(), key, #key, data, #data, digest, dlen)
|
|
||||||
return ffi.string(digest, 32)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── JWT helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local HEADER_B64 = base64.urlencode(json.encode({ alg = "HS256", typ = "JWT" }))
|
|
||||||
|
|
||||||
local function jwt_sign(payload_tbl)
|
|
||||||
local payload = base64.urlencode(json.encode(payload_tbl))
|
|
||||||
local signing = HEADER_B64 .. "." .. payload
|
|
||||||
local sig = base64.urlencode(hmac_sha256(config.jwt_secret, signing))
|
|
||||||
return signing .. "." .. sig
|
|
||||||
end
|
|
||||||
|
|
||||||
local function jwt_verify(token)
|
|
||||||
local h, p, s = token:match("^([^.]+)%.([^.]+)%.([^.]+)$")
|
|
||||||
if not h then return nil, "malformed token" end
|
|
||||||
|
|
||||||
local expected = base64.urlencode(hmac_sha256(config.jwt_secret, h .. "." .. p))
|
|
||||||
if s ~= expected then return nil, "invalid signature" end
|
|
||||||
|
|
||||||
local payload, err = json.decode(base64.urldecode(p))
|
|
||||||
if not payload then return nil, "invalid payload: " .. (err or "?") end
|
|
||||||
|
|
||||||
if os.time() > (payload.exp or 0) then return nil, "token expired" end
|
|
||||||
|
|
||||||
return payload
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- Validate client_id / client_secret pair; returns true + scopes or false + error.
|
|
||||||
function M.validate_client(client_id, client_secret)
|
|
||||||
local client = config.clients[client_id]
|
|
||||||
if not client then return false, "unknown client" end
|
|
||||||
if client.secret ~= client_secret then return false, "invalid client secret" end
|
|
||||||
return true, client.scopes
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Issue an access token for a validated client.
|
|
||||||
function M.issue_token(client_id, scopes)
|
|
||||||
local now = os.time()
|
|
||||||
return jwt_sign({
|
|
||||||
iss = "darktable-api",
|
|
||||||
sub = client_id,
|
|
||||||
scope = table.concat(scopes, " "),
|
|
||||||
iat = now,
|
|
||||||
exp = now + config.token_ttl,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Extract and verify a Bearer token from a request.
|
|
||||||
-- Returns payload table on success, or nil + error string on failure.
|
|
||||||
function M.authenticate(req)
|
|
||||||
local auth = (req.headers or {})["authorization"] or ""
|
|
||||||
local token = auth:match("^[Bb]earer%s+(.+)$")
|
|
||||||
if not token then return nil, "missing Bearer token" end
|
|
||||||
return jwt_verify(token)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Check that the authenticated payload contains a required scope.
|
|
||||||
function M.require_scope(payload, scope)
|
|
||||||
if not payload then return false end
|
|
||||||
local scopes = payload.scope or ""
|
|
||||||
for s in scopes:gmatch("%S+") do
|
|
||||||
if s == scope then return true end
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
-- Base64 and Base64url encode/decode (pure Lua, no dependencies)
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
||||||
local LOOKUP = {}
|
|
||||||
for i = 1, #CHARS do LOOKUP[CHARS:sub(i,i)] = i - 1 end
|
|
||||||
|
|
||||||
function M.encode(s)
|
|
||||||
local out = {}
|
|
||||||
for i = 1, #s, 3 do
|
|
||||||
local a, b, c = s:byte(i, i + 2)
|
|
||||||
b, c = b or 0, c or 0
|
|
||||||
local n = a * 65536 + b * 256 + c
|
|
||||||
out[#out+1] = CHARS:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
|
|
||||||
out[#out+1] = CHARS:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
|
|
||||||
out[#out+1] = CHARS:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
|
|
||||||
out[#out+1] = CHARS:sub( n % 64 + 1, n % 64 + 1)
|
|
||||||
end
|
|
||||||
local rem = #s % 3
|
|
||||||
if rem == 1 then out[#out-1], out[#out] = "=", "=" end
|
|
||||||
if rem == 2 then out[#out] = "=" end
|
|
||||||
return table.concat(out)
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.decode(s)
|
|
||||||
s = s:gsub("[^A-Za-z0-9+/=]", "")
|
|
||||||
local out = {}
|
|
||||||
for i = 1, #s, 4 do
|
|
||||||
local a = LOOKUP[s:sub(i, i )] or 0
|
|
||||||
local b = LOOKUP[s:sub(i+1, i+1)] or 0
|
|
||||||
local c = LOOKUP[s:sub(i+2, i+2)] or 0
|
|
||||||
local d = LOOKUP[s:sub(i+3, i+3)] or 0
|
|
||||||
local n = a * 262144 + b * 4096 + c * 64 + d
|
|
||||||
out[#out+1] = string.char(math.floor(n / 65536))
|
|
||||||
if s:sub(i+2, i+2) ~= "=" then out[#out+1] = string.char(math.floor(n / 256) % 256) end
|
|
||||||
if s:sub(i+3, i+3) ~= "=" then out[#out+1] = string.char(n % 256) end
|
|
||||||
end
|
|
||||||
return table.concat(out)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- URL-safe Base64 (RFC 4648 §5): replace +/ with -_ and strip padding
|
|
||||||
function M.urlencode(s)
|
|
||||||
return M.encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "")
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.urldecode(s)
|
|
||||||
s = s:gsub("-", "+"):gsub("_", "/")
|
|
||||||
local pad = (4 - #s % 4) % 4
|
|
||||||
return M.decode(s .. string.rep("=", pad))
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Health Check
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/health
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200", function() {
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("status is ok", function() {
|
|
||||||
expect(res.body.status).to.equal("ok");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has timestamp", function() {
|
|
||||||
expect(res.body.timestamp).to.be.a("number");
|
|
||||||
expect(res.body.timestamp).to.be.above(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Health Check
|
|
||||||
|
|
||||||
Liveness probe — no authentication required.
|
|
||||||
Returns `{"status":"ok","timestamp":<unix>}`.
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get Token (Bad Secret)
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{baseUrl}}/oauth/token
|
|
||||||
body: form-urlencoded
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
body:form-urlencoded {
|
|
||||||
grant_type: client_credentials
|
|
||||||
client_id: {{clientId}}
|
|
||||||
client_secret: wrong-secret
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 401", function() {
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("error is invalid_client", function() {
|
|
||||||
expect(res.body.error).to.equal("invalid_client");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Negative test — wrong client secret
|
|
||||||
|
|
||||||
Verifies the server rejects bad credentials with 401.
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get Token
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{baseUrl}}/oauth/token
|
|
||||||
body: form-urlencoded
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
body:form-urlencoded {
|
|
||||||
grant_type: client_credentials
|
|
||||||
client_id: {{clientId}}
|
|
||||||
client_secret: {{clientSecret}}
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
if (res.status === 200) {
|
|
||||||
bru.setVar("accessToken", res.body.access_token);
|
|
||||||
console.log("Token stored. Expires in", res.body.expires_in, "seconds.");
|
|
||||||
console.log("Scopes:", res.body.scope);
|
|
||||||
} else {
|
|
||||||
console.error("Token request failed:", res.status, res.body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200", function() {
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has access_token", function() {
|
|
||||||
expect(res.body.access_token).to.be.a("string");
|
|
||||||
expect(res.body.access_token.length).to.be.above(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("token_type is Bearer", function() {
|
|
||||||
expect(res.body.token_type).to.equal("Bearer");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("scope includes images:read", function() {
|
|
||||||
expect(res.body.scope).to.include("images:read");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("scope includes cache:refresh", function() {
|
|
||||||
expect(res.body.scope).to.include("cache:refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("expires_in is 3600", function() {
|
|
||||||
expect(res.body.expires_in).to.equal(3600);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## OAuth 2.0 — Client Credentials Grant
|
|
||||||
|
|
||||||
Exchanges `client_id` + `client_secret` for a short-lived Bearer JWT.
|
|
||||||
The post-response script stores the token in `{{accessToken}}` so all
|
|
||||||
other requests pick it up automatically.
|
|
||||||
|
|
||||||
Run this request first before calling any protected endpoint.
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1",
|
|
||||||
"name": "Darktable API",
|
|
||||||
"type": "collection",
|
|
||||||
"ignore": []
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
vars {
|
|
||||||
baseUrl: http://127.0.0.1:7645
|
|
||||||
clientId: darktable-client
|
|
||||||
clientSecret: changeme
|
|
||||||
accessToken:
|
|
||||||
imageId: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
vars:secret [
|
|
||||||
clientSecret,
|
|
||||||
accessToken
|
|
||||||
]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
vars {
|
|
||||||
baseUrl: http://192.168.1.134:7645
|
|
||||||
clientId: darktable-client
|
|
||||||
clientSecret: changeme
|
|
||||||
accessToken:
|
|
||||||
imageId: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
vars:secret [
|
|
||||||
clientSecret,
|
|
||||||
accessToken
|
|
||||||
]
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: List Films
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/films
|
|
||||||
body: none
|
|
||||||
auth: bearer
|
|
||||||
}
|
|
||||||
|
|
||||||
auth:bearer {
|
|
||||||
token: {{accessToken}}
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
limit: 50
|
|
||||||
offset: 0
|
|
||||||
~search: 2018
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200", function() {
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has items array", function() {
|
|
||||||
expect(res.body.items).to.be.an("array");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("each film has id, folder, image_count", function() {
|
|
||||||
res.body.items.forEach(function(film) {
|
|
||||||
expect(film.id).to.be.a("number");
|
|
||||||
expect(film.folder).to.be.a("string");
|
|
||||||
expect(film.image_count).to.be.a("number");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("image counts are positive", function() {
|
|
||||||
res.body.items.forEach(function(film) {
|
|
||||||
expect(film.image_count).to.be.above(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## List Films
|
|
||||||
|
|
||||||
Returns all film rolls (import folders) with their image counts, sorted by folder path.
|
|
||||||
|
|
||||||
Use `?search=` to filter by folder path substring.
|
|
||||||
Use `?limit` and `?offset` for pagination (max 2000 per page).
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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`
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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`
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get Image (Not Found)
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/images/999999
|
|
||||||
body: none
|
|
||||||
auth: bearer
|
|
||||||
}
|
|
||||||
|
|
||||||
auth:bearer {
|
|
||||||
token: {{accessToken}}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 404", function() {
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("error is not_found", function() {
|
|
||||||
expect(res.body.error).to.equal("not_found");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Get Image — Not Found
|
|
||||||
|
|
||||||
Verifies a non-existent image ID returns 404.
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get Image
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/images/{{imageId}}
|
|
||||||
body: none
|
|
||||||
auth: bearer
|
|
||||||
}
|
|
||||||
|
|
||||||
auth:bearer {
|
|
||||||
token: {{accessToken}}
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
if (res.status === 200) {
|
|
||||||
// Store this image's ID for use by preview / refresh requests
|
|
||||||
bru.setVar("imageId", res.body.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200", function() {
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has correct id", function() {
|
|
||||||
expect(res.body.id).to.equal(Number(bru.getVar("imageId")));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has filename", function() {
|
|
||||||
expect(res.body.filename).to.be.a("string");
|
|
||||||
expect(res.body.filename.length).to.be.above(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has path", function() {
|
|
||||||
expect(res.body.path).to.be.a("string");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has rating in valid range", function() {
|
|
||||||
expect(res.body.rating).to.be.at.least(-1);
|
|
||||||
expect(res.body.rating).to.be.at.most(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has tags array", function() {
|
|
||||||
expect(res.body.tags).to.be.an("array");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has color_labels array", function() {
|
|
||||||
expect(res.body.color_labels).to.be.an("array");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has metadata object", function() {
|
|
||||||
expect(res.body.metadata).to.be.an("object");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has boolean flags", function() {
|
|
||||||
expect(res.body.is_raw).to.be.a("boolean");
|
|
||||||
expect(res.body.is_hdr).to.be.a("boolean");
|
|
||||||
expect(res.body.is_ldr).to.be.a("boolean");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Get Image
|
|
||||||
|
|
||||||
Returns full detail for a single image including tags, color labels, and metadata.
|
|
||||||
|
|
||||||
Set `{{imageId}}` in the environment (or pick up from "List Images" run).
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get Preview
|
|
||||||
type: http
|
|
||||||
seq: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/preview
|
|
||||||
body: none
|
|
||||||
auth: bearer
|
|
||||||
}
|
|
||||||
|
|
||||||
auth:bearer {
|
|
||||||
token: {{accessToken}}
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
size: small
|
|
||||||
~size: medium
|
|
||||||
~size: large
|
|
||||||
~size: thumb
|
|
||||||
~size: xlarge
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200 or 404", function() {
|
|
||||||
expect([200, 404]).to.include(res.status);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("if 200, content-type is image/jpeg or image/png", function() {
|
|
||||||
if (res.status === 200) {
|
|
||||||
expect(res.headers["content-type"]).to.match(/image\/(jpeg|png)/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("if 200, body is non-empty", function() {
|
|
||||||
if (res.status === 200) {
|
|
||||||
expect(res.body.length).to.be.above(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("if 404, no_preview error returned", function() {
|
|
||||||
if (res.status === 404) {
|
|
||||||
expect(res.body.error).to.equal("no_preview");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Get Preview
|
|
||||||
|
|
||||||
Returns a JPEG thumbnail from darktable's mipmap cache.
|
|
||||||
|
|
||||||
### Size options
|
|
||||||
| Value | Approx. long edge |
|
|
||||||
|---|---|
|
|
||||||
| `thumb` | ~135 px |
|
|
||||||
| `small` | ~270 px |
|
|
||||||
| `medium` | ~540 px (default) |
|
|
||||||
| `large` | ~1080 px |
|
|
||||||
| `xlarge` | ~2160 px |
|
|
||||||
| `0`–`5` | Raw mip level |
|
|
||||||
|
|
||||||
Only one `size` param is active at a time — uncomment the desired one above.
|
|
||||||
Falls back to lower mip levels automatically if the requested size isn't cached.
|
|
||||||
Returns 404 if no cached thumbnail exists at any level.
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: List Images (No Auth)
|
|
||||||
type: http
|
|
||||||
seq: 6
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/images
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 401 without token", function() {
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("error is unauthorized", function() {
|
|
||||||
expect(res.body.error).to.equal("unauthorized");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## List Images — No Auth
|
|
||||||
|
|
||||||
Verifies that protected endpoints return 401 when no Bearer token is supplied.
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: List Images
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/images
|
|
||||||
body: none
|
|
||||||
auth: bearer
|
|
||||||
}
|
|
||||||
|
|
||||||
auth:bearer {
|
|
||||||
token: {{accessToken}}
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
limit: 10
|
|
||||||
offset: 0
|
|
||||||
sort_by: datetime_taken
|
|
||||||
sort_dir: desc
|
|
||||||
~rating_min: 1
|
|
||||||
~rating_max: 5
|
|
||||||
~rating: 3
|
|
||||||
~film_id: 1
|
|
||||||
~from: 2018-01-01
|
|
||||||
~to: 2018-12-31
|
|
||||||
~color_label: red
|
|
||||||
~is_raw: true
|
|
||||||
~is_hdr: false
|
|
||||||
~search: 500mm
|
|
||||||
~tag: realestate
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200", function() {
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has items array", function() {
|
|
||||||
expect(res.body.items).to.be.an("array");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has total count", function() {
|
|
||||||
expect(res.body.total).to.be.a("number");
|
|
||||||
expect(res.body.total).to.be.above(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has limit and offset", function() {
|
|
||||||
expect(res.body.limit).to.equal(10);
|
|
||||||
expect(res.body.offset).to.equal(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("each image has required fields", function() {
|
|
||||||
res.body.items.forEach(function(img) {
|
|
||||||
expect(img.id).to.be.a("number");
|
|
||||||
expect(img.filename).to.be.a("string");
|
|
||||||
expect(img.path).to.be.a("string");
|
|
||||||
expect(img.rating).to.be.a("number");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## List Images
|
|
||||||
|
|
||||||
Returns a paginated list of images from the darktable catalog.
|
|
||||||
|
|
||||||
### Active query params (remove `~` prefix to enable)
|
|
||||||
| Param | Type | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `limit` | int | Results per page (max 500, default 50) |
|
|
||||||
| `offset` | int | Pagination offset |
|
|
||||||
| `sort_by` | string | `id`, `filename`, `datetime_taken`, `rating`, `focal_length`, `iso` |
|
|
||||||
| `sort_dir` | string | `asc` or `desc` |
|
|
||||||
| `rating_min` | int | Minimum star rating (−1=rejected, 0=unrated, 1–5=stars) |
|
|
||||||
| `rating_max` | int | Maximum star rating |
|
|
||||||
| `rating` | int | Exact rating |
|
|
||||||
| `film_id` | int | Filter by film roll ID |
|
|
||||||
| `from` | date | Earliest capture date (YYYY-MM-DD) |
|
|
||||||
| `to` | date | Latest capture date (YYYY-MM-DD) |
|
|
||||||
| `color_label` | string | `red`, `yellow`, `green`, `blue`, `purple` |
|
|
||||||
| `is_raw` | bool | `true` / `false` |
|
|
||||||
| `is_hdr` | bool | `true` / `false` |
|
|
||||||
| `search` | string | Free-text search in filename, maker, model, lens |
|
|
||||||
| `tag` | string | Filter by exact tag name |
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Refresh Cache
|
|
||||||
type: http
|
|
||||||
seq: 5
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/refresh
|
|
||||||
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 queue path", function() {
|
|
||||||
expect(res.body.queue).to.be.a("string");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## Refresh Cache
|
|
||||||
|
|
||||||
Enqueues the image for thumbnail regeneration inside the running darktable instance.
|
|
||||||
|
|
||||||
Returns **202 Accepted** immediately. The darktable bridge plugin
|
|
||||||
(`darktable-api-bridge.lua`) polls `/tmp/dt-api-refresh.queue` every second
|
|
||||||
and calls `image:drop_cache()` + `image:generate_cache()` when it finds this ID.
|
|
||||||
|
|
||||||
Requires scope: `cache:refresh`
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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`
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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 |
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: List Tags
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/api/v1/tags
|
|
||||||
body: none
|
|
||||||
auth: bearer
|
|
||||||
}
|
|
||||||
|
|
||||||
auth:bearer {
|
|
||||||
token: {{accessToken}}
|
|
||||||
}
|
|
||||||
|
|
||||||
params:query {
|
|
||||||
limit: 50
|
|
||||||
offset: 0
|
|
||||||
~search: darktable
|
|
||||||
}
|
|
||||||
|
|
||||||
tests {
|
|
||||||
test("returns 200", function() {
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has items array", function() {
|
|
||||||
expect(res.body.items).to.be.an("array");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("each tag has id, name, image_count", function() {
|
|
||||||
res.body.items.forEach(function(tag) {
|
|
||||||
expect(tag.id).to.be.a("number");
|
|
||||||
expect(tag.name).to.be.a("string");
|
|
||||||
expect(tag.image_count).to.be.a("number");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tags are sorted alphabetically", function() {
|
|
||||||
var names = res.body.items.map(function(t) { return t.name; });
|
|
||||||
var sorted = names.slice().sort();
|
|
||||||
expect(names).to.deep.equal(sorted);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs {
|
|
||||||
## List Tags
|
|
||||||
|
|
||||||
Returns all tags in the catalog with their image counts, sorted alphabetically.
|
|
||||||
|
|
||||||
Use `?search=keyword` to filter by tag name prefix/substring.
|
|
||||||
Use `?limit` and `?offset` for pagination (max 2000 per page).
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
local M = {}
|
|
||||||
|
|
||||||
local home = os.getenv("HOME") or "/root"
|
|
||||||
|
|
||||||
M.host = os.getenv("DT_API_HOST") or "127.0.0.1"
|
|
||||||
M.port = tonumber(os.getenv("DT_API_PORT")) or 7645
|
|
||||||
|
|
||||||
-- Darktable paths
|
|
||||||
M.library_db = os.getenv("DT_LIBRARY") or (home .. "/.config/darktable/library.db")
|
|
||||||
M.data_db = os.getenv("DT_DATA_DB") or (home .. "/.config/darktable/data.db")
|
|
||||||
M.cache_dir = os.getenv("DT_CACHE_DIR") or (home .. "/.cache/darktable")
|
|
||||||
|
|
||||||
-- JWT / OAuth
|
|
||||||
M.jwt_secret = os.getenv("DT_JWT_SECRET") or "change-this-secret-in-production"
|
|
||||||
M.token_ttl = tonumber(os.getenv("DT_TOKEN_TTL")) or 3600
|
|
||||||
|
|
||||||
-- OAuth 2.0 clients table: client_id -> { secret, scopes[] }
|
|
||||||
-- Additional clients can be registered by adding entries here or setting
|
|
||||||
-- DT_CLIENT_ID / DT_CLIENT_SECRET environment variables.
|
|
||||||
M.clients = {}
|
|
||||||
local default_id = os.getenv("DT_CLIENT_ID") or "darktable-client"
|
|
||||||
local default_secret = os.getenv("DT_CLIENT_SECRET") or "changeme"
|
|
||||||
M.clients[default_id] = {
|
|
||||||
secret = default_secret,
|
|
||||||
scopes = { "images:read", "previews:read", "tags:read", "films:read", "cache:refresh" },
|
|
||||||
}
|
|
||||||
|
|
||||||
-- 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
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
--[[
|
|
||||||
darktable-api-bridge.lua
|
|
||||||
Companion plugin for the darktable REST API server.
|
|
||||||
|
|
||||||
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.
|
|
||||||
--]]
|
|
||||||
|
|
||||||
local dt = require "darktable"
|
|
||||||
local du = require "lib/dtutils"
|
|
||||||
|
|
||||||
du.check_min_api_version("7.0.0", "darktable-api-bridge")
|
|
||||||
|
|
||||||
local MODULE = "darktable-api-bridge"
|
|
||||||
|
|
||||||
-- ── Configuration ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- 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 ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local function log(msg)
|
|
||||||
dt.print_log("[" .. MODULE .. "] " .. msg)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function notify(msg)
|
|
||||||
dt.print("[" .. MODULE .. "] " .. msg)
|
|
||||||
log(msg)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Image lookup ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- Build a fast id→image map from dt.database.
|
|
||||||
-- dt.database is a table-like object; iterating it is O(n) but fast enough
|
|
||||||
-- for typical library sizes (thousands of images).
|
|
||||||
local function build_id_map()
|
|
||||||
local map = {}
|
|
||||||
for _, img in ipairs(dt.database) do
|
|
||||||
map[img.id] = img
|
|
||||||
end
|
|
||||||
return map
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Queue file handling ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- Atomically claim the queue file by renaming it, then read all IDs from it.
|
|
||||||
-- Returns a set table { [id] = true } of unique image IDs, or {} if none.
|
|
||||||
local function drain_queue()
|
|
||||||
local processing = QUEUE_FILE .. ".processing"
|
|
||||||
|
|
||||||
-- Rename is atomic on POSIX filesystems: the server's append-only writes
|
|
||||||
-- will start a fresh QUEUE_FILE after we claim this one.
|
|
||||||
local ok = os.rename(QUEUE_FILE, processing)
|
|
||||||
if not ok then
|
|
||||||
return {} -- queue file doesn't exist yet
|
|
||||||
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
|
|
||||||
|
|
||||||
-- ── Mipmap directory ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- Darktable's mipmap_cache->cachedir is not directly accessible from Lua, so
|
|
||||||
-- we replicate the naming: SHA1 hex of realpath(library.db) + ".d".
|
|
||||||
-- We compute it once and cache it here.
|
|
||||||
local _mipmap_cachedir
|
|
||||||
|
|
||||||
|
|
||||||
local function mipmap_cachedir()
|
|
||||||
if _mipmap_cachedir then return _mipmap_cachedir end
|
|
||||||
local cache_dir = os.getenv("DT_CACHE_DIR")
|
|
||||||
if not cache_dir then
|
|
||||||
local home = os.getenv("HOME") or "~"
|
|
||||||
cache_dir = home .. "/.cache/darktable"
|
|
||||||
end
|
|
||||||
-- There is exactly one mipmaps-<sha1>.d directory per library.
|
|
||||||
-- Find it by globbing rather than recomputing the SHA1.
|
|
||||||
local h = io.popen(string.format("ls -d %q/mipmaps-*.d 2>/dev/null | head -1", cache_dir))
|
|
||||||
local found = h and h:read("*l"); if h then h:close() end
|
|
||||||
if found and #found > 0 then
|
|
||||||
-- Strip trailing ".d" to get the base prefix used in file paths.
|
|
||||||
_mipmap_cachedir = found:match("^(.+)%.d$") or found
|
|
||||||
log("mipmap cachedir: " .. _mipmap_cachedir .. ".d")
|
|
||||||
return _mipmap_cachedir
|
|
||||||
end
|
|
||||||
log("WARNING: could not find mipmap cache directory under " .. cache_dir)
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Delete all mipmap disk files for an image so generate_cache regenerates them.
|
|
||||||
local function delete_mipmap_files(imgid)
|
|
||||||
local base = mipmap_cachedir()
|
|
||||||
if not base then return end
|
|
||||||
for level = 0, 8 do
|
|
||||||
local path = string.format("%s.d/%d/%d.jpg", base, level, imgid)
|
|
||||||
os.remove(path)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Cache regeneration ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local function refresh_image(img)
|
|
||||||
local label = string.format("id=%d %s/%s", img.id, img.path, img.filename)
|
|
||||||
log("refreshing " .. label)
|
|
||||||
|
|
||||||
-- Remove disk mipmap files first so generate_cache doesn't skip any level.
|
|
||||||
-- drop_cache() only clears the in-memory entry; existing disk files would
|
|
||||||
-- cause generate_cache to skip those levels (it guards with dt_util_test_image_file).
|
|
||||||
delete_mipmap_files(img.id)
|
|
||||||
img:drop_cache()
|
|
||||||
|
|
||||||
-- create_dirs=true ensures the per-level directories exist.
|
|
||||||
img:generate_cache(true, MIP_MIN, MIP_MAX)
|
|
||||||
|
|
||||||
log("done " .. label)
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
|
||||||
if count == 0 then return end
|
|
||||||
|
|
||||||
log(string.format("processing %d queued refresh request(s)", count))
|
|
||||||
local id_map = build_id_map()
|
|
||||||
|
|
||||||
for id, _ in pairs(ids) do
|
|
||||||
local img = id_map[id]
|
|
||||||
if img then
|
|
||||||
local ok, err = pcall(refresh_image, img)
|
|
||||||
if not ok then
|
|
||||||
log("error refreshing id=" .. id .. ": " .. tostring(err))
|
|
||||||
end
|
|
||||||
else
|
|
||||||
log("image id=" .. id .. " not found in database (may have been removed)")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 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
|
|
||||||
-- thread pool, leaving the UI thread free. We loop inside with sleep() so
|
|
||||||
-- one dispatch call drives the whole bridge.
|
|
||||||
local running = true
|
|
||||||
|
|
||||||
local function poll_loop()
|
|
||||||
notify(string.format(
|
|
||||||
"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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
notify("stopped")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Plugin lifecycle ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local script_data = {}
|
|
||||||
|
|
||||||
script_data.metadata = {
|
|
||||||
name = "darktable API bridge",
|
|
||||||
purpose = "Watch the REST API refresh queue and regenerate image thumbnails",
|
|
||||||
author = "darktable-api",
|
|
||||||
help = "https://github.com/darktable-org/lua-scripts",
|
|
||||||
}
|
|
||||||
|
|
||||||
local function destroy()
|
|
||||||
running = false
|
|
||||||
-- Give the poll loop one final chance to exit cleanly.
|
|
||||||
dt.control.sleep(POLL_MS + 200)
|
|
||||||
dt.destroy_event(MODULE, "shortcut")
|
|
||||||
end
|
|
||||||
script_data.destroy = destroy
|
|
||||||
|
|
||||||
-- Register a manual-trigger shortcut so the user can force an immediate poll
|
|
||||||
-- without waiting for the next timer tick.
|
|
||||||
dt.register_event(MODULE, "shortcut",
|
|
||||||
function(event, 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
-- Start the background loop.
|
|
||||||
dt.control.dispatch(poll_loop)
|
|
||||||
|
|
||||||
return script_data
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
-- 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,
|
|
||||||
-- maker_id, model_id, lens_id, camera_id,
|
|
||||||
-- exposure, aperture, iso, focal_length, focus_distance,
|
|
||||||
-- datetime_taken INTEGER (Unix epoch),
|
|
||||||
-- flags, orientation, longitude, latitude, altitude,
|
|
||||||
-- version, max_version, write_timestamp, change_timestamp, ...)
|
|
||||||
-- makers(id, name) models(id, name) lens(id, name)
|
|
||||||
-- film_rolls(id, access_timestamp, folder)
|
|
||||||
-- tagged_images(imgid, tagid, position)
|
|
||||||
-- color_labels(imgid, color) -- color: 0=red 1=yellow 2=green 3=blue 4=purple
|
|
||||||
-- data.tags(id, name, ...) -- in data.db
|
|
||||||
-- meta_data(id, key, value) -- key references data.meta_data.key
|
|
||||||
--
|
|
||||||
-- images.flags & 0x07 → rating (0=unrated, 1-5=stars, 6=rejected)
|
|
||||||
-- images.flags & 64 → DT_IMAGE_RAW
|
|
||||||
-- images.flags & 128 → DT_IMAGE_HDR
|
|
||||||
-- images.flags & 32 → DT_IMAGE_LDR
|
|
||||||
local sqlite3 = require "lsqlite3"
|
|
||||||
local bit = require "bit" -- LuaJIT built-in; Lua 5.1 bitwise
|
|
||||||
local config = require "config"
|
|
||||||
|
|
||||||
local band = bit.band
|
|
||||||
local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- ── Connections ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local _db -- read-only, lazy
|
|
||||||
local _db_rw -- read-write, opened on first write
|
|
||||||
|
|
||||||
local function get_db()
|
|
||||||
if _db then return _db end
|
|
||||||
_db = sqlite3.open(config.library_db, sqlite3.OPEN_READONLY)
|
|
||||||
if not _db then
|
|
||||||
error("Cannot open darktable library: " .. config.library_db)
|
|
||||||
end
|
|
||||||
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)
|
|
||||||
local d = get_db()
|
|
||||||
local stmt = d:prepare(sql)
|
|
||||||
if not stmt then
|
|
||||||
error("SQL error: " .. (d:errmsg() or "?") .. "\nSQL: " .. sql)
|
|
||||||
end
|
|
||||||
if params and #params > 0 then
|
|
||||||
stmt:bind_values(unpack(params))
|
|
||||||
end
|
|
||||||
local rows = {}
|
|
||||||
for row in stmt:nrows() do
|
|
||||||
rows[#rows+1] = row
|
|
||||||
end
|
|
||||||
stmt:finalize()
|
|
||||||
return rows
|
|
||||||
end
|
|
||||||
|
|
||||||
local function query_one(sql, params)
|
|
||||||
return query(sql, params)[1]
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Schema helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- Rating is packed into the low 3 bits of flags: 0=unrated, 1-5=stars, 6=rejected
|
|
||||||
local function decode_rating(flags)
|
|
||||||
local r = band(flags or 0, 0x07)
|
|
||||||
return r == 6 and -1 or r
|
|
||||||
end
|
|
||||||
|
|
||||||
-- datetime_taken is stored as GLib microseconds since 0001-01-01 00:00:00 UTC.
|
|
||||||
-- Unix epoch (1970-01-01) = 62135596800 seconds after 0001-01-01.
|
|
||||||
local GLIB_EPOCH_OFFSET = 62135596800 -- seconds
|
|
||||||
|
|
||||||
local function glib_to_unix(glib_us)
|
|
||||||
if not glib_us or glib_us == 0 then return nil end
|
|
||||||
return math.floor(glib_us / 1000000) - GLIB_EPOCH_OFFSET
|
|
||||||
end
|
|
||||||
|
|
||||||
local function fmt_datetime(glib_us)
|
|
||||||
local unix_ts = glib_to_unix(glib_us)
|
|
||||||
if not unix_ts then return nil end
|
|
||||||
return os.date("!%Y-%m-%dT%H:%M:%SZ", unix_ts)
|
|
||||||
end
|
|
||||||
|
|
||||||
local LABEL_NAMES = { [0]="red", [1]="yellow", [2]="green", [3]="blue", [4]="purple" }
|
|
||||||
|
|
||||||
local function row_to_image(row)
|
|
||||||
if not row then return nil end
|
|
||||||
return {
|
|
||||||
id = row.id,
|
|
||||||
film_id = row.film_id,
|
|
||||||
group_id = row.group_id,
|
|
||||||
filename = row.filename,
|
|
||||||
path = row.folder,
|
|
||||||
width = row.width,
|
|
||||||
height = row.height,
|
|
||||||
aspect_ratio = row.aspect_ratio,
|
|
||||||
maker = row.maker,
|
|
||||||
model = row.model,
|
|
||||||
lens = row.lens,
|
|
||||||
exposure = row.exposure,
|
|
||||||
aperture = row.aperture,
|
|
||||||
iso = row.iso,
|
|
||||||
focal_length = row.focal_length,
|
|
||||||
datetime_taken = fmt_datetime(row.datetime_taken),
|
|
||||||
rating = decode_rating(row.flags),
|
|
||||||
is_raw = band(row.flags or 0, 64) ~= 0,
|
|
||||||
is_hdr = band(row.flags or 0, 128) ~= 0,
|
|
||||||
is_ldr = band(row.flags or 0, 32) ~= 0,
|
|
||||||
latitude = row.latitude,
|
|
||||||
longitude = row.longitude,
|
|
||||||
altitude = row.altitude,
|
|
||||||
version = row.version,
|
|
||||||
write_timestamp = row.write_timestamp,
|
|
||||||
change_timestamp = row.change_timestamp,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Standard SELECT columns + JOINs used by both list_images and get_image.
|
|
||||||
local IMAGE_SELECT = [[
|
|
||||||
SELECT i.id, i.group_id, i.film_id, i.filename,
|
|
||||||
i.width, i.height, i.aspect_ratio,
|
|
||||||
mk.name AS maker, mo.name AS model, ln.name AS lens,
|
|
||||||
i.exposure, i.aperture, i.iso, i.focal_length,
|
|
||||||
i.datetime_taken, i.flags, i.version,
|
|
||||||
i.longitude, i.latitude, i.altitude,
|
|
||||||
i.write_timestamp, i.change_timestamp,
|
|
||||||
f.folder
|
|
||||||
FROM images i
|
|
||||||
JOIN film_rolls f ON f.id = i.film_id
|
|
||||||
LEFT JOIN makers mk ON mk.id = i.maker_id
|
|
||||||
LEFT JOIN models mo ON mo.id = i.model_id
|
|
||||||
LEFT JOIN lens ln ON ln.id = i.lens_id
|
|
||||||
]]
|
|
||||||
|
|
||||||
-- ── Filter builder ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local COLOR_MAP = { red=0, yellow=1, green=2, blue=3, purple=4 }
|
|
||||||
local VALID_SORT = {
|
|
||||||
id=true, filename=true, datetime_taken=true, rating=true,
|
|
||||||
focal_length=true, iso=true,
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Convert a YYYY-MM-DD string to a GLib microsecond timestamp for DB comparison.
|
|
||||||
local function date_to_glib_us(date_str, end_of_day)
|
|
||||||
local y, m, d = date_str:match("(%d%d%d%d)[%-:](%d%d)[%-:](%d%d)")
|
|
||||||
if not y then return nil end
|
|
||||||
local unix_ts = os.time({
|
|
||||||
year=tonumber(y), month=tonumber(m), day=tonumber(d),
|
|
||||||
hour = end_of_day and 23 or 0,
|
|
||||||
min = end_of_day and 59 or 0,
|
|
||||||
sec = end_of_day and 59 or 0,
|
|
||||||
})
|
|
||||||
return (unix_ts + GLIB_EPOCH_OFFSET) * 1000000
|
|
||||||
end
|
|
||||||
|
|
||||||
local function build_filter(filters)
|
|
||||||
local conds = {}
|
|
||||||
local params = {}
|
|
||||||
|
|
||||||
-- Star rating (packed in flags & 0x07; 6 = rejected)
|
|
||||||
if filters.rating_min then
|
|
||||||
local r = tonumber(filters.rating_min) or 0
|
|
||||||
table.insert(conds, "(i.flags & 7) >= ?")
|
|
||||||
table.insert(params, r == -1 and 6 or r)
|
|
||||||
end
|
|
||||||
if filters.rating_max then
|
|
||||||
local r = tonumber(filters.rating_max) or 5
|
|
||||||
table.insert(conds, "(i.flags & 7) <= ?")
|
|
||||||
table.insert(params, r == -1 and 6 or r)
|
|
||||||
end
|
|
||||||
if filters.rating then
|
|
||||||
local r = tonumber(filters.rating)
|
|
||||||
if r then
|
|
||||||
table.insert(conds, "(i.flags & 7) = ?")
|
|
||||||
table.insert(params, r == -1 and 6 or r)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Film roll / folder
|
|
||||||
if filters.film_id then
|
|
||||||
table.insert(conds, "i.film_id = ?")
|
|
||||||
table.insert(params, tonumber(filters.film_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Date range (datetime_taken is GLib microseconds since 0001-01-01)
|
|
||||||
if filters.from then
|
|
||||||
local glib_us = date_to_glib_us(filters.from, false)
|
|
||||||
if glib_us then
|
|
||||||
table.insert(conds, "i.datetime_taken >= ?")
|
|
||||||
table.insert(params, glib_us)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if filters.to then
|
|
||||||
local glib_us = date_to_glib_us(filters.to, true)
|
|
||||||
if glib_us then
|
|
||||||
table.insert(conds, "i.datetime_taken <= ?")
|
|
||||||
table.insert(params, glib_us)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Color label
|
|
||||||
if filters.color_label then
|
|
||||||
local cid = COLOR_MAP[filters.color_label:lower()]
|
|
||||||
if cid ~= nil then
|
|
||||||
table.insert(conds, "EXISTS (SELECT 1 FROM color_labels cl WHERE cl.imgid=i.id AND cl.color=?)")
|
|
||||||
table.insert(params, cid)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- RAW / HDR flags (SQLite supports & bitwise)
|
|
||||||
if filters.is_raw == "true" then table.insert(conds, "(i.flags & 64) != 0") end
|
|
||||||
if filters.is_raw == "false" then table.insert(conds, "(i.flags & 64) = 0") end
|
|
||||||
if filters.is_hdr == "true" then table.insert(conds, "(i.flags & 128) != 0") end
|
|
||||||
|
|
||||||
-- Free-text search across maker/model/lens/filename
|
|
||||||
if filters.search and filters.search ~= "" then
|
|
||||||
table.insert(conds, "(i.filename LIKE ? OR mk.name LIKE ? OR mo.name LIKE ? OR ln.name LIKE ?)")
|
|
||||||
local pat = "%" .. filters.search .. "%"
|
|
||||||
for _ = 1, 4 do table.insert(params, pat) end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Tag filter (correlated EXISTS; works for both data.tags and main.tags layouts)
|
|
||||||
if filters.tag and filters.tag ~= "" then
|
|
||||||
table.insert(conds, [[EXISTS (
|
|
||||||
SELECT 1 FROM tagged_images ti2, data.tags t2
|
|
||||||
WHERE ti2.imgid = i.id AND t2.id = ti2.tagid AND t2.name = ?)]])
|
|
||||||
table.insert(params, filters.tag)
|
|
||||||
end
|
|
||||||
|
|
||||||
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
|
|
||||||
return where, params
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function M.list_images(filters)
|
|
||||||
filters = filters or {}
|
|
||||||
local where, params = build_filter(filters)
|
|
||||||
|
|
||||||
local limit = math.max(1, math.min(tonumber(filters.limit) or 50, 500))
|
|
||||||
local offset = math.max(0, tonumber(filters.offset) or 0)
|
|
||||||
local sort_by = VALID_SORT[filters.sort_by] and ("i." .. filters.sort_by) or "i.datetime_taken"
|
|
||||||
local sort_dir = (filters.sort_dir == "asc") and "ASC" or "DESC"
|
|
||||||
|
|
||||||
-- Total count (same filter, no pagination)
|
|
||||||
local count_params = {}
|
|
||||||
for _, v in ipairs(params) do count_params[#count_params+1] = v end
|
|
||||||
|
|
||||||
local count_sql = string.format(
|
|
||||||
"SELECT COUNT(*) AS total FROM images i " ..
|
|
||||||
"JOIN film_rolls f ON f.id = i.film_id " ..
|
|
||||||
"LEFT JOIN makers mk ON mk.id = i.maker_id " ..
|
|
||||||
"LEFT JOIN models mo ON mo.id = i.model_id " ..
|
|
||||||
"LEFT JOIN lens ln ON ln.id = i.lens_id %s",
|
|
||||||
where)
|
|
||||||
local count_row = query_one(count_sql, count_params)
|
|
||||||
local total = count_row and count_row.total or 0
|
|
||||||
|
|
||||||
-- Data rows
|
|
||||||
local data_params = {}
|
|
||||||
for _, v in ipairs(params) do data_params[#data_params+1] = v end
|
|
||||||
data_params[#data_params+1] = limit
|
|
||||||
data_params[#data_params+1] = offset
|
|
||||||
|
|
||||||
local data_sql = IMAGE_SELECT ..
|
|
||||||
where ..
|
|
||||||
string.format(" ORDER BY %s %s LIMIT ? OFFSET ?", sort_by, sort_dir)
|
|
||||||
|
|
||||||
local images = {}
|
|
||||||
for _, row in ipairs(query(data_sql, data_params)) do
|
|
||||||
images[#images+1] = row_to_image(row)
|
|
||||||
end
|
|
||||||
|
|
||||||
return { total = total, limit = limit, offset = offset, items = images }
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.get_image(id)
|
|
||||||
local row = query_one(IMAGE_SELECT .. " WHERE i.id = ?", { tonumber(id) })
|
|
||||||
if not row then return nil end
|
|
||||||
|
|
||||||
local img = row_to_image(row)
|
|
||||||
|
|
||||||
-- Tags
|
|
||||||
local tag_rows = query([[
|
|
||||||
SELECT t.name FROM tagged_images ti
|
|
||||||
JOIN data.tags t ON t.id = ti.tagid
|
|
||||||
WHERE ti.imgid = ? ORDER BY t.name
|
|
||||||
]], { img.id })
|
|
||||||
img.tags = {}
|
|
||||||
for _, tr in ipairs(tag_rows) do img.tags[#img.tags+1] = tr.name end
|
|
||||||
|
|
||||||
-- Color labels
|
|
||||||
local cl_rows = query("SELECT color FROM color_labels WHERE imgid=?", { img.id })
|
|
||||||
img.color_labels = {}
|
|
||||||
for _, cl in ipairs(cl_rows) do
|
|
||||||
if LABEL_NAMES[cl.color] then
|
|
||||||
img.color_labels[#img.color_labels+1] = LABEL_NAMES[cl.color]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Metadata (caption, description, creator, etc. via data.meta_data key map)
|
|
||||||
local ok_meta, meta_rows = pcall(query, [[
|
|
||||||
SELECT dm.tagname, m.value
|
|
||||||
FROM meta_data m JOIN data.meta_data dm ON dm.key = m.key
|
|
||||||
WHERE m.id = ?
|
|
||||||
]], { img.id })
|
|
||||||
img.metadata = {}
|
|
||||||
if ok_meta then
|
|
||||||
for _, mr in ipairs(meta_rows) do
|
|
||||||
local subkey = mr.tagname:match("%.([^%.]+)$") or mr.tagname
|
|
||||||
img.metadata[subkey] = mr.value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return img
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Tags ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function M.list_tags(filters)
|
|
||||||
filters = filters or {}
|
|
||||||
local conds, params = {}, {}
|
|
||||||
if filters.search and filters.search ~= "" then
|
|
||||||
table.insert(conds, "t.name LIKE ?")
|
|
||||||
table.insert(params, "%" .. filters.search .. "%")
|
|
||||||
end
|
|
||||||
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
|
|
||||||
local limit = math.min(tonumber(filters.limit) or 200, 2000)
|
|
||||||
local offset = tonumber(filters.offset) or 0
|
|
||||||
params[#params+1] = limit
|
|
||||||
params[#params+1] = offset
|
|
||||||
|
|
||||||
local rows = query(string.format([[
|
|
||||||
SELECT t.id, t.name,
|
|
||||||
(SELECT COUNT(*) FROM tagged_images ti WHERE ti.tagid=t.id) AS image_count
|
|
||||||
FROM data.tags t %s ORDER BY t.name LIMIT ? OFFSET ?
|
|
||||||
]], where), params)
|
|
||||||
|
|
||||||
local tags = {}
|
|
||||||
for _, r in ipairs(rows) do
|
|
||||||
tags[#tags+1] = { id=r.id, name=r.name, image_count=r.image_count }
|
|
||||||
end
|
|
||||||
return tags
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Film rolls ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function M.list_films(filters)
|
|
||||||
filters = filters or {}
|
|
||||||
local conds, params = {}, {}
|
|
||||||
if filters.search and filters.search ~= "" then
|
|
||||||
table.insert(conds, "f.folder LIKE ?")
|
|
||||||
table.insert(params, "%" .. filters.search .. "%")
|
|
||||||
end
|
|
||||||
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
|
|
||||||
local limit = math.min(tonumber(filters.limit) or 200, 2000)
|
|
||||||
local offset = tonumber(filters.offset) or 0
|
|
||||||
params[#params+1] = limit
|
|
||||||
params[#params+1] = offset
|
|
||||||
|
|
||||||
local rows = query(string.format([[
|
|
||||||
SELECT f.id, f.folder, f.access_timestamp,
|
|
||||||
(SELECT COUNT(*) FROM images i WHERE i.film_id=f.id) AS image_count
|
|
||||||
FROM film_rolls f %s ORDER BY f.folder LIMIT ? OFFSET ?
|
|
||||||
]], where), params)
|
|
||||||
|
|
||||||
local films = {}
|
|
||||||
for _, r in ipairs(rows) do
|
|
||||||
films[#films+1] = {
|
|
||||||
id = r.id,
|
|
||||||
folder = r.folder,
|
|
||||||
access_timestamp = r.access_timestamp,
|
|
||||||
image_count = r.image_count,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
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.
|
|
||||||
function M.get_module(imgid, 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
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
-- Minimal JSON encoder/decoder (pure Lua, no dependencies)
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- ── Encoder ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- Iterate byte-by-byte to avoid null-byte issues in Lua pattern ranges.
|
|
||||||
local function encode_str(s)
|
|
||||||
local buf = {'"'}
|
|
||||||
for i = 1, #s do
|
|
||||||
local b = s:byte(i)
|
|
||||||
if b == 34 then buf[#buf+1] = '\\"'
|
|
||||||
elseif b == 92 then buf[#buf+1] = '\\\\'
|
|
||||||
elseif b == 10 then buf[#buf+1] = '\\n'
|
|
||||||
elseif b == 13 then buf[#buf+1] = '\\r'
|
|
||||||
elseif b == 9 then buf[#buf+1] = '\\t'
|
|
||||||
elseif b < 32 or b == 127 then
|
|
||||||
buf[#buf+1] = string.format('\\u%04x', b)
|
|
||||||
else
|
|
||||||
buf[#buf+1] = s:sub(i, i)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
buf[#buf+1] = '"'
|
|
||||||
return table.concat(buf)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function enc(v, depth)
|
|
||||||
if depth > 64 then error("json.encode: table too deeply nested") end
|
|
||||||
local t = type(v)
|
|
||||||
if t == "nil" then return "null"
|
|
||||||
elseif t == "boolean" then return tostring(v)
|
|
||||||
elseif t == "number" then
|
|
||||||
if v ~= v or math.abs(v) == math.huge then return "null" end
|
|
||||||
return v == math.floor(v) and string.format("%d", v) or string.format("%.10g", v)
|
|
||||||
elseif t == "string" then
|
|
||||||
return encode_str(v)
|
|
||||||
elseif t == "table" then
|
|
||||||
-- Treat as array when it has a consecutive integer key sequence from 1
|
|
||||||
if #v > 0 then
|
|
||||||
local parts = {}
|
|
||||||
for i = 1, #v do parts[i] = enc(v[i], depth + 1) end
|
|
||||||
return "[" .. table.concat(parts, ",") .. "]"
|
|
||||||
end
|
|
||||||
local parts = {}
|
|
||||||
for k, x in pairs(v) do
|
|
||||||
if type(k) == "string" then
|
|
||||||
parts[#parts+1] = encode_str(k) .. ":" .. enc(x, depth + 1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return "{" .. table.concat(parts, ",") .. "}"
|
|
||||||
end
|
|
||||||
return "null"
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.encode(v) return enc(v, 0) end
|
|
||||||
|
|
||||||
-- ── Decoder ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function M.decode(src)
|
|
||||||
local pos = 1
|
|
||||||
|
|
||||||
local function skip()
|
|
||||||
while pos <= #src and src:byte(pos) <= 32 do pos = pos + 1 end
|
|
||||||
end
|
|
||||||
|
|
||||||
local parse -- forward declaration
|
|
||||||
|
|
||||||
local function parse_string()
|
|
||||||
pos = pos + 1 -- consume opening "
|
|
||||||
local buf = {}
|
|
||||||
while pos <= #src do
|
|
||||||
local c = src:sub(pos, pos)
|
|
||||||
if c == '"' then pos = pos + 1; return table.concat(buf) end
|
|
||||||
if c == '\\' then
|
|
||||||
pos = pos + 1
|
|
||||||
c = src:sub(pos, pos)
|
|
||||||
if c == '"' then buf[#buf+1] = '"'
|
|
||||||
elseif c == '\\' then buf[#buf+1] = '\\'
|
|
||||||
elseif c == '/' then buf[#buf+1] = '/'
|
|
||||||
elseif c == 'n' then buf[#buf+1] = '\n'
|
|
||||||
elseif c == 'r' then buf[#buf+1] = '\r'
|
|
||||||
elseif c == 't' then buf[#buf+1] = '\t'
|
|
||||||
elseif c == 'b' then buf[#buf+1] = '\b'
|
|
||||||
elseif c == 'f' then buf[#buf+1] = '\f'
|
|
||||||
elseif c == 'u' then
|
|
||||||
local hex = tonumber(src:sub(pos+1, pos+4), 16) or 0
|
|
||||||
-- Only emit ASCII; multi-byte unicode emits a placeholder
|
|
||||||
buf[#buf+1] = hex < 128 and string.char(hex) or '\xEF\xBF\xBD'
|
|
||||||
pos = pos + 4
|
|
||||||
end
|
|
||||||
else
|
|
||||||
buf[#buf+1] = c
|
|
||||||
end
|
|
||||||
pos = pos + 1
|
|
||||||
end
|
|
||||||
error("json.decode: unterminated string near pos " .. pos)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function parse_object()
|
|
||||||
pos = pos + 1 -- consume '{'
|
|
||||||
local obj = {}
|
|
||||||
skip()
|
|
||||||
if src:sub(pos, pos) == '}' then pos = pos + 1; return obj end
|
|
||||||
while true do
|
|
||||||
skip()
|
|
||||||
local key = parse_string()
|
|
||||||
skip()
|
|
||||||
if src:sub(pos, pos) ~= ':' then error("json.decode: expected ':' near pos " .. pos) end
|
|
||||||
pos = pos + 1
|
|
||||||
skip()
|
|
||||||
obj[key] = parse()
|
|
||||||
skip()
|
|
||||||
local ch = src:sub(pos, pos); pos = pos + 1
|
|
||||||
if ch == '}' then return obj end
|
|
||||||
if ch ~= ',' then error("json.decode: expected ',' or '}' near pos " .. pos) end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function parse_array()
|
|
||||||
pos = pos + 1 -- consume '['
|
|
||||||
local arr = {}
|
|
||||||
skip()
|
|
||||||
if src:sub(pos, pos) == ']' then pos = pos + 1; return arr end
|
|
||||||
while true do
|
|
||||||
skip()
|
|
||||||
arr[#arr+1] = parse()
|
|
||||||
skip()
|
|
||||||
local ch = src:sub(pos, pos); pos = pos + 1
|
|
||||||
if ch == ']' then return arr end
|
|
||||||
if ch ~= ',' then error("json.decode: expected ',' or ']' near pos " .. pos) end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
parse = function()
|
|
||||||
skip()
|
|
||||||
local c = src:sub(pos, pos)
|
|
||||||
if c == '"' then return parse_string()
|
|
||||||
elseif c == '{' then return parse_object()
|
|
||||||
elseif c == '[' then return parse_array()
|
|
||||||
elseif c == 't' then pos = pos + 4; return true
|
|
||||||
elseif c == 'f' then pos = pos + 5; return false
|
|
||||||
elseif c == 'n' then pos = pos + 4; return nil
|
|
||||||
else
|
|
||||||
-- Parentheses create the capture; without them find returns only start/end.
|
|
||||||
local _, e, n = src:find("^(-?%d+%.?%d*[eE]?[+-]?%d*)", pos)
|
|
||||||
if n then pos = e + 1; return tonumber(n) end
|
|
||||||
error("json.decode: unexpected token '" .. c .. "' at pos " .. pos)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local ok, result = pcall(parse)
|
|
||||||
if not ok then return nil, result end
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
-- Serve image previews from darktable's mipmap cache.
|
|
||||||
--
|
|
||||||
-- Darktable stores mipmaps as individual JPEG files at:
|
|
||||||
-- <cache_dir>/mipmaps-<sha1_of_library_realpath>.d/<mip_level>/<imgid>.jpg
|
|
||||||
--
|
|
||||||
-- Mip levels (approximate pixel sizes on the long edge):
|
|
||||||
-- 0 → ~67 px 1 → ~135 px 2 → ~270 px 3 → ~540 px
|
|
||||||
-- 4 → ~1080 px (good "medium") 5 → ~2160 px
|
|
||||||
--
|
|
||||||
-- If no cached mipmap is found we fall back to exiftool (embedded EXIF
|
|
||||||
-- thumbnail) and finally to serving the original file path as a redirect.
|
|
||||||
local ffi = require "ffi"
|
|
||||||
local config = require "config"
|
|
||||||
|
|
||||||
ffi.cdef[[
|
|
||||||
unsigned char *SHA1(const unsigned char *d, size_t n, unsigned char *md);
|
|
||||||
]]
|
|
||||||
local crypto = ffi.load("crypto")
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
-- ── Locate the mipmap cache directory (computed once) ─────────────────────────
|
|
||||||
|
|
||||||
local _mipmap_base -- e.g. "/home/user/.cache/darktable/mipmaps-abc123"
|
|
||||||
|
|
||||||
local function mipmap_base()
|
|
||||||
if _mipmap_base then return _mipmap_base end
|
|
||||||
|
|
||||||
-- Resolve the real path of library.db (darktable uses realpath before hashing)
|
|
||||||
local h = io.popen("realpath " .. config.library_db .. " 2>/dev/null")
|
|
||||||
local realpath = h:read("*l"); h:close()
|
|
||||||
realpath = realpath or config.library_db
|
|
||||||
|
|
||||||
-- SHA1 hex of the realpath (same algorithm as mipmap_cache.c)
|
|
||||||
local md = ffi.new("unsigned char[20]")
|
|
||||||
crypto.SHA1(realpath, #realpath, md)
|
|
||||||
local hex = {}
|
|
||||||
for i = 0, 19 do hex[i+1] = string.format("%02x", md[i]) end
|
|
||||||
local sha1hex = table.concat(hex)
|
|
||||||
|
|
||||||
_mipmap_base = config.cache_dir .. "/mipmaps-" .. sha1hex
|
|
||||||
return _mipmap_base
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Size → mip level mapping ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
-- size param: "thumb" (~135 px), "small" (~270 px), "medium" (~540 px),
|
|
||||||
-- "large" (~1080 px), "xlarge" (~2160 px), or an integer level 0-5.
|
|
||||||
local SIZE_TO_MIP = {
|
|
||||||
thumb = 1,
|
|
||||||
small = 2,
|
|
||||||
medium = 3,
|
|
||||||
large = 4,
|
|
||||||
xlarge = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
local function size_to_mip(size)
|
|
||||||
if not size or size == "" then return 3 end -- default: "medium"
|
|
||||||
local n = tonumber(size)
|
|
||||||
if n then return math.max(0, math.min(5, math.floor(n))) end
|
|
||||||
return SIZE_TO_MIP[size:lower()] or 3
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Try to read a file, return nil on failure ─────────────────────────────────
|
|
||||||
|
|
||||||
local function read_file(path)
|
|
||||||
local f = io.open(path, "rb")
|
|
||||||
if not f then return nil end
|
|
||||||
local data = f:read("*a")
|
|
||||||
f:close()
|
|
||||||
return data
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Public: get preview data for an image id ──────────────────────────────────
|
|
||||||
--
|
|
||||||
-- Returns: data (string), content_type (string)
|
|
||||||
-- or: nil, nil, redirect_url (caller should issue a 302)
|
|
||||||
-- or: nil, nil, nil (404)
|
|
||||||
function M.get(image_id, image_path, filename, size_param)
|
|
||||||
local mip = size_to_mip(size_param)
|
|
||||||
|
|
||||||
-- 1. Walk mip levels: first downward (smaller = smaller file, faster),
|
|
||||||
-- then upward (larger mip levels, let the browser scale down).
|
|
||||||
-- This ensures we serve something even when only higher-resolution
|
|
||||||
-- mipmaps have been cached (e.g. darktable only wrote levels 2-4).
|
|
||||||
local base = mipmap_base()
|
|
||||||
for level = mip, 0, -1 do
|
|
||||||
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
|
|
||||||
local data = read_file(path)
|
|
||||||
if data then return data, "image/jpeg" end
|
|
||||||
end
|
|
||||||
for level = mip + 1, 5 do
|
|
||||||
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
|
|
||||||
local data = read_file(path)
|
|
||||||
if data then return data, "image/jpeg" end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 2. Try exiftool to extract the embedded EXIF thumbnail from the source file.
|
|
||||||
if image_path and filename then
|
|
||||||
local src = image_path .. "/" .. filename
|
|
||||||
-- exiftool writes the thumbnail to stdout with -b
|
|
||||||
local cmd = string.format("exiftool -b -ThumbnailImage %q 2>/dev/null", src)
|
|
||||||
local fh = io.popen(cmd)
|
|
||||||
if fh then
|
|
||||||
local data = fh:read("*a"); fh:close()
|
|
||||||
if data and #data > 100 then
|
|
||||||
return data, "image/jpeg"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 3. For JPEG/PNG source files redirect to the original (client caches it).
|
|
||||||
if image_path and filename then
|
|
||||||
local src = image_path .. "/" .. filename
|
|
||||||
local ext = filename:match("%.([^%.]+)$") or ""
|
|
||||||
if ext:lower():match("^jpe?g$") or ext:lower() == "png" then
|
|
||||||
return nil, nil, "file://" .. src -- caller issues redirect or reads directly
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil, nil, nil -- no preview available
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Helper: read a local file and serve it inline ─────────────────────────────
|
|
||||||
-- Used by the API handler when get() returns a file:// redirect.
|
|
||||||
function M.read_local(path)
|
|
||||||
local stripped = path:match("^file://(.+)$") or path
|
|
||||||
return read_file(stripped)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
-- Simple radix-style HTTP router.
|
|
||||||
-- Supports static segments and named captures (:name).
|
|
||||||
-- Usage:
|
|
||||||
-- router.get("/api/v1/images", handler)
|
|
||||||
-- router.get("/api/v1/images/:id", handler)
|
|
||||||
-- router.post("/oauth/token", handler)
|
|
||||||
--
|
|
||||||
-- local handler, params = router.match("GET", "/api/v1/images/42")
|
|
||||||
-- -- handler = <function>, params = { id = "42" }
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local routes = {} -- { method, pattern_parts, handler }
|
|
||||||
|
|
||||||
-- Convert a route pattern like "/api/v1/images/:id/preview" into a segment list.
|
|
||||||
local function compile(pattern)
|
|
||||||
local parts = {}
|
|
||||||
-- Strip leading slash so the segment list aligns with the path splitter below.
|
|
||||||
for seg in (pattern:gsub("^/", "") .. "/"):gmatch("([^/]*)/") do
|
|
||||||
if seg:sub(1,1) == ":" then
|
|
||||||
parts[#parts+1] = { capture = seg:sub(2) }
|
|
||||||
else
|
|
||||||
parts[#parts+1] = { literal = seg }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return parts
|
|
||||||
end
|
|
||||||
|
|
||||||
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.patch(pattern, handler) register("PATCH", pattern, handler) end
|
|
||||||
function M.delete(pattern, handler) register("DELETE", pattern, handler) end
|
|
||||||
|
|
||||||
-- Match a request against registered routes.
|
|
||||||
-- Returns: handler, path_params or nil, nil
|
|
||||||
function M.match(method, path)
|
|
||||||
-- Split path into segments
|
|
||||||
local segs = {}
|
|
||||||
for seg in (path:gsub("^/","") .. "/"):gmatch("([^/]*)/") do
|
|
||||||
segs[#segs+1] = seg
|
|
||||||
end
|
|
||||||
|
|
||||||
method = method:upper()
|
|
||||||
|
|
||||||
for _, route in ipairs(routes) do
|
|
||||||
if route.method == method and #route.parts == #segs then
|
|
||||||
local params = {}
|
|
||||||
local matched = true
|
|
||||||
for i, part in ipairs(route.parts) do
|
|
||||||
if part.capture then
|
|
||||||
params[part.capture] = segs[i]
|
|
||||||
elseif part.literal ~= segs[i] then
|
|
||||||
matched = false
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if matched then return route.handler, params end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
#!/usr/bin/env luajit
|
|
||||||
-- Darktable REST API — standalone LuaJIT HTTP server
|
|
||||||
-- Usage: luajit server.lua [port] [host]
|
|
||||||
--
|
|
||||||
-- Dependencies (install via luarocks):
|
|
||||||
-- luasocket — luarocks install luasocket
|
|
||||||
-- lsqlite3 — luarocks install lsqlite3
|
|
||||||
-- libcrypto — provided by the system OpenSSL package
|
|
||||||
--
|
|
||||||
-- Quick-start:
|
|
||||||
-- export DT_CLIENT_ID=myclient DT_CLIENT_SECRET=mysecret DT_JWT_SECRET=longrandomstring
|
|
||||||
-- luajit server.lua
|
|
||||||
--
|
|
||||||
-- Then get a token:
|
|
||||||
-- curl -s -X POST http://127.0.0.1:7645/oauth/token \
|
|
||||||
-- -d 'grant_type=client_credentials&client_id=myclient&client_secret=mysecret'
|
|
||||||
--
|
|
||||||
-- And use it:
|
|
||||||
-- curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:7645/api/v1/images
|
|
||||||
|
|
||||||
-- ── Bootstrap: make local modules findable ────────────────────────────────────
|
|
||||||
|
|
||||||
local script_dir = debug.getinfo(1, "S").source:match("^@(.+)/[^/]+$") or "."
|
|
||||||
package.path = script_dir .. "/?.lua;" .. package.path
|
|
||||||
|
|
||||||
-- ── Load modules ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local socket = require "socket"
|
|
||||||
local config = require "config"
|
|
||||||
local router = require "router"
|
|
||||||
local api = require "api"
|
|
||||||
|
|
||||||
-- ── Static file server (PWA) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local WEB_BUILD = script_dir .. "/web/build"
|
|
||||||
|
|
||||||
local MIME = {
|
|
||||||
html = "text/html; charset=utf-8",
|
|
||||||
js = "application/javascript; charset=utf-8",
|
|
||||||
mjs = "application/javascript; charset=utf-8",
|
|
||||||
css = "text/css; charset=utf-8",
|
|
||||||
json = "application/json",
|
|
||||||
webmanifest = "application/manifest+json",
|
|
||||||
png = "image/png",
|
|
||||||
jpg = "image/jpeg",
|
|
||||||
jpeg = "image/jpeg",
|
|
||||||
svg = "image/svg+xml",
|
|
||||||
ico = "image/x-icon",
|
|
||||||
woff = "font/woff",
|
|
||||||
woff2 = "font/woff2",
|
|
||||||
txt = "text/plain; charset=utf-8",
|
|
||||||
map = "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Read a file from the web build directory. Returns data, content-type or nil.
|
|
||||||
-- Prevents directory traversal by rejecting any path containing "..".
|
|
||||||
local function read_static(req_path)
|
|
||||||
if req_path:find("%.%.", 1, true) then return nil end
|
|
||||||
|
|
||||||
-- Strip leading slash; normalise double-slashes.
|
|
||||||
local rel = req_path:gsub("^/+", ""):gsub("//+", "/")
|
|
||||||
local full = WEB_BUILD .. "/" .. rel
|
|
||||||
|
|
||||||
local f = io.open(full, "rb")
|
|
||||||
if not f then return nil end
|
|
||||||
local data = f:read("*a")
|
|
||||||
f:close()
|
|
||||||
|
|
||||||
local ext = rel:match("%.([^%.]+)$") or ""
|
|
||||||
local ct = MIME[ext:lower()] or "application/octet-stream"
|
|
||||||
return data, ct
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Cache headers: hashed assets get long TTL; everything else no-store.
|
|
||||||
local function cache_header(path)
|
|
||||||
-- Vite fingerprints assets with a hash in the filename (e.g. index-Bx3Rk.js)
|
|
||||||
if path:match("/assets/") then
|
|
||||||
return "public, max-age=31536000, immutable"
|
|
||||||
end
|
|
||||||
return "no-store"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Register routes ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
router.post("/oauth/token", api.oauth_token)
|
|
||||||
|
|
||||||
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.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)
|
|
||||||
|
|
||||||
-- ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local STATUS_TEXT = {
|
|
||||||
[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", [504] = "Gateway Timeout",
|
|
||||||
}
|
|
||||||
|
|
||||||
local function url_decode(s)
|
|
||||||
return s:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Parse the raw HTTP request from a connected socket.
|
|
||||||
-- Returns a request table, or nil on parse/timeout error.
|
|
||||||
local function read_request(client)
|
|
||||||
client:settimeout(10)
|
|
||||||
|
|
||||||
local line, lerr = client:receive("*l")
|
|
||||||
if not line then return nil end
|
|
||||||
|
|
||||||
local method, raw_path = line:match("^(%S+)%s+(%S+)%s+HTTP/")
|
|
||||||
if not method then return nil end
|
|
||||||
|
|
||||||
-- Headers
|
|
||||||
local headers = {}
|
|
||||||
while true do
|
|
||||||
local hline, herr = client:receive("*l")
|
|
||||||
if not hline or hline == "" then break end
|
|
||||||
local name, value = hline:match("^([^:]+):%s*(.*)$")
|
|
||||||
if name then
|
|
||||||
-- normalise to lowercase, strip trailing whitespace
|
|
||||||
headers[name:lower():gsub("%s+$", "")] = value:gsub("%s+$", "")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Body
|
|
||||||
local body = ""
|
|
||||||
local clen = tonumber(headers["content-length"])
|
|
||||||
if clen and clen > 0 then
|
|
||||||
body = client:receive(math.min(clen, 4 * 1024 * 1024)) or "" -- 4 MiB cap
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Split path from query string
|
|
||||||
local path, qs = raw_path:match("^([^?]*)%??(.*)")
|
|
||||||
|
|
||||||
-- Parse query parameters
|
|
||||||
local params = {}
|
|
||||||
for k, v in (qs or ""):gmatch("([^&=]+)=([^&]*)") do
|
|
||||||
params[url_decode(k)] = url_decode(v)
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
method = method:upper(),
|
|
||||||
path = path,
|
|
||||||
raw_path = raw_path,
|
|
||||||
headers = headers,
|
|
||||||
body = body,
|
|
||||||
params = params,
|
|
||||||
path_params = {},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
local CORS_HEADERS = {
|
|
||||||
["Access-Control-Allow-Origin"] = "*",
|
|
||||||
["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS",
|
|
||||||
["Access-Control-Allow-Headers"] = "Authorization, Content-Type",
|
|
||||||
["Access-Control-Max-Age"] = "86400",
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Write an HTTP response to the socket.
|
|
||||||
local function write_response(client, status, body, content_type, extra_headers)
|
|
||||||
body = body or ""
|
|
||||||
content_type = content_type or "application/json; charset=utf-8"
|
|
||||||
local phrase = STATUS_TEXT[status] or "Unknown"
|
|
||||||
|
|
||||||
local lines = {
|
|
||||||
string.format("HTTP/1.1 %d %s\r\n", status, phrase),
|
|
||||||
"Server: darktable-api/1.0\r\n",
|
|
||||||
"Content-Type: " .. content_type .. "\r\n",
|
|
||||||
"Content-Length: " .. #body .. "\r\n",
|
|
||||||
"Connection: close\r\n",
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v in pairs(CORS_HEADERS) do
|
|
||||||
lines[#lines+1] = k .. ": " .. v .. "\r\n"
|
|
||||||
end
|
|
||||||
if extra_headers then
|
|
||||||
for k, v in pairs(extra_headers) do
|
|
||||||
lines[#lines+1] = k .. ": " .. v .. "\r\n"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
lines[#lines+1] = "\r\n"
|
|
||||||
lines[#lines+1] = body
|
|
||||||
|
|
||||||
client:send(table.concat(lines))
|
|
||||||
end
|
|
||||||
|
|
||||||
local json = require "json"
|
|
||||||
|
|
||||||
local function handle_client(client)
|
|
||||||
local req = read_request(client)
|
|
||||||
if not req then
|
|
||||||
write_response(client, 400, '{"error":"bad_request"}')
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- CORS preflight
|
|
||||||
if req.method == "OPTIONS" then
|
|
||||||
write_response(client, 204, "")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- API / OAuth routes take priority over static files.
|
|
||||||
local handler, path_params = router.match(req.method, req.path)
|
|
||||||
if handler then
|
|
||||||
req.path_params = path_params or {}
|
|
||||||
local status, body, ct, extra = handler(req)
|
|
||||||
write_response(client, status, body, ct, extra)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Static file serving for the PWA (GET/HEAD only).
|
|
||||||
if req.method == "GET" or req.method == "HEAD" then
|
|
||||||
local data, ct = read_static(req.path)
|
|
||||||
|
|
||||||
if not data then
|
|
||||||
-- SPA fallback: any unknown path gets index.html so client-side routing works.
|
|
||||||
data, ct = read_static("/index.html")
|
|
||||||
end
|
|
||||||
|
|
||||||
if data then
|
|
||||||
local extra = { ["Cache-Control"] = cache_header(req.path) }
|
|
||||||
write_response(client, 200, req.method == "HEAD" and "" or data, ct, extra)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Main server loop ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
local port = tonumber(arg and arg[1]) or config.port
|
|
||||||
local host = (arg and arg[2]) or config.host
|
|
||||||
|
|
||||||
local server, serr = socket.bind(host, port)
|
|
||||||
if not server then
|
|
||||||
io.stderr:write("darktable-api: cannot bind " .. host .. ":" .. port .. ": " .. tostring(serr) .. "\n")
|
|
||||||
os.exit(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
server:settimeout(0) -- non-blocking accept
|
|
||||||
|
|
||||||
io.write(string.format(
|
|
||||||
"darktable-api: listening on http://%s:%d\n" ..
|
|
||||||
" library : %s\n" ..
|
|
||||||
" cache : %s\n" ..
|
|
||||||
" web app : http://%s:%d/\n",
|
|
||||||
host, port, config.library_db, config.cache_dir, host, port))
|
|
||||||
io.flush()
|
|
||||||
|
|
||||||
while true do
|
|
||||||
local client, cerr = server:accept()
|
|
||||||
if client then
|
|
||||||
local ok, result = pcall(handle_client, client)
|
|
||||||
if not ok then
|
|
||||||
pcall(write_response, client, 500,
|
|
||||||
json.encode({ error = "internal_server_error", detail = tostring(result) }))
|
|
||||||
io.stderr:write("darktable-api: error: " .. tostring(result) .. "\n")
|
|
||||||
end
|
|
||||||
pcall(function() client:close() end)
|
|
||||||
else
|
|
||||||
-- No connection yet — yield CPU briefly.
|
|
||||||
socket.select(nil, nil, 0.05)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# start-with-api.sh — Launch the darktable REST API server and darktable together.
|
|
||||||
#
|
|
||||||
# What it does:
|
|
||||||
# 1. Optionally configures darktable's luarc to load the bridge plugin.
|
|
||||||
# 2. Starts the standalone LuaJIT REST API server in the background.
|
|
||||||
# 3. Starts darktable in the foreground.
|
|
||||||
# 4. Stops the API server when darktable exits.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./start-with-api.sh [darktable-options...]
|
|
||||||
#
|
|
||||||
# Environment variables (all optional):
|
|
||||||
# DT_CLIENT_ID OAuth client ID (default: darktable-client)
|
|
||||||
# DT_CLIENT_SECRET OAuth client secret (default: changeme)
|
|
||||||
# DT_JWT_SECRET JWT signing secret (default: change-this-secret-in-production)
|
|
||||||
# DT_API_HOST Server bind address (default: 127.0.0.1)
|
|
||||||
# DT_API_PORT Server port (default: 7645)
|
|
||||||
# DT_LIBRARY darktable library DB (default: ~/.config/darktable/library.db)
|
|
||||||
# DT_REFRESH_QUEUE IPC queue file (default: /tmp/dt-api-refresh.queue)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
: "${DT_CLIENT_ID:=darktable-client}"
|
|
||||||
: "${DT_CLIENT_SECRET:=changeme}"
|
|
||||||
: "${DT_JWT_SECRET:=change-this-secret-in-production}"
|
|
||||||
: "${DT_API_HOST:=127.0.0.1}"
|
|
||||||
: "${DT_API_PORT:=7645}"
|
|
||||||
: "${DT_REFRESH_QUEUE:=/tmp/dt-api-refresh.queue}"
|
|
||||||
|
|
||||||
export DT_CLIENT_ID DT_CLIENT_SECRET DT_JWT_SECRET
|
|
||||||
export DT_API_HOST DT_API_PORT DT_REFRESH_QUEUE
|
|
||||||
|
|
||||||
# ── 1. Ensure the bridge plugin is in darktable's luarc ───────────────────────
|
|
||||||
|
|
||||||
LUARC="$HOME/.config/darktable/luarc"
|
|
||||||
BRIDGE_REQUIRE='require "contrib/darktable-api/darktable-api-bridge"'
|
|
||||||
|
|
||||||
if ! grep -qF "$BRIDGE_REQUIRE" "$LUARC" 2>/dev/null; then
|
|
||||||
echo "[start-with-api] Adding bridge plugin to $LUARC"
|
|
||||||
echo "$BRIDGE_REQUIRE" >> "$LUARC"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Install the bridge plugin into darktable's lua directory ───────────────
|
|
||||||
# darktable searches $HOME/.config/darktable/lua/ for scripts.
|
|
||||||
# We copy (not symlink) the bridge plugin file there so darktable can
|
|
||||||
# require "contrib/darktable-api/darktable-api-bridge" without any extra path config.
|
|
||||||
|
|
||||||
DT_LUA_DIR="$HOME/.config/darktable/lua"
|
|
||||||
BRIDGE_DEST="$DT_LUA_DIR/contrib/darktable-api"
|
|
||||||
BRIDGE_SRC="$SCRIPT_DIR/darktable-api-bridge.lua"
|
|
||||||
|
|
||||||
mkdir -p "$BRIDGE_DEST"
|
|
||||||
if ! cmp -s "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua" 2>/dev/null; then
|
|
||||||
cp "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua"
|
|
||||||
echo "[start-with-api] Installed bridge plugin → $BRIDGE_DEST/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. Clean up any stale queue file ─────────────────────────────────────────
|
|
||||||
|
|
||||||
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
|
|
||||||
|
|
||||||
# ── 4. Start the LuaJIT REST API server ───────────────────────────────────────
|
|
||||||
|
|
||||||
API_LOG="${TMPDIR:-/tmp}/darktable-api.log"
|
|
||||||
echo "[start-with-api] Starting API server on http://${DT_API_HOST}:${DT_API_PORT}"
|
|
||||||
echo "[start-with-api] Log: $API_LOG"
|
|
||||||
|
|
||||||
luajit "$SCRIPT_DIR/server.lua" "$DT_API_PORT" "$DT_API_HOST" >"$API_LOG" 2>&1 &
|
|
||||||
API_PID=$!
|
|
||||||
|
|
||||||
# Give the server a moment to bind and confirm it's up.
|
|
||||||
sleep 0.5
|
|
||||||
if ! kill -0 "$API_PID" 2>/dev/null; then
|
|
||||||
echo "[start-with-api] ERROR: API server failed to start. Check $API_LOG" >&2
|
|
||||||
cat "$API_LOG" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! curl -sf "http://${DT_API_HOST}:${DT_API_PORT}/health" >/dev/null 2>&1; then
|
|
||||||
echo "[start-with-api] WARNING: health check failed; server may still be starting"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[start-with-api] API server running (PID $API_PID)"
|
|
||||||
echo ""
|
|
||||||
echo " Get a token:"
|
|
||||||
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/oauth/token \\"
|
|
||||||
echo " -d 'grant_type=client_credentials&client_id=${DT_CLIENT_ID}&client_secret=${DT_CLIENT_SECRET}'"
|
|
||||||
echo ""
|
|
||||||
echo " Trigger a cache refresh (replace TOKEN and IMAGE_ID):"
|
|
||||||
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/api/v1/images/IMAGE_ID/refresh \\"
|
|
||||||
echo " -H 'Authorization: Bearer TOKEN'"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── 5. Start darktable ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo "[start-with-api] Starting darktable..."
|
|
||||||
darktable "$@" || true
|
|
||||||
|
|
||||||
# ── 6. Stop the API server on exit ───────────────────────────────────────────
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[start-with-api] darktable exited; stopping API server (PID $API_PID)"
|
|
||||||
kill "$API_PID" 2>/dev/null || true
|
|
||||||
wait "$API_PID" 2>/dev/null || true
|
|
||||||
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
|
|
||||||
echo "[start-with-api] done"
|
|
||||||
4
contrib/darktable-api/web/.gitignore
vendored
4
contrib/darktable-api/web/.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
.vite/
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
||||||
<meta name="theme-color" content="#0d0d0d" />
|
|
||||||
<meta name="description" content="darktable REST API web interface" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="darktable" />
|
|
||||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
|
||||||
<title>darktable Gallery</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
6058
contrib/darktable-api/web/package-lock.json
generated
6058
contrib/darktable-api/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "darktable-api-pwa",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"vite": "^5.4.11",
|
|
||||||
"vite-plugin-pwa": "^0.21.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<!-- Dark background -->
|
|
||||||
<rect width="512" height="512" rx="80" fill="#0d0d0d"/>
|
|
||||||
<!-- Aperture blades in orange accent -->
|
|
||||||
<!-- Center circle -->
|
|
||||||
<circle cx="256" cy="256" r="60" fill="none" stroke="#f4a22d" stroke-width="8"/>
|
|
||||||
<!-- Aperture blade 1 -->
|
|
||||||
<path d="M256 120 L296 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 2 -->
|
|
||||||
<path d="M370 181 L316 240 L296 196 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 3 -->
|
|
||||||
<path d="M392 296 L316 240 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 4 -->
|
|
||||||
<path d="M336 392 L256 316 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 5 -->
|
|
||||||
<path d="M176 392 L196 316 L256 316 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 6 -->
|
|
||||||
<path d="M120 296 L196 316 L176 240 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 7 -->
|
|
||||||
<path d="M120 181 L176 240 L196 196 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Aperture blade 8 -->
|
|
||||||
<path d="M216 120 L216 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
|
|
||||||
<!-- Outer ring -->
|
|
||||||
<circle cx="256" cy="256" r="180" fill="none" stroke="#f4a22d" stroke-width="12" opacity="0.6"/>
|
|
||||||
<!-- Inner highlight -->
|
|
||||||
<circle cx="256" cy="256" r="40" fill="#f4a22d" opacity="0.3"/>
|
|
||||||
<circle cx="256" cy="256" r="20" fill="#f4a22d"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,69 +0,0 @@
|
|||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useReducer,
|
|
||||||
} from 'react';
|
|
||||||
import { ToastProvider } from './components/Toast.jsx';
|
|
||||||
import { getSettings } from './api.js';
|
|
||||||
import Settings from './screens/Settings.jsx';
|
|
||||||
import Gallery from './screens/Gallery.jsx';
|
|
||||||
|
|
||||||
// ── App Context ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const AppContext = createContext(null);
|
|
||||||
|
|
||||||
export function useApp() {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (!ctx) throw new Error('useApp must be used within AppContext');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
screen: 'gallery', // 'settings' | 'gallery'
|
|
||||||
configured: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reducer(state, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'INIT':
|
|
||||||
return { ...state, configured: action.configured, screen: action.configured ? 'gallery' : 'settings' };
|
|
||||||
case 'GO_SETTINGS':
|
|
||||||
return { ...state, screen: 'settings' };
|
|
||||||
case 'GO_GALLERY':
|
|
||||||
return { ...state, screen: 'gallery', configured: true };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App Root ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const settings = getSettings();
|
|
||||||
dispatch({ type: 'INIT', configured: !!settings });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const goSettings = useCallback(() => dispatch({ type: 'GO_SETTINGS' }), []);
|
|
||||||
const goGallery = useCallback(() => dispatch({ type: 'GO_GALLERY' }), []);
|
|
||||||
|
|
||||||
const contextValue = { goSettings, goGallery, configured: state.configured };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContext.Provider value={contextValue}>
|
|
||||||
<ToastProvider>
|
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{state.screen === 'settings' ? (
|
|
||||||
<Settings />
|
|
||||||
) : (
|
|
||||||
<Gallery />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ToastProvider>
|
|
||||||
</AppContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
// Darktable REST API client
|
|
||||||
// Handles OAuth2 client_credentials, token caching, all endpoints.
|
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
SETTINGS: 'dt_settings',
|
|
||||||
TOKEN: 'dt_token',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function getSettings() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
|
||||||
return raw ? JSON.parse(raw) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSettings(settings) {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSettings() {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.SETTINGS);
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Token management ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getCachedToken() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.TOKEN);
|
|
||||||
if (!raw) return null;
|
|
||||||
const token = JSON.parse(raw);
|
|
||||||
// Treat as expired 30 seconds early to avoid race conditions
|
|
||||||
if (Date.now() >= token.expiresAt - 30_000) return null;
|
|
||||||
return token.accessToken;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheToken(accessToken, expiresIn) {
|
|
||||||
const token = {
|
|
||||||
accessToken,
|
|
||||||
expiresAt: Date.now() + expiresIn * 1000,
|
|
||||||
};
|
|
||||||
localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokenFetchPromise = null;
|
|
||||||
|
|
||||||
async function fetchToken(settings) {
|
|
||||||
const url = `${settings.baseUrl}/oauth/token`;
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
grant_type: 'client_credentials',
|
|
||||||
client_id: settings.clientId,
|
|
||||||
client_secret: settings.clientSecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new ApiError(res.status, `Token request failed: ${text || res.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const expiresIn = data.expires_in ?? 3600;
|
|
||||||
cacheToken(data.access_token, expiresIn);
|
|
||||||
return data.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getToken() {
|
|
||||||
const settings = getSettings();
|
|
||||||
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
|
|
||||||
|
|
||||||
const cached = getCachedToken();
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
// Deduplicate concurrent token fetches
|
|
||||||
if (!tokenFetchPromise) {
|
|
||||||
tokenFetchPromise = fetchToken(settings).finally(() => {
|
|
||||||
tokenFetchPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return tokenFetchPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Error class ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(status, message) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
this.name = 'ApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Core fetch helper ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function apiFetch(path, options = {}) {
|
|
||||||
const settings = getSettings();
|
|
||||||
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
|
|
||||||
|
|
||||||
const token = await getToken();
|
|
||||||
const url = `${settings.baseUrl}${path}`;
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...options.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch(url, { ...options, headers });
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
let message;
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
message = json.error || json.message || text;
|
|
||||||
} catch {
|
|
||||||
message = text || res.statusText;
|
|
||||||
}
|
|
||||||
throw new ApiError(res.status, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiJson(path, options = {}) {
|
|
||||||
const res = await apiFetch(path, {
|
|
||||||
...options,
|
|
||||||
headers: { Accept: 'application/json', ...options.headers },
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Blob URL cache ────────────────────────────────────────────────────────────
|
|
||||||
// Keeps a reference-counted map of blob URLs so we don't recreate them for the
|
|
||||||
// same image+size combination within a session.
|
|
||||||
|
|
||||||
const blobCache = new Map(); // key → { url, refs }
|
|
||||||
|
|
||||||
export function releaseBlobUrl(key) {
|
|
||||||
const entry = blobCache.get(key);
|
|
||||||
if (!entry) return;
|
|
||||||
entry.refs -= 1;
|
|
||||||
if (entry.refs <= 0) {
|
|
||||||
URL.revokeObjectURL(entry.url);
|
|
||||||
blobCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPreviewBlob(id, size = 'thumb') {
|
|
||||||
const key = `preview:${id}:${size}`;
|
|
||||||
const existing = blobCache.get(key);
|
|
||||||
if (existing) {
|
|
||||||
existing.refs += 1;
|
|
||||||
return { url: existing.url, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use raw fetch so we can inspect the status before assuming it's an image.
|
|
||||||
const settings = getSettings();
|
|
||||||
if (!settings) throw new ApiError(0, 'Not configured.');
|
|
||||||
const token = await getToken();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${settings.baseUrl}/api/v1/images/${id}/preview?size=${size}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
);
|
|
||||||
|
|
||||||
// 202 means the server has enqueued thumbnail generation; client should retry.
|
|
||||||
if (res.status === 202) {
|
|
||||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '4', 10);
|
|
||||||
return { generating: true, retryAfter };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new ApiError(res.status, text || res.statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
blobCache.set(key, { url, refs: 1 });
|
|
||||||
return { url, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFullBlob(id) {
|
|
||||||
const key = `full:${id}`;
|
|
||||||
const existing = blobCache.get(key);
|
|
||||||
if (existing) {
|
|
||||||
existing.refs += 1;
|
|
||||||
return { url: existing.url, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await apiFetch(`/api/v1/images/${id}/full`);
|
|
||||||
// May return 202 with Retry-After; caller handles polling
|
|
||||||
if (res.status === 202) {
|
|
||||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10);
|
|
||||||
return { pending: true, retryAfter };
|
|
||||||
}
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
blobCache.set(key, { url, refs: 1 });
|
|
||||||
return { url, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Images ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function listImages(params = {}) {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
const allowed = [
|
|
||||||
'limit', 'offset', 'sort_by', 'sort_dir',
|
|
||||||
'rating_min', 'rating_max', 'film_id',
|
|
||||||
'from', 'to', 'search', 'tag',
|
|
||||||
];
|
|
||||||
for (const key of allowed) {
|
|
||||||
if (params[key] !== undefined && params[key] !== '' && params[key] !== null) {
|
|
||||||
qs.set(key, String(params[key]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const path = `/api/v1/images${qs.toString() ? '?' + qs.toString() : ''}`;
|
|
||||||
return apiJson(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImage(id) {
|
|
||||||
return apiJson(`/api/v1/images/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshImage(id) {
|
|
||||||
return apiJson(`/api/v1/images/${id}/refresh`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportImage(id, { signal } = {}) {
|
|
||||||
// 1. Enqueue the export (POST returns 202 immediately).
|
|
||||||
await apiFetch(`/api/v1/images/${id}/export`, { method: 'POST', signal });
|
|
||||||
|
|
||||||
// 2. Poll GET /full until the JPEG is ready (up to 120 s).
|
|
||||||
const settings = getSettings();
|
|
||||||
const token = await getToken();
|
|
||||||
const deadline = Date.now() + 120_000;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
|
|
||||||
|
|
||||||
const res = await fetch(`${settings.baseUrl}/api/v1/images/${id}/full`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) return res.blob();
|
|
||||||
if (res.status !== 202) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new ApiError(res.status, text || res.statusText);
|
|
||||||
}
|
|
||||||
// 202 → still generating, loop again
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ApiError(504, 'Export timed out after 120 s');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modules ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function listModules(imageId) {
|
|
||||||
return apiJson(`/api/v1/images/${imageId}/modules`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getModule(imageId, op) {
|
|
||||||
return apiJson(`/api/v1/images/${imageId}/modules/${op}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch a module and optionally request a preview render.
|
|
||||||
* Returns { preview: blobUrl } when previewSize is set and the server responds
|
|
||||||
* with image/jpeg, or { data: json } for plain JSON responses.
|
|
||||||
*
|
|
||||||
* The PATCH with preview can take up to 30 seconds.
|
|
||||||
*/
|
|
||||||
export async function patchModule(imageId, op, body, previewSize = null) {
|
|
||||||
const settings = getSettings();
|
|
||||||
if (!settings) throw new ApiError(0, 'Not configured.');
|
|
||||||
|
|
||||||
const token = await getToken();
|
|
||||||
|
|
||||||
let path = `/api/v1/images/${imageId}/modules/${op}`;
|
|
||||||
if (previewSize) path += `?preview=${previewSize}`;
|
|
||||||
|
|
||||||
const url = `${settings.baseUrl}${path}`;
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json, image/jpeg',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
// 35s timeout implemented via AbortController below
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new ApiError(res.status, text || res.statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = res.headers.get('Content-Type') ?? '';
|
|
||||||
if (contentType.includes('image/jpeg') || contentType.includes('image/')) {
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
return { previewUrl: url };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
return { data };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tags & Films ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function listTags() {
|
|
||||||
return apiJson('/api/v1/tags');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listFilms() {
|
|
||||||
return apiJson('/api/v1/films');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Health ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function checkHealth(baseUrl) {
|
|
||||||
const url = `${baseUrl}/health`;
|
|
||||||
const res = await fetch(url, { method: 'GET' });
|
|
||||||
if (!res.ok) throw new ApiError(res.status, `Health check failed: ${res.statusText}`);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test the full connection: health check + token fetch.
|
|
||||||
* Returns { ok: true } or throws ApiError.
|
|
||||||
*/
|
|
||||||
export async function testConnection(settings) {
|
|
||||||
await checkHealth(settings.baseUrl);
|
|
||||||
await fetchToken(settings);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { listFilms, ApiError } from '../api.js';
|
|
||||||
|
|
||||||
const SORT_OPTIONS = [
|
|
||||||
{ value: 'datetime_taken:desc', label: 'Newest first' },
|
|
||||||
{ value: 'datetime_taken:asc', label: 'Oldest first' },
|
|
||||||
{ value: 'filename:asc', label: 'Filename A→Z' },
|
|
||||||
{ value: 'filename:desc', label: 'Filename Z→A' },
|
|
||||||
{ value: 'rating:desc', label: 'Top rated' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STAR_OPTIONS = [
|
|
||||||
{ value: '', label: 'Any rating' },
|
|
||||||
{ value: '1', label: '★ and up' },
|
|
||||||
{ value: '2', label: '★★ and up' },
|
|
||||||
{ value: '3', label: '★★★ and up' },
|
|
||||||
{ value: '4', label: '★★★★ and up' },
|
|
||||||
{ value: '5', label: '★★★★★ only' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function FilterBar({ filters, onChange }) {
|
|
||||||
const [films, setFilms] = useState([]);
|
|
||||||
const [filmsError, setFilmsError] = useState(null);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const searchRef = useRef(null);
|
|
||||||
const searchTimer = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listFilms()
|
|
||||||
.then(data => setFilms(Array.isArray(data) ? data : (data.items ?? [])))
|
|
||||||
.catch(err => {
|
|
||||||
if (err instanceof ApiError && err.status === 0) return; // not configured yet
|
|
||||||
setFilmsError('Could not load film rolls');
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSearchChange = useCallback((e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
// Debounce search by 400ms
|
|
||||||
clearTimeout(searchTimer.current);
|
|
||||||
searchTimer.current = setTimeout(() => {
|
|
||||||
onChange({ ...filters, search: value, offset: 0 });
|
|
||||||
}, 400);
|
|
||||||
}, [filters, onChange]);
|
|
||||||
|
|
||||||
const handleSortChange = useCallback((e) => {
|
|
||||||
const [sort_by, sort_dir] = e.target.value.split(':');
|
|
||||||
onChange({ ...filters, sort_by, sort_dir, offset: 0 });
|
|
||||||
}, [filters, onChange]);
|
|
||||||
|
|
||||||
const handleRatingChange = useCallback((e) => {
|
|
||||||
onChange({ ...filters, rating_min: e.target.value, offset: 0 });
|
|
||||||
}, [filters, onChange]);
|
|
||||||
|
|
||||||
const handleFilmChange = useCallback((e) => {
|
|
||||||
onChange({ ...filters, film_id: e.target.value, offset: 0 });
|
|
||||||
}, [filters, onChange]);
|
|
||||||
|
|
||||||
const currentSort = `${filters.sort_by ?? 'datetime_taken'}:${filters.sort_dir ?? 'desc'}`;
|
|
||||||
const activeFilterCount = [
|
|
||||||
filters.search,
|
|
||||||
filters.rating_min,
|
|
||||||
filters.film_id,
|
|
||||||
].filter(Boolean).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.root}>
|
|
||||||
{/* Search row */}
|
|
||||||
<div style={styles.searchRow}>
|
|
||||||
<div style={styles.searchWrap}>
|
|
||||||
<span style={styles.searchIcon}>⌕</span>
|
|
||||||
<input
|
|
||||||
ref={searchRef}
|
|
||||||
type="search"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="Search images…"
|
|
||||||
defaultValue={filters.search ?? ''}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
style={{ paddingLeft: 36, height: 40 }}
|
|
||||||
aria-label="Search images"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
padding: '0 12px',
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
gap: 6,
|
|
||||||
color: expanded || activeFilterCount > 0 ? 'var(--color-accent)' : undefined,
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onClick={() => setExpanded(x => !x)}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-label="Toggle filters"
|
|
||||||
>
|
|
||||||
<span>⚙</span>
|
|
||||||
<span>Filters</span>
|
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<span style={styles.badge}>{activeFilterCount}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded filters */}
|
|
||||||
{expanded && (
|
|
||||||
<div style={styles.filterRow}>
|
|
||||||
<div style={{ flex: 1, minWidth: 140 }}>
|
|
||||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
|
||||||
Sort by
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
style={{ height: 38, fontSize: '0.875rem' }}
|
|
||||||
value={currentSort}
|
|
||||||
onChange={handleSortChange}
|
|
||||||
aria-label="Sort order"
|
|
||||||
>
|
|
||||||
{SORT_OPTIONS.map(o => (
|
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 130 }}>
|
|
||||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
|
||||||
Min rating
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
style={{ height: 38, fontSize: '0.875rem' }}
|
|
||||||
value={filters.rating_min ?? ''}
|
|
||||||
onChange={handleRatingChange}
|
|
||||||
aria-label="Minimum star rating"
|
|
||||||
>
|
|
||||||
{STAR_OPTIONS.map(o => (
|
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{films.length > 0 && (
|
|
||||||
<div style={{ flex: 1, minWidth: 150 }}>
|
|
||||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
|
||||||
Film roll
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
style={{ height: 38, fontSize: '0.875rem' }}
|
|
||||||
value={filters.film_id ?? ''}
|
|
||||||
onChange={handleFilmChange}
|
|
||||||
aria-label="Film roll"
|
|
||||||
>
|
|
||||||
<option value="">All rolls</option>
|
|
||||||
{films.map(f => (
|
|
||||||
<option key={f.id} value={f.id}>
|
|
||||||
{f.folder ?? f.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeFilterCount > 0 && (
|
|
||||||
<div style={{ alignSelf: 'flex-end', paddingBottom: 0 }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ height: 38, padding: '0 12px', fontSize: '0.8125rem', color: 'var(--color-danger)' }}
|
|
||||||
onClick={() => onChange({ ...filters, search: '', rating_min: '', film_id: '', offset: 0 })}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
root: {
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
padding: '10px 12px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 10,
|
|
||||||
position: 'sticky',
|
|
||||||
top: 'var(--header-height)',
|
|
||||||
zIndex: 'var(--z-filter)',
|
|
||||||
},
|
|
||||||
searchRow: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
searchWrap: {
|
|
||||||
flex: 1,
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
searchIcon: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 10,
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
fontSize: '1.1rem',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
lineHeight: 1,
|
|
||||||
},
|
|
||||||
filterRow: {
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 10,
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
paddingTop: 4,
|
|
||||||
},
|
|
||||||
badge: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 6,
|
|
||||||
right: 4,
|
|
||||||
minWidth: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: '9999px',
|
|
||||||
background: 'var(--color-accent)',
|
|
||||||
color: '#0d0d0d',
|
|
||||||
fontSize: '0.625rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '0 3px',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { getPreviewBlob, releaseBlobUrl } from '../api.js';
|
|
||||||
|
|
||||||
const COLOR_LABEL_COLORS = {
|
|
||||||
red: '#e05252',
|
|
||||||
yellow: '#f4c842',
|
|
||||||
green: '#52c06e',
|
|
||||||
blue: '#5299e0',
|
|
||||||
purple: '#a052c0',
|
|
||||||
};
|
|
||||||
|
|
||||||
function StarRating({ rating }) {
|
|
||||||
if (!rating || rating < 1) return null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
fontSize: '0.6875rem',
|
|
||||||
lineHeight: 1,
|
|
||||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
|
||||||
}}>
|
|
||||||
{'★'.repeat(Math.min(5, rating))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorDots({ labels }) {
|
|
||||||
if (!labels || labels.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: 3 }}>
|
|
||||||
{labels.map(label => (
|
|
||||||
<span
|
|
||||||
key={label}
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: COLOR_LABEL_COLORS[label] ?? '#aaa',
|
|
||||||
display: 'inline-block',
|
|
||||||
}}
|
|
||||||
title={label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ImageCard({ image, onClick }) {
|
|
||||||
const [blobUrl, setBlobUrl] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const blobKeyRef = useRef(null);
|
|
||||||
const isMounted = useRef(true);
|
|
||||||
const retriesRef = useRef(0);
|
|
||||||
const retryTimerRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMounted.current = true;
|
|
||||||
retriesRef.current = 0;
|
|
||||||
setLoading(true);
|
|
||||||
setError(false);
|
|
||||||
setGenerating(false);
|
|
||||||
|
|
||||||
function loadPreview() {
|
|
||||||
getPreviewBlob(image.id, 'thumb')
|
|
||||||
.then((result) => {
|
|
||||||
if (!isMounted.current) {
|
|
||||||
if (result.key) releaseBlobUrl(result.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.generating) {
|
|
||||||
setLoading(false);
|
|
||||||
setGenerating(true);
|
|
||||||
if (retriesRef.current < 15) {
|
|
||||||
retriesRef.current += 1;
|
|
||||||
retryTimerRef.current = setTimeout(loadPreview, (result.retryAfter ?? 4) * 1000);
|
|
||||||
} else {
|
|
||||||
setGenerating(false);
|
|
||||||
setError(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
|
|
||||||
blobKeyRef.current = result.key;
|
|
||||||
setBlobUrl(result.url);
|
|
||||||
setLoading(false);
|
|
||||||
setGenerating(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (isMounted.current) {
|
|
||||||
setLoading(false);
|
|
||||||
setGenerating(false);
|
|
||||||
setError(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadPreview();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted.current = false;
|
|
||||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
||||||
if (blobKeyRef.current) {
|
|
||||||
releaseBlobUrl(blobKeyRef.current);
|
|
||||||
blobKeyRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [image.id]);
|
|
||||||
|
|
||||||
// Compute aspect ratio for the thumbnail placeholder
|
|
||||||
const aspect = image.width && image.height
|
|
||||||
? Math.min(1.5, Math.max(0.6, image.height / image.width))
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
transition: 'transform var(--transition-fast), box-shadow var(--transition-fast)',
|
|
||||||
width: '100%',
|
|
||||||
padding: 0,
|
|
||||||
WebkitTapHighlightColor: 'transparent',
|
|
||||||
}}
|
|
||||||
aria-label={`Open ${image.filename}`}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{/* Thumbnail container — padding-bottom trick for aspect ratio */}
|
|
||||||
<div style={{
|
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
paddingBottom: `${aspect * 100}%`,
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
}}>
|
|
||||||
{(loading || generating) && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}>
|
|
||||||
<span className="spinner" />
|
|
||||||
{generating && (
|
|
||||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', opacity: 0.7 }}>
|
|
||||||
Generating…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && !loading && !generating && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', inset: 0,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: '1.5rem', opacity: 0.4 }}>⊘</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{blobUrl && !error && (
|
|
||||||
<img
|
|
||||||
src={blobUrl}
|
|
||||||
alt={image.filename}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bottom overlay: stars + color dots */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 4,
|
|
||||||
left: 4,
|
|
||||||
right: 4,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 4,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}>
|
|
||||||
<StarRating rating={image.rating} />
|
|
||||||
<ColorDots labels={image.color_labels} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RAW badge */}
|
|
||||||
{image.is_raw && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
background: 'rgba(0,0,0,0.65)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
fontSize: '0.625rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
padding: '1px 5px',
|
|
||||||
borderRadius: 3,
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
}}>
|
|
||||||
RAW
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Caption */}
|
|
||||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
display: 'block',
|
|
||||||
}}>
|
|
||||||
{image.filename}
|
|
||||||
</span>
|
|
||||||
{image.datetime_taken && (
|
|
||||||
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
|
||||||
{formatDate(image.datetime_taken)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dt) {
|
|
||||||
if (!dt) return '';
|
|
||||||
try {
|
|
||||||
return new Date(dt).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return dt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { listModules, ApiError } from '../api.js';
|
|
||||||
import ModulePanel from './ModulePanel.jsx';
|
|
||||||
|
|
||||||
export default function ModuleList({ imageId, onPreviewUpdate }) {
|
|
||||||
const [modules, setModules] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!imageId) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setModules(null);
|
|
||||||
|
|
||||||
listModules(imageId)
|
|
||||||
.then(data => {
|
|
||||||
// API may return an array or { items: [] }
|
|
||||||
const list = Array.isArray(data) ? data : (data.items ?? data.modules ?? []);
|
|
||||||
setModules(list);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
const msg = err instanceof ApiError ? err.message : String(err);
|
|
||||||
setError(msg);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [imageId]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32 }}>
|
|
||||||
<span className="spinner spinner-lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="error-banner" style={{ margin: '0 0 8px' }}>
|
|
||||||
<span>⚠</span>
|
|
||||||
<span>Modules: {error}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!modules || modules.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="empty-state" style={{ padding: '24px' }}>
|
|
||||||
<span className="empty-state-icon">◻</span>
|
|
||||||
<p className="text-sm text-muted">No development modules found</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<h3 style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
|
||||||
Development modules ({modules.length})
|
|
||||||
</h3>
|
|
||||||
{modules.map((mod, idx) => (
|
|
||||||
<ModulePanel
|
|
||||||
key={`${mod.operation}-${mod.history_num ?? idx}`}
|
|
||||||
imageId={imageId}
|
|
||||||
module={mod}
|
|
||||||
onPreviewUpdate={onPreviewUpdate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
|
||||||
import { patchModule, ApiError } from '../api.js';
|
|
||||||
import { useToast } from './Toast.jsx';
|
|
||||||
|
|
||||||
// ── Known param schemas ───────────────────────────────────────────────────────
|
|
||||||
// Provides type and range hints for well-known module parameters.
|
|
||||||
// Fallback: treat everything as a generic number / string.
|
|
||||||
|
|
||||||
const MODULE_PARAMS = {
|
|
||||||
exposure: {
|
|
||||||
exposure: { type: 'float', min: -18, max: 18, step: 0.01, label: 'Exposure (EV)' },
|
|
||||||
black: { type: 'float', min: -1, max: 1, step: 0.001, label: 'Black point' },
|
|
||||||
mode: { type: 'int', label: 'Mode' },
|
|
||||||
},
|
|
||||||
temperature: {
|
|
||||||
red: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Red' },
|
|
||||||
green: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green' },
|
|
||||||
blue: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Blue' },
|
|
||||||
g2: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green2' },
|
|
||||||
},
|
|
||||||
filmicrgb: {
|
|
||||||
grey_point_source: { type: 'float', min: 0.1, max: 36, step: 0.01, label: 'Grey point src (%)' },
|
|
||||||
black_point_source: { type: 'float', min: -16, max: -0.1, step: 0.01, label: 'Black point src (EV)' },
|
|
||||||
white_point_source: { type: 'float', min: 0.1, max: 16, step: 0.01, label: 'White point src (EV)' },
|
|
||||||
contrast: { type: 'float', min: 0, max: 5, step: 0.01, label: 'Contrast' },
|
|
||||||
saturation: { type: 'float', min: 0, max: 4, step: 0.01, label: 'Saturation' },
|
|
||||||
balance: { type: 'float', min: -50, max: 50, step: 0.1, label: 'Shadows↔Highlights' },
|
|
||||||
output_power: { type: 'float', min: 0, max: 10, step: 0.01, label: 'Output power' },
|
|
||||||
latitude: { type: 'float', min: 0.01, max: 99, step: 0.1, label: 'Latitude (%)' },
|
|
||||||
noise_level: { type: 'float', min: 0, max: 0.5, step: 0.001, label: 'Noise level' },
|
|
||||||
},
|
|
||||||
highlights: {
|
|
||||||
mode: { type: 'int', label: 'Mode' },
|
|
||||||
strength: { type: 'float', min: 0, max: 1, step: 0.01, label: 'Strength' },
|
|
||||||
clip: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Clip' },
|
|
||||||
noise_level: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Noise level' },
|
|
||||||
iterations: { type: 'int', label: 'Iterations' },
|
|
||||||
},
|
|
||||||
channelmixerrgb: {
|
|
||||||
temperature: { type: 'float', min: 1667, max: 25000, step: 10, label: 'Temperature (K)' },
|
|
||||||
illuminant: { type: 'int', label: 'Illuminant' },
|
|
||||||
adaptation: { type: 'int', label: 'Adaptation' },
|
|
||||||
},
|
|
||||||
flip: {
|
|
||||||
orientation: { type: 'int', label: 'Orientation' },
|
|
||||||
},
|
|
||||||
gamma: {
|
|
||||||
gamma: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Gamma' },
|
|
||||||
linear: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Linear' },
|
|
||||||
},
|
|
||||||
rawprepare: {
|
|
||||||
left: { type: 'int', label: 'Left crop (px)' },
|
|
||||||
top: { type: 'int', label: 'Top crop (px)' },
|
|
||||||
right: { type: 'int', label: 'Right crop (px)' },
|
|
||||||
bottom: { type: 'int', label: 'Bottom crop (px)' },
|
|
||||||
raw_white_point: { type: 'int', label: 'Raw white point' },
|
|
||||||
},
|
|
||||||
demosaic: {
|
|
||||||
demosaicing_method: { type: 'int', label: 'Method' },
|
|
||||||
green_eq: { type: 'int', label: 'Green equalization' },
|
|
||||||
median_thrs: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Median threshold' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function inferType(key, value, opName) {
|
|
||||||
const schema = MODULE_PARAMS[opName]?.[key];
|
|
||||||
if (schema) return schema;
|
|
||||||
|
|
||||||
const numVal = Number(value);
|
|
||||||
if (typeof value === 'boolean') return { type: 'bool' };
|
|
||||||
if (!isNaN(numVal)) {
|
|
||||||
const isFloat = String(value).includes('.') || (Number.isFinite(numVal) && !Number.isInteger(numVal));
|
|
||||||
return isFloat
|
|
||||||
? { type: 'float', min: -100, max: 100, step: 0.01, label: key }
|
|
||||||
: { type: 'int', label: key };
|
|
||||||
}
|
|
||||||
return { type: 'string', label: key };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ParamField ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ParamField({ paramKey, value, schema, onChange }) {
|
|
||||||
const label = schema?.label ?? paramKey;
|
|
||||||
|
|
||||||
if (schema?.type === 'bool' || typeof value === 'boolean') {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, minHeight: 36 }}>
|
|
||||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>{label}</span>
|
|
||||||
<label className="toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!value}
|
|
||||||
onChange={e => onChange(paramKey, e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schema?.type === 'float' && schema.min !== undefined && schema.max !== undefined) {
|
|
||||||
const numVal = Number(value);
|
|
||||||
const displayVal = Number.isNaN(numVal) ? 0 : numVal;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>{label}</span>
|
|
||||||
<span style={{ fontSize: '0.8125rem', fontFamily: 'var(--font-mono)', color: 'var(--color-text)' }}>
|
|
||||||
{displayVal.toFixed(schema.step < 0.01 ? 4 : schema.step < 0.1 ? 3 : 2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="param-slider"
|
|
||||||
min={schema.min}
|
|
||||||
max={schema.max}
|
|
||||||
step={schema.step ?? 0.01}
|
|
||||||
value={displayVal}
|
|
||||||
onChange={e => onChange(paramKey, parseFloat(e.target.value))}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.min}</span>
|
|
||||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.max}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// int / string — text input
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 36 }}>
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', flex: 1 }}>{label}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
style={{ width: 90, height: 32, padding: '0 8px', fontSize: '0.8125rem' }}
|
|
||||||
value={value ?? ''}
|
|
||||||
step={schema?.type === 'float' ? (schema.step ?? 0.01) : 1}
|
|
||||||
onChange={e => {
|
|
||||||
const raw = e.target.value;
|
|
||||||
const num = Number(raw);
|
|
||||||
onChange(paramKey, Number.isNaN(num) ? raw : num);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ModulePanel ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function paramsReducer(state, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'RESET':
|
|
||||||
return { ...action.params };
|
|
||||||
case 'SET':
|
|
||||||
return { ...state, [action.key]: action.value };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModulePanel({ imageId, module: mod, onPreviewUpdate }) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [enabled, setEnabled] = useState(mod.enabled ?? true);
|
|
||||||
const [params, dispatchParams] = useReducer(paramsReducer, mod.params ?? {});
|
|
||||||
const [dirty, setDirty] = useState(false);
|
|
||||||
const [applying, setApplying] = useState(false);
|
|
||||||
const [renderState, setRenderState] = useState(null); // null | 'rendering' | 'done' | 'error'
|
|
||||||
const [previewUrl, setPreviewUrl] = useState(null);
|
|
||||||
const previewUrlRef = useRef(null);
|
|
||||||
|
|
||||||
// Sync when module prop changes (e.g., after gallery refresh)
|
|
||||||
useEffect(() => {
|
|
||||||
setEnabled(mod.enabled ?? true);
|
|
||||||
dispatchParams({ type: 'RESET', params: mod.params ?? {} });
|
|
||||||
setDirty(false);
|
|
||||||
}, [mod]);
|
|
||||||
|
|
||||||
// Cleanup preview blob URL on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function handleParamChange(key, value) {
|
|
||||||
dispatchParams({ type: 'SET', key, value });
|
|
||||||
setDirty(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEnabledChange(newEnabled) {
|
|
||||||
setEnabled(newEnabled);
|
|
||||||
setDirty(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleApply() {
|
|
||||||
setApplying(true);
|
|
||||||
setRenderState('rendering');
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
enabled,
|
|
||||||
params,
|
|
||||||
};
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 35_000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await patchModule(imageId, mod.operation, body, 'medium');
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (result.previewUrl) {
|
|
||||||
// Revoke previous preview blob
|
|
||||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
|
||||||
previewUrlRef.current = result.previewUrl;
|
|
||||||
setPreviewUrl(result.previewUrl);
|
|
||||||
onPreviewUpdate?.(result.previewUrl);
|
|
||||||
setRenderState('done');
|
|
||||||
toast(`${mod.operation}: applied`, 'success');
|
|
||||||
} else {
|
|
||||||
setRenderState('done');
|
|
||||||
toast(`${mod.operation}: applied (no preview)`, 'success');
|
|
||||||
}
|
|
||||||
setDirty(false);
|
|
||||||
} catch (err) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
setRenderState('error');
|
|
||||||
toast(`${mod.operation}: render timed out`, 'error');
|
|
||||||
} else {
|
|
||||||
const msg = err instanceof ApiError ? err.message : String(err);
|
|
||||||
setRenderState('error');
|
|
||||||
toast(`${mod.operation}: ${msg}`, 'error');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setApplying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paramEntries = Object.entries(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
}}>
|
|
||||||
{/* Header row — always visible */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(o => !o)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
padding: '10px 14px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
minHeight: 44,
|
|
||||||
}}
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
{/* Enabled toggle — stop propagation so clicking it doesn't toggle open */}
|
|
||||||
<span
|
|
||||||
role="none"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEnabledChange(!enabled);
|
|
||||||
}}
|
|
||||||
style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}
|
|
||||||
title={enabled ? 'Disable module' : 'Enable module'}
|
|
||||||
>
|
|
||||||
<label className="toggle" style={{ pointerEvents: 'none' }}>
|
|
||||||
<input type="checkbox" readOnly checked={enabled} tabIndex={-1} />
|
|
||||||
<span className="toggle-track" />
|
|
||||||
</label>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span style={{ flex: 1, fontSize: '0.9rem', fontWeight: 500, color: enabled ? 'var(--color-text)' : 'var(--color-text-muted)' }}>
|
|
||||||
{mod.multi_name ? `${mod.operation} · ${mod.multi_name}` : mod.operation}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{dirty && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.6875rem',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
background: 'var(--color-accent-dim)',
|
|
||||||
padding: '1px 7px',
|
|
||||||
borderRadius: '9999px',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}>
|
|
||||||
modified
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--color-text-faint)',
|
|
||||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
|
||||||
transition: 'transform var(--transition-fast)',
|
|
||||||
display: 'inline-block',
|
|
||||||
}}>
|
|
||||||
▾
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Expanded body */}
|
|
||||||
{open && (
|
|
||||||
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<div style={{ height: 1, background: 'var(--color-border)' }} />
|
|
||||||
|
|
||||||
{/* Module info row */}
|
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
||||||
{mod.history_num !== undefined && (
|
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
|
|
||||||
history #{mod.history_num}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{mod.module_version !== undefined && (
|
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
|
|
||||||
v{mod.module_version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Param fields */}
|
|
||||||
{paramEntries.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{paramEntries.map(([key, value]) => {
|
|
||||||
const schema = inferType(key, value, mod.operation);
|
|
||||||
return (
|
|
||||||
<ParamField
|
|
||||||
key={key}
|
|
||||||
paramKey={key}
|
|
||||||
value={value}
|
|
||||||
schema={schema}
|
|
||||||
onChange={handleParamChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-faint)' }}>
|
|
||||||
No editable parameters
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Apply button + render state */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ width: '100%', height: 40, fontSize: '0.875rem' }}
|
|
||||||
onClick={handleApply}
|
|
||||||
disabled={applying || (!dirty && renderState !== null)}
|
|
||||||
>
|
|
||||||
{applying ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
|
||||||
Rendering…
|
|
||||||
</>
|
|
||||||
) : 'Apply'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{renderState === 'rendering' && (
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center' }}>
|
|
||||||
Waiting for preview render (up to 30 s)…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{renderState === 'error' && (
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-danger)', textAlign: 'center' }}>
|
|
||||||
Render failed or timed out.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inline preview after apply */}
|
|
||||||
{previewUrl && renderState === 'done' && (
|
|
||||||
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt="Module preview"
|
|
||||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
|
||||||
/>
|
|
||||||
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-faint)', textAlign: 'center', padding: '4px 0' }}>
|
|
||||||
Preview after {mod.operation}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
|
|
||||||
|
|
||||||
// ── Context ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ToastContext = createContext(null);
|
|
||||||
|
|
||||||
let nextId = 0;
|
|
||||||
|
|
||||||
function reducer(state, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'ADD':
|
|
||||||
return [...state, action.toast];
|
|
||||||
case 'REMOVE':
|
|
||||||
return state.filter(t => t.id !== action.id);
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastProvider({ children }) {
|
|
||||||
const [toasts, dispatch] = useReducer(reducer, []);
|
|
||||||
const timers = useRef({});
|
|
||||||
|
|
||||||
const dismiss = useCallback((id) => {
|
|
||||||
clearTimeout(timers.current[id]);
|
|
||||||
delete timers.current[id];
|
|
||||||
dispatch({ type: 'REMOVE', id });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toast = useCallback((message, type = 'info', duration = 4000) => {
|
|
||||||
const id = ++nextId;
|
|
||||||
dispatch({ type: 'ADD', toast: { id, message, type } });
|
|
||||||
if (duration > 0) {
|
|
||||||
timers.current[id] = setTimeout(() => dismiss(id), duration);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}, [dismiss]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={{ toast, dismiss }}>
|
|
||||||
{children}
|
|
||||||
<ToastContainer toasts={toasts} dismiss={dismiss} />
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToast() {
|
|
||||||
const ctx = useContext(ToastContext);
|
|
||||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
info: 'ℹ',
|
|
||||||
success: '✓',
|
|
||||||
warning: '⚠',
|
|
||||||
error: '✕',
|
|
||||||
};
|
|
||||||
|
|
||||||
function ToastContainer({ toasts, dismiss }) {
|
|
||||||
if (toasts.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 'max(80px, calc(env(safe-area-inset-bottom, 0px) + 80px))',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
zIndex: 'var(--z-toast)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 'min(calc(100vw - 32px), 400px)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}>
|
|
||||||
{toasts.map(t => (
|
|
||||||
<ToastItem key={t.id} toast={t} dismiss={dismiss} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToastItem({ toast, dismiss }) {
|
|
||||||
const colorMap = {
|
|
||||||
info: 'var(--color-text)',
|
|
||||||
success: 'var(--color-success)',
|
|
||||||
warning: 'var(--color-warning)',
|
|
||||||
error: 'var(--color-danger)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '12px 16px',
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 'var(--radius-lg)',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
animation: 'toastIn 200ms ease',
|
|
||||||
maxWidth: '100%',
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
onClick={() => dismiss(toast.id)}
|
|
||||||
>
|
|
||||||
<span style={{ color: colorMap[toast.type] ?? colorMap.info, fontSize: '1rem', flexShrink: 0, lineHeight: 1.5 }}>
|
|
||||||
{ICONS[toast.type] ?? ICONS.info}
|
|
||||||
</span>
|
|
||||||
<span style={{ flex: 1, color: 'var(--color-text)', lineHeight: 1.5, wordBreak: 'break-word' }}>
|
|
||||||
{toast.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyframe injected once
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes toastIn {
|
|
||||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
/* ── CSS Custom Properties ───────────────────────────────────────────────── */
|
|
||||||
:root {
|
|
||||||
--color-bg: #0d0d0d;
|
|
||||||
--color-surface: #1a1a1a;
|
|
||||||
--color-surface-2: #242424;
|
|
||||||
--color-surface-3: #2e2e2e;
|
|
||||||
--color-border: #333333;
|
|
||||||
--color-accent: #f4a22d;
|
|
||||||
--color-accent-dim: rgba(244, 162, 45, 0.15);
|
|
||||||
--color-accent-hover: #f8b84a;
|
|
||||||
--color-text: #e0e0e0;
|
|
||||||
--color-text-muted: #888888;
|
|
||||||
--color-text-faint: #555555;
|
|
||||||
--color-danger: #e05252;
|
|
||||||
--color-success: #52c06e;
|
|
||||||
--color-warning: #f4a22d;
|
|
||||||
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-xl: 16px;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
|
|
||||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
|
|
||||||
|
|
||||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
||||||
|
|
||||||
--tap-target: 44px;
|
|
||||||
--header-height: 56px;
|
|
||||||
--filter-height: 56px;
|
|
||||||
|
|
||||||
--transition-fast: 150ms ease;
|
|
||||||
--transition-med: 250ms ease;
|
|
||||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
--z-header: 100;
|
|
||||||
--z-filter: 90;
|
|
||||||
--z-backdrop: 200;
|
|
||||||
--z-sheet: 210;
|
|
||||||
--z-toast: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Reset ───────────────────────────────────────────────────────────────── */
|
|
||||||
*, *::before, *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: 100%;
|
|
||||||
font-size: 16px;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
line-height: 1.5;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select, textarea {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--color-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Typography ─────────────────────────────────────────────────────────── */
|
|
||||||
h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
|
|
||||||
h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; }
|
|
||||||
h3 { font-size: 1rem; font-weight: 600; }
|
|
||||||
h4 { font-size: 0.875rem; font-weight: 600; }
|
|
||||||
|
|
||||||
.text-sm { font-size: 0.875rem; }
|
|
||||||
.text-xs { font-size: 0.75rem; }
|
|
||||||
.text-muted { color: var(--color-text-muted); }
|
|
||||||
.text-accent { color: var(--color-accent); }
|
|
||||||
.text-mono { font-family: var(--font-mono); }
|
|
||||||
|
|
||||||
/* ── Utility classes ────────────────────────────────────────────────────── */
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex { display: flex; }
|
|
||||||
.flex-col { flex-direction: column; }
|
|
||||||
.items-center { align-items: center; }
|
|
||||||
.justify-between { justify-content: space-between; }
|
|
||||||
.justify-center { justify-content: center; }
|
|
||||||
.gap-1 { gap: 4px; }
|
|
||||||
.gap-2 { gap: 8px; }
|
|
||||||
.gap-3 { gap: 12px; }
|
|
||||||
.gap-4 { gap: 16px; }
|
|
||||||
.flex-1 { flex: 1; }
|
|
||||||
|
|
||||||
.w-full { width: 100%; }
|
|
||||||
.h-full { height: 100%; }
|
|
||||||
|
|
||||||
.overflow-hidden { overflow: hidden; }
|
|
||||||
.overflow-auto { overflow: auto; }
|
|
||||||
|
|
||||||
/* ── Shared component styles ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-height: var(--tap-target);
|
|
||||||
padding: 0 18px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: #0d0d0d;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--color-surface-3);
|
|
||||||
color: var(--color-text);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-surface-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-surface-2);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: var(--color-danger);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
padding: 0;
|
|
||||||
width: var(--tap-target);
|
|
||||||
height: var(--tap-target);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form controls */
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
height: var(--tap-target);
|
|
||||||
padding: 0 12px;
|
|
||||||
background-color: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--color-accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input::placeholder {
|
|
||||||
color: var(--color-text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select {
|
|
||||||
height: var(--tap-target);
|
|
||||||
padding: 0 32px 0 12px;
|
|
||||||
background-color: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 12px center;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--color-accent-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card */
|
|
||||||
.card {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Spinner */
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid var(--color-border);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-lg {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Divider */
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
background-color: var(--color-border);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tag pill */
|
|
||||||
.tag-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background-color: var(--color-surface-3);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Star display */
|
|
||||||
.stars {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 2px;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 48px 24px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error state */
|
|
||||||
.error-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background-color: rgba(224, 82, 82, 0.12);
|
|
||||||
border: 1px solid rgba(224, 82, 82, 0.3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: #f08080;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle switch */
|
|
||||||
.toggle {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 44px;
|
|
||||||
height: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-track {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-color: var(--color-surface-3);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:checked + .toggle-track {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-track::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:checked + .toggle-track::after {
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slider */
|
|
||||||
.param-slider {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--color-surface-3);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.param-slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent);
|
|
||||||
cursor: pointer;
|
|
||||||
border: 2px solid #0d0d0d;
|
|
||||||
box-shadow: 0 0 0 2px rgba(244, 162, 45, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.param-slider::-moz-range-thumb {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent);
|
|
||||||
cursor: pointer;
|
|
||||||
border: 2px solid #0d0d0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-surface-3);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Safe area insets for notched phones */
|
|
||||||
.safe-bottom {
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import './index.css';
|
|
||||||
import App from './App.jsx';
|
|
||||||
|
|
||||||
const root = createRoot(document.getElementById('root'));
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useReducer,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { listImages, ApiError } from '../api.js';
|
|
||||||
import { useApp } from '../App.jsx';
|
|
||||||
import { useToast } from '../components/Toast.jsx';
|
|
||||||
import FilterBar from '../components/FilterBar.jsx';
|
|
||||||
import ImageCard from '../components/ImageCard.jsx';
|
|
||||||
import ImageDetail from './ImageDetail.jsx';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
// ── State machine for the image list ─────────────────────────────────────────
|
|
||||||
|
|
||||||
const initialListState = {
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
loading: false,
|
|
||||||
loadingMore: false,
|
|
||||||
error: null,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function listReducer(state, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'LOAD_START':
|
|
||||||
return { ...state, loading: true, error: null };
|
|
||||||
case 'LOAD_MORE_START':
|
|
||||||
return { ...state, loadingMore: true, error: null };
|
|
||||||
case 'LOAD_SUCCESS':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
loading: false,
|
|
||||||
loadingMore: false,
|
|
||||||
items: action.replace ? action.items : [...state.items, ...action.items],
|
|
||||||
total: action.total,
|
|
||||||
offset: action.offset + action.items.length,
|
|
||||||
hasMore: action.offset + action.items.length < action.total,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
case 'LOAD_ERROR':
|
|
||||||
return { ...state, loading: false, loadingMore: false, error: action.error };
|
|
||||||
case 'RESET':
|
|
||||||
return { ...initialListState };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultFilters = {
|
|
||||||
sort_by: 'datetime_taken',
|
|
||||||
sort_dir: 'desc',
|
|
||||||
search: '',
|
|
||||||
rating_min: '',
|
|
||||||
film_id: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PTR_THRESHOLD = 70; // px to trigger refresh
|
|
||||||
|
|
||||||
// ── Gallery ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function Gallery() {
|
|
||||||
const { goSettings } = useApp();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [listState, dispatch] = useReducer(listReducer, initialListState);
|
|
||||||
const [filters, setFilters] = useState(defaultFilters);
|
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
|
||||||
|
|
||||||
// Pull-to-refresh state
|
|
||||||
const [ptrY, setPtrY] = useState(0);
|
|
||||||
const [ptrActive, setPtrActive] = useState(false);
|
|
||||||
const touchStartY = useRef(null);
|
|
||||||
const scrollRef = useRef(null);
|
|
||||||
|
|
||||||
// Sentinel element for IntersectionObserver (infinite scroll)
|
|
||||||
const sentinelRef = useRef(null);
|
|
||||||
const observerRef = useRef(null);
|
|
||||||
|
|
||||||
// Current active fetch abort controller
|
|
||||||
const fetchControllerRef = useRef(null);
|
|
||||||
|
|
||||||
// ── Data loading ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const loadPage = useCallback(async (currentOffset, replace) => {
|
|
||||||
// Cancel any previous fetch
|
|
||||||
if (fetchControllerRef.current) fetchControllerRef.current.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
fetchControllerRef.current = controller;
|
|
||||||
|
|
||||||
dispatch({ type: replace ? 'LOAD_START' : 'LOAD_MORE_START' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
offset: currentOffset,
|
|
||||||
sort_by: filters.sort_by,
|
|
||||||
sort_dir: filters.sort_dir,
|
|
||||||
};
|
|
||||||
if (filters.search) params.search = filters.search;
|
|
||||||
if (filters.rating_min) params.rating_min = filters.rating_min;
|
|
||||||
if (filters.film_id) params.film_id = filters.film_id;
|
|
||||||
|
|
||||||
const data = await listImages(params);
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
|
|
||||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
|
||||||
const total = data.total ?? items.length;
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'LOAD_SUCCESS',
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
offset: currentOffset,
|
|
||||||
replace,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const msg = err instanceof ApiError ? err.message : String(err);
|
|
||||||
dispatch({ type: 'LOAD_ERROR', error: msg });
|
|
||||||
toast(msg, 'error');
|
|
||||||
}
|
|
||||||
}, [filters, toast]);
|
|
||||||
|
|
||||||
// Initial load and when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch({ type: 'RESET' });
|
|
||||||
loadPage(0, true);
|
|
||||||
// Scroll to top
|
|
||||||
scrollRef.current?.scrollTo({ top: 0 });
|
|
||||||
}, [filters]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
// Note: we intentionally omit loadPage from deps to avoid double-fires;
|
|
||||||
// filters change triggers reset + load, which is correct.
|
|
||||||
|
|
||||||
// ── IntersectionObserver for infinite scroll ────────────────────────────────
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
observerRef.current?.disconnect();
|
|
||||||
|
|
||||||
observerRef.current = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (
|
|
||||||
entries[0].isIntersecting &&
|
|
||||||
!listState.loading &&
|
|
||||||
!listState.loadingMore &&
|
|
||||||
listState.hasMore
|
|
||||||
) {
|
|
||||||
loadPage(listState.offset, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: '200px' }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sentinelRef.current) {
|
|
||||||
observerRef.current.observe(sentinelRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observerRef.current?.disconnect();
|
|
||||||
}, [listState.loading, listState.loadingMore, listState.hasMore, listState.offset, loadPage]);
|
|
||||||
|
|
||||||
// ── Pull-to-refresh touch handlers ─────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleTouchStart(e) {
|
|
||||||
if (scrollRef.current?.scrollTop === 0) {
|
|
||||||
touchStartY.current = e.touches[0].clientY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchMove(e) {
|
|
||||||
if (touchStartY.current === null) return;
|
|
||||||
const dy = e.touches[0].clientY - touchStartY.current;
|
|
||||||
if (dy > 0) {
|
|
||||||
setPtrY(Math.min(dy * 0.5, PTR_THRESHOLD + 20));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchEnd() {
|
|
||||||
if (ptrY >= PTR_THRESHOLD) {
|
|
||||||
setPtrActive(true);
|
|
||||||
dispatch({ type: 'RESET' });
|
|
||||||
loadPage(0, true).finally(() => {
|
|
||||||
setPtrActive(false);
|
|
||||||
setPtrY(0);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setPtrY(0);
|
|
||||||
}
|
|
||||||
touchStartY.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Filter handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handleFiltersChange = useCallback((newFilters) => {
|
|
||||||
setFilters(newFilters);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const showEmpty = !listState.loading && !listState.error && listState.items.length === 0;
|
|
||||||
const showError = !!listState.error && listState.items.length === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
{/* Fixed header */}
|
|
||||||
<header style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '0 12px',
|
|
||||||
height: 'var(--header-height)',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
flexShrink: 0,
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
zIndex: 'var(--z-header)',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<img src="/icon.svg" alt="" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
|
||||||
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--color-accent)' }}>
|
|
||||||
darktable
|
|
||||||
</span>
|
|
||||||
{listState.total > 0 && (
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
|
||||||
{listState.total.toLocaleString()} images
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-icon"
|
|
||||||
onClick={goSettings}
|
|
||||||
aria-label="Settings"
|
|
||||||
title="Settings"
|
|
||||||
style={{ fontSize: '1.25rem' }}
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Filter bar */}
|
|
||||||
<FilterBar filters={filters} onChange={handleFiltersChange} />
|
|
||||||
|
|
||||||
{/* Scrollable gallery area */}
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain', position: 'relative' }}
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
>
|
|
||||||
{/* Pull-to-refresh indicator */}
|
|
||||||
{ptrY > 10 && (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: Math.min(ptrY, PTR_THRESHOLD + 20),
|
|
||||||
color: ptrY >= PTR_THRESHOLD ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
gap: 8,
|
|
||||||
transition: ptrActive ? 'height var(--transition-med)' : 'none',
|
|
||||||
}}>
|
|
||||||
{ptrActive
|
|
||||||
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Refreshing…</>
|
|
||||||
: (ptrY >= PTR_THRESHOLD ? '↑ Release to refresh' : '↓ Pull to refresh')
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Initial loading */}
|
|
||||||
{listState.loading && listState.items.length === 0 && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 64, gap: 12 }}>
|
|
||||||
<span className="spinner spinner-lg" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error (no items) */}
|
|
||||||
{showError && (
|
|
||||||
<div style={{ padding: '24px 16px' }}>
|
|
||||||
<div className="error-banner">
|
|
||||||
<span>⚠</span>
|
|
||||||
<div>
|
|
||||||
<p>{listState.error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ marginTop: 10, height: 36, fontSize: '0.875rem' }}
|
|
||||||
onClick={() => loadPage(0, true)}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{showEmpty && (
|
|
||||||
<div className="empty-state">
|
|
||||||
<span className="empty-state-icon">◻</span>
|
|
||||||
<p>No images found</p>
|
|
||||||
<p className="text-sm text-muted">Try adjusting your filters</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Image grid */}
|
|
||||||
{listState.items.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
|
||||||
gap: 6,
|
|
||||||
padding: 6,
|
|
||||||
}}>
|
|
||||||
{listState.items.map(img => (
|
|
||||||
<ImageCard
|
|
||||||
key={img.id}
|
|
||||||
image={img}
|
|
||||||
onClick={() => setSelectedImage(img)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Infinite scroll sentinel */}
|
|
||||||
<div
|
|
||||||
ref={sentinelRef}
|
|
||||||
style={{ height: 1 }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Load-more spinner */}
|
|
||||||
{listState.loadingMore && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px 0' }}>
|
|
||||||
<span className="spinner" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* End-of-list indicator */}
|
|
||||||
{!listState.hasMore && listState.items.length > 0 && (
|
|
||||||
<p style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '16px 0',
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
color: 'var(--color-text-faint)',
|
|
||||||
}}>
|
|
||||||
All {listState.total.toLocaleString()} images loaded
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image detail bottom sheet */}
|
|
||||||
{selectedImage && (
|
|
||||||
<ImageDetail
|
|
||||||
image={selectedImage}
|
|
||||||
onClose={() => setSelectedImage(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { getPreviewBlob, releaseBlobUrl, refreshImage, exportImage, ApiError } from '../api.js';
|
|
||||||
import { useToast } from '../components/Toast.jsx';
|
|
||||||
import ModuleList from '../components/ModuleList.jsx';
|
|
||||||
|
|
||||||
const PREVIEW_SIZES = ['thumb', 'small', 'medium', 'large', 'xlarge'];
|
|
||||||
|
|
||||||
// ── EXIF metadata rows ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
|
||||||
if (!value && value !== 0) return null;
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: 8, padding: '5px 0' }}>
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120, flexShrink: 0 }}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text)', wordBreak: 'break-word' }}>
|
|
||||||
{String(value)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Preview area ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function PreviewImage({ imageId, size, onModulePreview, modulePreviewUrl }) {
|
|
||||||
const [blobUrl, setBlobUrl] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const blobKeyRef = useRef(null);
|
|
||||||
const isMounted = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMounted.current = true;
|
|
||||||
setLoading(true);
|
|
||||||
setError(false);
|
|
||||||
|
|
||||||
getPreviewBlob(imageId, size)
|
|
||||||
.then(({ url, key }) => {
|
|
||||||
if (!isMounted.current) { releaseBlobUrl(key); return; }
|
|
||||||
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
|
|
||||||
blobKeyRef.current = key;
|
|
||||||
setBlobUrl(url);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (isMounted.current) { setLoading(false); setError(true); }
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted.current = false;
|
|
||||||
if (blobKeyRef.current) { releaseBlobUrl(blobKeyRef.current); blobKeyRef.current = null; }
|
|
||||||
};
|
|
||||||
}, [imageId, size]);
|
|
||||||
|
|
||||||
const displayUrl = modulePreviewUrl ?? blobUrl;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
minHeight: 200,
|
|
||||||
background: '#111',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
}}>
|
|
||||||
{loading && !displayUrl && (
|
|
||||||
<span className="spinner spinner-lg" />
|
|
||||||
)}
|
|
||||||
{error && !displayUrl && (
|
|
||||||
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
|
||||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>⊘</div>
|
|
||||||
<p className="text-sm">Preview unavailable</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayUrl && (
|
|
||||||
<img
|
|
||||||
src={displayUrl}
|
|
||||||
alt="Image preview"
|
|
||||||
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain', display: 'block' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{modulePreviewUrl && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8, right: 8,
|
|
||||||
background: 'rgba(244, 162, 45, 0.9)',
|
|
||||||
color: '#000',
|
|
||||||
fontSize: '0.6875rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '9999px',
|
|
||||||
}}>
|
|
||||||
MODULE PREVIEW
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ImageDetail({ image, onClose }) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [previewSizeIdx, setPreviewSizeIdx] = useState(2); // 'medium'
|
|
||||||
const [tab, setTab] = useState('info'); // 'info' | 'modules'
|
|
||||||
const [actionBusy, setActionBusy] = useState(null); // 'refresh' | 'export' | null
|
|
||||||
const [modulePreviewUrl, setModulePreviewUrl] = useState(null);
|
|
||||||
const modulePreviewUrlRef = useRef(null);
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
const t = requestAnimationFrame(() => setVisible(true));
|
|
||||||
return () => cancelAnimationFrame(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup module preview blob
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function cyclePreviewSize() {
|
|
||||||
setPreviewSizeIdx(i => (i + 1) % PREVIEW_SIZES.length);
|
|
||||||
setModulePreviewUrl(null); // clear module preview when switching size
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleModulePreview(url) {
|
|
||||||
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
|
|
||||||
modulePreviewUrlRef.current = url;
|
|
||||||
setModulePreviewUrl(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRefresh() {
|
|
||||||
setActionBusy('refresh');
|
|
||||||
try {
|
|
||||||
await refreshImage(image.id);
|
|
||||||
toast('Thumbnails regenerated', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
toast(err instanceof ApiError ? err.message : String(err), 'error');
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportAbortRef = useRef(null);
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
// Cancel any in-progress export if the button is clicked again.
|
|
||||||
exportAbortRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
exportAbortRef.current = controller;
|
|
||||||
|
|
||||||
setActionBusy('export');
|
|
||||||
try {
|
|
||||||
const blob = await exportImage(image.id, { signal: controller.signal });
|
|
||||||
const stem = image.filename?.replace(/\.[^.]+$/, '') ?? 'export';
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${stem}.jpg`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
toast('Export downloaded', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.message === 'Cancelled') return;
|
|
||||||
toast(e instanceof ApiError ? e.message : String(e), 'error');
|
|
||||||
} finally {
|
|
||||||
setActionBusy(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackdropClick(e) {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSize = PREVIEW_SIZES[previewSizeIdx];
|
|
||||||
|
|
||||||
const meta = image.metadata ?? {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
onClick={handleBackdropClick}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.7)',
|
|
||||||
zIndex: 'var(--z-backdrop)',
|
|
||||||
transition: 'opacity var(--transition-slow)',
|
|
||||||
opacity: visible ? 1 : 0,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bottom sheet */}
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={image.filename}
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 'var(--z-sheet)',
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
maxHeight: '92dvh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
transform: visible ? 'translateY(0)' : 'translateY(100%)',
|
|
||||||
transition: 'transform var(--transition-slow)',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
|
|
||||||
<div style={{
|
|
||||||
width: 40, height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: 'var(--color-border)',
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sheet header */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '4px 16px 12px',
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
gap: 12,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<h2 style={{
|
|
||||||
fontSize: '1rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{image.filename}
|
|
||||||
</h2>
|
|
||||||
{image.path && (
|
|
||||||
<p style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
marginTop: 2,
|
|
||||||
}}>
|
|
||||||
{image.path}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-icon"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable content */}
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain' }}>
|
|
||||||
{/* Preview image — tap to cycle sizes */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cyclePreviewSize}
|
|
||||||
title={`Preview size: ${currentSize} (tap to cycle)`}
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
background: '#0a0a0a',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: 0,
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PreviewImage
|
|
||||||
imageId={image.id}
|
|
||||||
size={currentSize}
|
|
||||||
modulePreviewUrl={modulePreviewUrl}
|
|
||||||
/>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 8,
|
|
||||||
right: 8,
|
|
||||||
background: 'rgba(0,0,0,0.65)',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
fontSize: '0.6875rem',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '9999px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}>
|
|
||||||
{currentSize} · tap to cycle
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div style={{ display: 'flex', gap: 10, padding: '12px 16px' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={actionBusy !== null}
|
|
||||||
>
|
|
||||||
{actionBusy === 'refresh'
|
|
||||||
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Refreshing…</>
|
|
||||||
: '↺ Refresh'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={actionBusy !== null}
|
|
||||||
>
|
|
||||||
{actionBusy === 'export'
|
|
||||||
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Exporting…</>
|
|
||||||
: '⬇ Export'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab switcher */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
borderBottom: '1px solid var(--color-border)',
|
|
||||||
padding: '0 16px',
|
|
||||||
gap: 4,
|
|
||||||
}}>
|
|
||||||
{['info', 'modules'].map(t => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab(t)}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: `2px solid ${tab === t ? 'var(--color-accent)' : 'transparent'}`,
|
|
||||||
padding: '10px 16px',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontWeight: tab === t ? 600 : 400,
|
|
||||||
color: tab === t ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'color var(--transition-fast), border-color var(--transition-fast)',
|
|
||||||
minHeight: 44,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t === 'info' ? 'Info' : 'Modules'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div style={{ padding: '16px' }}>
|
|
||||||
{tab === 'info' && (
|
|
||||||
<InfoTab image={image} meta={meta} />
|
|
||||||
)}
|
|
||||||
{tab === 'modules' && (
|
|
||||||
<ModuleList
|
|
||||||
imageId={image.id}
|
|
||||||
onPreviewUpdate={handleModulePreview}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Safe area bottom padding */}
|
|
||||||
<div style={{ height: 'max(16px, env(safe-area-inset-bottom, 16px))' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Info tab ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function InfoTab({ image, meta }) {
|
|
||||||
const stars = image.rating > 0 ? '★'.repeat(image.rating) : '—';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
{/* Tags */}
|
|
||||||
{image.tags && image.tags.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, paddingBottom: 12 }}>
|
|
||||||
{image.tags.map(tag => (
|
|
||||||
<span key={tag} className="tag-pill">{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0 8px' }} />
|
|
||||||
|
|
||||||
<MetaRow label="Rating" value={stars} />
|
|
||||||
<MetaRow label="Date taken" value={image.datetime_taken} />
|
|
||||||
<MetaRow label="Dimensions" value={image.width && image.height ? `${image.width} × ${image.height}` : null} />
|
|
||||||
<MetaRow label="Camera" value={[image.maker, image.model].filter(Boolean).join(' ')} />
|
|
||||||
<MetaRow label="Type" value={image.is_raw ? 'RAW' : 'JPEG/other'} />
|
|
||||||
<MetaRow label="Film roll" value={image.film_name ?? image.film_id} />
|
|
||||||
|
|
||||||
{/* Color labels */}
|
|
||||||
{image.color_labels && image.color_labels.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', gap: 8, padding: '5px 0', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120 }}>
|
|
||||||
Color labels
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
{image.color_labels.map(l => (
|
|
||||||
<span key={l} className="tag-pill" style={{ textTransform: 'capitalize' }}>{l}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Extra metadata fields from image.metadata object */}
|
|
||||||
{Object.keys(meta).length > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '8px 0 4px' }} />
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)', marginBottom: 4 }}>
|
|
||||||
Additional metadata
|
|
||||||
</p>
|
|
||||||
{Object.entries(meta).map(([key, value]) => (
|
|
||||||
<MetaRow key={key} label={key} value={value} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import React, { useEffect, useReducer } from 'react';
|
|
||||||
import { useApp } from '../App.jsx';
|
|
||||||
import { getSettings, saveSettings, testConnection, ApiError } from '../api.js';
|
|
||||||
import { useToast } from '../components/Toast.jsx';
|
|
||||||
|
|
||||||
const initialForm = {
|
|
||||||
baseUrl: '',
|
|
||||||
clientId: '',
|
|
||||||
clientSecret: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formReducer(state, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'SET_FIELD':
|
|
||||||
return { ...state, [action.field]: action.value };
|
|
||||||
case 'LOAD':
|
|
||||||
return { ...action.data };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusIdle = { kind: 'idle' };
|
|
||||||
const statusTesting = { kind: 'testing' };
|
|
||||||
const statusOk = { kind: 'ok' };
|
|
||||||
const statusErr = (msg) => ({ kind: 'error', msg });
|
|
||||||
|
|
||||||
export default function Settings() {
|
|
||||||
const { goGallery } = useApp();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [form, dispatchForm] = useReducer(formReducer, initialForm);
|
|
||||||
const [status, setStatus] = React.useState(statusIdle);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const s = getSettings();
|
|
||||||
if (s) {
|
|
||||||
dispatchForm({
|
|
||||||
type: 'LOAD',
|
|
||||||
data: {
|
|
||||||
baseUrl: s.baseUrl ?? '',
|
|
||||||
clientId: s.clientId ?? '',
|
|
||||||
clientSecret: s.clientSecret ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function setField(field) {
|
|
||||||
return (e) => dispatchForm({ type: 'SET_FIELD', field, value: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTest(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
setStatus(statusTesting);
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
baseUrl: form.baseUrl.replace(/\/$/, ''),
|
|
||||||
clientId: form.clientId.trim(),
|
|
||||||
clientSecret: form.clientSecret.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await testConnection(settings);
|
|
||||||
setStatus(statusOk);
|
|
||||||
toast('Connection successful', 'success');
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof ApiError ? err.message : String(err);
|
|
||||||
setStatus(statusErr(msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
setStatus(statusTesting);
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
baseUrl: form.baseUrl.replace(/\/$/, ''),
|
|
||||||
clientId: form.clientId.trim(),
|
|
||||||
clientSecret: form.clientSecret.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await testConnection(settings);
|
|
||||||
saveSettings(settings);
|
|
||||||
setStatus(statusOk);
|
|
||||||
toast('Settings saved', 'success');
|
|
||||||
goGallery();
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof ApiError ? err.message : String(err);
|
|
||||||
setStatus(statusErr(msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBusy = status.kind === 'testing';
|
|
||||||
const isValid = form.baseUrl.trim() && form.clientId.trim() && form.clientSecret.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={styles.root}>
|
|
||||||
{/* Header */}
|
|
||||||
<header style={styles.header}>
|
|
||||||
<div style={styles.logoRow}>
|
|
||||||
<img src="/icon.svg" alt="" style={styles.logo} />
|
|
||||||
<div>
|
|
||||||
<h1 style={{ fontSize: '1.25rem' }}>darktable Gallery</h1>
|
|
||||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
|
|
||||||
API connection settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<main style={styles.main}>
|
|
||||||
<form onSubmit={handleSave} style={styles.form} noValidate>
|
|
||||||
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" htmlFor="baseUrl">
|
|
||||||
API Base URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="baseUrl"
|
|
||||||
className="form-input"
|
|
||||||
type="url"
|
|
||||||
placeholder="http://localhost:8080"
|
|
||||||
value={form.baseUrl}
|
|
||||||
onChange={setField('baseUrl')}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
Leave empty to use same-origin (when served by the Lua server)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" htmlFor="clientId">
|
|
||||||
Client ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="clientId"
|
|
||||||
className="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="my-client"
|
|
||||||
value={form.clientId}
|
|
||||||
onChange={setField('clientId')}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" htmlFor="clientSecret">
|
|
||||||
Client Secret
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="clientSecret"
|
|
||||||
className="form-input"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={form.clientSecret}
|
|
||||||
onChange={setField('clientSecret')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status.kind === 'error' && (
|
|
||||||
<div className="error-banner">
|
|
||||||
<span>⚠</span>
|
|
||||||
<span>{status.msg}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status.kind === 'ok' && (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '12px 16px',
|
|
||||||
background: 'rgba(82, 192, 110, 0.1)',
|
|
||||||
border: '1px solid rgba(82, 192, 110, 0.3)',
|
|
||||||
borderRadius: 'var(--radius-md)',
|
|
||||||
color: 'var(--color-success)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}>
|
|
||||||
<span>✓</span>
|
|
||||||
<span>Connection verified</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
onClick={handleTest}
|
|
||||||
disabled={isBusy || !isValid}
|
|
||||||
>
|
|
||||||
{isBusy ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
|
||||||
Testing…
|
|
||||||
</>
|
|
||||||
) : 'Test Connection'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
disabled={isBusy || !isValid}
|
|
||||||
>
|
|
||||||
{isBusy ? (
|
|
||||||
<>
|
|
||||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
|
||||||
Saving…
|
|
||||||
</>
|
|
||||||
) : 'Save & Connect'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted" style={{ textAlign: 'center', lineHeight: 1.6 }}>
|
|
||||||
Credentials are stored only in your browser's localStorage and
|
|
||||||
are never sent anywhere except the configured API URL.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
root: {
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 0,
|
|
||||||
overflowY: 'auto',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
padding: '24px 20px 0',
|
|
||||||
},
|
|
||||||
logoRow: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 14,
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
logo: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
},
|
|
||||||
main: {
|
|
||||||
flex: 1,
|
|
||||||
padding: '0 20px 32px',
|
|
||||||
maxWidth: 480,
|
|
||||||
width: '100%',
|
|
||||||
margin: '0 auto',
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 20,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
root: '.',
|
|
||||||
build: {
|
|
||||||
outDir: 'build',
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
VitePWA({
|
|
||||||
registerType: 'autoUpdate',
|
|
||||||
workbox: {
|
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
|
||||||
runtimeCaching: [
|
|
||||||
{
|
|
||||||
urlPattern: /^.*\/api\/v1\/images\?/,
|
|
||||||
handler: 'NetworkFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-images-list',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 50,
|
|
||||||
maxAgeSeconds: 60 * 5,
|
|
||||||
},
|
|
||||||
networkTimeoutSeconds: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: /^.*\/api\/v1\/images\/\d+\/preview/,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'image-previews',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 500,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
urlPattern: /^.*\/api\/v1\/(tags|films)/,
|
|
||||||
handler: 'NetworkFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'api-metadata',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 20,
|
|
||||||
maxAgeSeconds: 60 * 10,
|
|
||||||
},
|
|
||||||
networkTimeoutSeconds: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
manifest: {
|
|
||||||
name: 'darktable Gallery',
|
|
||||||
short_name: 'darktable',
|
|
||||||
description: 'darktable REST API web interface',
|
|
||||||
theme_color: '#0d0d0d',
|
|
||||||
background_color: '#0d0d0d',
|
|
||||||
display: 'standalone',
|
|
||||||
orientation: 'any',
|
|
||||||
start_url: '/',
|
|
||||||
icons: [
|
|
||||||
{
|
|
||||||
src: 'icon.svg',
|
|
||||||
sizes: 'any',
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
purpose: 'any maskable',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
devOptions: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -138,8 +138,9 @@ script_data.show = nil -- only required for libs since the destroy_method only h
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
local PS = dt.configuration.runnin_os == "windows" and "\\" or "/"
|
local PS <const> = dt.configuration.runnin_os == "windows" and "\\" or "/"
|
||||||
local ERROR = -1
|
local ERROR <const> = -1
|
||||||
|
local HAS_INTERNAL_METADATA = dt.metadata.exists("Exif.Image.DataTime")
|
||||||
|
|
||||||
-- function to convert from exif time to system time
|
-- function to convert from exif time to system time
|
||||||
local function exiftime2systime(exiftime)
|
local function exiftime2systime(exiftime)
|
||||||
@@ -241,15 +242,16 @@ end
|
|||||||
|
|
||||||
local function _get_windows_image_file_creation_time(image)
|
local function _get_windows_image_file_creation_time(image)
|
||||||
local datetime = nil
|
local datetime = nil
|
||||||
|
local datestring = nil
|
||||||
|
|
||||||
|
if HAS_INTERNAL_METADATA then
|
||||||
|
datestring = image.DateString
|
||||||
|
else
|
||||||
local p = io.popen("dir " .. image.path .. PS .. image.filename)
|
local p = io.popen("dir " .. image.path .. PS .. image.filename)
|
||||||
if p then
|
if p then
|
||||||
for line in p:lines() do
|
for line in p:lines() do
|
||||||
if string.match(line, ds.sanitize_lua(image.filename)) then
|
if string.match(line, ds.sanitize_lua(image.filename)) then
|
||||||
local mo, day, yr, hr, min, apm = string.match(line, "(%d+)/(%d+)/(%d+) (%d-):(%d+) (%S+)")
|
datestring = line
|
||||||
if apm == "PM" then
|
|
||||||
hr = hr + 12
|
|
||||||
end
|
|
||||||
datetime = vars2exiftime(yr, mo, day, hr, min, 0)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
p:close()
|
p:close()
|
||||||
@@ -257,6 +259,14 @@ local function _get_windows_image_file_creation_time(image)
|
|||||||
dt.print(string.format(_("unable to get information for %s"), image.filename))
|
dt.print(string.format(_("unable to get information for %s"), image.filename))
|
||||||
datetime = ERROR
|
datetime = ERROR
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
if datestring then
|
||||||
|
local mo, day, yr, hr, min, apm = string.match(datestring, "(%d+)/(%d+)/(%d+) (%d-):(%d+) (%S+)")
|
||||||
|
if apm == "PM" then
|
||||||
|
hr = hr + 12
|
||||||
|
end
|
||||||
|
datetime = vars2exiftime(yr, mo, day, hr, min, 0)
|
||||||
|
end
|
||||||
return datetime
|
return datetime
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
323
examples/ai_denoise.lua
Normal file
323
examples/ai_denoise.lua
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
--[[
|
||||||
|
This file is part of darktable,
|
||||||
|
copyright (c) 2026 darktable developers.
|
||||||
|
|
||||||
|
darktable is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
darktable is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
]]
|
||||||
|
--[[
|
||||||
|
ai_denoise - example script demonstrating the darktable.ai Lua
|
||||||
|
API by denoising the selected images with tile-based inference.
|
||||||
|
|
||||||
|
Adds an "AI denoise" panel to the lighttable right column with a
|
||||||
|
"denoise selected" button. Each selected image is loaded through
|
||||||
|
the develop pipeline (so denoise sees the output of all enabled
|
||||||
|
IOPs, not the original raw), run tile-by-tile through the active
|
||||||
|
denoise model, and saved as a 16-bit TIFF grouped with the source.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
* model lookup via dt.ai.model_for_task
|
||||||
|
* tensor crop/paste for tile-based inference
|
||||||
|
* linear <-> sRGB conversion (NR models train on gamma input)
|
||||||
|
* edge-replicated padding to suppress border artifacts
|
||||||
|
* background job with progress bar and cancellation
|
||||||
|
|
||||||
|
LIMITATIONS
|
||||||
|
* Image must be at least TILE_SIZE x TILE_SIZE (768 x 768 for
|
||||||
|
denoise-nind / denoise-nafnet); smaller inputs return an error.
|
||||||
|
* If a save fails mid-batch the Lua error propagates and aborts
|
||||||
|
the whole job. Wrap denoise_one in pcall if you need
|
||||||
|
batch-robust behaviour.
|
||||||
|
|
||||||
|
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||||
|
* a denoise model enabled in preferences -> AI (denoise-nind,
|
||||||
|
denoise-nafnet, ...)
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
* require this script from your luarc
|
||||||
|
* select one or more images in lighttable
|
||||||
|
* click the "denoise selected" button in the AI denoise panel
|
||||||
|
|
||||||
|
BUGS, COMMENTS, SUGGESTIONS
|
||||||
|
* file an issue on https://github.com/darktable-org/darktable
|
||||||
|
|
||||||
|
CHANGES
|
||||||
|
]]
|
||||||
|
|
||||||
|
local dt = require "darktable"
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- C O N S T A N T S
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
local MODULE_NAME = "ai_denoise"
|
||||||
|
|
||||||
|
-- darktable's NR models use static-shape ONNX: each model has a fixed
|
||||||
|
-- input dimension baked in at training time. The Lua API doesn't
|
||||||
|
-- expose this, so keep TILE_SIZE in sync with the model's
|
||||||
|
-- `attributes.input_sizes` in its config.json.
|
||||||
|
local TILE_SIZE = 768 -- denoise-nind, denoise-nafnet
|
||||||
|
|
||||||
|
-- overlap between adjacent tiles. larger values produce smoother
|
||||||
|
-- seams at the cost of more compute per pixel
|
||||||
|
local OVERLAP = 64
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- F U N C T I O N S
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
-- Surround the input with `pad` pixels on every side, replicating the
|
||||||
|
-- outermost rows/columns ("edge clamp" padding). Boundary tiles then
|
||||||
|
-- see plausible context instead of a literal image edge, so the
|
||||||
|
-- network's internal zero-padding no longer leaks into the visible
|
||||||
|
-- output as colour artifacts along the image rim.
|
||||||
|
local function pad_replicate(input, pad)
|
||||||
|
local C = input:shape()[2]
|
||||||
|
local H = input:shape()[3]
|
||||||
|
local W = input:shape()[4]
|
||||||
|
local padded = dt.ai.create_tensor({1, C, H + 2 * pad, W + 2 * pad})
|
||||||
|
|
||||||
|
-- center: original image
|
||||||
|
padded:paste(input, pad, pad)
|
||||||
|
|
||||||
|
-- left and right strips: replicate first / last column of input
|
||||||
|
local left_col = input:crop(0, 0, H, 1)
|
||||||
|
local right_col = input:crop(0, W - 1, H, 1)
|
||||||
|
for i = 0, pad - 1 do
|
||||||
|
padded:paste(left_col, pad, i)
|
||||||
|
padded:paste(right_col, pad, pad + W + i)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- top and bottom strips: replicate first / last row of the already
|
||||||
|
-- horizontally-padded buffer so the four corners get filled correctly
|
||||||
|
local top_row = padded:crop(pad, 0, 1, W + 2 * pad)
|
||||||
|
local bottom_row = padded:crop(pad + H - 1, 0, 1, W + 2 * pad)
|
||||||
|
for i = 0, pad - 1 do
|
||||||
|
padded:paste(top_row, i, 0)
|
||||||
|
padded:paste(bottom_row, pad + H + i, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
return padded
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Tile a [1,C,H,W] tensor through the model with overlap-blended seams.
|
||||||
|
-- Each tile is tile_size x tile_size, successive tiles step by
|
||||||
|
-- (tile_size - 2*overlap), and only the central (non-overlap) region
|
||||||
|
-- of each tile is pasted into the output. Image-boundary edges keep
|
||||||
|
-- their full extent; only image-interior seams drop the overlap margin.
|
||||||
|
--
|
||||||
|
-- The trailing tile in each axis is pinned to the image boundary so
|
||||||
|
-- the residual right/bottom strip is never smaller than 2*overlap --
|
||||||
|
-- otherwise it would have to be skipped (the model has a fixed input
|
||||||
|
-- size) and those pixels would never be written.
|
||||||
|
local function run_tiled(ctx, input, tile_size, overlap,
|
||||||
|
job, base_pct, span_pct)
|
||||||
|
local C = input:shape()[2]
|
||||||
|
local H = input:shape()[3]
|
||||||
|
local W = input:shape()[4]
|
||||||
|
if H < tile_size or W < tile_size then
|
||||||
|
return nil, string.format("image too small (%dx%d) for tile %d",
|
||||||
|
W, H, tile_size)
|
||||||
|
end
|
||||||
|
|
||||||
|
local step = tile_size - 2 * overlap
|
||||||
|
|
||||||
|
-- enumerate tile origins along one axis, ending exactly at the boundary
|
||||||
|
local function origins(extent)
|
||||||
|
local out, p = {}, 0
|
||||||
|
while p + tile_size < extent do
|
||||||
|
out[#out + 1] = p
|
||||||
|
p = p + step
|
||||||
|
end
|
||||||
|
out[#out + 1] = math.max(0, extent - tile_size)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
local ys, xs = origins(H), origins(W)
|
||||||
|
local total = #xs * #ys
|
||||||
|
|
||||||
|
local output = dt.ai.create_tensor({1, C, H, W})
|
||||||
|
local done = 0
|
||||||
|
|
||||||
|
for _, ty in ipairs(ys) do
|
||||||
|
for _, tx in ipairs(xs) do
|
||||||
|
if job and not job.valid then return nil, "cancelled" end
|
||||||
|
|
||||||
|
local tile_in = input:crop(ty, tx, tile_size, tile_size)
|
||||||
|
local tile_out = ctx:run(tile_in)
|
||||||
|
|
||||||
|
-- central region to keep: drop overlap on every inner edge;
|
||||||
|
-- full extent at image-boundary edges
|
||||||
|
local at_top = (ty == 0)
|
||||||
|
local at_left = (tx == 0)
|
||||||
|
local at_bottom = (ty + tile_size >= H)
|
||||||
|
local at_right = (tx + tile_size >= W)
|
||||||
|
|
||||||
|
local cy = at_top and 0 or overlap
|
||||||
|
local cx = at_left and 0 or overlap
|
||||||
|
local ch = tile_size - cy - (at_bottom and 0 or overlap)
|
||||||
|
local cw = tile_size - cx - (at_right and 0 or overlap)
|
||||||
|
|
||||||
|
output:paste(tile_out:crop(cy, cx, ch, cw), ty + cy, tx + cx)
|
||||||
|
|
||||||
|
done = done + 1
|
||||||
|
if job then
|
||||||
|
job.percent = base_pct + span_pct * (done / total)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return output
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Denoise one image: load through the develop pipeline, run tiled
|
||||||
|
-- inference, save as 16-bit TIFF, group with the source.
|
||||||
|
local function denoise_one(ctx, img, tile_size, job, base_pct, span_pct)
|
||||||
|
-- darktable returns the develop-pipeline output as scene-linear RGB;
|
||||||
|
-- the NR models were trained on sRGB-gamma input, so we re-encode
|
||||||
|
-- before inference and decode back to linear before saving
|
||||||
|
local input = dt.ai.load_image(img)
|
||||||
|
if not input then return false, "load_image failed" end
|
||||||
|
input:linear_to_srgb()
|
||||||
|
|
||||||
|
-- pad so the image's outer rim is no longer at a tile boundary;
|
||||||
|
-- the padded strip absorbs the network's edge artifacts and gets
|
||||||
|
-- cropped away after inference
|
||||||
|
local H = input:shape()[3]
|
||||||
|
local W = input:shape()[4]
|
||||||
|
local padded_in = pad_replicate(input, OVERLAP)
|
||||||
|
|
||||||
|
local padded_out, err = run_tiled(ctx, padded_in, tile_size, OVERLAP,
|
||||||
|
job, base_pct, span_pct)
|
||||||
|
if not padded_out then return false, err end
|
||||||
|
|
||||||
|
local output = padded_out:crop(OVERLAP, OVERLAP, H, W)
|
||||||
|
output:srgb_to_linear()
|
||||||
|
|
||||||
|
local base = img.filename:match("(.+)%..+$") or img.filename
|
||||||
|
local out_path = img.path .. "/" .. base .. "_denoised.tif"
|
||||||
|
output:save_tiff(out_path, 16, img)
|
||||||
|
|
||||||
|
-- import the result and group with the source so they live together
|
||||||
|
-- in the lighttable
|
||||||
|
local imported = dt.database.import(out_path)
|
||||||
|
if imported then imported:group_with(img) end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Button handler: looks up the active denoise model, fans out the
|
||||||
|
-- selected images, and reports progress via a cancellable job.
|
||||||
|
local function process_denoise()
|
||||||
|
local model_id = dt.ai.model_for_task("denoise")
|
||||||
|
if not model_id then
|
||||||
|
dt.print("no denoise model enabled -- pick one in preferences -> AI")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local images = dt.gui.selection()
|
||||||
|
if #images == 0 then
|
||||||
|
dt.print("select at least one image")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- progress + cancel job. the cancel callback flips job.valid; the
|
||||||
|
-- main loop polls it between tiles to stop cleanly
|
||||||
|
local job = dt.gui.create_job(
|
||||||
|
string.format("AI denoise (%d image%s)",
|
||||||
|
#images, #images == 1 and "" or "s"),
|
||||||
|
true,
|
||||||
|
function(j) j.valid = false end)
|
||||||
|
|
||||||
|
dt.print_log(string.format(
|
||||||
|
"[ai_denoise] starting: %d image%s with model %s",
|
||||||
|
#images, #images == 1 and "" or "s", model_id))
|
||||||
|
local t_start = os.time()
|
||||||
|
|
||||||
|
local ctx = dt.ai.load_model(model_id)
|
||||||
|
if not ctx then
|
||||||
|
dt.print("failed to load model: " .. model_id)
|
||||||
|
job.valid = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local span = 1.0 / #images
|
||||||
|
local done = 0
|
||||||
|
for i, img in ipairs(images) do
|
||||||
|
if not job.valid then break end
|
||||||
|
dt.print_log(string.format("[ai_denoise] [%d/%d] %s",
|
||||||
|
i, #images, img.filename))
|
||||||
|
local ok, err = denoise_one(ctx, img, TILE_SIZE, job,
|
||||||
|
(i - 1) * span, span)
|
||||||
|
if not ok then
|
||||||
|
dt.print(string.format("[%d/%d] %s: %s",
|
||||||
|
i, #images, img.filename, tostring(err)))
|
||||||
|
dt.print_log(string.format("[ai_denoise] [%d/%d] %s FAILED: %s",
|
||||||
|
i, #images, img.filename, tostring(err)))
|
||||||
|
else
|
||||||
|
done = done + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ctx:close()
|
||||||
|
job.valid = false
|
||||||
|
local elapsed = os.time() - t_start
|
||||||
|
dt.print(string.format("denoise complete: %d image%s",
|
||||||
|
#images, #images == 1 and "" or "s"))
|
||||||
|
dt.print_log(string.format(
|
||||||
|
"[ai_denoise] finished: %d/%d in %ds",
|
||||||
|
done, #images, elapsed))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- script_manager integration: called when the user disables the script.
|
||||||
|
-- darktable can't remove a registered lib from the UI at runtime, so we
|
||||||
|
-- only stop any in-flight work; the panel itself remains until restart.
|
||||||
|
local function destroy()
|
||||||
|
-- nothing persistent to clean up: each click runs its own job and
|
||||||
|
-- closes its own model context
|
||||||
|
end
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- M A I N
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
dt.register_lib(
|
||||||
|
MODULE_NAME, -- plugin id
|
||||||
|
"AI denoise", -- displayed name
|
||||||
|
true, -- expandable
|
||||||
|
false, -- no reset button
|
||||||
|
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}},
|
||||||
|
dt.new_widget("box") {
|
||||||
|
orientation = "vertical",
|
||||||
|
dt.new_widget("button") {
|
||||||
|
label = "denoise selected",
|
||||||
|
clicked_callback = process_denoise,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nil, nil
|
||||||
|
)
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
local script_data = {}
|
||||||
|
|
||||||
|
script_data.metadata = {
|
||||||
|
name = "AI denoise",
|
||||||
|
purpose = "tile-based AI denoise using the darktable.ai Lua API",
|
||||||
|
author = "Andrii Ryzhkov",
|
||||||
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_denoise"
|
||||||
|
}
|
||||||
|
|
||||||
|
script_data.destroy = destroy
|
||||||
|
|
||||||
|
return script_data
|
||||||
481
examples/ai_raw_denoise.lua
Normal file
481
examples/ai_raw_denoise.lua
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
--[[
|
||||||
|
This file is part of darktable,
|
||||||
|
copyright (c) 2026 darktable developers.
|
||||||
|
|
||||||
|
darktable is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
darktable is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
]]
|
||||||
|
--[[
|
||||||
|
ai_raw_denoise - example script demonstrating the darktable.ai Lua
|
||||||
|
API by denoising the selected raw images with tile-based inference
|
||||||
|
on the raw CFA.
|
||||||
|
|
||||||
|
Adds an "AI raw denoise" panel to the lighttable right column with
|
||||||
|
a "denoise selected" button. Each selected image's raw CFA is
|
||||||
|
loaded, Bayer-packed to 4 channels, run tile-by-tile through the
|
||||||
|
currently enabled rawdenoise model (which denoises AND demosaicks
|
||||||
|
in one pass), and saved as a LinearRaw DNG grouped with the
|
||||||
|
source image.
|
||||||
|
|
||||||
|
Demonstrates:
|
||||||
|
* model lookup via dt.ai.model_for_task
|
||||||
|
* loading model variant files via dt.ai.load_model file arg
|
||||||
|
* raw CFA loading via dt.ai.load_raw
|
||||||
|
* tensor bayer_pack for 4-channel CFA tiling
|
||||||
|
* tile loop with non-trivial input->output scale (here 1 -> 2)
|
||||||
|
* edge-replicated padding to suppress border artifacts
|
||||||
|
* LinearRaw DNG output via dt.ai.save_dng_linear
|
||||||
|
* background job with progress bar and cancellation
|
||||||
|
|
||||||
|
LIMITATIONS
|
||||||
|
* Bayer sensors only. The script loads model_bayer.onnx and uses
|
||||||
|
bayer_pack, which assumes a 2x2 Bayer layout. X-Trans and
|
||||||
|
Foveon sensors need model_linear.onnx with a different
|
||||||
|
preprocessing pipeline (no bayer_pack, demosaicked input).
|
||||||
|
* Image must be at least TILE_SIZE x TILE_SIZE in packed pixels
|
||||||
|
(1024 x 1024 of CFA for the default 512 packed tile size).
|
||||||
|
* If a save fails mid-batch the Lua error propagates and aborts
|
||||||
|
the whole job. Wrap denoise_one in pcall if you need
|
||||||
|
batch-robust behaviour.
|
||||||
|
|
||||||
|
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||||
|
* a rawdenoise model enabled in preferences -> AI
|
||||||
|
(e.g. rawdenoise-nind)
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
* require this script from your luarc
|
||||||
|
* select one or more raw images in lighttable
|
||||||
|
* click the "denoise selected" button in the AI raw denoise panel
|
||||||
|
|
||||||
|
BUGS, COMMENTS, SUGGESTIONS
|
||||||
|
* file an issue on https://github.com/darktable-org/darktable
|
||||||
|
|
||||||
|
CHANGES
|
||||||
|
]]
|
||||||
|
|
||||||
|
local dt = require "darktable"
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- C O N S T A N T S
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
local MODULE_NAME = "ai_raw_denoise"
|
||||||
|
|
||||||
|
-- The rawdenoise-nind bayer model is a denoise+demosaic combo:
|
||||||
|
-- input : [1, 4, 512, 512] packed CFA (4 phase planes)
|
||||||
|
-- output : [1, 3, 1024, 1024] linear RGB at 2x the input resolution
|
||||||
|
-- So each tile-size pixel of the input covers a 2x2 CFA block, and the
|
||||||
|
-- model emits two output pixels per input pixel in each axis.
|
||||||
|
-- TILE_SIZE is in packed pixels (model input); SCALE turns those into
|
||||||
|
-- output pixels. Keep in sync with `attributes.input_sizes` in the
|
||||||
|
-- model's config.json.
|
||||||
|
local TILE_SIZE = 512 -- rawdenoise-nind
|
||||||
|
local SCALE = 2 -- model output dim / input dim
|
||||||
|
local OUT_CH = 3 -- model emits linear RGB
|
||||||
|
|
||||||
|
-- overlap between adjacent tiles, in packed pixels (input space).
|
||||||
|
-- larger values produce smoother seams at the cost of more compute
|
||||||
|
local OVERLAP = 32
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- F U N C T I O N S
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
-- Bayer-only CFA channel lookup. Returns 0/1/2 (R/G/B) for the cell
|
||||||
|
-- at sensor position (r, c) given the 32-bit `filters` mask from
|
||||||
|
-- load_raw's meta table. Used to find the R-cell origin so bayer_pack's
|
||||||
|
-- plane 0 always holds R. X-Trans (filters == 9) uses meta.xtrans
|
||||||
|
-- instead and is not handled here.
|
||||||
|
local function fc(r, c, filters)
|
||||||
|
local shift = ((r % 2) * 4) + ((c % 2) * 2)
|
||||||
|
return math.floor(filters / (2 ^ shift)) % 4
|
||||||
|
end
|
||||||
|
|
||||||
|
-- daylight WB multipliers (R, G=1, B) derived from the camera's
|
||||||
|
-- XYZ->camRGB color matrix and the D65 white point. mirrors the C
|
||||||
|
-- path's _bayer_wb_daylight in restore_raw_bayer.c — the rawdenoise
|
||||||
|
-- bayer model is trained on daylight-WB'd input, so feeding it
|
||||||
|
-- un-WB'd camRGB produces wrong colors. Falls back to {1, 1, 1} if
|
||||||
|
-- the matrix is missing or degenerate.
|
||||||
|
local function daylight_wb(color_matrix)
|
||||||
|
local d65 = {0.95047, 1.0, 1.08883}
|
||||||
|
if not color_matrix then return 1.0, 1.0, 1.0 end
|
||||||
|
local resp = {0, 0, 0}
|
||||||
|
for c = 1, 3 do
|
||||||
|
resp[c] = color_matrix[c][1] * d65[1]
|
||||||
|
+ color_matrix[c][2] * d65[2]
|
||||||
|
+ color_matrix[c][3] * d65[3]
|
||||||
|
end
|
||||||
|
if resp[1] <= 0 or resp[2] <= 0 or resp[3] <= 0 then
|
||||||
|
return 1.0, 1.0, 1.0
|
||||||
|
end
|
||||||
|
return resp[2] / resp[1], 1.0, resp[2] / resp[3]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Surround the input with `pad` pixels on every side, replicating the
|
||||||
|
-- outermost rows/columns ("edge clamp" padding). Boundary tiles then
|
||||||
|
-- see plausible context instead of a literal image edge, so the
|
||||||
|
-- network's internal zero-padding no longer leaks into the visible
|
||||||
|
-- output as colour artifacts along the image rim.
|
||||||
|
local function pad_replicate(input, pad)
|
||||||
|
local C = input:shape()[2]
|
||||||
|
local H = input:shape()[3]
|
||||||
|
local W = input:shape()[4]
|
||||||
|
local padded = dt.ai.create_tensor({1, C, H + 2 * pad, W + 2 * pad})
|
||||||
|
|
||||||
|
-- center: original image
|
||||||
|
padded:paste(input, pad, pad)
|
||||||
|
|
||||||
|
-- left and right strips: replicate first / last column of input
|
||||||
|
local left_col = input:crop(0, 0, H, 1)
|
||||||
|
local right_col = input:crop(0, W - 1, H, 1)
|
||||||
|
for i = 0, pad - 1 do
|
||||||
|
padded:paste(left_col, pad, i)
|
||||||
|
padded:paste(right_col, pad, pad + W + i)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- top and bottom strips: replicate first / last row of the already
|
||||||
|
-- horizontally-padded buffer so the four corners get filled correctly
|
||||||
|
local top_row = padded:crop(pad, 0, 1, W + 2 * pad)
|
||||||
|
local bottom_row = padded:crop(pad + H - 1, 0, 1, W + 2 * pad)
|
||||||
|
for i = 0, pad - 1 do
|
||||||
|
padded:paste(top_row, i, 0)
|
||||||
|
padded:paste(bottom_row, pad + H + i, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
return padded
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Tile a [1,C_in,H,W] tensor through a model that emits [1,C_out,H*scale,
|
||||||
|
-- W*scale]. Each tile is tile_size x tile_size in input space; successive
|
||||||
|
-- tiles step by (tile_size - 2*overlap); only the central (non-overlap)
|
||||||
|
-- region of each tile's output is pasted into the result, with all
|
||||||
|
-- offsets multiplied by `scale`. Image-boundary edges keep their full
|
||||||
|
-- extent; only image-interior seams drop the overlap margin.
|
||||||
|
--
|
||||||
|
-- The trailing tile in each axis is pinned to the image boundary so
|
||||||
|
-- the residual right/bottom strip is never smaller than 2*overlap --
|
||||||
|
-- otherwise it would have to be skipped (the model has a fixed input
|
||||||
|
-- size) and those pixels would never be written.
|
||||||
|
local function run_tiled(ctx, input, tile_size, overlap, scale, c_out,
|
||||||
|
job, base_pct, span_pct)
|
||||||
|
local H = input:shape()[3]
|
||||||
|
local W = input:shape()[4]
|
||||||
|
if H < tile_size or W < tile_size then
|
||||||
|
return nil, string.format("image too small (%dx%d) for tile %d",
|
||||||
|
W, H, tile_size)
|
||||||
|
end
|
||||||
|
|
||||||
|
local step = tile_size - 2 * overlap
|
||||||
|
|
||||||
|
-- enumerate tile origins along one axis, ending exactly at the boundary
|
||||||
|
local function origins(extent)
|
||||||
|
local out, p = {}, 0
|
||||||
|
while p + tile_size < extent do
|
||||||
|
out[#out + 1] = p
|
||||||
|
p = p + step
|
||||||
|
end
|
||||||
|
out[#out + 1] = math.max(0, extent - tile_size)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
local ys, xs = origins(H), origins(W)
|
||||||
|
local total = #xs * #ys
|
||||||
|
|
||||||
|
-- output is scale-times larger spatially and may have a different
|
||||||
|
-- channel count than the input
|
||||||
|
local output = dt.ai.create_tensor({1, c_out, H * scale, W * scale})
|
||||||
|
local done = 0
|
||||||
|
|
||||||
|
for _, ty in ipairs(ys) do
|
||||||
|
for _, tx in ipairs(xs) do
|
||||||
|
if job and not job.valid then return nil, "cancelled" end
|
||||||
|
|
||||||
|
local tile_in = input:crop(ty, tx, tile_size, tile_size)
|
||||||
|
local tile_out = ctx:run(tile_in)
|
||||||
|
|
||||||
|
-- central region to keep: drop overlap on every inner edge;
|
||||||
|
-- full extent at image-boundary edges. all dimensions in OUTPUT
|
||||||
|
-- (post-scale) space, since that's where we paste
|
||||||
|
local at_top = (ty == 0)
|
||||||
|
local at_left = (tx == 0)
|
||||||
|
local at_bottom = (ty + tile_size >= H)
|
||||||
|
local at_right = (tx + tile_size >= W)
|
||||||
|
|
||||||
|
local ov = overlap * scale
|
||||||
|
local tos = tile_size * scale
|
||||||
|
local cy = at_top and 0 or ov
|
||||||
|
local cx = at_left and 0 or ov
|
||||||
|
local ch = tos - cy - (at_bottom and 0 or ov)
|
||||||
|
local cw = tos - cx - (at_right and 0 or ov)
|
||||||
|
|
||||||
|
output:paste(tile_out:crop(cy, cx, ch, cw),
|
||||||
|
ty * scale + cy, tx * scale + cx)
|
||||||
|
|
||||||
|
done = done + 1
|
||||||
|
if job then
|
||||||
|
job.percent = base_pct + span_pct * (done / total)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return output
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Denoise one raw image. The bayer rawdenoise model denoises AND
|
||||||
|
-- demosaicks in one pass, so its output is linear RGB at the
|
||||||
|
-- original CFA resolution (not a CFA mosaic). Saved as a LinearRaw
|
||||||
|
-- DNG, which darktable can re-import as an already-debayered raw.
|
||||||
|
--
|
||||||
|
-- Pipeline:
|
||||||
|
-- load CFA [1,1,H,W] + image metadata
|
||||||
|
-- -> bayer_pack [1,4,H/2,W/2]
|
||||||
|
-- -> normalize to [0, 1] using black/white levels (the model is
|
||||||
|
-- trained on (val - black) / (white - black), not raw ADC)
|
||||||
|
-- -> pad to [1,4,H/2+2P,W/2+2P]
|
||||||
|
-- -> tile through model (scale=2, c_out=3) -> [1,3,(H+4P),(W+4P)]
|
||||||
|
-- -> crop padding back off -> [1,3,H,W]
|
||||||
|
-- -> save linear DNG -> import + group with source
|
||||||
|
local function denoise_one(ctx, img, tile_size, job, base_pct, span_pct)
|
||||||
|
-- load_raw returns (tensor, meta). tensor is [1,1,H,W] in raw ADC
|
||||||
|
-- units; meta carries black/white/wb/CFA info we need to normalise
|
||||||
|
-- the input into the [0, 1] range the model was trained on
|
||||||
|
local cfa, meta = dt.ai.load_raw(img)
|
||||||
|
if not cfa then return false, "load_raw failed" end
|
||||||
|
|
||||||
|
-- this script is Bayer-only: filters == 9 is X-Trans (handled by a
|
||||||
|
-- different preprocessing pipeline + model_linear.onnx); filters == 0
|
||||||
|
-- means monochrome / non-CFA, which bayer_pack can't handle either
|
||||||
|
if not meta.filters or meta.filters == 0 or meta.filters == 9 then
|
||||||
|
return false, "unsupported CFA pattern (Bayer-only script)"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Crop to the visible region and shift the origin onto the R cell
|
||||||
|
-- of the CFA pattern. bayer_pack splits by position only, so to make
|
||||||
|
-- plane 0 always hold R (which the model expects) the cropped CFA's
|
||||||
|
-- (0, 0) must sit at an R site. Mirrors the C path's _bayer_origin
|
||||||
|
-- + RGGB shift in restore_raw_bayer.c.
|
||||||
|
local cy = 0
|
||||||
|
local cx = 0
|
||||||
|
local cw = cfa:shape()[4]
|
||||||
|
local ch = cfa:shape()[3]
|
||||||
|
if meta.crop_x and meta.crop_y then
|
||||||
|
cy = meta.crop_y - (meta.crop_y % 2)
|
||||||
|
cx = meta.crop_x - (meta.crop_x % 2)
|
||||||
|
cw = (meta.visible_width or cw) + (meta.crop_x - cx)
|
||||||
|
ch = (meta.visible_height or ch) + (meta.crop_y - cy)
|
||||||
|
end
|
||||||
|
-- find R cell within the 2x2 starting at (cy, cx)
|
||||||
|
local sy, sx = 0, 0
|
||||||
|
for ty = 0, 1 do
|
||||||
|
for tx = 0, 1 do
|
||||||
|
if fc(cy + ty, cx + tx, meta.filters) == 0 then
|
||||||
|
sy, sx = ty, tx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
cy = cy + sy
|
||||||
|
cx = cx + sx
|
||||||
|
ch = ch - sy
|
||||||
|
cw = cw - sx
|
||||||
|
-- ensure even dims for bayer_pack
|
||||||
|
ch = ch - (ch % 2)
|
||||||
|
cw = cw - (cw % 2)
|
||||||
|
if cy > 0 or cx > 0
|
||||||
|
or cw < cfa:shape()[4] or ch < cfa:shape()[3]
|
||||||
|
then
|
||||||
|
cfa = cfa:crop(cy, cx, ch, cw)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- uniform-black approximation: most sensors report identical black
|
||||||
|
-- levels across the 4 CFA sites, in which case a single scalar
|
||||||
|
-- (val - black) / (white - black) normalisation is exact. cameras
|
||||||
|
-- with non-uniform per-site black levels (e.g. some PDAF sensors)
|
||||||
|
-- would need per-plane scaling via scale_add_planes -- left as an
|
||||||
|
-- exercise
|
||||||
|
local black = meta.black_level
|
||||||
|
local range = meta.white_level - black
|
||||||
|
if range <= 0 then range = 65535 end
|
||||||
|
|
||||||
|
-- pack the 1-channel CFA into 4 phase planes so each channel holds
|
||||||
|
-- one Bayer site type. After the RGGB shift above:
|
||||||
|
-- plane 0 = R, planes 1,2 = G, plane 3 = B
|
||||||
|
local packed = cfa:bayer_pack()
|
||||||
|
packed:scale_add(1.0 / range, -black / range)
|
||||||
|
|
||||||
|
-- apply daylight WB so the four planes sit in the same range the
|
||||||
|
-- model was trained on (R, G, G, B → R*wb_R, G, G, B*wb_B). without
|
||||||
|
-- this, the model sees unbalanced channels and produces wrong colors
|
||||||
|
local wb_R, wb_G, wb_B = daylight_wb(meta.color_matrix)
|
||||||
|
packed:scale_add_planes({wb_R, wb_G, wb_G, wb_B})
|
||||||
|
local pH = packed:shape()[3]
|
||||||
|
local pW = packed:shape()[4]
|
||||||
|
|
||||||
|
-- save the post-normalisation input mean for gain-matching the
|
||||||
|
-- model output: the bayer rawdenoise model emits values on its own
|
||||||
|
-- internal scale, not [0, 1], so we rescale so output mean matches
|
||||||
|
-- input mean (the per-tile equivalent of dt's C reference path)
|
||||||
|
local in_mean = packed:mean()
|
||||||
|
|
||||||
|
-- pad so the image rim is no longer at a tile boundary; the padded
|
||||||
|
-- strip absorbs the network's edge artifacts and is cropped away
|
||||||
|
-- after inference (in output space, where the pad covers 2*OVERLAP
|
||||||
|
-- pixels per side)
|
||||||
|
local padded_in = pad_replicate(packed, OVERLAP)
|
||||||
|
|
||||||
|
local padded_out, err = run_tiled(ctx, padded_in, tile_size, OVERLAP,
|
||||||
|
SCALE, OUT_CH,
|
||||||
|
job, base_pct, span_pct)
|
||||||
|
if not padded_out then return false, err end
|
||||||
|
|
||||||
|
-- crop the padded region (in output coords: pad * scale per side)
|
||||||
|
-- back to the original-CFA resolution
|
||||||
|
local out_rgb = padded_out:crop(OVERLAP * SCALE, OVERLAP * SCALE,
|
||||||
|
pH * SCALE, pW * SCALE)
|
||||||
|
|
||||||
|
-- gain-match: rescale the output so its mean matches the input
|
||||||
|
-- mean, bringing the model's internal-scale output back into the
|
||||||
|
-- [0, 1] range save_dng_linear expects
|
||||||
|
local out_mean = out_rgb:mean()
|
||||||
|
if out_mean > 1e-8 then
|
||||||
|
out_rgb:scale_add(in_mean / out_mean)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- invert the daylight WB so the saved tensor is un-WB'd camRGB
|
||||||
|
-- (save_dng_linear's contract — the consumer applies AsShotNeutral
|
||||||
|
-- on import)
|
||||||
|
out_rgb:scale_add_planes({1.0 / wb_R, 1.0 / wb_G, 1.0 / wb_B})
|
||||||
|
|
||||||
|
local base = img.filename:match("(.+)%..+$") or img.filename
|
||||||
|
local out_path = img.path .. "/" .. base .. "_rawdenoised.dng"
|
||||||
|
-- LinearRaw DNG: tensor values must be normalised to [0, 1] camRGB;
|
||||||
|
-- the model handles black-subtract + scale internally, so we just
|
||||||
|
-- pass the output through
|
||||||
|
dt.ai.save_dng_linear(out_rgb, img, out_path)
|
||||||
|
|
||||||
|
-- import the result and group with the source so they live together
|
||||||
|
-- in the lighttable
|
||||||
|
local imported = dt.database.import(out_path)
|
||||||
|
if imported then imported:group_with(img) end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Button handler: looks up the active rawdenoise model, fans out the
|
||||||
|
-- selected images, and reports progress via a cancellable job.
|
||||||
|
local function process_raw_denoise()
|
||||||
|
local model_id = dt.ai.model_for_task("rawdenoise")
|
||||||
|
if not model_id then
|
||||||
|
dt.print("no rawdenoise model enabled -- pick one in preferences -> AI")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local images = dt.gui.selection()
|
||||||
|
if #images == 0 then
|
||||||
|
dt.print("select at least one image")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- progress + cancel job. the cancel callback flips job.valid; the
|
||||||
|
-- main loop polls it between tiles to stop cleanly
|
||||||
|
local job = dt.gui.create_job(
|
||||||
|
string.format("AI raw denoise (%d image%s)",
|
||||||
|
#images, #images == 1 and "" or "s"),
|
||||||
|
true,
|
||||||
|
function(j) j.valid = false end)
|
||||||
|
|
||||||
|
dt.print_log(string.format(
|
||||||
|
"[ai_raw_denoise] starting: %d image%s with model %s",
|
||||||
|
#images, #images == 1 and "" or "s", model_id))
|
||||||
|
local t_start = os.time()
|
||||||
|
|
||||||
|
-- rawdenoise packages ship multiple files (one per CFA type); pick
|
||||||
|
-- the Bayer variant. X-Trans / Foveon would use model_linear.onnx
|
||||||
|
-- with a different preprocessing pipeline (no bayer_pack)
|
||||||
|
local ctx = dt.ai.load_model(model_id, nil, "model_bayer.onnx")
|
||||||
|
if not ctx then
|
||||||
|
dt.print("failed to load model: " .. model_id)
|
||||||
|
job.valid = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local span = 1.0 / #images
|
||||||
|
local done = 0
|
||||||
|
for i, img in ipairs(images) do
|
||||||
|
if not job.valid then break end
|
||||||
|
dt.print_log(string.format("[ai_raw_denoise] [%d/%d] %s",
|
||||||
|
i, #images, img.filename))
|
||||||
|
local ok, err = denoise_one(ctx, img, TILE_SIZE, job,
|
||||||
|
(i - 1) * span, span)
|
||||||
|
if not ok then
|
||||||
|
dt.print(string.format("[%d/%d] %s: %s",
|
||||||
|
i, #images, img.filename, tostring(err)))
|
||||||
|
dt.print_log(string.format("[ai_raw_denoise] [%d/%d] %s FAILED: %s",
|
||||||
|
i, #images, img.filename, tostring(err)))
|
||||||
|
else
|
||||||
|
done = done + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ctx:close()
|
||||||
|
job.valid = false
|
||||||
|
local elapsed = os.time() - t_start
|
||||||
|
dt.print(string.format("raw denoise complete: %d image%s",
|
||||||
|
#images, #images == 1 and "" or "s"))
|
||||||
|
dt.print_log(string.format(
|
||||||
|
"[ai_raw_denoise] finished: %d/%d in %ds",
|
||||||
|
done, #images, elapsed))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- script_manager integration: called when the user disables the script.
|
||||||
|
-- darktable can't remove a registered lib from the UI at runtime, so we
|
||||||
|
-- only stop any in-flight work; the panel itself remains until restart.
|
||||||
|
local function destroy()
|
||||||
|
-- nothing persistent to clean up: each click runs its own job and
|
||||||
|
-- closes its own model context
|
||||||
|
end
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- M A I N
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
dt.register_lib(
|
||||||
|
MODULE_NAME, -- plugin id
|
||||||
|
"AI raw denoise", -- displayed name
|
||||||
|
true, -- expandable
|
||||||
|
false, -- no reset button
|
||||||
|
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}},
|
||||||
|
dt.new_widget("box") {
|
||||||
|
orientation = "vertical",
|
||||||
|
dt.new_widget("button") {
|
||||||
|
label = "denoise selected",
|
||||||
|
clicked_callback = process_raw_denoise,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nil, nil
|
||||||
|
)
|
||||||
|
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||||
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
local script_data = {}
|
||||||
|
|
||||||
|
script_data.metadata = {
|
||||||
|
name = "AI raw denoise",
|
||||||
|
purpose = "tile-based AI raw denoise using the darktable.ai Lua API",
|
||||||
|
author = "Andrii Ryzhkov",
|
||||||
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_raw_denoise"
|
||||||
|
}
|
||||||
|
|
||||||
|
script_data.destroy = destroy
|
||||||
|
|
||||||
|
return script_data
|
||||||
@@ -211,9 +211,14 @@ local function straighten_image(image)
|
|||||||
for line in pipe:lines() do
|
for line in pipe:lines() do
|
||||||
local pr, val = string.match(line, "(.).+: (.+)")
|
local pr, val = string.match(line, "(.).+: (.+)")
|
||||||
if pr == "R" then
|
if pr == "R" then
|
||||||
|
local sign = 1.0
|
||||||
dt.print_log("roll angle is " .. val)
|
dt.print_log("roll angle is " .. val)
|
||||||
log.msg(log.debug, "roll angle is " .. val)
|
log.msg(log.debug, "roll angle is " .. val)
|
||||||
roll = determine_correction(tonumber(val))
|
-- check for Nidon and change the sign
|
||||||
|
if string.match(string.upper(image.exif_maker), "NIKON") then
|
||||||
|
sign = -1.0
|
||||||
|
end
|
||||||
|
roll = determine_correction(tonumber(val) * sign)
|
||||||
dt.print_log("corrected roll angle is " .. roll)
|
dt.print_log("corrected roll angle is " .. roll)
|
||||||
log.msg(log.debug, "corrected roll angle is " .. roll)
|
log.msg(log.debug, "corrected roll angle is " .. roll)
|
||||||
elseif pr == "P" then
|
elseif pr == "P" then
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
|
|||||||
local EXIFTOOL <const> = df.check_if_bin_exists("exiftool")
|
local EXIFTOOL <const> = df.check_if_bin_exists("exiftool")
|
||||||
local EXIV2 <const> = df.check_if_bin_exists("exiv2")
|
local EXIV2 <const> = df.check_if_bin_exists("exiv2")
|
||||||
local DNGLAB <const> = df.check_if_bin_exists("dnglab")
|
local DNGLAB <const> = df.check_if_bin_exists("dnglab")
|
||||||
|
local EXIV2TAG <const> = "Exif.Canon.RawBurstModeRoll"
|
||||||
|
|
||||||
|
local HAS_INTERNAL_METADATA <const> = dt.metadata.exists(EXIV2TAG)
|
||||||
|
|
||||||
local DNGLAB_ARGS <const> = " convert --image-index all --embed-raw false "
|
local DNGLAB_ARGS <const> = " convert --image-index all --embed-raw false "
|
||||||
local EXIV2_ARGS <const> = " -K Exif.Canon.RawBurstModeRoll -pt "
|
local EXIV2_ARGS <const> = " -K Exif.Canon.RawBurstModeRoll -pt "
|
||||||
@@ -150,6 +153,7 @@ local extract_burst_roll_images = {}
|
|||||||
|
|
||||||
extract_burst_roll_images.preferred_tag_reader = nil
|
extract_burst_roll_images.preferred_tag_reader = nil
|
||||||
extract_burst_roll_images.on_import = true
|
extract_burst_roll_images.on_import = true
|
||||||
|
extract_burst_roll_images.select_button = true
|
||||||
extract_burst_roll_images.imported_images = {}
|
extract_burst_roll_images.imported_images = {}
|
||||||
extract_burst_roll_images.log_level = DEFAULT_LOG_LEVEL
|
extract_burst_roll_images.log_level = DEFAULT_LOG_LEVEL
|
||||||
|
|
||||||
@@ -172,9 +176,18 @@ dt.preferences.register(MODULE, -- script: This is a string used to avoid
|
|||||||
"bool", -- type
|
"bool", -- type
|
||||||
_("extract burst roll images on import"), -- label
|
_("extract burst roll images on import"), -- label
|
||||||
_("extract burst roll images on import"), -- tooltip
|
_("extract burst roll images on import"), -- tooltip
|
||||||
|
false) -- default
|
||||||
|
|
||||||
|
-- display selection button
|
||||||
|
dt.preferences.register(MODULE, -- script: This is a string used to avoid name collision in preferences (i.e namespace). Set it to something unique, usually the name of the script handling the preference.
|
||||||
|
"select_button", -- name
|
||||||
|
"bool", -- type
|
||||||
|
_("display select burst roll images button"), -- label
|
||||||
|
_("add a button to the select module to select burst roll images"), -- tooltip
|
||||||
true) -- default
|
true) -- default
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
-- A L I A S E S
|
-- A L I A S E S
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
@@ -256,6 +269,18 @@ end
|
|||||||
local function is_burst_roll_image(image)
|
local function is_burst_roll_image(image)
|
||||||
local old_log_level = set_log_level()
|
local old_log_level = set_log_level()
|
||||||
local result = false
|
local result = false
|
||||||
|
|
||||||
|
if HAS_INTERNAL_METADATA then
|
||||||
|
if image.RawBurstModeRoll then
|
||||||
|
local val = get_last_value(image.RawBurstModeRoll)
|
||||||
|
val = tonumber(val)
|
||||||
|
if type(val) == "number" then
|
||||||
|
if val > 0 then
|
||||||
|
result = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
local cmd = EXIFTOOL .. EXIFTOOL_ARGS
|
local cmd = EXIFTOOL .. EXIFTOOL_ARGS
|
||||||
|
|
||||||
if ebri.preferred_tag_reader == "exiv2" then
|
if ebri.preferred_tag_reader == "exiv2" then
|
||||||
@@ -270,7 +295,7 @@ local function is_burst_roll_image(image)
|
|||||||
end
|
end
|
||||||
line = tonumber(line)
|
line = tonumber(line)
|
||||||
if type(line) == "number" then
|
if type(line) == "number" then
|
||||||
if tonumber(line) > 0 then
|
if line > 0 then
|
||||||
log.msg(log.info, image.filename .. " is a burst roll container image")
|
log.msg(log.info, image.filename .. " is a burst roll container image")
|
||||||
result = true
|
result = true
|
||||||
end
|
end
|
||||||
@@ -278,6 +303,7 @@ local function is_burst_roll_image(image)
|
|||||||
end
|
end
|
||||||
p:close()
|
p:close()
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
restore_log_level(old_log_level)
|
restore_log_level(old_log_level)
|
||||||
return result
|
return result
|
||||||
@@ -296,7 +322,7 @@ local function group_extracted_dngs(image)
|
|||||||
local p = io.popen(cmd)
|
local p = io.popen(cmd)
|
||||||
if p then
|
if p then
|
||||||
for line in p:lines() do
|
for line in p:lines() do
|
||||||
if line:length() > 5 then -- skip blank lines created by forfiles
|
if line:len() > 5 then -- skip blank lines created by forfiles
|
||||||
local dng_image = dt.database.import(line)
|
local dng_image = dt.database.import(line)
|
||||||
dng_image:group_with(image)
|
dng_image:group_with(image)
|
||||||
count = count + 1
|
count = count + 1
|
||||||
@@ -321,6 +347,14 @@ local function extract_burst(image)
|
|||||||
restore_log_level(old_log_level)
|
restore_log_level(old_log_level)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function extract_burst_images(images)
|
||||||
|
for _, image in ipairs(images) do
|
||||||
|
if is_burst_roll_image(image) then
|
||||||
|
extract_burst(image)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function process_image(image)
|
local function process_image(image)
|
||||||
local old_log_level = set_log_level()
|
local old_log_level = set_log_level()
|
||||||
if is_burst_roll_image(image) then
|
if is_burst_roll_image(image) then
|
||||||
@@ -384,6 +418,37 @@ local function process_import(images)
|
|||||||
restore_log_level(old_log_level)
|
restore_log_level(old_log_level)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function select_burst_roll_images(event, images)
|
||||||
|
local old_log_level = set_log_level()
|
||||||
|
local selected = {}
|
||||||
|
|
||||||
|
for _, image in ipairs(images) do
|
||||||
|
if is_burst_roll_image(image) then
|
||||||
|
table.insert(selected, image)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
restore_log_level(old_log_level)
|
||||||
|
return selected
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_select_sensitive()
|
||||||
|
if #dt.collection > 0 then
|
||||||
|
dt.gui.libs.select.set_sensitive(MODULE, true)
|
||||||
|
else
|
||||||
|
dt.gui.libs.select.set_sensitive(MODULE, false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_action_sensitive()
|
||||||
|
local sensitive = false
|
||||||
|
if #dt.gui.action_images > 0 then
|
||||||
|
if is_burst_roll_image(dt.gui.action_images[1]) then
|
||||||
|
sensitive = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dt.gui.libs.image.set_sensitive(MODULE, sensitive)
|
||||||
|
end
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
-- M A I N P R O G R A M
|
-- M A I N P R O G R A M
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
@@ -394,6 +459,9 @@ if not DNGLAB then
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if HAS_INTERNAL_METADATA then
|
||||||
|
log.msg(log.info, "Exif.Canon.RawBurstModeRoll detected, using internal metadata")
|
||||||
|
else
|
||||||
if not EXIFTOOL and not EXIV2 then
|
if not EXIFTOOL and not EXIV2 then
|
||||||
log.msg(log.error, MODULE .. _("no exif tag reader available"))
|
log.msg(log.error, MODULE .. _("no exif tag reader available"))
|
||||||
log.msg(log.screen, script_data.metadata.name .. _(" no exif tag reader available, exiting..."))
|
log.msg(log.screen, script_data.metadata.name .. _(" no exif tag reader available, exiting..."))
|
||||||
@@ -407,13 +475,29 @@ if not string.match(EXIFTOOL, ebri.preferred_tag_reader) and not string.match(EX
|
|||||||
log.msg(log.screen, script_data.metadata.name .. string.format(_(" selected tag reader %s not available"), ebri.preferred_tag_reader))
|
log.msg(log.screen, script_data.metadata.name .. string.format(_(" selected tag reader %s not available"), ebri.preferred_tag_reader))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
ebri.on_import = dt.preferences.read(MODULE, "on_import", "bool")
|
ebri.on_import = pref_read("on_import", "bool")
|
||||||
|
ebri.select_button = pref_read("select_button", "bool")
|
||||||
|
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
-- U S E R I N T E R F A C E
|
-- U S E R I N T E R F A C E
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
if ebri.select_button then
|
||||||
|
dt.gui.libs.select.register_selection(MODULE, _("select burst roll images"), select_burst_roll_images,
|
||||||
|
_("select burst roll image containers"))
|
||||||
|
end
|
||||||
|
|
||||||
|
dt.gui.libs.image.register_action(
|
||||||
|
MODULE,
|
||||||
|
_("extract burst roll images"),
|
||||||
|
function(event, images)
|
||||||
|
extract_burst_images(images)
|
||||||
|
end,
|
||||||
|
_("extract and group burst roll images with container")
|
||||||
|
)
|
||||||
|
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
-- D A R K T A B L E I N T E G R A T I O N
|
-- D A R K T A B L E I N T E G R A T I O N
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
@@ -421,6 +505,10 @@ ebri.on_import = dt.preferences.read(MODULE, "on_import", "bool")
|
|||||||
local function destroy()
|
local function destroy()
|
||||||
dt.destroy_event(MODULE, "post-import-film")
|
dt.destroy_event(MODULE, "post-import-film")
|
||||||
dt.destroy_event(MODULE, "post-import-image")
|
dt.destroy_event(MODULE, "post-import-image")
|
||||||
|
if ebri.select_button then
|
||||||
|
dt.gui.libs.select.destroy_selection(MODULE)
|
||||||
|
end
|
||||||
|
dt.gui.libs.image.action.destroy_action(MODULE)
|
||||||
end
|
end
|
||||||
|
|
||||||
script_data.destroy = destroy
|
script_data.destroy = destroy
|
||||||
@@ -444,6 +532,27 @@ dt.register_event(MODULE, "post-import-image",
|
|||||||
end
|
end
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dt.register_event(MODULE, "selection-changed",
|
||||||
|
function(event)
|
||||||
|
set_action_sensitive()
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
if dt.preferences.read("darktable", "plugins/lighttable/act_on", "bool") then
|
||||||
|
dt.print_log("adding mouse-over-image-changed event")
|
||||||
|
dt.register_event(MODULE, "mouse-over-image-changed",
|
||||||
|
function(event, image)
|
||||||
|
set_action_sensitive()
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
dt.register_event(MODULE, "collection-changed",
|
||||||
|
function(event)
|
||||||
|
set_select_sensitive()
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
if not dt.query_event(MODULE, "shortcut") then
|
if not dt.query_event(MODULE, "shortcut") then
|
||||||
dt.register_event(MODULE, "shortcut",
|
dt.register_event(MODULE, "shortcut",
|
||||||
function(event, shortcut)
|
function(event, shortcut)
|
||||||
|
|||||||
@@ -117,10 +117,10 @@ script_data.restart = nil -- how to restart the (lib) script after it'
|
|||||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = "group_persistence", -- name of script
|
name = _("group persistence"), -- name of script
|
||||||
purpose = _("preserve groups between darktable instances"), -- purpose of script
|
purpose = _("preserve groups between darktable instances"), -- purpose of script
|
||||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||||
help = "" -- URL to help/documentation
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/group_persistence" -- URL to help/documentation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ script_data.restart = nil -- how to restart the (lib) script after it'
|
|||||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = _("regenerate_thumbnails"), -- visible name of script
|
name = _("regenerate thumbnails"), -- visible name of script
|
||||||
purpose = _("regenerate mipmap cache for selected images"), -- purpose of script
|
purpose = _("regenerate mipmap cache for selected images"), -- purpose of script
|
||||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||||
help = "https://docs.darktable.org/lua/development/lua.scripts.manual/scripts/official/regenerate_thumbnails" -- URL to help/documentation
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/regenerate_thumbnails" -- URL to help/documentation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ local function generate_thumbnails(images)
|
|||||||
local has_job = false
|
local has_job = false
|
||||||
|
|
||||||
if #images > 50 then
|
if #images > 50 then
|
||||||
rt.job = dt.gui.create_job("regenerating thumbnails", true, stop_job)
|
rt.job = dt.gui.create_job(_("regenerating thumbnails"), true, stop_job)
|
||||||
has_job = true
|
has_job = true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ dt.register_event(MODULE, "shortcut",
|
|||||||
if images then
|
if images then
|
||||||
generate_thumbnails(images)
|
generate_thumbnails(images)
|
||||||
end
|
end
|
||||||
end, "regenerate thumbnails"
|
end, _("regenerate thumbnails")
|
||||||
)
|
)
|
||||||
|
|
||||||
dt.register_event(MODULE, "selection-changed",
|
dt.register_event(MODULE, "selection-changed",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ script_data.restart = nil -- how to restart the (lib) script after it'
|
|||||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = "scheduler", -- name of script
|
name = _("scheduler"), -- name of script
|
||||||
purpose = _("provide scheduler services for multitasking"), -- purpose of script
|
purpose = _("provide scheduler services for multitasking"), -- purpose of script
|
||||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/scheduler/" -- URL to help/documentation
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/scheduler/" -- URL to help/documentation
|
||||||
@@ -126,7 +126,8 @@ scheduler.log_level = log.debug
|
|||||||
-- P R E F E R E N C E S
|
-- P R E F E R E N C E S
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
dt.preferences.register(MODULE, "target_time_slice_seconds", "float", "scheduler preferred time slice", "target time slice before interrupt", 1.0, 0.1, 1.0, 0.1)
|
dt.preferences.register(MODULE, "target_time_slice_seconds", "float",
|
||||||
|
_("scheduler preferred time slice"), _("target time slice before interrupt"), 1.0, 0.1, 1.0, 0.1)
|
||||||
|
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
-- A L I A S E S
|
-- A L I A S E S
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ script_data.restart = nil -- how to restart the (lib) script after it'
|
|||||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = "select_raw_non_raw", -- name of script
|
name = _("select raw/non-raw"), -- name of script
|
||||||
purpose = _("select raw or non raw files from collection"), -- purpose of script
|
purpose = _("select raw or non raw files from collection"), -- purpose of script
|
||||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_raw_non_raw" -- URL to help/documentation
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_raw_non_raw" -- URL to help/documentation
|
||||||
@@ -158,8 +158,8 @@ end
|
|||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
dt.gui.libs.select.register_selection(MODULE .. "_raws", _("select raw"), select_raw_files,
|
dt.gui.libs.select.register_selection(MODULE .. "_raws", _("select raw"), select_raw_files,
|
||||||
_("select raw files"))
|
_("select raw files"))
|
||||||
dt.gui.libs.select.register_selection(MODULE .. "_non_raws", _("select non raw"), select_non_raw_files,
|
dt.gui.libs.select.register_selection(MODULE .. "_non_raws", _("select non-raw"), select_non_raw_files,
|
||||||
_("select non raw files"))
|
_("select non-raw files"))
|
||||||
|
|
||||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
-- D A R K T A B L E I N T E G R A T I O N
|
-- D A R K T A B L E I N T E G R A T I O N
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ script_data.restart = nil -- how to restart the (lib) script after it'
|
|||||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = _("select_unaltered"), -- visible name of script
|
name = _("select unaltered"), -- visible name of script
|
||||||
purpose = _("select images that have not been altered in any way"), -- purpose of script
|
purpose = _("select images that have not been altered in any way"), -- purpose of script
|
||||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_unaltered/" -- URL to help/documentation
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/select_unaltered/" -- URL to help/documentation
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ script_data.restart = nil -- how to restart the (lib) script after it'
|
|||||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = _("selection2collection"), -- visible name of script
|
name = _("selection to collection"), -- visible name of script
|
||||||
purpose = _("create a temporary collection from a selection"), -- purpose of script
|
purpose = _("create a temporary collection from a selection"), -- purpose of script
|
||||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection2collection/" -- URL to help/documentation
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection2collection/" -- URL to help/documentation
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ local script_data = {}
|
|||||||
|
|
||||||
script_data.metadata = {
|
script_data.metadata = {
|
||||||
name = _("selection to PDF"),
|
name = _("selection to PDF"),
|
||||||
purpose = _("generate a pdf file of selected images"),
|
purpose = _("generate a PDF file of selected images"),
|
||||||
author = "Jérémy Rosen & Pascal Obry",
|
author = "Jérémy Rosen & Pascal Obry",
|
||||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection_to_pdf"
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection_to_pdf"
|
||||||
}
|
}
|
||||||
@@ -66,8 +66,8 @@ script_data.show = nil -- only required for libs since the destroy_method only h
|
|||||||
|
|
||||||
dt.preferences.register
|
dt.preferences.register
|
||||||
("selection_to_pdf","Open with","string",
|
("selection_to_pdf","Open with","string",
|
||||||
_("a pdf viewer"),
|
_("a PDF viewer"),
|
||||||
_("can be an absolute pathname or the tool may be in the PATH\nif not specified the system default will be used to open the pdf"),
|
_("can be an absolute pathname or the tool may be in the PATH\nif not specified the system default will be used to open the PDF"),
|
||||||
"")
|
"")
|
||||||
|
|
||||||
local title_widget = dt.new_widget("entry") {
|
local title_widget = dt.new_widget("entry") {
|
||||||
@@ -134,7 +134,7 @@ local function destroy()
|
|||||||
dt.print_log("done destroying")
|
dt.print_log("done destroying")
|
||||||
end
|
end
|
||||||
|
|
||||||
dt.register_storage("export_pdf", _("export thumbnails to pdf"),
|
dt.register_storage("export_pdf", _("export thumbnails to PDF"),
|
||||||
nil,
|
nil,
|
||||||
function(storage,image_table)
|
function(storage,image_table)
|
||||||
local my_title = title_widget.text
|
local my_title = title_widget.text
|
||||||
@@ -200,7 +200,7 @@ dt.register_storage("export_pdf", _("export thumbnails to pdf"),
|
|||||||
command = command .. " " .. pdffile
|
command = command .. " " .. pdffile
|
||||||
local result = dt.control.execute(command)
|
local result = dt.control.execute(command)
|
||||||
if result ~= 0 then
|
if result ~= 0 then
|
||||||
dt.print(_("problem running pdf viewer")) -- this one is probably usefull to the user
|
dt.print(_("problem running PDF viewer")) -- this one is probably usefull to the user
|
||||||
error("Problem running "..command)
|
error("Problem running "..command)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ local scripts_path = info.source:sub(2)
|
|||||||
-- assume user based
|
-- assume user based
|
||||||
local system_based = false
|
local system_based = false
|
||||||
|
|
||||||
if string.match(scripts_path, dt.configuration.data_dir) then
|
if string.match(scripts_path, ds.sanitize_lua(dt.configuration.data_dir)) then
|
||||||
log.msg(log.info, "script_manager using system based scripts")
|
log.msg(log.info, "script_manager using system based scripts")
|
||||||
system_based = true
|
system_based = true
|
||||||
end
|
end
|
||||||
@@ -748,7 +748,7 @@ local function scan_scripts(script_dir)
|
|||||||
local find_cmd = "find -L " .. script_dir .. " -name \\*.lua -print | sort"
|
local find_cmd = "find -L " .. script_dir .. " -name \\*.lua -print | sort"
|
||||||
|
|
||||||
if dt.configuration.running_os == "windows" then
|
if dt.configuration.running_os == "windows" then
|
||||||
find_cmd = "dir /b/s \"" .. script_dir .. "\\*.lua\" | sort"
|
find_cmd = "dir /b/s \"" .. script_dir .. "\\*.lua\" 2>nul | sort"
|
||||||
end
|
end
|
||||||
|
|
||||||
log.msg(log.debug, "find command is " .. find_cmd)
|
log.msg(log.debug, "find command is " .. find_cmd)
|
||||||
@@ -842,7 +842,7 @@ local function scan_repositories()
|
|||||||
local find_cmd = "find -L " .. LUA_DIR .. " -name \\*.git -print | sort"
|
local find_cmd = "find -L " .. LUA_DIR .. " -name \\*.git -print | sort"
|
||||||
|
|
||||||
if dt.configuration.running_os == "windows" then
|
if dt.configuration.running_os == "windows" then
|
||||||
find_cmd = "dir /b/s /a:d " .. LUA_DIR .. PS .. "*.git | sort"
|
find_cmd = "dir /b/s /a:d " .. LUA_DIR .. PS .. "*.git 2>nul | sort"
|
||||||
end
|
end
|
||||||
|
|
||||||
log.msg(log.debug, "find command is " .. find_cmd)
|
log.msg(log.debug, "find command is " .. find_cmd)
|
||||||
|
|||||||
Reference in New Issue
Block a user