diff --git a/.gitignore b/.gitignore index 3eed0ed..8675d74 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ piwigoPublish.lrplugin/PiwigoHelper.lua # Other files *.bak .claude/ +claude.md __pycache__/ cat layout.odt deploy.sh diff --git a/Auto_update_documentation.md b/Auto_update_documentation.md deleted file mode 100644 index 5a90308..0000000 --- a/Auto_update_documentation.md +++ /dev/null @@ -1,129 +0,0 @@ -# Auto-Update System for PiwigoPublish Plugin - -By Gotcha26 - contact@julien-moreau.fr - -## Overview - -The auto-update system allows the PiwigoPublish Lightroom plugin to check for new versions via the GitHub Releases API and notify users when updates are available. - -## Features - -- **Automatic check on startup**: Silent check when Lightroom loads (once per day) -- **Manual check**: Button in Plugin Manager to check on demand -- **Multi-format versioning**: Supports both date-based and SemVer formats -- **Cross-format comparison**: Can compare versions even when switching versioning schemes -- **User-friendly notifications**: Dialog with changelog and download link - -## Version Format Support - -| Format | Example | Use Case | -|--------|---------|----------| -| Date-based | `20260121.3` or `v20260121.3` | Current format, revision incremented daily | -| SemVer | `1.2.3` or `v1.2.3` | Industry standard, for future migration | - -The system automatically detects which format is used and applies the appropriate comparison logic. - -### Cross-Format Comparison - -When the local and remote versions use different formats, the system falls back to comparing the **GitHub release publish date** (`published_at` metadata) against the **build date** embedded in the local version. - -This ensures seamless updates even if the versioning scheme changes in the future. - -## Configuration - -In `UpdateChecker.lua`, the following constants can be adjusted: - -```lua -UpdateChecker.GITHUB_OWNER = "Piwigo" -- GitHub organization/user -UpdateChecker.GITHUB_REPO = "PiwigoPublish-lrc-plugin" -- Repository name -UpdateChecker.CHECK_INTERVAL_DAYS = 1 -- Days between automatic checks -``` - -## User Interface - -### Plugin Manager Section - -A new "Plugin Updates" section appears in **File > Plug-in Manager > Piwigo Publisher**: - -- Current version display -- Update status indicator -- "Check for Updates" button -- "Visit GitHub Repository" button - -### Update Notification Dialog - -When an update is available, users see: - -- Current vs. new version comparison -- Changelog excerpt (first 500 characters) -- "Download" button → opens GitHub release page -- "Later" button → dismisses until next check - -## Creating a New Release - -1. Update `VERSION` in `Info.lua`: - ```lua - VERSION = { major=20260122, minor=1, revision=0 }, - ``` - -2. Commit and push changes - -3. Create a GitHub Release: - - **Tag**: `v20260122.1` (or `v1.0.0` for SemVer) - - **Title**: Version number or descriptive title - - **Description**: Changelog in Markdown format - -4. The plugin will automatically detect the new release - -## Technical Details - -### API Endpoint - -``` -GET https://api.github.com/repos/{owner}/{repo}/releases/latest -``` - -### Response Fields Used - -| Field | Purpose | -|-------|---------| -| `tag_name` | Version identifier | -| `published_at` | Release date (ISO 8601) for cross-format comparison | -| `body` | Changelog content (Markdown) | -| `html_url` | Download page URL | - -### Storage (LrPrefs) - -| Key | Purpose | -|-----|---------| -| `lastUpdateCheck` | Timestamp of last check | -| `latestVersion` | Cached latest version string | -| `latestVersionUrl` | Cached download URL | -| `pluginBuildDate` | Build date for SemVer installations | - -## Functions Reference - -| Function | Description | -|----------|-------------| -| `parseVersion(versionStr)` | Converts version string to comparable number | -| `parseGitHubDate(dateStr)` | Parses ISO 8601 date to timestamp | -| `getInstalledVersionDate()` | Extracts build date from installed version | -| `shouldCheckForUpdates()` | Checks if interval has elapsed | -| `checkForUpdates(silent)` | Main update check logic | -| `openDownloadPage(url)` | Opens browser to download page | -| `getUpdateStatus()` | Returns status string for UI | - -## Future Considerations - -- **Migration to SemVer**: The system is ready for a versioning scheme change without breaking update detection -- **Pre-release support**: Could be extended to check for beta/RC releases via `prerelease` flag -- **Auto-download**: Could be enhanced to download and extract updates automatically (requires additional permissions) - -## Files Modified - -| File | Changes | -|------|---------| -| `UpdateChecker.lua` | New file - update checking logic | -| `Init.lua` | Added require and startup check | -| `PluginInfoDialogSections.lua` | Added "Plugin Updates" UI section | -| `Info.lua` | VERSION table (existing, used as source of truth) | \ No newline at end of file diff --git a/piwigoPublish.lrplugin/CustomMetadata.lua b/piwigoPublish.lrplugin/CustomMetadata.lua index d4e7ace..579ac66 100644 --- a/piwigoPublish.lrplugin/CustomMetadata.lua +++ b/piwigoPublish.lrplugin/CustomMetadata.lua @@ -23,7 +23,7 @@ return { - schemaVersion = 5, + schemaVersion = 6, metadataFieldsForPhotos = { { @@ -32,7 +32,7 @@ return { searchable = true, browsable = true, id = 'pwHostURL', - title = 'Piwigo Host', + title = "Piwigo Host", version = 3 }, { @@ -41,7 +41,7 @@ return { searchable = true, browsable = true, id = 'pwAlbumName', - title = 'Album Name', + title = "Album Name", version = 2 }, { @@ -50,7 +50,7 @@ return { searchable = false, browsable = false, id = 'pwAlbumURL', - title = 'Album URL', + title = "Album URL", version = 4 }, { @@ -59,7 +59,7 @@ return { searchable = false, browsable = false, id = 'pwImageURL', - title = 'Photo URL', + title = "Photo URL", version = 4 }, { @@ -68,7 +68,7 @@ return { searchable = true, browsable = true, id = 'pwUploadDate', - title = 'Upload Date', + title = "Upload Date", version = 2 }, { @@ -77,7 +77,7 @@ return { searchable = true, browsable = true, id = 'pwUploadTime', - title = 'Upload Time', + title = "Upload Time", version = 2 }, { @@ -86,8 +86,17 @@ return { searchable = false, browsable = false, id = 'pwCommentSync', - title = 'pwCommentSync', + title = "pwCommentSync", version = 1 }, + { + dataType = 'string', + readOnly = true, + searchable = false, + browsable = false, + id = 'pwVideoPreset', + title = "Video Preset", + version = 6 + }, } } diff --git a/piwigoPublish.lrplugin/Info.lua b/piwigoPublish.lrplugin/Info.lua index b506f8b..555f994 100644 --- a/piwigoPublish.lrplugin/Info.lua +++ b/piwigoPublish.lrplugin/Info.lua @@ -37,8 +37,8 @@ return { -- define custom metadata data for this plugin PublishSettings = { publishMetadata = { - { id = 'myCustomStatus', title = 'Status', type = 'string' }, - { id = 'syncToken', title = 'Token', type = 'string' }, + { id = 'myCustomStatus', title = "Status", type = 'string' }, + { id = 'syncToken', title = "Token", type = 'string' }, }, }, diff --git a/piwigoPublish.lrplugin/Init.lua b/piwigoPublish.lrplugin/Init.lua index 3cdb1f0..e922c46 100644 --- a/piwigoPublish.lrplugin/Init.lua +++ b/piwigoPublish.lrplugin/Init.lua @@ -51,23 +51,27 @@ _G.utils = require "utils" _G.PiwigoAPI = require "PiwigoAPI" _G.PWImportService = require "PWImportService" _G.PWStatusManager = require "PWStatusManager" +_G.vtk_core = require "vtk_core" +_G.vtk_ui = require "vtk_ui" --- Global initializations +-- Global initializations +-- Detect macOS vs Windows based on path separator in Lightroom's standard paths +local testPath = LrPathUtils.getStandardFilePath("documents") +_G.MAC_ENV = testPath:find("/") and not testPath:find("\\") or false _G.prefs = _G.LrPrefs.prefsForPlugin() -- logger setup _G.log = import 'LrLogger' ('PiwigoPublishPlugin') if prefs.debugEnabled == nil then prefs.debugEnabled = false end -if prefs.debugToFile == nil then - prefs.debugToFile = false +if prefs.clearLogOnReload == nil then + prefs.clearLogOnReload = false end if prefs.debugEnabled then - if prefs.debugToFile then - log:enable("logfile") - else - log:enable("print") + if prefs.clearLogOnReload then + utils.clearLogFiles() -- truncate log at each reload (dev mode) end + log:enable("logfile") else log:disable() end diff --git a/piwigoPublish.lrplugin/PWCollToSet.lua b/piwigoPublish.lrplugin/PWCollToSet.lua index aeef332..679327b 100644 --- a/piwigoPublish.lrplugin/PWCollToSet.lua +++ b/piwigoPublish.lrplugin/PWCollToSet.lua @@ -20,6 +20,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + --******************************************* local function CollToSet() log:info("CollToSet") diff --git a/piwigoPublish.lrplugin/PWExtraOptions.lua b/piwigoPublish.lrplugin/PWExtraOptions.lua index efd94d5..a4e8b83 100644 --- a/piwigoPublish.lrplugin/PWExtraOptions.lua +++ b/piwigoPublish.lrplugin/PWExtraOptions.lua @@ -20,6 +20,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + require "UIHelpers" -- ************************************************* @@ -96,7 +98,7 @@ local function main() f:spacer { height = 1 }, f:row { f:push_button { - title = 'Set Piwigo Album Cover', + title = "Set Piwigo Album Cover", tooltip = "Sets selected image as Piwigo album cover ", action = function(button) LrTasks.startAsyncTask(function() diff --git a/piwigoPublish.lrplugin/PWImportService.lua b/piwigoPublish.lrplugin/PWImportService.lua index 87fb567..120fd53 100644 --- a/piwigoPublish.lrplugin/PWImportService.lua +++ b/piwigoPublish.lrplugin/PWImportService.lua @@ -20,6 +20,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + local PWIMportService = {} local SPECIAL_PREFIX = "※" -- U+203B Reference Mark used by another plugin to identify super collections @@ -394,11 +396,7 @@ local function createTree(nodes, parentSet, publishService, created, childrenInd if pwDetails.isPiwigo then local collectionSettings = newCollorSet:getCollectionSetInfoSummary().collectionSettings or {} -- album settings set to correspond to service being cloned - if propertyTable.syncAlbumDescriptions then - collectionSettings.albumDescription = comment - else - collectionSettings.albumDescription = "" - end + collectionSettings.albumDescription = comment if status == "private" then collectionSettings.albumPrivate = true else @@ -409,13 +407,7 @@ local function createTree(nodes, parentSet, publishService, created, childrenInd local thisCat = PiwigoAPI.pwCategoriesGetThis(propertyTable, remoteAlbumId) if thisCat then -- use settings directly from Piwigo, overriding local settings - if propertyTable.syncAlbumDescriptions then - if thisCat.description then - collectionSettings.albumDescription = thisCat.description - else - collectionSettings.albumDescription = "" - end - end + collectionSettings.albumDescription = thisCat.description or "" if thisCat.status == "public" then collectionSettings.albumPrivate = false else @@ -476,11 +468,7 @@ local function createTree(nodes, parentSet, publishService, created, childrenInd if pwDetails.isPiwigo and pwDetails.isSameHost then -- this is the same Piwigo host then we can copy remote ids etc local collectionSettings = newCollorSet:getCollectionInfoSummary().collectionSettings or {} - if propertyTable.syncAlbumDescriptions then - collectionSettings.albumDescription = comment - else - collectionSettings.albumDescription = "" - end + collectionSettings.albumDescription = comment if status == "private" then collectionSettings.albumPrivate = true else @@ -490,13 +478,7 @@ local function createTree(nodes, parentSet, publishService, created, childrenInd -- check if remoote album exists and add to collection if so local thisCat = PiwigoAPI.pwCategoriesGetThis(propertyTable, remoteAlbumId) if thisCat then - if propertyTable.syncAlbumDescriptions then - if thisCat.description then - collectionSettings.albumDescription = thisCat.description - else - collectionSettings.albumDescription = "" - end - end + collectionSettings.albumDescription = thisCat.description or "" if thisCat.status == "public" then collectionSettings.albumPrivate = false else diff --git a/piwigoPublish.lrplugin/PWSendMetadata.lua b/piwigoPublish.lrplugin/PWSendMetadata.lua index 4fc74e0..fe9492e 100644 --- a/piwigoPublish.lrplugin/PWSendMetadata.lua +++ b/piwigoPublish.lrplugin/PWSendMetadata.lua @@ -20,6 +20,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + --******************************************* local function SendMetadata() log:info("SendMetadata") @@ -65,6 +67,11 @@ local function SendMetadata() return false end + if not useSource then + LrDialogs.message("SendMetadata - Can't find publish collection source", "", "warning") + return false + end + local result = LrDialogs.confirm("Send Metadata to Piwigo", "Send metadata to Piwigo for " .. #selPhotos .. " photo(s) in album " .. useSource:getName() .. "?", "Ok", "Cancel") diff --git a/piwigoPublish.lrplugin/PWSetAlbumCover.lua b/piwigoPublish.lrplugin/PWSetAlbumCover.lua index e134208..9f139ca 100644 --- a/piwigoPublish.lrplugin/PWSetAlbumCover.lua +++ b/piwigoPublish.lrplugin/PWSetAlbumCover.lua @@ -20,6 +20,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + --******************************************* local function SetAlbumCover() -- alternative routine that does not require a publish service to be selected @@ -78,13 +80,12 @@ local function SetAlbumCover() "warning") return false end - if not catId then - LrDialogs.message("SetAlbumCover - Can't find Piwigo album ID for remoteId for this publish collection", "", - "warning") + + if not useSource then + LrDialogs.message("SetAlbumCover - Can't find publish collection source", "", "warning") return false end - -- find publised photo in this collection / set local thisPubPhoto = utils.findPhotoInCollectionSet(useSource, selPhoto) if not thisPubPhoto then diff --git a/piwigoPublish.lrplugin/PiwigoAPI.lua b/piwigoPublish.lrplugin/PiwigoAPI.lua index de99f37..47169a3 100644 --- a/piwigoPublish.lrplugin/PiwigoAPI.lua +++ b/piwigoPublish.lrplugin/PiwigoAPI.lua @@ -22,12 +22,27 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . ]] + +---@diagnostic disable: undefined-global + local PiwigoAPI = {} -- ************************************************* -- L O C A L F U N C T I O N S -- ************************************************* +-- Strip PHP warnings/notices that Piwigo servers sometimes prepend to JSON responses. +-- Finds the first '{' or '[' and returns the substring from there. +local function stripPhpWarnings(body) + if not body then return body end + local j = body:find("[{%[]") + if j and j > 1 then + log:warn("PiwigoAPI - stripped " .. (j-1) .. " bytes of PHP output before JSON") + return body:sub(j) + end + return body +end + -- ************************************************* local function httpGet(url, params, headers) -- generic function to call LrHttp.Get @@ -67,7 +82,8 @@ local function httpGet(url, params, headers) return getResponse end - -- Try decoding JSON + -- Try decoding JSON (strip PHP warnings/notices if present) + body = stripPhpWarnings(body) local decoded = JSON:decode(body) if not decoded then log:info("PWAPI.httpGet - calling " .. getUrl) @@ -108,18 +124,14 @@ local function httpPost(propertyTable, params, headers) local body = utils.buildBodyFromParams(params) log:info("PiwigoAPI.pwConnect - connecting to " .. propertyTable.pwurl) - log:info("PiwigoAPI.pwConnect - body:\n" .. utils.serialiseVar(body)) local httpResponse, httpHeaders = LrHttp.post(propertyTable.pwurl, body, headers) - log:info("PiwigoAPI.pwConnect - response headers:\n" .. utils.serialiseVar(httpHeaders)) - log:info("PiwigoAPI.pwConnect - response body:\n" .. tostring(httpResponse)) - if (httpHeaders.status == 201) or (httpHeaders.status == 200) then -- successful connection to Piwigo -- Now check login result - local rtnBody = JSON:decode(httpResponse) - if rtnBody.stat == "ok" then + local rtnBody = JSON:decode(stripPhpWarnings(httpResponse)) + if rtnBody and rtnBody.stat == "ok" then -- login ok - store session cookies local cookies = {} local SessionCookie = "" @@ -142,7 +154,7 @@ local function httpPost(propertyTable, params, headers) propertyTable.cookieHeader = table.concat(propertyTable.cookies, "; ") propertyTable.Connected = true else - LrDialogs.message("Cannot log in to Piwigo - ", rtnBody.err .. ", " .. rtnBody.message) + LrDialogs.message("Cannot log in to Piwigo - ", (rtnBody and (rtnBody.err .. ", " .. rtnBody.message) or "Unknown error")) return false end else @@ -219,11 +231,10 @@ local function pwGetSessionStatus(propertyTable) if getResponse.response.result.status and (getResponse.response.result.status == "webmaster") then propertyTable.userStatus = getResponse.response.result.status propertyTable.token = getResponse.response.result.pwg_token - propertyTable.pwVersion = getResponse.response.result.version + propertyTable.pwVersion = getResponse.response.result.version or "unknown" propertyTable.Connected = true - propertyTable.ConCheck = false - propertyTable.ConStatus = "Connected to Piwigo Gallery at " .. propertyTable.host .. " as " .. - propertyTable.userStatus .. " - Piwigo version " .. propertyTable.pwVersion + propertyTable.ConCheck = true + propertyTable.ConStatus = "Connected to your Piwigo gallery v" .. propertyTable.pwVersion return true else propertyTable.Connected = false @@ -245,31 +256,39 @@ local function buildCatHierarchy(allCats) local nodes = {} local roots = {} for _, cat in ipairs(allCats) do - local id = tonumber(cat.id) - nodes[id] = { - id = id, - name = cat.name, - comment = cat.comment, - status = cat.status, - children = {} - } + if cat then + local id = tonumber(cat.id) + if id then + nodes[id] = { + id = id, + name = cat.name, + comment = cat.comment, + status = cat.status, + children = {} + } + end + end end -- 2. Attach nodes to parents for _, cat in ipairs(allCats) do - -- uppercats is a comma-separated list like "16,28" or "24,27" - local path = utils.stringtoTable(cat.uppercats, ",") - local id = tonumber(cat.id) - local node = nodes[id] - - if #path == 1 then - -- Top-level category - roots[#roots + 1] = node - else - -- Parent is the second-to-last element - local parent_id = tonumber(path[#path - 1]) - local parent = nodes[parent_id] - if parent then - parent.children[#parent.children + 1] = node + if cat then + -- uppercats is a comma-separated list like "16,28" or "24,27" + local path = utils.stringtoTable(cat.uppercats, ",") + local id = tonumber(cat.id) + if id then + local node = nodes[id] + + if #path == 1 then + -- Top-level category + roots[#roots + 1] = node + else + -- Parent is the second-to-last element + local parent_id = tonumber(path[#path - 1]) + local parent = nodes[parent_id] + if parent then + parent.children[#parent.children + 1] = node + end + end end end end @@ -775,11 +794,7 @@ function PiwigoAPI.createCollection(propertyTable, node, parentNode, isLeafNode, stat.errors = stat.errors + 1 else collectionSettings = newColl:getCollectionInfoSummary().collectionSettings or {} - if propertyTable.syncAlbumDescriptions then - collectionSettings.albumDescription = collDescription - else - collectionSettings.albumDescription = "" - end + collectionSettings.albumDescription = collDescription if collStatus == "private" then collectionSettings.albumPrivate = true else @@ -809,11 +824,7 @@ function PiwigoAPI.createCollection(propertyTable, node, parentNode, isLeafNode, else -- now add remoteids and urls to collections and collection sets, and description and status collectionSettings = newColl:getCollectionSetInfoSummary().collectionSettings or {} - if propertyTable.syncAlbumDescriptions then - collectionSettings.albumDescription = collDescription - else - collectionSettings.albumDescription = "" - end + collectionSettings.albumDescription = collDescription if collStatus == "private" then collectionSettings.albumPrivate = true else @@ -838,11 +849,7 @@ function PiwigoAPI.createCollection(propertyTable, node, parentNode, isLeafNode, -- existing collection log:info("createCollection - updating existing PublishedCollection " .. existingColl:getName()) collectionSettings = existingColl:getCollectionInfoSummary().collectionSettings or {} - if propertyTable.syncAlbumDescriptions then - collectionSettings.albumDescription = collDescription - else - collectionSettings.albumDescription = "" - end + collectionSettings.albumDescription = collDescription if collStatus == "private" then collectionSettings.albumPrivate = true else @@ -856,11 +863,7 @@ function PiwigoAPI.createCollection(propertyTable, node, parentNode, isLeafNode, -- existing collection set log:info("createCollection - updating existing PublishedCollectionSet " .. existingColl:getName()) collectionSettings = existingColl:getCollectionSetInfoSummary().collectionSettings or {} - if propertyTable.syncAlbumDescriptions then - collectionSettings.albumDescription = collDescription - else - collectionSettings.albumDescription = "" - end + collectionSettings.albumDescription = collDescription if collStatus == "private" then collectionSettings.albumPrivate = true else @@ -1088,6 +1091,9 @@ function PiwigoAPI.storeMetaData(catalog, lrPhoto, pluginData) lrPhoto:setPropertyForPlugin(_PLUGIN, "pwUploadDate", pluginData.pwUploadDate) lrPhoto:setPropertyForPlugin(_PLUGIN, "pwUploadTime", pluginData.pwUploadTime) lrPhoto:setPropertyForPlugin(_PLUGIN, "pwCommentSync", pluginData.pwCommentSync) + if pluginData.pwVideoPreset then + lrPhoto:setPropertyForPlugin(_PLUGIN, "pwVideoPreset", pluginData.pwVideoPreset) + end end) end @@ -1314,12 +1320,10 @@ function PiwigoAPI.pwConnect(propertyTable) -- successful connection to Piwigo -- Now check login result -- Decode JSON safely - local ok, rtnBody = pcall(JSON.decode, JSON, httpResponse) + local ok, rtnBody = pcall(JSON.decode, JSON, stripPhpWarnings(httpResponse)) if not ok or type(rtnBody) ~= "table" then log:info("PiwigoAPI.pwConnect - connecting to " .. propertyTable.pwurl) - log:info("PiwigoAPI.pwConnect - body:\n" .. utils.serialiseVar(body)) - log:info("PiwigoAPI.pwConnect - response headers:\n" .. utils.serialiseVar(httpHeaders)) - log:info("PiwigoAPI.pwConnect - response body:\n" .. tostring(httpResponse)) + log:info("PiwigoAPI.pwConnect - invalid/unreadable server response") LrDialogs.message("Cannot log in to Piwigo", "Invalid or unreadable server response") return false end @@ -1346,17 +1350,13 @@ function PiwigoAPI.pwConnect(propertyTable) propertyTable.cookieHeader = table.concat(propertyTable.cookies, "; ") propertyTable.Connected = true else - log:info("PiwigoAPI.pwConnect - connecting to " .. propertyTable.pwurl) - log:info("PiwigoAPI.pwConnect - body:\n" .. utils.serialiseVar(body)) + log:info("PiwigoAPI.pwConnect - login rejected by server: " .. tostring(rtnBody.err or "?")) LrDialogs.message("Cannot log in to Piwigo", tostring(rtnBody.err or "Unknown error") .. (rtnBody.message and (", " .. rtnBody.message) or "")) return false end else - log:info("PiwigoAPI.pwConnect - connecting to " .. propertyTable.pwurl) - log:info("PiwigoAPI.pwConnect - body:\n" .. utils.serialiseVar(body)) - log:info("PiwigoAPI.pwConnect - response headers:\n" .. utils.serialiseVar(httpHeaders)) - log:info("PiwigoAPI.pwConnect - response body:\n" .. tostring(httpResponse)) + log:info("PiwigoAPI.pwConnect - HTTP error connecting to " .. propertyTable.pwurl) local statusCode, statusDesc status = httpHeaders and httpHeaders.status if httpHeaders and httpHeaders.error then @@ -1431,14 +1431,9 @@ function PiwigoAPI.getInfos(propertyTable) end local getResponse = httpGet(propertyTable.pwurl, Params, headers) if getResponse.errorMessage or (not getResponse.response) then - log:info("PiwigoAPI.getInfos - Params\n" .. utils.serialiseVar(Params)) - log:info("PiwigoAPI.getInfos - headers\n" .. utils.serialiseVar(headers)) - log:info("PiwigoAPI.getInfos - getResponse\n" .. utils.serialiseVar(getResponse)) - LrDialogs.message("PiwigoAPI.getInfos - Cannot get host information from Piwigo - " .. - (getResponse.errorMessage or "Unknown error")) + LrDialogs.message("Cannot get user status from Piwigo - " .. (getResponse.errorMessage or "Unknown error")) return false end - if getResponse.status == "ok" then rtnStatus.status = true local apiResult = getResponse.response.result @@ -1454,15 +1449,145 @@ function PiwigoAPI.getInfos(propertyTable) end end else - log:info("PiwigoAPI.getInfos - Params\n" .. utils.serialiseVar(Params)) - log:info("PiwigoAPI.getInfos - headers\n" .. utils.serialiseVar(headers)) - log:info("PiwigoAPI.getInfos - getResponse\n" .. utils.serialiseVar(getResponse)) rtnStatus.message = "Cannot get host information from Piwigo - " .. ((getResponse.status .. " - " .. (getResponse.errorMessage or "Unknown error")) or "Unknown error") end + return rtnStatus +end +-- ************************************************* +function PiwigoAPI.getServerVideoSupport(propertyTable) + -- Check server capabilities for video support + -- Returns { status, piwigoVersion, videoJsInstalled, videoJsActive, serverInfos } + log:info("PiwigoAPI.getServerVideoSupport") + local result = { + status = false, + piwigoVersion = propertyTable.pwVersion or "unknown", + videoJsInstalled = false, + videoJsActive = false, + serverInfos = {}, + } - return rtnStatus + -- 1. Get server infos (photo/album counts etc.) + local infosResult = PiwigoAPI.getInfos(propertyTable) + if infosResult.status and infosResult.result then + -- pwg.getInfos returns a named array of {name, value} items + for _, item in ipairs(infosResult.result) do + if item.name and item.value then + result.serverInfos[item.name] = item.value + end + end + end + + -- 2. Check for VideoJS plugin via pwg.plugins.getList + local pluginParams = { { + name = "method", + value = "pwg.plugins.getList" + } } + local headers = {} + if propertyTable.cookieHeader ~= nil then + headers = { + ["Cookie"] = propertyTable.cookieHeader + } + end + local getResponse = httpGet(propertyTable.pwurl, pluginParams, headers) + if getResponse.status == "ok" and getResponse.response and getResponse.response.result then + -- pwg.plugins.getList may return plugins under .plugins key or directly as result + local responseResult = getResponse.response.result + + -- Try to find the plugins array in various possible structures + local plugins = nil + if type(responseResult) == "table" then + if responseResult.plugins and type(responseResult.plugins) == "table" then + plugins = responseResult.plugins + elseif #responseResult > 0 then + -- result is directly an array of plugins + plugins = responseResult + end + end + + if plugins then + for _, plugin in ipairs(plugins) do + if type(plugin) == "table" then + local pluginId = tostring(plugin.id or "") + local pluginName = tostring(plugin.name or "") + -- Match on id or name, covering "piwigo-videojs", "videojs", "VideoJS", etc. + local idLower = pluginId:lower() + local nameLower = pluginName:lower() + if idLower:find("videojs") or idLower:find("video_js") + or nameLower:find("videojs") or nameLower:find("video_js") then + result.videoJsInstalled = true + local state = plugin.state and tostring(plugin.state) or "unknown" + result.videoJsActive = (state == "active") + result.videoJsName = plugin.name or pluginId + log:info("PiwigoAPI.getServerVideoSupport - VideoJS plugin found: id=" .. + pluginId .. " name=" .. pluginName .. " state=" .. state) + break + end + end + end + if not result.videoJsInstalled then + log:info("PiwigoAPI.getServerVideoSupport - scanned " .. #plugins .. + " plugins, VideoJS not found") + end + else + log:info("PiwigoAPI.getServerVideoSupport - unexpected plugin list structure") + end + else + log:info("PiwigoAPI.getServerVideoSupport - cannot retrieve plugin list: " .. + (getResponse.errorMessage or "unknown error")) + end + + -- 3. Get detailed server config via pwg.companion.getConfig (PiwigoPublish Companion plugin) + result.serverConfig = nil + result.companionAvailable = false + local configParams = { { + name = "method", + value = "pwg.companion.getConfig" + } } + local cfgHeaders = {} + if propertyTable.cookieHeader ~= nil then + cfgHeaders = { + ["Cookie"] = propertyTable.cookieHeader + } + end + local cfgResponse = httpGet(propertyTable.pwurl, configParams, cfgHeaders) + if cfgResponse.status == "ok" and cfgResponse.response and cfgResponse.response.result then + result.serverConfig = cfgResponse.response.result + result.companionAvailable = true + log:info("PiwigoAPI.getServerVideoSupport - companion plugin detected, config retrieved") + else + log:info("PiwigoAPI.getServerVideoSupport - serverinfo plugin not available: " .. + (cfgResponse.errorMessage or "unknown error")) + end + + result.status = true + return result +end + +-- ************************************************* +function PiwigoAPI.enableVideoSupport(propertyTable) + -- Call pwg.companion.enableVideoSupport to auto-configure video uploads on the server + log:info("PiwigoAPI.enableVideoSupport") + local params = { { + name = "method", + value = "pwg.companion.enableVideoSupport" + } } + local headers = {} + if propertyTable.cookieHeader ~= nil then + headers = { + ["Cookie"] = propertyTable.cookieHeader + } + end + local getResponse = httpGet(propertyTable.pwurl, params, headers) + if getResponse.status == "ok" and getResponse.response and getResponse.response.result then + local apiResult = getResponse.response.result + log:info("PiwigoAPI.enableVideoSupport - result: " .. utils.serialiseVar(apiResult)) + return apiResult + else + log:info("PiwigoAPI.enableVideoSupport - failed: " .. (getResponse.errorMessage or "unknown error")) + return { status = "error", message = getResponse.errorMessage or "unknown error" } + end end -- ************************************************* @@ -1629,6 +1754,7 @@ function PiwigoAPI.pwCategoriesGetThis(propertyTable, thisCat) return nil end -- go through allCats to find thisCat + allCats = allCats or {} for _, cat in ipairs(allCats) do if tostring(cat.id) == tostring(thisCat) then return cat @@ -1739,19 +1865,19 @@ function PiwigoAPI.pwCategoriesMove(propertyTable, info, thisCat, newCat, callSt local parseResp if httpResponse then - parseResp = JSON:decode(httpResponse) + parseResp = JSON:decode(stripPhpWarnings(httpResponse)) end if httpHeaders.status == 201 or httpHeaders.status == 200 then - if parseResp.stat == "ok" then + if parseResp and parseResp.stat == "ok" then callStatus.status = true callStatus.statusMsg = "" else callStatus.status = false - callStatus.statusMsg = parseResp.message or "" + callStatus.statusMsg = (parseResp and parseResp.message) or "" end else callStatus.status = false - callStatus.statusMsg = parseResp.message or "" + callStatus.statusMsg = (parseResp and parseResp.message) or "" end return callStatus @@ -1798,18 +1924,16 @@ function PiwigoAPI.pwCategoriesAdd(propertyTable, info, metaData, callStatus) value = propertyTable.token } } - if propertyTable.syncAlbumDescriptions then - table.insert(Params, { - name = "comment", - value = description - }) - end + table.insert(Params, { + name = "comment", + value = description + }) + table.insert(Params, { name = "status", value = albumstatus }) - if metaData.parentCat ~= "" then table.insert(Params, { name = "parent", @@ -1912,19 +2036,19 @@ function PiwigoAPI.pwCategoriesDelete(propertyTable, info, metaData, callStatus) local parseResp if httpResponse then - parseResp = JSON:decode(httpResponse) + parseResp = JSON:decode(stripPhpWarnings(httpResponse)) end if httpHeaders.status == 201 or httpHeaders.status == 200 then - if parseResp.stat == "ok" then + if parseResp and parseResp.stat == "ok" then callStatus.status = true callStatus.statusMsg = "" else callStatus.status = false - callStatus.statusMsg = parseResp.message or "" + callStatus.statusMsg = (parseResp and parseResp.message) or "" end else callStatus.status = false - callStatus.statusMsg = parseResp.message or "" + callStatus.statusMsg = (parseResp and parseResp.message) or "" end return callStatus end @@ -1973,12 +2097,10 @@ function PiwigoAPI.pwCategoriesSetinfo(propertyTable, info, metaData) name = "pwg_token", value = propertyTable.token } } - if propertyTable.syncAlbumDescriptions then - table.insert(params, { - name = "comment", - value = description - }) - end + table.insert(params, { + name = "comment", + value = description + }) table.insert(params, { name = "status", value = status @@ -1994,10 +2116,10 @@ function PiwigoAPI.pwCategoriesSetinfo(propertyTable, info, metaData) local body if httpResponse then - body = JSON:decode(httpResponse) + body = JSON:decode(stripPhpWarnings(httpResponse)) end if httpHeaders.status == 201 or httpHeaders.status == 200 then - if body.stat == "ok" then + if body and body.stat == "ok" then callStatus.status = true callStatus.statusMsg = "" else @@ -2005,14 +2127,14 @@ function PiwigoAPI.pwCategoriesSetinfo(propertyTable, info, metaData) log:info("PiwigoAPI.pwCategoriesSetinfo - httpHeaders\n" .. utils.serialiseVar(httpHeaders)) log:info("PiwigoAPI.pwCategoriesSetinfo - httpResponse\n" .. utils.serialiseVar(httpResponse)) callStatus.status = false - callStatus.statusMsg = "Category " .. tostring(remoteId) .. " - " .. (body.message or "") + callStatus.statusMsg = "Category " .. tostring(remoteId) .. " - " .. ((body and body.message) or "") end else log:info("PiwigoAPI.pwCategoriesSetinfo - params \n" .. utils.serialiseVar(params)) log:info("PiwigoAPI.pwCategoriesSetinfo - httpHeaders\n" .. utils.serialiseVar(httpHeaders)) log:info("PiwigoAPI.pwCategoriesSetinfo - httpResponse\n" .. utils.serialiseVar(httpResponse)) callStatus.status = false - callStatus.statusMsg = "Category " .. tostring(remoteId) .. " - " .. (body.message or "") + callStatus.statusMsg = "Category " .. tostring(remoteId) .. " - " .. ((body and body.message) or "") end return callStatus @@ -2142,43 +2264,36 @@ function PiwigoAPI.dissociateImageFromCategory(propertyTable, imageId, categoryI end log:info("PiwigoAPI.dissociateImageFromCategory - remaining categories: " .. #newCategoryIds) - callStatus.deletedImage = false + -- If image would be orphaned (no remaining categories), delete it entirely if #newCategoryIds == 0 then log:info("PiwigoAPI.dissociateImageFromCategory - image would be orphaned, deleting entirely") - local delcallStatus = PiwigoAPI.deletePhoto(propertyTable, categoryId, imageId, callStatus) - if delcallStatus.status then - callStatus.status = true - callStatus.statusMsg = "Image removed from category and deleted (was orphaned)" - else - callStatus.statusMsg = delcallStatus.statusMsg or "Failed to delete orphaned image" - end + return PiwigoAPI.deletePhoto(propertyTable, categoryId, imageId, callStatus) + end - callStatus.deletedImage = true - else - -- Update image with new categories list (replaces all associations) - local categoriesStr = table.concat(newCategoryIds, ";") + -- Update image with new categories list (replaces all associations) + local categoriesStr = table.concat(newCategoryIds, ";") - local params = { - { name = "method", value = "pwg.images.setInfo" }, - { name = "image_id", value = tostring(imageId) }, - { name = "categories", value = categoriesStr }, - { name = "multiple_value_mode", value = "replace" }, - { name = "pwg_token", value = propertyTable.token } - } + local params = { + { name = "method", value = "pwg.images.setInfo" }, + { name = "image_id", value = tostring(imageId) }, + { name = "categories", value = categoriesStr }, + { name = "multiple_value_mode", value = "replace" }, + { name = "pwg_token", value = propertyTable.token } + } - log:info("PiwigoAPI.dissociateImageFromCategory - new categories string: " .. categoriesStr) + log:info("PiwigoAPI.dissociateImageFromCategory - new categories string: " .. categoriesStr) - local postResponse = PiwigoAPI.httpPostMultiPart(propertyTable, params) + local postResponse = PiwigoAPI.httpPostMultiPart(propertyTable, params) - if postResponse.status then - callStatus.status = true - log:info("PiwigoAPI.dissociateImageFromCategory - success") - else - callStatus.statusMsg = postResponse.statusMsg or "Dissociation failed" - log:info("PiwigoAPI.dissociateImageFromCategory - failed: " .. callStatus.statusMsg) - end + if postResponse.status then + callStatus.status = true + log:info("PiwigoAPI.dissociateImageFromCategory - success") + else + callStatus.statusMsg = postResponse.statusMsg or "Dissociation failed" + log:info("PiwigoAPI.dissociateImageFromCategory - failed: " .. callStatus.statusMsg) end + return callStatus end @@ -2235,15 +2350,25 @@ function PiwigoAPI.updateGallery(propertyTable, exportFilename, metaData) end end local fileType = LrPathUtils.extension(exportFilename):lower() - local contentType = "" - if fileType == "png" then - contentType = "image/png" - elseif fileType == "jpg" or fileType == "jpeg" then - contentType = "image/jpeg" - else - callStatus.statusMsg = "Upload failed - forbidden file type" + local contentTypeMap = { + png = "image/png", + jpg = "image/jpeg", + jpeg = "image/jpeg", + mp4 = "video/mp4", + m4v = "video/mp4", + mov = "video/quicktime", + avi = "video/x-msvideo", + mpg = "video/mpeg", + mpeg = "video/mpeg", + ogg = "video/ogg", + ogv = "video/ogg", + webm = "video/webm", + } + local contentType = contentTypeMap[fileType] + if not contentType then + callStatus.statusMsg = "Upload failed - unsupported file type: " .. fileType LrDialogs.message("Cannot upload " .. LrPathUtils.leafName(exportFilename) .. - " to Piwigo - forbidden file type. Check file settings in Publishing Manager.") + " to Piwigo - unsupported file type (" .. fileType .. "). Check file settings in Publishing Manager.") return callStatus end table.insert(params, { @@ -2268,18 +2393,30 @@ function PiwigoAPI.updateGallery(propertyTable, exportFilename, metaData) if httpHeaders.status == 201 or httpHeaders.status == 200 then local rv, response = pcall(function() - return JSON:decode(httpResponse) + return JSON:decode(stripPhpWarnings(httpResponse)) end) if not (rv) then log:info("PiwigoAPI.updateGallery - params \n" .. utils.serialiseVar(params)) log:info("PiwigoAPI.updateGallery - httpHeaders\n" .. utils.serialiseVar(httpHeaders)) log:info("PiwigoAPI.updateGallery - httpResponse\n" .. utils.serialiseVar(httpResponse)) - callStatus.statusMsg = "Upload failed - Invalid JSON response - " .. tostring(httpResponse) - LrDialogs.message("Cannot upload " .. LrPathUtils.leafName(exportFilename) .. - " to Piwigo - Invalid JSON response - " .. tostring(httpResponse)) + -- Detect Piwigo server-side file type rejection (die() responses, not JSON) + local rawResponse = tostring(httpResponse):lower() + if rawResponse:find("forbidden file type") or rawResponse:find("unexpected file type") then + callStatus.statusMsg = "Upload rejected by Piwigo server - file type not allowed" + LrDialogs.message("Cannot upload " .. LrPathUtils.leafName(exportFilename) .. + " to Piwigo — the server rejected this file type.\n\n" .. + "To allow video uploads, configure your Piwigo server:\n" .. + "1. Edit local/config/config.inc.php\n" .. + "2. Add: $conf['upload_form_all_types'] = true;\n" .. + "3. Add video extensions to $conf['file_ext']") + else + callStatus.statusMsg = "Upload failed - Invalid JSON response - " .. tostring(httpResponse) + LrDialogs.message("Cannot upload " .. LrPathUtils.leafName(exportFilename) .. + " to Piwigo - Invalid JSON response - " .. tostring(httpResponse)) + end return callStatus end - if response.stat == "ok" then + if response and response.stat == "ok" then callStatus.remoteid = response.result.image_id callStatus.remoteurl = response.result.url callStatus.status = true @@ -2333,10 +2470,10 @@ function PiwigoAPI.updateGallery(propertyTable, exportFilename, metaData) end if not (uploadSuccess) then if httpHeaders.error then - statusDes = httpHeaders.error.name or "" + statusDes = httpHeaders.error.name status = httpHeaders.error.errorCode else - statusDes = httpHeaders.statusDes or "" + statusDes = httpHeaders.statusDes status = httpHeaders.status end LrDialogs.message("Cannot upload - " .. metaData.fileName .. " to Piwigo - " .. status, statusDes) @@ -2522,30 +2659,28 @@ function PiwigoAPI.deletePhoto(propertyTable, pwCatID, pwImageID, callStatus) local body if httpResponse then - body = JSON:decode(httpResponse) + body = JSON:decode(stripPhpWarnings(httpResponse)) end if httpHeaders.status == 201 or httpHeaders.status == 200 then - if body.stat == "ok" then + if body and body.stat == "ok" then callStatus.status = true callStatus.statusMsg = "" else - log:info("PiwigoAPI.deletePhoto - propertyTable \n " .. - utils.serialiseVar(utils.anonymisePropertyTable(propertyTable))) + log:info("PiwigoAPI.deletePhoto - propertyTable \n " .. utils.serialiseVar(utils.anonymisePropertyTable(propertyTable))) log:info("PiwigoAPI.deletePhoto - params \n" .. utils.serialiseVar(params)) log:info("PiwigoAPI.deletePhoto - httpResponse \n" .. utils.serialiseVar(httpResponse)) log:info("PiwigoAPI.deletePhoto - httpHeaders \n" .. utils.serialiseVar(httpHeaders)) callStatus.status = false - callStatus.statusMsg = body.message or "" + callStatus.statusMsg = (body and body.message) or "" end else - log:info("PiwigoAPI.deletePhoto - propertyTable \n " .. - utils.serialiseVar(utils.anonymisePropertyTable(propertyTable))) + log:info("PiwigoAPI.deletePhoto - propertyTable \n " .. utils.serialiseVar(utils.anonymisePropertyTable(propertyTable))) log:info("PiwigoAPI.deletePhoto - params \n" .. utils.serialiseVar(params)) log:info("PiwigoAPI.deletePhoto - httpResponse \n" .. utils.serialiseVar(httpResponse)) log:info("PiwigoAPI.deletePhoto - httpHeaders \n" .. utils.serialiseVar(httpHeaders)) callStatus.status = false - callStatus.statusMsg = body.message or "" + callStatus.statusMsg = (body and body.message) or "" end return callStatus end @@ -2644,8 +2779,7 @@ function PiwigoAPI.addComment(publishSettings, metaData) -- get antispam token from image details (unique for each image) local rtnStatus = PiwigoAPI.checkPhoto(publishSettings, metaData.remoteId) if not rtnStatus.status then - log:info("PiwigoAPI.addComment - unanble to retrieve token\n" .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info("PiwigoAPI.addComment - unanble to retrieve token\n" .. utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) return false end local imageDets = rtnStatus.imageDets @@ -2668,13 +2802,11 @@ function PiwigoAPI.addComment(publishSettings, metaData) return false end if utils.nilOrEmpty(author) then - log:info("PiwigoAPI.addComment - missing author\n" .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info("PiwigoAPI.addComment - missing author\n" .. utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) return false end if utils.nilOrEmpty(key) then - log:info("PiwigoAPI.addComment - missing key\n" .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info("PiwigoAPI.addComment - missing key\n" .. utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) return false end -- Piwigo antispam forces a delay between the key being created and used @@ -2771,7 +2903,6 @@ function PiwigoAPI.setAlbumCover(publishService) log:info("publishservice" .. publishService:getName()) local catalog = LrApplication.activeCatalog() local publishSettings = publishService:getPublishSettings() - log:info("publishSettings\n" .. utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) if not publishSettings then LrDialogs.message("PiwigoAPI.setAlbumCover - Can't find PublishSettings for this publish collection", "", @@ -3020,7 +3151,7 @@ function PiwigoAPI.httpPostMultiPart(propertyTable, params) local body if httpResponse then - body = JSON:decode(httpResponse) + body = JSON:decode(stripPhpWarnings(httpResponse)) end if httpHeaders then postHeaders.status = httpHeaders.status @@ -3058,6 +3189,72 @@ function PiwigoAPI.httpPostMultiPart(propertyTable, params) return postResponse end +-- ************************************************* +function PiwigoAPI.pwImagesSetRank(publishSettings, categoryId, imageIdSequence) + -- Set the display rank (sort order) of images within a Piwigo album + -- Uses Mode B of pwg.images.setRank: pass all image_id values as a + -- comma-separated list + category_id. Piwigo assigns rank 1,2,3... automatically. + + log:info("PiwigoAPI.pwImagesSetRank - category " .. tostring(categoryId) .. + ", " .. #imageIdSequence .. " images") + + local callStatus = {} + callStatus.status = false + callStatus.statusMsg = "" + + if not categoryId or #imageIdSequence == 0 then + callStatus.statusMsg = "PiwigoAPI.pwImagesSetRank - missing categoryId or empty sequence" + return callStatus + end + + local rv + -- check connection to piwigo + if not (publishSettings.Connected) then + rv = PiwigoAPI.login(publishSettings) + if not rv then + callStatus.statusMsg = "PiwigoAPI.pwImagesSetRank - cannot connect to piwigo" + return callStatus + end + end + + -- check role is admin level + if publishSettings.userStatus ~= "webmaster" then + callStatus.statusMsg = "PiwigoAPI.pwImagesSetRank - User needs webmaster role on piwigo gallery at " .. + publishSettings.host .. " to set photo sort order" + return callStatus + end + + -- Build comma-separated list of image IDs + local idStrings = {} + for _, id in ipairs(imageIdSequence) do + table.insert(idStrings, tostring(id)) + end + local imageIdList = table.concat(idStrings, ",") + + local params = { { + name = "method", + value = "pwg.images.setRank" + }, { + name = "image_id", + value = imageIdList + }, { + name = "category_id", + value = tostring(categoryId) + } } + + local postResponse = PiwigoAPI.httpPostMultiPart(publishSettings, params) + + if postResponse.status then + callStatus.status = true + log:info("PiwigoAPI.pwImagesSetRank - success for category " .. tostring(categoryId)) + else + callStatus.statusMsg = "PiwigoAPI.pwImagesSetRank - " .. (postResponse.statusMsg or "unknown error") + log:info(callStatus.statusMsg) + end + + return callStatus +end + -- ************************************************* function PiwigoAPI.createHeaders(propertyTable) return { { @@ -3101,115 +3298,259 @@ function PiwigoAPI.createHeadersForMultipartPut(propertyTable, boundary, length) end -- ************************************************* -function PiwigoAPI.getServerVideoSupport(propertyTable) - -- Check server capabilities for video support - -- Returns { status, piwigoVersion, videoJsInstalled, videoJsActive, serverInfos } - log:info("PiwigoAPI.getServerVideoSupport") - local result = { - status = false, - piwigoVersion = propertyTable.pwVersion or "unknown", - videoJsInstalled = false, - videoJsActive = false, - serverInfos = {}, - } +function PiwigoAPI.uploadVideoChunked(propertyTable, filePath, metaData, chunkSizeBytes) + -- Upload a video file in chunks via pwg.images.upload (bypasses PHP upload_max_filesize) + -- chunkSizeBytes defaults to 512 KB + -- Returns { status, remoteid, remoteurl, statusMsg } - -- 1. Get server infos (photo/album counts etc.) - local infosResult = PiwigoAPI.getInfos(propertyTable) - if infosResult.status and infosResult.result then - -- pwg.getInfos returns a named array of {name, value} items - for _, item in ipairs(infosResult.result) do - if item.name and item.value then - result.serverInfos[item.name] = item.value - end - end - end + local callStatus = { status = false, remoteid = "", remoteurl = "", statusMsg = "" } + chunkSizeBytes = chunkSizeBytes or (512 * 1024) - -- 2. Check for VideoJS plugin via pwg.plugins.getList - local pluginParams = { { - name = "method", - value = "pwg.plugins.getList" - } } local headers = {} if propertyTable.cookieHeader ~= nil then - headers = { - ["Cookie"] = propertyTable.cookieHeader - } + headers = { ["Cookie"] = propertyTable.cookieHeader } end - local getResponse = httpGet(propertyTable.pwurl, pluginParams, headers) - if getResponse.status == "ok" and getResponse.response and getResponse.response.result then - -- pwg.plugins.getList may return plugins under .plugins key or directly as result - local responseResult = getResponse.response.result - log:info("PiwigoAPI.getServerVideoSupport - plugin list response keys: " .. - utils.serialiseVar(responseResult)) - -- Try to find the plugins array in various possible structures - local plugins = nil - if type(responseResult) == "table" then - if responseResult.plugins and type(responseResult.plugins) == "table" then - plugins = responseResult.plugins - elseif #responseResult > 0 then - -- result is directly an array of plugins - plugins = responseResult - end + -- Open file + local fh = io.open(filePath, "rb") + if not fh then + callStatus.statusMsg = "uploadVideoChunked - cannot open file: " .. filePath + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus + end + + local fileSize = fh:seek("end") + fh:seek("set", 0) + + local originalFilename = LrPathUtils.leafName(filePath) + local fileType = LrPathUtils.extension(filePath):lower() + local contentTypeMap = { + mp4 = "video/mp4", m4v = "video/mp4", mov = "video/quicktime", + avi = "video/x-msvideo", mpg = "video/mpeg", mpeg = "video/mpeg", + ogg = "video/ogg", ogv = "video/ogg", webm = "video/webm", + } + local contentType = contentTypeMap[fileType] or "application/octet-stream" + + local totalChunks = math.ceil(fileSize / chunkSizeBytes) + local uploadedImageId = nil + log:info(string.format("PiwigoAPI.uploadVideoChunked - %s, size=%d, chunks=%d", + originalFilename, fileSize, totalChunks)) + + for chunkIdx = 0, totalChunks - 1 do + local data = fh:read(chunkSizeBytes) + if not data then break end + + -- Write chunk to a temp file (LrHttp.postMultipart needs a file path) + local tmpPath = LrPathUtils.child(LrPathUtils.getStandardFilePath("temp"), + "vtk_chunk_" .. chunkIdx .. ".bin") + local tmpFh = io.open(tmpPath, "wb") + if not tmpFh then + fh:close() + callStatus.statusMsg = "uploadVideoChunked - cannot write temp chunk: " .. tmpPath + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus end + tmpFh:write(data) + tmpFh:close() - if plugins then - for _, plugin in ipairs(plugins) do - if type(plugin) == "table" then - local pluginId = tostring(plugin.id or "") - local pluginName = tostring(plugin.name or "") - -- Match on id or name, covering "piwigo-videojs", "videojs", "VideoJS", etc. - local idLower = pluginId:lower() - local nameLower = pluginName:lower() - if idLower:find("videojs") or idLower:find("video_js") - or nameLower:find("videojs") or nameLower:find("video_js") then - result.videoJsInstalled = true - local state = plugin.state and tostring(plugin.state) or "unknown" - result.videoJsActive = (state == "active") - result.videoJsName = plugin.name or pluginId - log:info("PiwigoAPI.getServerVideoSupport - VideoJS plugin found: id=" .. - pluginId .. " name=" .. pluginName .. " state=" .. state) - break - end - end - end - if not result.videoJsInstalled then - log:info("PiwigoAPI.getServerVideoSupport - scanned " .. #plugins .. - " plugins, VideoJS not found") - end - else - log:info("PiwigoAPI.getServerVideoSupport - unexpected plugin list structure") + local params = { + { name = "method", value = "pwg.images.upload" }, + { name = "category", value = tostring(metaData.Albumid) }, + { name = "pwg_token", value = propertyTable.token }, + { name = "original_sum", value = "" }, -- filled after all chunks if needed + { name = "position", value = tostring(chunkIdx) }, + { name = "type", value = "file" }, + { name = "filename", value = originalFilename }, + { + name = "file", + filePath = tmpPath, + fileName = originalFilename, + contentType = contentType, + }, + } + if uploadedImageId then + table.insert(params, { name = "image_id", value = tostring(uploadedImageId) }) end + if metaData.Title and metaData.Title ~= "" then + table.insert(params, { name = "name", value = metaData.Title }) + end + + local httpResponse, httpHeaders = LrHttp.postMultipart(propertyTable.pwurl, params, { + headers = { field = "Cookie", value = propertyTable.SessionCookie } + }) + LrFileUtils.delete(tmpPath) + + if not httpHeaders or (httpHeaders.status ~= 200 and httpHeaders.status ~= 201) then + fh:close() + callStatus.statusMsg = "uploadVideoChunked - HTTP error on chunk " .. chunkIdx + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus + end + + local ok, body = pcall(function() return JSON:decode(stripPhpWarnings(httpResponse)) end) + if not ok or not body or body.stat ~= "ok" then + fh:close() + local msg = (body and body.message) or tostring(httpResponse) + callStatus.statusMsg = "uploadVideoChunked - API error on chunk " .. chunkIdx .. ": " .. msg + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus + end + + -- After first chunk Piwigo returns the image_id + if body.result and body.result.image_id then + uploadedImageId = tostring(body.result.image_id) + end + if body.result and body.result.url and callStatus.remoteurl == "" then + callStatus.remoteurl = body.result.url + end + + log:info(string.format("PiwigoAPI.uploadVideoChunked - chunk %d/%d ok, image_id=%s", + chunkIdx + 1, totalChunks, tostring(uploadedImageId))) + end + + fh:close() + + if not uploadedImageId then + callStatus.statusMsg = "uploadVideoChunked - no image_id returned by Piwigo" + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus + end + + -- Finalise upload + local finalParams = { + { name = "method", value = "pwg.images.uploadCompleted" }, + { name = "image_id", value = uploadedImageId }, + { name = "pwg_token", value = propertyTable.token }, + { name = "category_id", value = tostring(metaData.Albumid) }, + } + local finalResp = httpGet(propertyTable.pwurl, finalParams, headers) + if finalResp.status ~= "ok" then + callStatus.statusMsg = "uploadVideoChunked - uploadCompleted failed: " + .. (finalResp.errorMessage or "unknown") + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus + end + + callStatus.status = true + callStatus.remoteid = uploadedImageId + log:info("PiwigoAPI.uploadVideoChunked - done, image_id=" .. uploadedImageId) + return callStatus +end + +-- ************************************************* +function PiwigoAPI.setRepresentative(propertyTable, imageId, posterPath) + -- Upload a poster/thumbnail image for a video via pwg.companion.setRepresentative + -- Returns { status, statusMsg } + + local callStatus = { status = false, statusMsg = "" } + + if not LrFileUtils.exists(posterPath) then + callStatus.statusMsg = "setRepresentative - poster file not found: " .. posterPath + log:info("PiwigoAPI." .. callStatus.statusMsg) + return callStatus + end + + local params = { + { name = "method", value = "pwg.companion.setRepresentative" }, + { name = "image_id", value = tostring(imageId) }, + { + name = "file", + filePath = posterPath, + fileName = LrPathUtils.leafName(posterPath), + contentType = "image/jpeg", + }, + } + + local postResp = PiwigoAPI.httpPostMultiPart(propertyTable, params) + if postResp.status then + callStatus.status = true + log:info("PiwigoAPI.setRepresentative - ok for image_id=" .. tostring(imageId)) else - log:info("PiwigoAPI.getServerVideoSupport - cannot retrieve plugin list: " .. - (getResponse.errorMessage or "unknown error")) + callStatus.statusMsg = "setRepresentative - failed: " .. (postResp.statusMsg or "") + log:info("PiwigoAPI." .. callStatus.statusMsg) end + return callStatus +end - -- 3. Get detailed server config via pwg.companion.getConfig (PiwigoPublish Companion plugin) - result.serverConfig = nil - result.companionAvailable = false - local configParams = { { - name = "method", - value = "pwg.companion.getConfig" - } } - local cfgHeaders = {} - if propertyTable.cookieHeader ~= nil then - cfgHeaders = { - ["Cookie"] = propertyTable.cookieHeader - } +-- ************************************************* +function PiwigoAPI.setVideoInfo(propertyTable, imageId, width, height, filesize) + -- Set video dimensions and filesize via pwg.companion.setVideoInfo + -- Returns { status, statusMsg } + + local callStatus = { status = false, statusMsg = "" } + + local params = { + { name = "method", value = "pwg.companion.setVideoInfo" }, + { name = "image_id", value = tostring(imageId) }, + } + + if width and width > 0 then + table.insert(params, { name = "width", value = tostring(width) }) end - local cfgResponse = httpGet(propertyTable.pwurl, configParams, cfgHeaders) - if cfgResponse.status == "ok" and cfgResponse.response and cfgResponse.response.result then - result.serverConfig = cfgResponse.response.result - result.companionAvailable = true - log:info("PiwigoAPI.getServerVideoSupport - companion plugin detected, config retrieved") + if height and height > 0 then + table.insert(params, { name = "height", value = tostring(height) }) + end + if filesize and filesize > 0 then + table.insert(params, { name = "filesize", value = tostring(filesize) }) + end + + local postResp = PiwigoAPI.httpPostMultiPart(propertyTable, params) + if postResp.status then + callStatus.status = true + log:info("PiwigoAPI.setVideoInfo - ok for image_id=" .. tostring(imageId) + .. " (" .. tostring(width) .. "x" .. tostring(height) .. ")") else - log:info("PiwigoAPI.getServerVideoSupport - serverinfo plugin not available: " .. - (cfgResponse.errorMessage or "unknown error")) + callStatus.statusMsg = "setVideoInfo - failed: " .. (postResp.statusMsg or "") + log:info("PiwigoAPI." .. callStatus.statusMsg) end + return callStatus +end - result.status = true - return result +-- ************************************************* +function PiwigoAPI.setVideoMeta(propertyTable, imageId, origData, convData) + -- Send extended video metadata to pwg.companion.setVideoMeta + -- origData / convData : { width, height, fps, bitrate, codec, format, filesize } + local callStatus = { status = false, statusMsg = "" } + + local params = { + { name = "method", value = "pwg.companion.setVideoMeta" }, + { name = "image_id", value = tostring(imageId) }, + } + + local function addField(prefix, key, val) + if val and val ~= 0 and val ~= "" then + table.insert(params, { name = prefix .. "_" .. key, value = tostring(val) }) + end + end + + if origData then + addField("orig", "width", origData.width) + addField("orig", "height", origData.height) + addField("orig", "fps", origData.fps) + addField("orig", "bitrate", origData.bitrate) + addField("orig", "codec", origData.codec) + addField("orig", "format", origData.format) + addField("orig", "filesize", origData.filesize) + end + if convData then + addField("conv", "width", convData.width) + addField("conv", "height", convData.height) + addField("conv", "fps", convData.fps) + addField("conv", "bitrate", convData.bitrate) + addField("conv", "codec", convData.codec) + addField("conv", "format", convData.format) + addField("conv", "filesize", convData.filesize) + end + + local postResp = PiwigoAPI.httpPostMultiPart(propertyTable, params) + if postResp.status then + callStatus.status = true + log:info("PiwigoAPI.setVideoMeta - ok for image_id=" .. tostring(imageId)) + else + callStatus.statusMsg = "setVideoMeta failed: " .. (postResp.statusMsg or "") + log:warn("PiwigoAPI." .. callStatus.statusMsg) + end + return callStatus end -- ************************************************* diff --git a/piwigoPublish.lrplugin/PluginInfo.lua b/piwigoPublish.lrplugin/PluginInfo.lua index 1454e3b..3c798eb 100644 --- a/piwigoPublish.lrplugin/PluginInfo.lua +++ b/piwigoPublish.lrplugin/PluginInfo.lua @@ -30,5 +30,4 @@ return { endDialog = PluginInfoDialogSections.endDialog, sectionsForTopOfDialog = PluginInfoDialogSections.sectionsForTopOfDialog, - --sectionsForBottomOfDialog = PluginInfoDialogSections.sectionsForBottomOfDialog, } \ No newline at end of file diff --git a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua index e2f70ff..0abf10b 100644 --- a/piwigoPublish.lrplugin/PluginInfoDialogSections.lua +++ b/piwigoPublish.lrplugin/PluginInfoDialogSections.lua @@ -64,9 +64,7 @@ function PluginInfoDialogSections.startDialog(propertyTable) if prefs.debugToFile == nil then prefs.debugToFile = false end - if prefs.debugFailedUpload == nil then - prefs.debugFailedUpload = false - end + -- Initialize update check preference if prefs.checkUpdatesOnStartup == nil then prefs.checkUpdatesOnStartup = true @@ -86,8 +84,6 @@ function PluginInfoDialogSections.startDialog(propertyTable) propertyTable.debugEnabled = prefs.debugEnabled propertyTable.debugToFile = prefs.debugToFile propertyTable.checkUpdatesOnStartup = prefs.checkUpdatesOnStartup - propertyTable.debugFailedUpload = prefs.debugFailedUpload - end -- ************************************************* @@ -129,7 +125,7 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) title = "Piwigo Publisher", font = "", alignment = 'left', - width = 250, + width = 250, }, -- Version @ UpdateStatus on one line, red if not up to date @@ -170,7 +166,7 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) f:row { f:static_text { title = "Made in England with cider and cheddar cheese in Somerset,\n" .. - "the Land of the Summer People.", + "the Land of the Summer People.", font = "", text_color = LrColor(0.5, 0.5, 0.5), alignment = 'center', @@ -293,7 +289,7 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) fill_horizontal = 1, f:static_text { title = "If you experience a problem, enable logging below and reproduce the issue.\n" .. - "You can then share the log with support.", + "You can then share the log with support.", fill_horizontal = 1, height_in_lines = 2, alignment = 'left', @@ -314,7 +310,7 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) f:radio_button { value = bind 'debugEnabled', checked_value = true, - title = "Logging on (to Lightroom Console unless 'Log to file' is enabled)", + title = "Live view in Lightroom (Help → Debug Console)", }, f:spacer { fill_horizontal = 1 }, f:push_button { @@ -332,27 +328,12 @@ function PluginInfoDialogSections.sectionsForTopOfDialog(f, propertyTable) enabled = bind 'debugEnabled', }, f:static_text { - title = "Log to file on disk (recommended for sharing with support)", - alignment = 'left', - fill_horizontal = 1, - width_in_chars = 40, - }, - }, - - f:row { - f:checkbox { - value = bind 'debugFailedUpload', - enabled = bind 'debugEnabled', - }, - f:static_text { - title = "Log extra information for failed uploads (creates PiwigoPublishDebug folder on your desktop)", + title = "Also save to log file on disk (recommended for sharing with support)", alignment = 'left', fill_horizontal = 1, width_in_chars = 40, }, }, - - }, -- Unsafe / developer group box diff --git a/piwigoPublish.lrplugin/PublishDialogSections.lua b/piwigoPublish.lrplugin/PublishDialogSections.lua index 836672b..cd29506 100644 --- a/piwigoPublish.lrplugin/PublishDialogSections.lua +++ b/piwigoPublish.lrplugin/PublishDialogSections.lua @@ -73,10 +73,36 @@ local function connectionDialog(f, propertyTable, pwInstance) bind_to_object = propertyTable, -- TOP: icon + version block - UIHelpers.createPluginHeader(f, share, iconPath, pluginVersion), + f:row { + spacing = f:dialog_spacing(), + + -- Left: icon + name + version + UIHelpers.createPluginHeader(f, share, iconPath, pluginVersion), + + -- Right: connection status (2 lines, aligned with left column) + f:column { + spacing = f:label_spacing(), + fill_horizontal = 1, + f:static_text { + title = LrView.bind { + key = 'ConStatus', + transform = function(v) + if v and v:find("Connected") then + return "✓ " .. v + else + return "✗ " .. (v or "Not Connected") + end + end, + }, + font = "", + text_color = LrColor(0.5, 0.5, 0.5), + alignment = 'left', + fill_horizontal = 1, + }, + }, + }, -- PW Host - f:spacer { height = 1 }, f:row { f:static_text { title = "", @@ -106,13 +132,32 @@ local function connectionDialog(f, propertyTable, pwInstance) end, }, f:push_button { - title = "Check Connection", + title = LrView.bind { + key = 'Connected', + transform = function(value) + return value and "Disconnect" or "Check Connection" + end + }, enabled = bind('ConCheck', propertyTable), font = "", action = function() LrTasks.startAsyncTask(function() - if not PiwigoAPI.login(propertyTable) then - LrDialogs.message("Connection NOT successful") + if propertyTable.Connected then + -- Déconnexion + propertyTable.Connected = false + propertyTable.ConCheck = true + propertyTable.ConStatus = "Not Connected" + propertyTable.SessionCookie = nil + propertyTable.cookies = nil + propertyTable.cookieHeader = nil + propertyTable.userStatus = nil + propertyTable.token = nil + propertyTable.pwVersion = nil + else + -- Connexion + if not PiwigoAPI.login(propertyTable) then + LrDialogs.message("Connection NOT successful") + end end end) end, @@ -120,7 +165,6 @@ local function connectionDialog(f, propertyTable, pwInstance) }, -- Username - f:spacer { height = 1 }, f:row { f:static_text { title = "", @@ -142,7 +186,6 @@ local function connectionDialog(f, propertyTable, pwInstance) }, -- Password - f:spacer { height = 1 }, f:row { f:static_text { title = "", @@ -163,16 +206,6 @@ local function connectionDialog(f, propertyTable, pwInstance) }, }, - -- Status row - f:spacer { height = 1 }, - f:row { - f:static_text { - title = bind 'ConStatus', - font = "", - alignment = 'center', - fill_horizontal = 1, - }, - }, } end @@ -191,7 +224,7 @@ local function prefsDialog(f, propertyTable) f:spacer { height = 2 }, f:row { f:push_button { - title = 'Import Albums', + title = "Import Albums", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), @@ -228,7 +261,7 @@ local function prefsDialog(f, propertyTable) f:spacer { height = 1 }, f:row { f:push_button { - title = 'Check and Link Piwigo Structure', + title = "Check and Link Piwigo Structure\n ", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), @@ -257,14 +290,14 @@ local function prefsDialog(f, propertyTable) alignment = 'left', -- width = share 'labelWidth', -- width_in_chars = 50, - tooltip = "Piwigo structure will be checked against local collection / set structure. Missing Piwigo albums will be created and links checked / updated" + tooltip = "Piwigo structure will be checked against local collection / set structure.\nMissing Piwigo albums will be created and links checked / updated" }, }, f:spacer { height = 1 }, f:row { f:push_button { - title = 'Clone Existing Publish Service', + title = "Clone Existing Publish Service\n ", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), @@ -283,12 +316,12 @@ local function prefsDialog(f, propertyTable) end, }, f:static_text { - title = "Collection/Set structure and images of selected Publish Service will be cloned to this one.", + title = "Collection/Set structure and images of selected Publish Service\nwill be cloned to this one.", font = "", alignment = 'left', -- width = share 'labelWidth', -- width_in_chars = 50, - tooltip = "Selected Collection/Set structure and images of selected Publish Service will be cloned to this one." + tooltip = "Selected Collection/Set structure and images of selected Publish Service\nwill be cloned to this one." }, }, @@ -296,7 +329,7 @@ local function prefsDialog(f, propertyTable) f:row { f:push_button { - title = 'Create Special Collections', + title = "Create Special Collections\n ", font = "", width = share 'buttonwidth', enabled = bind('Connected', propertyTable), @@ -320,12 +353,12 @@ local function prefsDialog(f, propertyTable) end, }, f:static_text { - title = "Create special publish collections to allow images to be published to albums with sub-albums on Piwigo", + title = "Create special publish collections to allow images to be published\nto albums with sub-albums on Piwigo", alignment = 'left', font = "", -- width = share 'labelWidth', -- width_in_chars = 50, - tooltip = "Create special collections to allow images to be published to Piwigo albums with sub-albums - which is not natively supported on LrC" + tooltip = "Create special collections to allow images to be published to Piwigo\nalbums with sub-albums - which is not natively supported on LrC" }, }, f:spacer { height = 1 }, @@ -339,129 +372,128 @@ local function prefsDialog(f, propertyTable) tooltip = "Show a summary of all albums with photo counts (published, modified, new to publish)", action = function(button) LrTasks.startAsyncTask(function() - local found, service = PiwigoAPI.getPublishService(propertyTable) - if not found or not service then - LrDialogs.message("Album Summary", - "Could not find the publish service. Please save the connection first.") - return - end + local found, service = PiwigoAPI.getPublishService(propertyTable) + if not found or not service then + LrDialogs.message("Album Summary", "Could not find the publish service. Please save the connection first.") + return + end - local summary = utils.buildAlbumSummary(service) - local allNodes = summary.nodes - local totals = summary.totals + local summary = utils.buildAlbumSummary(service) + local allNodes = summary.nodes + local totals = summary.totals - if #allNodes == 0 then - LrDialogs.message("Album Summary", "No albums with photos found.") - return - end + if #allNodes == 0 then + LrDialogs.message("Album Summary", "No albums with photos found.") + return + end - -- Build LrView dialog - local dlgF = LrView.osFactory() + -- Build LrView dialog + local dlgF = LrView.osFactory() - -- Column widths (pixels) - local colName = 370 - local colNum = 45 - local indentPx = 20 + -- Column widths (pixels) + local colName = 370 + local colNum = 45 + local indentPx = 20 - -- Count leaf albums - local albumCount = 0 - for _, node in ipairs(allNodes) do - if node.type == "collection" then albumCount = albumCount + 1 end - end + -- Count leaf albums + local albumCount = 0 + for _, node in ipairs(allNodes) do + if node.type == "collection" then albumCount = albumCount + 1 end + end - local function mkRow(indent, nameStr, nameFont, pub, pubFont, mod, modFont, new, newFont) - return dlgF:row { - dlgF:static_text { title = "", width = indent }, - dlgF:static_text { - title = nameStr, font = nameFont, - width = colName - indent, truncation = 'middle', - }, - dlgF:static_text { - title = pub, font = pubFont or "", - width = colNum, alignment = 'right', - }, - dlgF:static_text { - title = mod, font = modFont or "", - width = colNum, alignment = 'right', - }, - dlgF:static_text { - title = new, font = newFont or "", - width = colNum, alignment = 'right', - }, - } - end + local function mkRow(indent, nameStr, nameFont, pub, pubFont, mod, modFont, new, newFont) + return dlgF:row { + dlgF:static_text { title = "", width = indent }, + dlgF:static_text { + title = nameStr, font = nameFont, + width = colName - indent, truncation = 'middle', + }, + dlgF:static_text { + title = pub, font = pubFont or "", + width = colNum, alignment = 'right', + }, + dlgF:static_text { + title = mod, font = modFont or "", + width = colNum, alignment = 'right', + }, + dlgF:static_text { + title = new, font = newFont or "", + width = colNum, alignment = 'right', + }, + } + end - -- Header - local headerRow = mkRow(0, "Album", "", - "Pub.", "", "Mod.", "", "New", "") - - -- Build data rows - local dataRows = {} - for _, node in ipairs(allNodes) do - local indent = node.depth * indentPx - local modStr = node.modified > 0 and tostring(node.modified) or "-" - local newStr = node.new > 0 and tostring(node.new) or "-" - local modFont = node.modified > 0 and "" or "" - local newFont = node.new > 0 and "" or "" - - if node.type == "set" then - -- Parent set: separator + bold name + sub-totals in italic - if #dataRows > 0 then - table.insert(dataRows, dlgF:spacer { height = 6 }) + -- Header + local headerRow = mkRow(0, "Album", "", + "Pub.", "", "Mod.", "", "New", "") + + -- Build data rows + local dataRows = {} + for _, node in ipairs(allNodes) do + local indent = node.depth * indentPx + local modStr = node.modified > 0 and tostring(node.modified) or "-" + local newStr = node.new > 0 and tostring(node.new) or "-" + local modFont = node.modified > 0 and "" or "" + local newFont = node.new > 0 and "" or "" + + if node.type == "set" then + -- Parent set: separator + bold name + sub-totals in italic + if #dataRows > 0 then + table.insert(dataRows, dlgF:spacer { height = 6 }) + end + table.insert(dataRows, mkRow(indent, + node.name, "", + tostring(node.published), "", + modStr, modFont, + newStr, newFont + )) + else + -- Leaf album + local hasPending = node.modified > 0 or node.new > 0 + local nameFont = hasPending and "" or "" + table.insert(dataRows, mkRow(indent, + node.name, nameFont, + tostring(node.published), "", + modStr, modFont, + newStr, newFont + )) end - table.insert(dataRows, mkRow(indent, - node.name, "", - tostring(node.published), "", - modStr, modFont, - newStr, newFont - )) - else - -- Leaf album - local hasPending = node.modified > 0 or node.new > 0 - local nameFont = hasPending and "" or "" - table.insert(dataRows, mkRow(indent, - node.name, nameFont, - tostring(node.published), "", - modStr, modFont, - newStr, newFont - )) end - end - -- Totals row - local totalRow = mkRow(0, - "TOTAL (" .. albumCount .. " albums)", "", - tostring(totals.published), "", - tostring(totals.modified), "", - tostring(totals.new), "" - ) - - -- Assemble - local contentItems = { - headerRow, - dlgF:separator { fill_horizontal = 1 }, - } - for _, dr in ipairs(dataRows) do - table.insert(contentItems, dr) - end - table.insert(contentItems, dlgF:separator { fill_horizontal = 1 }) - table.insert(contentItems, totalRow) - contentItems.spacing = dlgF:control_spacing() - - local contents = dlgF:column(contentItems) - - local scrolled = dlgF:scrolled_view { - width = colName + colNum * 3 + 40, - height = math.min(500, 80 + #allNodes * 20), - contents, - } - - LrDialogs.presentModalDialog({ - title = "Album Summary — " .. (propertyTable.LR_publish_connectionName or ""), - contents = scrolled, - actionVerb = "OK", - cancelVerb = "< exclude >", - }) + -- Totals row + local totalRow = mkRow(0, + "TOTAL (" .. albumCount .. " albums)", "", + tostring(totals.published), "", + tostring(totals.modified), "", + tostring(totals.new), "" + ) + + -- Assemble + local contentItems = { + headerRow, + dlgF:separator { fill_horizontal = 1 }, + } + for _, dr in ipairs(dataRows) do + table.insert(contentItems, dr) + end + table.insert(contentItems, dlgF:separator { fill_horizontal = 1 }) + table.insert(contentItems, totalRow) + contentItems.spacing = dlgF:control_spacing() + + local contents = dlgF:column(contentItems) + + local scrolled = dlgF:scrolled_view { + width = colName + colNum * 3 + 40, + height = math.min(500, 80 + #allNodes * 20), + contents, + } + + LrDialogs.presentModalDialog({ + title = "Album Summary — " .. (propertyTable.LR_publish_connectionName or ""), + contents = scrolled, + actionVerb = "OK", + cancelVerb = "< exclude >", + }) end) end, }, @@ -483,295 +515,291 @@ local function prefsDialog(f, propertyTable) tooltip = "Show server capabilities and video support status", action = function(button) LrTasks.startAsyncTask(function() - local videoSupport = PiwigoAPI.getServerVideoSupport(propertyTable) - if not videoSupport.status then - LrDialogs.message("Server Info", - "Could not retrieve server information. Check your connection.") - return - end - - local dlgF = LrView.osFactory() - local colLabel = 220 - local colValue = 350 - - local function mkInfoRow(label, value, valueFont) - return dlgF:row { - dlgF:static_text { - title = label, - font = "", - width = colLabel, - alignment = 'right', - }, - dlgF:static_text { - title = tostring(value), - font = valueFont or "", - width = colValue, - alignment = 'left', - }, - } - end + local videoSupport = PiwigoAPI.getServerVideoSupport(propertyTable) + if not videoSupport.status then + LrDialogs.message("Server Info", "Could not retrieve server information. Check your connection.") + return + end - -- Helper: font for status display - local function statusFont(ok) - return ok and "" or "" - end + local dlgF = LrView.osFactory() + local colLabel = 220 + local colValue = 350 + + local function mkInfoRow(label, value, valueFont) + return dlgF:row { + dlgF:static_text { + title = label, + font = "", + width = colLabel, + alignment = 'right', + }, + dlgF:static_text { + title = tostring(value), + font = valueFont or "", + width = colValue, + alignment = 'left', + }, + } + end - -- Video support status - local videoStatus - local videoFont = "" - if videoSupport.videoJsActive then - local name = videoSupport.videoJsName or "VideoJS" - videoStatus = name .. " — Active" - elseif videoSupport.videoJsInstalled then - local name = videoSupport.videoJsName or "VideoJS" - videoStatus = name .. " — INACTIVE" - videoFont = "" - else - videoStatus = "Not installed" - videoFont = "" - end + -- Helper: font for status display + local function statusFont(ok) + return ok and "" or "" + end - local infos = videoSupport.serverInfos - local cfg = videoSupport.serverConfig -- may be nil if plugin not installed - - -- Section header helper - local function mkSectionHeader(title) - return dlgF:row { - dlgF:static_text { - title = title, - font = "", - width = colLabel + colValue, - }, - } - end + -- Video support status + local videoStatus + local videoFont = "" + if videoSupport.videoJsActive then + local name = videoSupport.videoJsName or "VideoJS" + videoStatus = name .. " — Active" + elseif videoSupport.videoJsInstalled then + local name = videoSupport.videoJsName or "VideoJS" + videoStatus = name .. " — INACTIVE" + videoFont = "" + else + videoStatus = "Not installed" + videoFont = "" + end - local rows = {} - - -- ===== Piwigo Gallery ===== - table.insert(rows, mkSectionHeader("Piwigo Gallery")) - table.insert(rows, dlgF:separator { fill_horizontal = 1 }) - table.insert(rows, mkInfoRow("Version:", videoSupport.piwigoVersion)) - table.insert(rows, mkInfoRow("Photos:", infos.nb_elements or "N/A")) - table.insert(rows, mkInfoRow("Albums:", infos.nb_categories or "N/A")) - table.insert(rows, mkInfoRow("Tags:", infos.nb_tags or "N/A")) - table.insert(rows, mkInfoRow("Users:", infos.nb_users or "N/A")) - table.insert(rows, mkInfoRow("Comments:", infos.nb_comments or "N/A")) - - if cfg and cfg.piwigo then - local allTypes = cfg.piwigo.upload_form_all_types - table.insert(rows, mkInfoRow("All file types upload:", - allTypes and "Enabled" or "Disabled", - statusFont(allTypes))) - if cfg.piwigo.file_ext then - local exts = table.concat(cfg.piwigo.file_ext, ", ") - table.insert(rows, mkInfoRow("Allowed extensions:", exts)) + local infos = videoSupport.serverInfos + local cfg = videoSupport.serverConfig -- may be nil if plugin not installed + + -- Section header helper + local function mkSectionHeader(title) + return dlgF:row { + dlgF:static_text { + title = title, + font = "", + width = colLabel + colValue, + }, + } end - end - table.insert(rows, dlgF:spacer { height = 6 }) + local rows = {} - -- ===== Server & PHP ===== - if cfg then - table.insert(rows, mkSectionHeader("Server && PHP")) + -- ===== Piwigo Gallery ===== + table.insert(rows, mkSectionHeader("Piwigo Gallery")) table.insert(rows, dlgF:separator { fill_horizontal = 1 }) - - if cfg.server then - table.insert(rows, mkInfoRow("OS:", cfg.server.os or "N/A")) - table.insert(rows, mkInfoRow("Web Server:", cfg.server.software or "N/A")) - end - - if cfg.php then - table.insert(rows, mkInfoRow("PHP Version:", cfg.php.version or "N/A")) - table.insert(rows, - mkInfoRow("upload_max_filesize:", cfg.php.upload_max_filesize or "N/A")) - table.insert(rows, mkInfoRow("post_max_size:", cfg.php.post_max_size or "N/A")) - table.insert(rows, mkInfoRow("memory_limit:", cfg.php.memory_limit or "N/A")) - table.insert(rows, - mkInfoRow("max_execution_time:", (cfg.php.max_execution_time or "N/A") .. "s")) + table.insert(rows, mkInfoRow("Version:", videoSupport.piwigoVersion)) + table.insert(rows, mkInfoRow("Photos:", infos.nb_elements or "N/A")) + table.insert(rows, mkInfoRow("Albums:", infos.nb_categories or "N/A")) + table.insert(rows, mkInfoRow("Tags:", infos.nb_tags or "N/A")) + table.insert(rows, mkInfoRow("Users:", infos.nb_users or "N/A")) + table.insert(rows, mkInfoRow("Comments:", infos.nb_comments or "N/A")) + + if cfg and cfg.piwigo then + local allTypes = cfg.piwigo.upload_form_all_types + table.insert(rows, mkInfoRow("All file types upload:", + allTypes and "Enabled" or "Disabled", + statusFont(allTypes))) + if cfg.piwigo.file_ext then + local exts = table.concat(cfg.piwigo.file_ext, ", ") + table.insert(rows, mkInfoRow("Allowed extensions:", exts)) + end end table.insert(rows, dlgF:spacer { height = 6 }) - -- ===== Graphics ===== - table.insert(rows, mkSectionHeader("Graphics Libraries")) - table.insert(rows, dlgF:separator { fill_horizontal = 1 }) + -- ===== Server & PHP ===== + if cfg then + table.insert(rows, mkSectionHeader("Server && PHP")) + table.insert(rows, dlgF:separator { fill_horizontal = 1 }) - if cfg.graphics then - if cfg.graphics.gd and type(cfg.graphics.gd) == "table" then - table.insert(rows, mkInfoRow("GD:", cfg.graphics.gd.version or "Installed")) - else - table.insert(rows, mkInfoRow("GD:", "Not available", "")) + if cfg.server then + table.insert(rows, mkInfoRow("OS:", cfg.server.os or "N/A")) + table.insert(rows, mkInfoRow("Web Server:", cfg.server.software or "N/A")) end - if cfg.graphics.imagick and type(cfg.graphics.imagick) == "table" then - table.insert(rows, - mkInfoRow("ImageMagick:", cfg.graphics.imagick.version or "Installed")) - else - table.insert(rows, mkInfoRow("ImageMagick:", "Not available")) + + if cfg.php then + table.insert(rows, mkInfoRow("PHP Version:", cfg.php.version or "N/A")) + table.insert(rows, mkInfoRow("upload_max_filesize:", cfg.php.upload_max_filesize or "N/A")) + table.insert(rows, mkInfoRow("post_max_size:", cfg.php.post_max_size or "N/A")) + table.insert(rows, mkInfoRow("memory_limit:", cfg.php.memory_limit or "N/A")) + table.insert(rows, mkInfoRow("max_execution_time:", (cfg.php.max_execution_time or "N/A") .. "s")) end - end - table.insert(rows, dlgF:spacer { height = 6 }) + table.insert(rows, dlgF:spacer { height = 6 }) - -- ===== Video Tools ===== - table.insert(rows, mkSectionHeader("Video && Media Tools")) - table.insert(rows, dlgF:separator { fill_horizontal = 1 }) - end + -- ===== Graphics ===== + table.insert(rows, mkSectionHeader("Graphics Libraries")) + table.insert(rows, dlgF:separator { fill_horizontal = 1 }) - table.insert(rows, mkInfoRow("VideoJS plugin:", videoStatus, videoFont)) - - if cfg then - -- exec() status - if cfg.php and cfg.php.exec_available ~= nil then - if not cfg.php.exec_available then - table.insert(rows, mkInfoRow("exec():", "DISABLED", "")) - table.insert(rows, dlgF:row { - dlgF:static_text { title = "", width = colLabel }, - dlgF:static_text { - title = "CLI tools (FFmpeg, ExifTool) cannot be detected.\nContact your hosting provider.", - font = "", width = colValue, height_in_lines = 2, - }, - }) + if cfg.graphics then + if cfg.graphics.gd and type(cfg.graphics.gd) == "table" then + table.insert(rows, mkInfoRow("GD:", cfg.graphics.gd.version or "Installed")) + else + table.insert(rows, mkInfoRow("GD:", "Not available", "")) + end + if cfg.graphics.imagick and type(cfg.graphics.imagick) == "table" then + table.insert(rows, mkInfoRow("ImageMagick:", cfg.graphics.imagick.version or "Installed")) + else + table.insert(rows, mkInfoRow("ImageMagick:", "Not available")) + end end - end - if cfg.ffmpeg then - local ffNotice = cfg.ffmpeg.notice - local ffVer = cfg.ffmpeg.installed and (cfg.ffmpeg.version or "Installed") - or (ffNotice or "Not found") - table.insert(rows, mkInfoRow("FFmpeg:", - ffVer, statusFont(cfg.ffmpeg.installed))) - if not cfg.ffmpeg.installed and not cfg.ffmpeg.notice then - table.insert(rows, dlgF:row { - dlgF:static_text { title = "", width = colLabel }, - dlgF:static_text { - title = "Without FFmpeg, videos will upload but Piwigo\nwill not generate a custom thumbnail for them.", - font = "", width = colValue, height_in_lines = 2, - }, - }) - end - end - if cfg.ffprobe then - local fpVer = cfg.ffprobe.installed and (cfg.ffprobe.version or "Available") - or "Not found" - table.insert(rows, mkInfoRow("FFprobe:", - fpVer, statusFont(cfg.ffprobe.installed))) - end + table.insert(rows, dlgF:spacer { height = 6 }) - if cfg.exiftool then - local etVer = cfg.exiftool.installed and ("v" .. (cfg.exiftool.version or "?")) - or (cfg.exiftool.notice or "Not found") - table.insert(rows, mkInfoRow("ExifTool:", - etVer, statusFont(cfg.exiftool.installed))) + -- ===== Video Tools ===== + table.insert(rows, mkSectionHeader("Video && Media Tools")) + table.insert(rows, dlgF:separator { fill_horizontal = 1 }) end - if cfg.mediainfo then - local miVer = cfg.mediainfo.installed and (cfg.mediainfo.version or "Installed") - or (cfg.mediainfo.notice or "Not found") - table.insert(rows, mkInfoRow("MediaInfo:", - miVer, statusFont(cfg.mediainfo.installed))) - end + table.insert(rows, mkInfoRow("VideoJS plugin:", videoStatus, videoFont)) + + if cfg then + -- exec() status + if cfg.php and cfg.php.exec_available ~= nil then + if not cfg.php.exec_available then + table.insert(rows, mkInfoRow("exec():", "DISABLED", "")) + table.insert(rows, dlgF:row { + dlgF:static_text { title = "", width = colLabel }, + dlgF:static_text { + title = "CLI tools (FFmpeg, ExifTool) cannot be detected.\nContact your hosting provider.", + font = "", width = colValue, height_in_lines = 2, + }, + }) + end + end - table.insert(rows, dlgF:spacer { height = 6 }) + if cfg.ffmpeg then + local ffNotice = cfg.ffmpeg.notice + local ffVer = cfg.ffmpeg.installed and (cfg.ffmpeg.version or "Installed") + or (ffNotice or "Not found") + table.insert(rows, mkInfoRow("FFmpeg:", + ffVer, statusFont(cfg.ffmpeg.installed))) + if not cfg.ffmpeg.installed and not cfg.ffmpeg.notice then + table.insert(rows, dlgF:row { + dlgF:static_text { title = "", width = colLabel }, + dlgF:static_text { + title = "Without FFmpeg, videos will upload but Piwigo\nwill not generate a custom thumbnail for them.", + font = "", width = colValue, height_in_lines = 2, + }, + }) + end + end + if cfg.ffprobe then + local fpVer = cfg.ffprobe.installed and (cfg.ffprobe.version or "Available") + or "Not found" + table.insert(rows, mkInfoRow("FFprobe:", + fpVer, statusFont(cfg.ffprobe.installed))) + end - -- ===== Video Readiness ===== - table.insert(rows, mkSectionHeader("Video Upload Readiness")) - table.insert(rows, dlgF:separator { fill_horizontal = 1 }) + if cfg.exiftool then + local etVer = cfg.exiftool.installed and ("v" .. (cfg.exiftool.version or "?")) + or (cfg.exiftool.notice or "Not found") + table.insert(rows, mkInfoRow("ExifTool:", + etVer, statusFont(cfg.exiftool.installed))) + end - if cfg.piwigo then - local videoReady = cfg.piwigo.video_ready - table.insert(rows, mkInfoRow("Video upload:", - videoReady and "Ready" or "NOT CONFIGURED", - statusFont(videoReady))) + if cfg.mediainfo then + local miVer = cfg.mediainfo.installed and (cfg.mediainfo.version or "Installed") + or (cfg.mediainfo.notice or "Not found") + table.insert(rows, mkInfoRow("MediaInfo:", + miVer, statusFont(cfg.mediainfo.installed))) + end - local allTypes = cfg.piwigo.upload_form_all_types - table.insert(rows, mkInfoRow("All file types:", - allTypes and "Enabled" or "Disabled", - statusFont(allTypes))) + table.insert(rows, dlgF:spacer { height = 6 }) + + -- ===== Video Readiness ===== + table.insert(rows, mkSectionHeader("Video Upload Readiness")) + table.insert(rows, dlgF:separator { fill_horizontal = 1 }) + + if cfg.piwigo then + local videoReady = cfg.piwigo.video_ready + table.insert(rows, mkInfoRow("Video upload:", + videoReady and "Ready" or "NOT CONFIGURED", + statusFont(videoReady))) + + local allTypes = cfg.piwigo.upload_form_all_types + table.insert(rows, mkInfoRow("All file types:", + allTypes and "Enabled" or "Disabled", + statusFont(allTypes))) + + if cfg.piwigo.video_ext_configured then + local vExts = cfg.piwigo.video_ext_configured + if type(vExts) == "table" and #vExts > 0 then + table.insert(rows, mkInfoRow("Video extensions:", + table.concat(vExts, ", "))) + else + table.insert(rows, mkInfoRow("Video extensions:", + "None configured", "")) + end + end - if cfg.piwigo.video_ext_configured then - local vExts = cfg.piwigo.video_ext_configured - if type(vExts) == "table" and #vExts > 0 then - table.insert(rows, mkInfoRow("Video extensions:", - table.concat(vExts, ", "))) - else - table.insert(rows, mkInfoRow("Video extensions:", - "None configured", "")) + local writable = cfg.piwigo.local_config_writable + table.insert(rows, mkInfoRow("Config writable:", + writable and "Yes" or "No (read-only)", + statusFont(writable))) + + -- Enable Video button if not ready and companion is available + if not videoReady and videoSupport.companionAvailable then + table.insert(rows, dlgF:spacer { height = 6 }) + table.insert(rows, dlgF:row { + dlgF:static_text { title = "", width = colLabel }, + dlgF:push_button { + title = "Enable Video Support", + width = 200, + action = function() + LrTasks.startAsyncTask(function() + local result = PiwigoAPI.enableVideoSupport(propertyTable) + if result.status == "ok" then + LrDialogs.message("Video Support Enabled", + result.message or "Video support has been configured.", + "info") + elseif result.status == "already_configured" then + LrDialogs.message("Video Support", + result.message or "Already configured.", + "info") + else + LrDialogs.message("Video Support Error", + result.message or "Failed to enable video support.", + "critical") + end + end) + end, + }, + }) end end + else + -- Companion plugin not installed + table.insert(rows, dlgF:spacer { height = 6 }) + table.insert(rows, dlgF:row { + dlgF:static_text { title = "", width = colLabel }, + dlgF:static_text { + title = "Install the 'PiwigoPublish Companion' plugin\non your Piwigo server for detailed diagnostics\nand automatic video configuration.", + font = "", width = colValue, height_in_lines = 3, + }, + }) + end - local writable = cfg.piwigo.local_config_writable - table.insert(rows, mkInfoRow("Config writable:", - writable and "Yes" or "No (read-only)", - statusFont(writable))) - - -- Enable Video button if not ready and companion is available - if not videoReady and videoSupport.companionAvailable then - table.insert(rows, dlgF:spacer { height = 6 }) - table.insert(rows, dlgF:row { - dlgF:static_text { title = "", width = colLabel }, - dlgF:push_button { - title = "Enable Video Support", - width = 200, - action = function() - LrTasks.startAsyncTask(function() - local result = PiwigoAPI.enableVideoSupport(propertyTable) - if result.status == "ok" then - LrDialogs.message("Video Support Enabled", - result.message or "Video support has been configured.", - "info") - elseif result.status == "already_configured" then - LrDialogs.message("Video Support", - result.message or "Already configured.", - "info") - else - LrDialogs.message("Video Support Error", - result.message or "Failed to enable video support.", - "critical") - end - end) - end, - }, - }) - end + if not videoSupport.videoJsActive then + table.insert(rows, dlgF:spacer { height = 4 }) + table.insert(rows, dlgF:row { + dlgF:static_text { title = "", width = colLabel }, + dlgF:static_text { + title = "Install and activate the VideoJS plugin\nfrom Piwigo administration for video playback.", + font = "", width = colValue, height_in_lines = 2, + }, + }) end - else - -- Companion plugin not installed - table.insert(rows, dlgF:spacer { height = 6 }) - table.insert(rows, dlgF:row { - dlgF:static_text { title = "", width = colLabel }, - dlgF:static_text { - title = "Install the 'PiwigoPublish Companion' plugin\non your Piwigo server for detailed diagnostics\nand automatic video configuration.", - font = "", width = colValue, height_in_lines = 3, - }, - }) - end - if not videoSupport.videoJsActive then - table.insert(rows, dlgF:spacer { height = 4 }) - table.insert(rows, dlgF:row { - dlgF:static_text { title = "", width = colLabel }, - dlgF:static_text { - title = "Install and activate the VideoJS plugin\nfrom Piwigo administration for video playback.", - font = "", width = colValue, height_in_lines = 2, - }, - }) - end + rows.spacing = dlgF:control_spacing() + local contents = dlgF:column(rows) - rows.spacing = dlgF:control_spacing() - local contents = dlgF:column(rows) - - local scrolled = dlgF:scrolled_view { - width = colLabel + colValue + 50, - height = 500, - contents, - } - - LrDialogs.presentModalDialog({ - title = "Server Info — " .. (propertyTable.host or ""), - contents = scrolled, - actionVerb = "OK", - cancelVerb = "< exclude >", - }) + local scrolled = dlgF:scrolled_view { + width = colLabel + colValue + 50, + height = 500, + contents, + } + + LrDialogs.presentModalDialog({ + title = "Server Info — " .. (propertyTable.host or ""), + contents = scrolled, + actionVerb = "OK", + cancelVerb = "< exclude >", + }) end) end, }, @@ -786,23 +814,145 @@ local function prefsDialog(f, propertyTable) }, + f:group_box { + title = "Metadata Settings", + font = "", + fill_horizontal = 1, - f:spacer { height = 2 }, - -- custom metadata fields (Title and description) - UIHelpers.createMetaDataGroupBox(f, bind), + f:spacer { height = 2 }, - f:spacer { height = 2 }, - -- keyword hierarchy and synonyms, and keyword filtering - UIHelpers.createKeywordSettingsGroupBox(f, bind), + f:row { + f:static_text { + title = "Title: ", + font = "", + alignment = 'right', + width_in_chars = 8, + }, + f:edit_field { + value = bind 'mdTitle', + font = "", + alignment = 'left', + width_in_chars = 60, + height_in_lines = 3, + }, + }, + + f:row { + f:static_text { + title = "Description: ", + font = "", + alignment = 'right', + width_in_chars = 8, + }, + f:edit_field { + value = bind 'mdDescription', + font = "", + alignment = 'left', + width_in_chars = 60, + height_in_lines = 3, + }, + }, + }, f:spacer { height = 2 }, - -- album custom settings and album association (one image, multiple albums) - UIHelpers.createAlbumSettingsGroupBox(f, bind, propertyTable), + UIHelpers.createKeywordSettingsGroupBox(f, bind), f:spacer { height = 2 }, - -- other settings (album descriptions and comments handling) - UIHelpers.createOtherSettingsGroupBox(f, bind, propertyTable), - + f:group_box { + title = "Other Settings", + font = "", + fill_horizontal = 1, + f:spacer { height = 1 }, + + + + + f:row { + fill_horizontal = 1, + f:static_text { + title = "Album description :", + font = "", + alignment = 'right', + width_in_chars = 18, + }, + f:popup_menu { + tooltip = "How to resolve conflicts between Lightroom and Piwigo album descriptions", + value = bind 'albumDescSyncMode', + items = { + { title = "Ask on conflict", value = "ask" }, + { title = "Always use Lightroom", value = "lightroom" }, + { title = "Always use Piwigo", value = "piwigo" }, + }, + }, + }, + f:row { + fill_horizontal = 1, + f:static_text { + title = "Album privacy :", + font = "", + alignment = 'right', + width_in_chars = 18, + }, + f:popup_menu { + tooltip = "How to resolve conflicts between Lightroom and Piwigo album privacy status", + value = bind 'albumStatusSyncMode', + items = { + { title = "Ask on conflict", value = "ask" }, + { title = "Always use Lightroom", value = "lightroom" }, + { title = "Always use Piwigo", value = "piwigo" }, + }, + }, + }, + f:spacer { height = 1 }, + + f:row { + fill_horizontal = 1, + f:static_text { + title = "", + alignment = 'right', + width_in_chars = 7, + }, + f:checkbox { + title = "Synchronise Photo Sort Order", + font = "", + tooltip = "If checked, the photo display order in Lightroom will be sent to Piwigo after each publish", + value = bind 'syncPhotoSortOrder', + }, + }, + f:spacer { height = 1 }, + + f:row { + fill_horizontal = 1, + f:static_text { + title = "", + alignment = 'right', + width_in_chars = 7, + }, + f:checkbox { + title = "Synchronise comments as part of a Publish Process", + font = "", + tooltip = "When checked, comments will be synchronised for all photos in a collection during a publish operation", + value = bind 'syncCommentsPublish', + }, + }, + f:row { + fill_horizontal = 1, + f:static_text { + title = "", + alignment = 'right', + width_in_chars = 7, + }, + f:checkbox { + title = "Only include Published Photos", + enabled = bind('syncCommentsPublish', propertyTable), + font = "", + tooltip = "When checked, only photos being published will have comments synchronised", + value = bind 'syncCommentsPubOnly', + }, + }, + + + }, } end -- @@ -810,6 +960,7 @@ end function PublishDialogSections.sectionsForTopOfDialog(f, propertyTable) local conDlg = connectionDialog(f, propertyTable) local prefDlg = prefsDialog(f, propertyTable) + local videoDlg = vtk_ui.videoDialog(f, propertyTable) if utils.nilOrEmpty(propertyTable.host) or utils.nilOrEmpty(propertyTable.userName) or utils.nilOrEmpty(propertyTable.userPW) then propertyTable.Connected = false propertyTable.ConCheck = true @@ -818,7 +969,7 @@ function PublishDialogSections.sectionsForTopOfDialog(f, propertyTable) end - return { conDlg, prefDlg } + return { conDlg, prefDlg, videoDlg } end -- ************************************************* diff --git a/piwigoPublish.lrplugin/PublishServiceProvider.lua b/piwigoPublish.lrplugin/PublishServiceProvider.lua index 3364a32..376bf20 100644 --- a/piwigoPublish.lrplugin/PublishServiceProvider.lua +++ b/piwigoPublish.lrplugin/PublishServiceProvider.lua @@ -24,8 +24,6 @@ require "PublishDialogSections" require "PublishTask" -require "PublishTaskImageProcessing" - return { @@ -35,40 +33,50 @@ return { sectionsForBottomOfDialog = PublishDialogSections.sectionsForBottomOfDialog, endDialog = PublishDialogSections.endDialog, - hideSections = { 'exportLocation' }, + hideSections = { 'exportLocation', 'videoFileSettings' }, -- Behaviour Settings - - -- Piwigo supports .jpg, .jpeg, .png, .gif, .webp, .heic - -- of these, LrC can only export JPEG and PNG, so we limit to those formats in the publish dialog. - allowFileFormats = { "JPEG", "PNG"}, - + allowFileFormats = { "JPEG", "PNG" }, allowColorSpaces = nil, - canExportVideo = false, - supportsCustomSortOrder = false, + canExportVideo = true, + allowVideoExportPresets = { + { formatID = "original" }, -- LrC does not re-encode; Video Toolkit handles transcoding + }, + supportsCustomSortOrder = true, hidePrintResolution = true, supportsIncrementalPublish = 'only', -- plugin only visible in publish services, not export -- these fields are stored in the publish service settings by Lightroom exportPresetFields = { - { key = 'host', default = '' }, - { key = "userName", default = '' }, - { key = "userPW", default = '' }, - { key = "KwFullHierarchy", default = true }, - { key = "KwSynonyms", default = true }, - { key = "mdTitle", default = "{{title}}" }, - { key = "mdDescription", default = "{{caption}}" }, - { key = "syncAlbumDescriptions", default = false }, - { key = "syncCommentsPublish", default = true }, - { key = "syncCommentsPubOnly", default = false }, - { key = "PWP_albumAssociation", default = true }, - { key = "PWP_customAlbumSettings", default = false }, - { key = "KwFilterExclude", default = '' }, - { key = "KwFilterInclude", default = '' }, - - + { key = 'host', default = '' }, + { key = "userName", default = '' }, + { key = "userPW", default = '' }, + { key = "KwFullHierarchy", default = true }, + { key = "KwSynonyms", default = true }, + { key = "mdTitle", default = "{{title}}" }, + { key = "mdDescription", default = "{{caption}}" }, + { key = "albumDescSyncMode", default = "ask" }, + { key = "albumStatusSyncMode", default = "ask" }, + { key = "syncPhotoSortOrder", default = false }, + { key = "syncCommentsPublish", default = true }, + { key = "syncCommentsPubOnly", default = false }, + { key = "KwFilterInclude", default = '' }, + { key = "KwFilterExclude", default = '' }, + -- Video Toolkit settings (Phase 2B) + { key = "vtkEnabled", default = false }, + { key = "vtkIncludeVideo", default = true }, + { key = "vtkToolkitPath", default = '' }, + { key = "vtkDefaultPreset", default = "medium" }, + { key = "vtkGeneratePoster", default = true }, + { key = "vtkPosterTimestamp", default = 10 }, + { key = "vtkPythonPath", default = '' }, + { key = "vtkFFmpegPath", default = '' }, + { key = "vtkFFprobePath", default = '' }, + { key = "vtkExifToolPath", default = '' }, + { key = "vtkPresetsFile", default = '' }, + { key = "vtkHardwareAccel", default = 'auto' }, }, metadataThatTriggersRepublish = function(publishSettings, photoId, fieldName) @@ -99,19 +107,18 @@ return { -- titleForPublishedSmartCollection_standalone = "" -- Images Processing function - processRenderedPhotos = PublishTaskImageProcessing.processRenderedPhotos, - addCommentToPublishedPhoto = PublishTaskImageProcessing.addCommentToPublishedPhoto, - getCommentsFromPublishedCollection = PublishTaskImageProcessing.getCommentsFromPublishedCollection, - deletePhotosFromPublishedCollection = PublishTaskImageProcessing.deletePhotosFromPublishedCollection, - + processRenderedPhotos = PublishTask.processRenderedPhotos, + canAddCommentsToService = PublishTask.canAddCommentsToService, + addCommentToPublishedPhoto = PublishTask.addCommentToPublishedPhoto, + getCommentsFromPublishedCollection = PublishTask.getCommentsFromPublishedCollection, + deletePhotosFromPublishedCollection = PublishTask.deletePhotosFromPublishedCollection, + shouldDeletePhotosFromServiceOnDeleteFromCatalog = PublishTask.shouldDeletePhotosFromServiceOnDeleteFromCatalog, -- PublishService processing functions didCreateNewPublishService = PublishTask.didCreateNewPublishService, didUpdatePublishService = PublishTask.didUpdatePublishService, shouldDeletePublishService = PublishTask.shouldDeletePublishService, willDeletePublishService = PublishTask.willDeletePublishService, - canAddCommentsToService = PublishTask.canAddCommentsToService, - shouldDeletePhotosFromServiceOnDeleteFromCatalog = PublishTask.shouldDeletePhotosFromServiceOnDeleteFromCatalog, -- Published Collections / CollectionSets Processing functions getCollectionBehaviorInfo = PublishTask.getCollectionBehaviorInfo, diff --git a/piwigoPublish.lrplugin/PublishTask.lua b/piwigoPublish.lrplugin/PublishTask.lua index 2d7a2dd..6242c6f 100644 --- a/piwigoPublish.lrplugin/PublishTask.lua +++ b/piwigoPublish.lrplugin/PublishTask.lua @@ -22,8 +22,810 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + PublishTask = {} +-- ************************************************ +-- Reconcile album metadata (description, privacy) between Lightroom and Piwigo. +-- Returns true to proceed, false if cancelled by user. +local function reconcileAlbumMeta(publishSettings, collectionSettings, thisCat) + if not thisCat then return true end + + local lrDesc = collectionSettings.albumDescription or "" + local pwDesc = thisCat.comment or "" + local lrPrivate = collectionSettings.albumPrivate or false + local pwPrivate = (thisCat.status == "private") + + local descDiff = (lrDesc ~= pwDesc) + local statusDiff = (lrPrivate ~= pwPrivate) + + if not descDiff and not statusDiff then return true end + + local descMode = publishSettings.albumDescSyncMode or "ask" + local statusMode = publishSettings.albumStatusSyncMode or "ask" + + -- Resolve automatically when a forced mode is set + if descDiff and descMode == "piwigo" then + collectionSettings.albumDescription = pwDesc + descDiff = false + end + if statusDiff and statusMode == "piwigo" then + collectionSettings.albumPrivate = pwPrivate + statusDiff = false + end + if descDiff and descMode == "lightroom" then + descDiff = false + end + if statusDiff and statusMode == "lightroom" then + statusDiff = false + end + + -- If all diffs resolved by forced modes, proceed + if not descDiff and not statusDiff then return true end + + -- Strip HTML tags for display + local function stripHtml(s) + if not s or s == "" then return s end + -- Strip all HTML tags + s = s:gsub("<[^>]+>", "") + -- Decode common HTML entities + s = s:gsub(" ", " "):gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub(""", '"'):gsub("'", "'") + -- Linearize: replace newlines/carriage returns with a single space + s = s:gsub("[\r\n]+", " ") + -- Collapse multiple spaces + s = s:gsub(" +", " ") + -- Trim + s = s:gsub("^%s+", ""):gsub("%s+$", "") + return s + end + + -- Build conflict dialog with formatted view + local f = LrView.osFactory() + local rows = {} + + table.insert(rows, f:static_text { + title = "Differences detected between Lightroom and Piwigo:", + font = "", + fill_horizontal = 1, + }) + table.insert(rows, f:static_text { title = " ", height_in_lines = 1 }) + + if descDiff then + table.insert(rows, f:static_text { + title = "Album Description", + font = "", + }) + table.insert(rows, f:static_text { title = " ", height_in_lines = 2 }) + table.insert(rows, f:row { + f:static_text { title = "Lightroom:", font = "", width = 80 }, + f:static_text { + title = lrDesc == "" and "(empty)" or stripHtml(lrDesc), + font = "", + width_in_chars = 50, + height_in_lines = 3, + }, + }) + table.insert(rows, f:row { + f:static_text { title = "Piwigo:", font = "", width = 80 }, + f:static_text { + title = pwDesc == "" and "(empty)" or stripHtml(pwDesc), + font = "", + width_in_chars = 50, + height_in_lines = 3, + }, + }) + table.insert(rows, f:static_text { title = " ", height_in_lines = 1 }) + end + + if statusDiff then + table.insert(rows, f:static_text { + title = "Album Privacy Status", + font = "", + }) + table.insert(rows, f:static_text { title = " ", height_in_lines = 1 }) + table.insert(rows, f:row { + f:static_text { title = "Lightroom:", font = "", width = 80 }, + f:static_text { + title = lrPrivate and "Private" or "Public", + font = "", + }, + }) + table.insert(rows, f:row { + f:static_text { title = "Piwigo:", font = "", width = 80 }, + f:static_text { + title = pwPrivate and "Private" or "Public", + font = "", + }, + }) + table.insert(rows, f:static_text { title = " ", height_in_lines = 1 }) + end + + table.insert(rows, f:separator { fill_horizontal = 1 }) + table.insert(rows, f:static_text { title = " ", height_in_lines = 1 }) + table.insert(rows, f:static_text { + title = "Your choice will overwrite the other version. This cannot be undone.", + font = "", + text_color = LrColor(0.8, 0, 0), + fill_horizontal = 1, + }) + + local contents = f:column(rows) + + local dialogResult = LrDialogs.presentModalDialog({ + title = "Album conflict: " .. (thisCat.name or ""), + contents = contents, + actionVerb = "Keep Lightroom (overwrite Piwigo)", + cancelVerb = "Cancel", + otherVerb = "Keep Piwigo (overwrite Lightroom)", + }) + + if dialogResult == "cancel" then + return false + elseif dialogResult == "other" then + if descDiff then collectionSettings.albumDescription = pwDesc end + if statusDiff then collectionSettings.albumPrivate = pwPrivate end + end + + return true +end + +-- ************************************************ +function PublishTask.processRenderedPhotos(functionContext, exportContext) + -- render photos and upload to Piwigo + + log:info("PublishTask.processRenderedPhotos - version: " .. utils.serialiseVar(_PLUGIN.VERSION)) + local callStatus = {} + local catalog = LrApplication.activeCatalog() + local exportSession = exportContext.exportSession + local propertyTable = exportContext.propertyTable + + local publishedCollection = exportContext.publishedCollection + local publishService = publishedCollection:getService() + local rv + if not publishService then + log:info('PublishTask.processRenderedPhotos - publishSettings:\n' .. utils.serialiseVar(propertyTable)) + LrErrors.throwUserError('Publish photos to Piwigo - cannot connect find publishService') + return nil + end + + local collectionInfo = publishedCollection:getCollectionInfoSummary() + local collectionSettings = collectionInfo.collectionSettings or {} + local collServiceState = {} + local serviceState = {} + if collectionSettings then + collServiceState = collectionSettings.serviceState or {} + end + -- serviceState is a table containing publishService specific statusData + if collServiceState then + serviceState = collServiceState + else + serviceState = PWStatusManager.getServiceState(publishService) + end + log:info("PublishTask.processRenderedPhotos - serviceState " .. utils.serialiseVar(serviceState)) + if serviceState.isCloningSync and serviceState.isCloningSync == true then + PWStatusManager.setisCloningSync(publishService, false) + -- use minimal render photos for smart collection cloning + PublishTask.processCloneSync(functionContext, exportContext) + return + end + if serviceState.PiwigoBusy then + return nil + end + PWStatusManager.setPiwigoBusy(publishService, true) + + -- Set progress title. + local nPhotos = exportSession:countRenditions() + local progressScope = exportContext:configureProgress { + title = "Publishing " .. nPhotos .. " photos to " .. propertyTable.host + } + -- check connection to piwigo + if not (propertyTable.Connected) then + rv = PiwigoAPI.login(propertyTable) + if not rv then + log:info('PublishTask.processRenderedPhotos - publishSettings:\n' .. utils.serialiseVar(propertyTable)) + PWStatusManager.setPiwigoBusy(publishService, false) + LrErrors.throwUserError('Publish photos to Piwigo - cannot connect to piwigo at ' .. propertyTable.host) + return nil + end + end + + -- log:info('PublishTask.processRenderedPhotos - collectionInfo:\n' .. utils.serialiseVar(collectionInfo)) + local parentCollSet = publishedCollection:getParent() + local parentID = "" + local albumName = publishedCollection:getName() + -- check if album is special collection and and use name of parent album if so + if string.sub(albumName, 1, 1) == "[" and string.sub(albumName, -1) == "]" then + if parentCollSet then + albumName = parentCollSet:getName() + end + end + local albumId = publishedCollection:getRemoteId() + local albumUrl = publishedCollection:getRemoteUrl() + + local requestRepub = false + if parentCollSet then + parentID = parentCollSet:getRemoteId() + end + local checkCats + -- Check that collection exists as an album on Piwigo and create if not + if albumId then + rv, checkCats = PiwigoAPI.pwCategoriesGet(propertyTable, albumId) + if not rv then + PWStatusManager.setPiwigoBusy(publishService, false) + LrErrors.throwUserError('Publish photos to Piwigo - cannot check category exists on piwigo at ' .. + propertyTable.host) + return nil + end + end + if not utils.nilOrEmpty(checkCats) and albumId then + -- album exists on Piwigo — reconcile metadata before updating + local thisCat = PiwigoAPI.pwCategoriesGetThis(propertyTable, albumId) + if not reconcileAlbumMeta(propertyTable, collectionSettings, thisCat) then + PWStatusManager.setPiwigoBusy(publishService, false) + return nil + end + local metaData = {} + metaData.name = albumName + metaData.remoteId = albumId + metaData.description = collectionSettings.albumDescription or "" + if collectionSettings.albumPrivate then + metaData.status = "private" + else + metaData.status = "public" + end + PiwigoAPI.pwCategoriesSetinfo(propertyTable, publishedCollection, metaData) + end + if utils.nilOrEmpty(checkCats) or not (albumId) then + -- create missing album on piwigo (may happen if album is deleted directly on Piwigo rather than via this plugin, or if smartcollectionimport is run) + local metaData = {} + callStatus = {} + metaData.name = albumName + metaData.parentCat = parentID + if collectionSettings.albumPrivate then + metaData.status = "private" + else + metaData.status = "public" + end + callStatus = PiwigoAPI.pwCategoriesAdd(propertyTable, publishedCollection, metaData, callStatus) + if callStatus.status then + -- reset album id to newly created one + albumId = callStatus.newCatId + exportSession:recordRemoteCollectionId(albumId) + exportSession:recordRemoteCollectionUrl(callStatus.albumURL) + LrDialogs.message("*** Missing Piwigo album ***", albumName .. ", Piwigo Cat ID " .. albumId .. " created") + requestRepub = true + else + PWStatusManager.setPiwigoBusy(publishService, false) + LrErrors.throwUserError('Publish photos to Piwigo - cannot create Piwigo album for ' .. albumName) + return nil + end + end + + -- Keyword filter setup + local kwFilterInclude = propertyTable.KwFilterInclude or "" + local kwFilterExclude = propertyTable.KwFilterExclude or "" + -- collection-level override if non-empty + if not utils.nilOrEmpty(collectionSettings.KwFilterInclude) then + kwFilterInclude = collectionSettings.KwFilterInclude + end + if not utils.nilOrEmpty(collectionSettings.KwFilterExclude) then + kwFilterExclude = collectionSettings.KwFilterExclude + end + local includePatterns = utils.parseFilterPatterns(kwFilterInclude) + local excludePatterns = utils.parseFilterPatterns(kwFilterExclude) + local kwFilterActive = #includePatterns > 0 or #excludePatterns > 0 + local blockedPhotos = {} + + log:info("KeywordFilter - active: " .. tostring(kwFilterActive) + .. " include: '" .. kwFilterInclude .. "'" + .. " exclude: '" .. kwFilterExclude .. "'") + + local resetConnectioncount = 0 + local renditionParams = { + stopIfCanceled = true, + } + -- flag to allow sync comments to manage process in PublishTask.getCommentsFromPublishedCollection + PWStatusManager.setRenderPhotos(publishService, true) + + -- Video pre-scan + server support check (delegated to vtk_core) + local batchTotalCount = exportSession:countRenditions() + local videoPhotos, batchVideoCount = vtk_core.preScan(exportSession, propertyTable, collectionSettings) + if batchVideoCount == 0 then + log:info("PublishTask - pre-scan: no videos detected in batch") + else + log:info("PublishTask - pre-scan: " .. batchVideoCount .. " video(s) detected in batch of " .. batchTotalCount) + end + + local videoUploadBlocked, serverMaxBytes, companionAvailable, shouldAbort = + vtk_core.checkServerSupport(propertyTable, videoPhotos, batchVideoCount, batchTotalCount, + exportSession, publishService) + if shouldAbort then return end + + -- ----------------------------------------------------------------------- + -- Phase 2C/2D — Video Toolkit (delegated to vtk_core) + -- ----------------------------------------------------------------------- + local vtkResults, metadataOnlyVideos + + if not videoUploadBlocked and batchVideoCount > 0 and propertyTable.vtkEnabled then + log:info("PublishTask - Video Toolkit enabled, processing " .. batchVideoCount .. " video(s)") + -- Remove ALL videos from export session to prevent LrC "This file is a video" dialog + for _, vEntry in ipairs(videoPhotos) do + exportSession:removePhoto(vEntry.photo) + end + + -- Run VTK batch + upload variants (delegated to vtk_core) + vtkResults, metadataOnlyVideos = vtk_core.runBatch( + videoPhotos, batchVideoCount, propertyTable, collectionSettings, progressScope) + vtk_core.uploadVariants( + vtkResults, propertyTable, collectionSettings, + albumId, albumName, albumUrl, + catalog, publishedCollection, + companionAvailable, serverMaxBytes, progressScope) + end + + vtkResults = vtkResults or {} + metadataOnlyVideos = metadataOnlyVideos or {} + + progressScope:setCaption("Publishing to Piwigo...") + progressScope:setPortionComplete(0, 100) + + -- now wait for photos to be exported and then upload to Piwigo + for i, rendition in exportContext:renditions(renditionParams) do + -- reset connection every 75 uploads + resetConnectioncount = resetConnectioncount + 1 + if resetConnectioncount > 75 then + resetConnectioncount = 0 + log:info("PublishTask.processRenderedPhotos - resetting Piwigo connection after 75 uploads") + rv = PiwigoAPI.login(propertyTable) + if not rv then + PWStatusManager.setPiwigoBusy(publishService, false) + PWStatusManager.setRenderPhotos(publishService, false) + log:info("PublishTask.processRenderedPhotos - renditionSettings\n" .. + utils.serialiseVar(renditionParams)) + LrErrors.throwUserError('Publish photos to Piwigo - cannot connect to piwigo at ' .. + propertyTable.host) + break + end + end + + local lrPhoto = rendition.photo + local remoteId = rendition.publishedPhotoId or "" + local fileFormat = lrPhoto:getRawMetadata("fileFormat") + local isVideo = (fileFormat == "VIDEO") + + -- Video: blocked/VTK videos already removed before the loop via removePhoto + local videoBlocked = false + if isVideo then + log:info("PublishTask.processRenderedPhotos - video detected: " + .. (lrPhoto:getFormattedMetadata("fileName") or "unknown")) + end + + -- Keyword filter check + local kwBlocked = false + if not videoBlocked and kwFilterActive then + local keywords = utils.getPhotoDirectKeywords(lrPhoto) + local allowed, reason = utils.checkKeywordFilter(keywords, includePatterns, excludePatterns) + if not allowed then + local fileName = lrPhoto:getFormattedMetadata("fileName") or "unknown" + log:info("KeywordFilter - BLOCKED: " .. fileName .. " - " .. reason) + table.insert(blockedPhotos, { name = fileName, reason = reason }) + -- wait for render to complete then discard + local bSuccess, bPath = rendition:waitForRender() + if bSuccess and LrFileUtils.exists(bPath) then + LrFileUtils.delete(bPath) + end + rendition:uploadFailed("Skipped (keyword filter): " .. reason) + kwBlocked = true + end + end + + if not kwBlocked and not videoBlocked then + -- Detect photo already published in this service (multi-album support) + local existingPwImageId = nil + if remoteId == "" then + -- Method 1: Via custom metadata (photos published with plugin >= 20251224.16) + local storedImageUrl = lrPhoto:getPropertyForPlugin(_PLUGIN, "pwImageURL") + local storedHost = lrPhoto:getPropertyForPlugin(_PLUGIN, "pwHostURL") + + if storedHost == propertyTable.host and storedImageUrl then + existingPwImageId = utils.extractPwImageIdFromUrl(storedImageUrl, propertyTable.host) + end + + -- Method 2: Search in other collections of the service (fallback) + if not existingPwImageId then + local publishService = publishedCollection:getService() + existingPwImageId = utils.findExistingPwImageId(publishService, lrPhoto) + end + + -- Verify the image still exists on Piwigo + if existingPwImageId then + local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingPwImageId) + if not checkStatus.status then + existingPwImageId = nil + end + end + end + + -- Wait for next photo to render. + local success, pathOrMessage = rendition:waitForRender() + -- Check for cancellation again after photo has been rendered. + if progressScope:isCanceled() then + if LrFileUtils.exists(pathOrMessage) then + LrFileUtils.delete(pathOrMessage) + end + break + end + + if success then + -- upload to Piwigo + callStatus = {} + local filePath = pathOrMessage + + -- If photo already exists on Piwigo, associate instead of uploading + if existingPwImageId then + log:info("Photo exists on Piwigo (ID " .. existingPwImageId .. "), associating to album " .. albumId) + callStatus = PiwigoAPI.associateImageToCategory(propertyTable, existingPwImageId, albumId) + + if callStatus.status then + rendition:recordPublishedPhotoId(callStatus.remoteid) + rendition:recordPublishedPhotoUrl(callStatus.remoteurl) + rendition:renditionIsDone(true) + LrFileUtils.delete(pathOrMessage) + else + log:warn("Association failed: " .. (callStatus.statusMsg or "") .. ", falling back to upload") + existingPwImageId = nil + end + end + + if not existingPwImageId then + -- Begin existing upload block (indent all upload code until end of if success) + local metaData = {} + -- build metadata structure + metaData = utils.getPhotoMetadata(propertyTable, lrPhoto) + metaData.Albumid = albumId + metaData.Remoteid = remoteId + -- run to build missingTags - tags that will be created on upload to Piwigo + -- will use this to decide whether to run build tagtable cache + -- means we don't have to rebuild after each uploaded photo + local tagIdList, missingTags = utils.tagsToIds(propertyTable, metaData.tagString) + + -- do the upload + callStatus = PiwigoAPI.updateGallery(propertyTable, filePath, metaData) + -- check status and complete rendition + if callStatus.status then + rendition:recordPublishedPhotoId(callStatus.remoteid or "") + rendition:recordPublishedPhotoUrl(callStatus.remoteurl or "") + rendition:renditionIsDone(true) + -- set metadata for photo + local pluginData = { + pwHostURL = propertyTable.host, + albumName = albumName, + albumUrl = albumUrl, + imageUrl = callStatus.remoteurl, + pwUploadDate = os.date("%Y-%m-%d"), + pwUploadTime = os.date("%H:%M:%S"), + pwCommentSync = "" + } + if propertyTable.syncCommentsPublish then + -- set to allow comments to sync for this photo if flag set + pluginData.pwCommentSync = "YES" + end + + -- store / update custom metadata + + + PiwigoAPI.storeMetaData(catalog, lrPhoto, pluginData) + + -- photo was uploaded with keywords included, but existing keywords aren't replaced by this process, + -- so force a metadata update using pwg.images.setInfo with single_value_mode set to "replace" to force old metadata/keywords to be replaced + metaData.Remoteid = callStatus.remoteid + if missingTags then + -- refresh cached tag list as new tags have been created during updateGallery + rv, propertyTable.tagTable = PiwigoAPI.getTagList(propertyTable) + if not rv then + LrDialogs.message("PiwigoAPI:updateMetadata - cannot get taglist from Piwigo") + else + utils.buildTagIndex(propertyTable) + end + end + callStatus = PiwigoAPI.updateMetadata(propertyTable, lrPhoto, metaData) + if not callStatus.status then + LrDialogs.message("Unable to set metadata for uploaded photo - " .. callStatus.statusMsg) + end + else + rendition:uploadFailed(callStatus.message or "Upload failed") + end + + -- When done with photo, delete temp file. + LrFileUtils.delete(pathOrMessage) + end -- end if not existingPwImageId + else + rendition:uploadFailed(pathOrMessage or "Render failed") + end + end -- end if not kwBlocked + end + + -- Phase 4C — Metadata-only video updates (delegated to vtk_core) + vtk_core.updateMetadataOnly( + metadataOnlyVideos, propertyTable, albumId, + catalog, publishedCollection, + companionAvailable, progressScope) + + progressScope:done() + PWStatusManager.setPiwigoBusy(publishService, false) +end + +-- ************************************************ +function PublishTask.processCloneSync(functionContext, exportContext) + -- minimal render function for service cloning + log:info("PublishTask.processCloneSync") + local exportSession = exportContext.exportSession + local propertyTable = exportContext.propertyTable + + local publishedCollection = exportContext.publishedCollection + local publishService = publishedCollection:getService() + + + local collectionInfo = publishedCollection:getCollectionInfoSummary() + local collectionSettings = collectionInfo.collectionSettings or {} + local collServiceState = {} + local serviceState = {} + if collectionSettings then + collServiceState = collectionSettings.serviceState or {} + end + + local collId = publishedCollection.localIdentifier + local remoteInfoTable = collServiceState.RemoteInfoTable[collId] or {} + + local renditionParams = { + stopIfCanceled = true, + } + for _, rendition in exportContext:renditions(renditionParams) do + --rendition:skipRender() + local lrPhoto = rendition.photo + local photoId = lrPhoto.localIdentifier + log:info("PublishTask.processCloneSync - photo " .. lrPhoto:getFormattedMetadata("fileName")) + + local success, pathOrMessage = rendition:waitForRender() + if not success then + rendition:renditionIsDone(false, pathOrMessage) + return + end + if LrFileUtils.exists(pathOrMessage) then + LrFileUtils.delete(pathOrMessage) + end + -- extract remoteid and url + local remoteInfo = remoteInfoTable[photoId] + local remoteId = "" + local remoteUrl = "" + if remoteInfo then + remoteId = remoteInfo.remoteId or "" + remoteUrl = remoteInfo.remoteUrl or "" + end + + if remoteId == "" then + rendition:uploadFailed("Render failed - No remote id found") + else + rendition:recordPublishedPhotoId(remoteId) + rendition:recordPublishedPhotoUrl(remoteUrl or "") + rendition:renditionIsDone(true) + end + end +end + +-- ************************************************ +function PublishTask.deletePhotosFromPublishedCollection(publishSettings, arrayOfPhotoIds, deletedCallback, + localCollectionId) + local callStatus = {} + local errStatus = "" + + + -- build tables to allow access to catalog LrPhoto object + local catalog = LrApplication.activeCatalog() + local publishedCollection = catalog:getPublishedCollectionByLocalIdentifier(localCollectionId) + local publishedPhotos = publishedCollection:getPublishedPhotos() + local publishService = publishedCollection:getService() + if not publishService then + log:info('deletePhotosFromPublishedCollection - publishSettings:\n' .. utils.serialiseVar(publishSettings)) + LrErrors.throwUserError('Publish photos to Piwigo - cannot connect find publishService') + return nil + end + -- serviceState is a global table containing publishService specific statusData + local serviceState = PWStatusManager.getServiceState(publishService) + if serviceState.PiwigoBusy then + return nil + end + PWStatusManager.setPiwigoBusy(publishService, true) + + -- build lookup table to access photos by remoteId + local photosToUnpublish = {} + local pubPhotoByRemoteID = {} + for _, pubPhoto in pairs(publishedPhotos) do + pubPhotoByRemoteID[pubPhoto:getRemoteId()] = pubPhoto + end + + -- build table of photo objects for each item in arrayofphotoids + local arrayPos = 1 + for i = 1, #arrayOfPhotoIds do + local pwImageID = arrayOfPhotoIds[i] or nil + if pwImageID then + local pubPhoto = pubPhotoByRemoteID[pwImageID] + local lrphoto = pubPhoto:getPhoto() + photosToUnpublish[arrayPos] = {} + photosToUnpublish[arrayPos][1] = lrphoto + photosToUnpublish[arrayPos][2] = pwImageID + photosToUnpublish[arrayPos][3] = pubPhoto + arrayPos = arrayPos + 1 + end + end + + -- piwigo album id + local pwCatID = publishedCollection:getRemoteId() + + -- check connection to piwigo + if not (publishSettings.Connected) then + local rv = PiwigoAPI.login(publishSettings) + if not rv then + PWStatusManager.setPiwigoBusy(publishService, false) + LrErrors.throwUserError('Delete Photos from Collection - cannot connect to piwigo at ' .. publishSettings + .url) + return nil + end + end + + -- set up async prococess for piwigo calls + LrTasks.startAsyncTask(function() + -- now go through each photo in photosToUnpublish and remove from Piwigo + for i, thisPhotoToUnpublish in pairs(photosToUnpublish) do + local thisLrPhoto = thisPhotoToUnpublish[1] + local thispwImageID = thisPhotoToUnpublish[2] + local thisPubPhoto = thisPhotoToUnpublish[3] + + -- Use dissociate instead of delete to preserve multi-album associations + log:info("PublishTask.deletePhotosFromPublishedCollection - dissociating photo " .. + thispwImageID .. " from category " .. pwCatID) + callStatus = PiwigoAPI.dissociateImageFromCategory(publishSettings, thispwImageID, pwCatID) + if callStatus.status then + -- Only clear metadata if photo is no longer in any other published collection + -- Check if photo exists in other collections of this service + local publishService = publishedCollection:getService() + local stillPublished = utils.findExistingPwImageId(publishService, thisLrPhoto) + + if not stillPublished then + -- Photo is no longer published anywhere, clear all metadata + log:info("PublishTask.deletePhotosFromPublishedCollection - photo " .. + thispwImageID .. " orphaned, clearing metadata") + catalog:withWriteAccessDo("Updating " .. thisLrPhoto:getFormattedMetadata("fileName"), + function() + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwHostURL", "") + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwAlbumName", "") + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwAlbumURL", "") + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwImageURL", "") + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwUploadDate", "") + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwUploadTime", "") + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwCommentSync", "") + end) + else + log:info("PublishTask.deletePhotosFromPublishedCollection - photo " .. + thispwImageID .. " still in other collections, keeping metadata") + end + thisPhotoToUnpublish[4] = true + else + PWStatusManager.setPiwigoBusy(publishService, false) + LrErrors.throwUserError( + 'Failed to delete photo ' .. thispwImageID .. ' from Piwigo - ' .. callStatus.statusMsg, + 'Failed to delete photo') + end + end + end, errStatus) + + -- now finish process via deletedCallback + for i, thisPhotoToUnpublish in pairs(photosToUnpublish) do + local thispwImageID = thisPhotoToUnpublish[2] + deletedCallback(thispwImageID) + end + + + PWStatusManager.setPiwigoBusy(publishService, false) +end + +-- ************************************************ +function PublishTask.getCommentsFromPublishedCollection(publishSettings, arrayOfPhotoInfo, commentCallback) + log:info("PublishTask.getCommentsFromPublishedCollection") + + --[[ + This callback is invoked in the following situations: + 1 - For every photo in the Published Collection whenever any photo in that collection is published or re-published. + 2 - When the user clicks Refresh in the Library module ▸ Comments panel. + 3 - After the user adds a new comment to a photo in the Library module ▸ Comments panel. +]] + + local rv, publishService = PiwigoAPI.getPublishService(publishSettings) + if not (publishService) or not (rv) then + log:info('PublishTask.getCommentsFromPublishedCollection - publishSettings:\n' .. + utils.serialiseVar(publishSettings)) + LrErrors.throwUserError('PublishTask.getCommentsFromPublishedCollection - cannot find publishService') + return nil + end + -- serviceState is a global table containing publishService specific statusData + local serviceState = PWStatusManager.getServiceState(publishService) + local serviceId = publishService.localIdentifier + -- check serviceState.PiwigoBusy flag + if serviceState.PiwigoBusy then + utils.pwBusyMessage("PublishTask.getCommentsFromPublishedCollection", "Sync Comments") + return + end + + -- check if being called by processRenderedPhotos + local syncPubOnly = false + if serviceState.RenderPhotos then + PWStatusManager.setRenderPhotos(publishService, false) + -- should we sync comments as part of the processRenderedPhotos operation + if not (publishSettings.syncCommentsPublish) then + log:info("PublishTask.getCommentsFromPublishedCollection - syncComments not enabled for publish") + return + end + -- should we sync comments only for photos published in preceding publish process + if publishSettings.syncCommentsPubOnly then + syncPubOnly = true + end + end + + local catalog = LrApplication.activeCatalog() + -- loop through all photos to check for any with pwCommentSync set to "NO" + for i, photoInfo in ipairs(arrayOfPhotoInfo) do + --log:info("PublishTask.getCommentsFromPublishedCollection - photoInfo:\n" .. utils.serialiseVar(photoInfo)) + local thisPubPhoto = photoInfo.publishedPhoto + local thisLrPhoto = thisPubPhoto:getPhoto() + -- assume to sync comments for all photos in arrayofphotoids + local syncThisPhoto = true + if syncPubOnly then + -- syncPubOnly will be set to true if getCommentsFromPublishedCollection has been called following processRenderedPhotos + -- and user has checked the option Only Include Published Photos + -- "pwCommentSync" gets set to YES by the renderphotos process indicating this photo is part of the latest publish process + local commentSync = thisLrPhoto:getPropertyForPlugin(_PLUGIN, "pwCommentSync") + if commentSync == "YES" then + -- reset metadata + catalog:withWriteAccessDo("Updating " .. thisLrPhoto:getFormattedMetadata("fileName"), + function() + thisLrPhoto:setPropertyForPlugin(_PLUGIN, "pwCommentSync", "") + end) + else + -- this photo was not part of recent processRenderedPhotos so ignore + syncThisPhoto = false + end + end + + if syncThisPhoto then + -- get table of comments for this photo from Piwigo + local metaData = {} + metaData.remoteId = photoInfo.remoteId + local pwComments = PiwigoAPI.getComments(publishSettings, metaData) + -- convert pwComments to format required by commentCallback + --log:info("PublishTask.getCommentsFromPublishedCollection - commentList:\n" .. utils.serialiseVar(pwComments)) + local commentList = {} + if pwComments and #pwComments > 0 then + for _, comment in ipairs(pwComments) do + local dateCreated = comment.date + local timeStamp = utils.timeStamp(dateCreated) + log:info("dateCreated " .. dateCreated .. ", timeStamp " .. timeStamp) + table.insert(commentList, { + commentId = comment.id, + commentText = comment.content, + dateCreated = LrDate.timeFromPosixDate(tonumber(timeStamp)), + username = comment.author, + realname = comment.author, + url = comment.page_url, + }) + end + end + --log:info("PublishTask.getCommentsFromPublishedCollection - commentList:\n" .. utils.serialiseVar(commentList)) + commentCallback { publishedPhoto = photoInfo, comments = commentList } + end + end +end + -- ************************************************ function PublishTask.canAddCommentsToService(publishSettings) log:info("PublishTask.canAddCommentToPublishedPhoto") @@ -33,6 +835,19 @@ function PublishTask.canAddCommentsToService(publishSettings) return commentsEnabled end +-- ************************************************ +function PublishTask.addCommentToPublishedPhoto(publishSettings, remotePhotoId, commentText) + log:info("PublishTask.addCommentToPublishedPhoto") + -- add comment to Piwigo Photo + + local metaData = {} + metaData.remoteId = remotePhotoId + metaData.comment = commentText + + local rv = PiwigoAPI.addComment(publishSettings, metaData) + return rv +end + -- ************************************************ function PublishTask.didCreateNewPublishService(publishSettings, info) log:info("PublishTask.didCreateNewPublishService") @@ -82,65 +897,94 @@ end -- ************************************************ function PublishTask.imposeSortOrderOnPublishedCollection(publishSettings, info, remoteIdSequence) - -- This callback is called by Lightroom for smart collections. - -- It allows you to detect published photos that no longer meet the criteria and mark them for - -- deletion. + -- This callback is called by Lightroom after each publish. + -- It handles two tasks: + -- 1. For smart collections: filter out photos that no longer meet the criteria + -- 2. Sync sort order to Piwigo via pwg.images.setRank (if enabled) log:info("PublishTask.imposeSortOrderOnPublishedCollection") - local validSequence = {} local publishedCollection = info.publishedCollection - if not publishedCollection then return nil end - -- Check if it is a smart collection - if not publishedCollection:isSmartCollection() then - return nil + local finalSequence = remoteIdSequence + local isSmartCollection = publishedCollection:isSmartCollection() + + -- PART 1: Smart collection filtering (existing logic) + if isSmartCollection then + local validSequence = {} + local currentPhotos = publishedCollection:getPhotos() + local currentPhotoIds = {} + for _, photo in ipairs(currentPhotos) do + currentPhotoIds[photo.localIdentifier] = true + end + + local publishedPhotos = publishedCollection:getPublishedPhotos() + local remoteIdToPhoto = {} + for _, pubPhoto in ipairs(publishedPhotos) do + local remoteId = pubPhoto:getRemoteId() + if remoteId then + remoteIdToPhoto[remoteId] = pubPhoto + end + end + + for _, remoteId in ipairs(remoteIdSequence) do + local pubPhoto = remoteIdToPhoto[remoteId] + if pubPhoto then + local lrPhoto = pubPhoto:getPhoto() + if lrPhoto and currentPhotoIds[lrPhoto.localIdentifier] then + table.insert(validSequence, remoteId) + end + end + end + + log:info("PublishTask.imposeSortOrderOnPublishedCollection - " .. + #remoteIdSequence .. " published, " .. + #validSequence .. " still match criteria, " .. + (#remoteIdSequence - #validSequence) .. " to delete") + + finalSequence = validSequence end - -- Retrieve photos currently in the smart collection (according to criteria) - local currentPhotos = publishedCollection:getPhotos() - local currentPhotoIds = {} - for _, photo in ipairs(currentPhotos) do - currentPhotoIds[photo.localIdentifier] = true + -- PART 2: Sync sort order to Piwigo + local shouldSync = false + local collectionInfo = publishedCollection:getCollectionInfoSummary() + local collectionSettings = collectionInfo.collectionSettings or {} + local override = collectionSettings.syncSortOrderOverride or "default" + + if override == "always" then + shouldSync = true + elseif override == "never" then + shouldSync = false + else + shouldSync = publishSettings.syncPhotoSortOrder or false end - -- Browse the remoteIds of published photos - -- remoteIdSequence contains the remoteIds in the current order - local publishedPhotos = publishedCollection:getPublishedPhotos() - local remoteIdToPhoto = {} - for _, pubPhoto in ipairs(publishedPhotos) do - local remoteId = pubPhoto:getRemoteId() - if remoteId then - remoteIdToPhoto[remoteId] = pubPhoto - end - end - - -- Build the valid sequence: only photos that still meet the criteria - for _, remoteId in ipairs(remoteIdSequence) do - local pubPhoto = remoteIdToPhoto[remoteId] - if pubPhoto then - local lrPhoto = pubPhoto:getPhoto() - if lrPhoto and currentPhotoIds[lrPhoto.localIdentifier] then - -- The photo still meets the criteria, keep it. - table.insert(validSequence, remoteId) + if shouldSync and #finalSequence > 0 then + local categoryId = info.remoteCollectionId + if categoryId then + log:info("PublishTask.imposeSortOrderOnPublishedCollection - syncing sort order for category " .. + tostring(categoryId) .. ", " .. #finalSequence .. " photos") + local callStatus = PiwigoAPI.pwImagesSetRank(publishSettings, categoryId, finalSequence) + if not callStatus.status then + log:info("PublishTask.imposeSortOrderOnPublishedCollection - setRank failed: " .. + (callStatus.statusMsg or "unknown error")) end - -- If the photo is no longer in currentPhotoIds, it will be marked for deletion because - -- its remoteId will not be in validSequence. + else + log:info("PublishTask.imposeSortOrderOnPublishedCollection - no remoteId for collection, skipping sort sync") end end - log:info("PublishTask.imposeSortOrderOnPublishedCollection - " .. - #remoteIdSequence .. " published, " .. - #validSequence .. " still match criteria, " .. - (#remoteIdSequence - #validSequence) .. " to delete") - - return validSequence + if isSmartCollection then + return finalSequence + end + return nil end -- ************************************************ function PublishTask.validatePublishedCollectionName(name) + log:info("PublishTask.validatePublishedCollectionName") -- look for [ and ] if string.sub(name, 1, 1) == "[" or string.sub(name, -1) == "]" then return false, "Cannot use [ ] at start and end of album name - clashes with special collections" @@ -173,28 +1017,27 @@ end -- ************************************************ local function initCollectionSettingsDefaults(collectionSettings) local defaults = { - albumDescription = "", - albumPrivate = false, - enableCustom = false, - reSize = false, - reSizeParam = "Long Edge", - reSizeNoEnlarge = true, - reSizeLongEdge = 1024, - reSizeShortEdge = 1024, - reSizeW = 1024, - reSizeH = 1024, - reSizeMP = 5, - reSizePC = 50, - metaData = "All", - metaDataNoPerson = true, - metaDataNoLocation = false, - mdTitle = "{{title}}", - mdDescription = "{{caption}}", - KwFullHierarchy = true, - KwSynonyms = true, - KwFilterInclude = "", - KwFilterExclude = "", - syncSortOrderOverride = "default", + albumDescription = "", + albumPrivate = false, + enableCustom = false, + reSize = false, + reSizeParam = "Long Edge", + reSizeNoEnlarge = true, + reSizeLongEdge = 1024, + reSizeShortEdge = 1024, + reSizeW = 1024, + reSizeH = 1024, + reSizeMP = 5, + reSizePC = 50, + metaData = "All", + metaDataNoPerson = true, + metaDataNoLocation = false, + KwFullHierarchy = true, + KwSynonyms = true, + KwFilterInclude = "", + KwFilterExclude = "", + syncSortOrderOverride = "default", + vtkPresetOverride = "", -- 5C: "" = use service default } for key, defaultVal in pairs(defaults) do if collectionSettings[key] == nil then @@ -203,12 +1046,9 @@ local function initCollectionSettingsDefaults(collectionSettings) end end --- ************************************************ -local function buildCommonCollectionUI(f, bind, share, collectionSettings, publishSettings) - local pwAlbumUI = UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings, publishSettings) - - local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings, publishSettings) - +local function buildCommonCollectionUI(f, bind, share, collectionSettings) + local pwAlbumUI = UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings) + local kwFilterUI = UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) local sortOrderUI = f:group_box { title = "Sort Order", font = "", @@ -232,9 +1072,9 @@ local function buildCommonCollectionUI(f, bind, share, collectionSettings, publi }, }, } - return pwAlbumUI, sortOrderUI, kwFilterUI end + -- ************************************************ function PublishTask.viewForCollectionSettings(f, publishSettings, info) log:info("PublishTask.viewForCollectionSettings") @@ -254,27 +1094,142 @@ function PublishTask.viewForCollectionSettings(f, publishSettings, info) local collectionSettings = assert(info.collectionSettings) initCollectionSettingsDefaults(collectionSettings) - local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings, - publishSettings) - - local allowCustomAlbumSettings = publishSettings and publishSettings.PWP_customAlbumSettings == true - local UI - if allowCustomAlbumSettings then - local customSettingsUI = UIHelpers.createExportSettingsGroupBox(f, bind, collectionSettings, publishSettings) - UI = f:column { - spacing = f:control_spacing(), - pwAlbumUI, - -- sortOrderUI, --todo - kwFilterUI, - customSettingsUI, - } - else - UI = f:column { + + -- build UI + local reSizeOptions = { + { title = "Long Edge", value = "Long Edge" }, + { title = "Short Edge", value = "Short Edge" }, + { title = "Dimensions", value = "Dimensions" }, + { title = "Megapixels", value = "MegaPixels" }, + { title = "Percent", value = "Percent" }, + } + local metaDataOpts = { + { title = "All Metadata", value = "All Metadata" }, + { title = "Copyright only", value = "Copyright Only" }, + { title = "Copyright & Contact Info Only", value = "Copyright & Contact Info Only" }, + { title = "All Except Camera Raw Info", value = "All Except Camera Raw Info" }, + { title = "All Except Camera & Camera Raw Info", value = "All Except Camera & Camera Raw Info" }, + } + + local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings) + + local pubSettingsUI = f:group_box { + title = "Custom Publish Settings (Overrides defaults set in Publish Settings)", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:column { spacing = f:control_spacing(), - pwAlbumUI, - -- sortOrderUI, --todo - } - end + fill_horizontal = 1, + f:separator { fill_horizontal = 1 }, + f:row { + f:checkbox { + title = "Use custom settings for this album", + tooltip = "If checked, these settings will replace the defaults set in Publish Settings", + value = bind 'enableCustom', + } + }, + f:row { + f:group_box { -- group for export parameters + title = "Export Settings", + visible = bind 'enableCustom', + font = "", + fill_horizontal = 1, + f:row { + fill_horizontal = 1, + spacing = f:label_spacing(), + + f:checkbox { + title = "Resize Image", + tooltip = "If checked, published image will be resized per these settings", + value = bind 'reSize', + }, + f:static_text { + title = "Use :", + alignment = 'right', + fill_horizontal = 1, + }, + + f:popup_menu { + value = bind 'reSizeParam', + items = sizeOpts, + value_equal = valueEqual, + }, + f:checkbox { + title = "Allow Enlarge Image", + tooltip = "If checked, published image will be enlarged if necessary", + value = bind 'reSizeEnlarge', + }, + + }, + + + }, + }, + f:row { + f:group_box { -- group for Metadata parameters + title = "Metadata Settings", + visible = bind 'enableCustom', + font = "", + fill_horizontal = 1, + f:spacer { height = 2 }, + + f:checkbox { title = "Include Full Keyword Hierarchy", + tooltip = "If checked, all keywords in a keyword hierarchy will be sent to Piwigo", + value = bind 'KwFullHierarchy', + }, + f:checkbox { title = "Include Keywords Synonyms", + tooltip = "If checked, keywords synonyms will be sent to Piwigo", + value = bind 'KwSynonyms', + } + + }, + }, + }, + } + + -- 5C — Video preset override per collection + local vtkPresetItems = { + { title = "Use service default", value = "" }, + { title = "Small (480p)", value = "small" }, + { title = "Medium (720p)", value = "medium" }, + { title = "Large (1080p)", value = "large" }, + { title = "XLarge (1440p)", value = "xlarge" }, + { title = "XXL (2160p)", value = "xxl" }, + { title = "Origin (no transcode)", value = "origin" }, + } + local vtkVideoUI = f:group_box { + title = "Video Preset Override", + font = "", + size = 'regular', + fill_horizontal = 1, + bind_to_object = assert(collectionSettings), + f:spacer { height = 2 }, + f:row { + fill_horizontal = 1, + f:static_text { + title = "Video preset:", + font = "", + alignment = 'right', + }, + f:popup_menu { + value = bind 'vtkPresetOverride', + items = vtkPresetItems, + tooltip = "Override the service-level video preset for this album only. 'Use service default' keeps the global setting.", + }, + }, + f:spacer { height = 2 }, + } + + local UI = f:column { + spacing = f:control_spacing(), + pwAlbumUI, + sortOrderUI, + kwFilterUI, + vtkVideoUI, + --pubSettingsUI, + } return UI end @@ -292,8 +1247,7 @@ function PublishTask.updateCollectionSettings(publishSettings, info) if not publishService then - log:info('updateCollectionSettings - publishSettings:\n' .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info('updateCollectionSettings - publishSettings:\n' .. utils.serialiseVar(publishSettings)) LrErrors.throwUserError('updateCollectionSettings - cannot connect find publishService') return nil end @@ -311,6 +1265,8 @@ function PublishTask.updateCollectionSettings(publishSettings, info) statusMsg = "" } + + local collectionSettings = assert(info.collectionSettings) -- piwigo album settings if collectionSettings.albumDescription == nil then @@ -339,6 +1295,11 @@ function PublishTask.updateCollectionSettings(publishSettings, info) publishSettings.host) return nil end + if not reconcileAlbumMeta(publishSettings, collectionSettings, thisCat) then + return { status = false, statusMsg = "Cancelled by user" } + end + metaData.description = collectionSettings.albumDescription or "" + metaData.status = collectionSettings.albumPrivate and "private" or "public" CallStatus = PiwigoAPI.pwCategoriesSetinfo(publishSettings, info, metaData) return CallStatus end @@ -378,31 +1339,16 @@ function PublishTask.viewForCollectionSetSettings(f, publishSettings, info) local share = LrView.share local collectionSettings = assert(info.collectionSettings) - initCollectionSettingsDefaults(collectionSettings) - local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings, - publishSettings) - - local allowCustomAlbumSettings = publishSettings and publishSettings.PWP_customAlbumSettings == true - local UI - if allowCustomAlbumSettings then - local customSettingsUI = UIHelpers.createExportSettingsGroupBox(f, bind, collectionSettings, publishSettings) - UI = f:column { - spacing = f:control_spacing(), - pwAlbumUI, - -- sortOrderUI, --todo - kwFilterUI, - customSettingsUI, - } - else - UI = f:column { - spacing = f:control_spacing(), - pwAlbumUI, - -- sortOrderUI, --todo - } - end + local pwAlbumUI, sortOrderUI, kwFilterUI = buildCommonCollectionUI(f, bind, share, collectionSettings) + local UI = f:column { + spacing = f:control_spacing(), + pwAlbumUI, + sortOrderUI, + kwFilterUI, + } return UI end @@ -417,8 +1363,7 @@ function PublishTask.updateCollectionSetSettings(publishSettings, info) local publishService = info.publishService if not publishService then - log:info('updateCollectionSettings - publishSettings:\n' .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info('updateCollectionSettings - publishSettings:\n' .. utils.serialiseVar(publishSettings)) LrErrors.throwUserError('updateCollectionSettings - cannot connect find publishService') return nil end @@ -475,6 +1420,11 @@ function PublishTask.updateCollectionSetSettings(publishSettings, info) publishSettings.host) return nil end + if not reconcileAlbumMeta(publishSettings, collectionSettings, thisCat) then + return { status = false, statusMsg = "Cancelled by user" } + end + metaData.description = collectionSettings.albumDescription or "" + metaData.status = collectionSettings.albumPrivate and "private" or "public" CallStatus = PiwigoAPI.pwCategoriesSetinfo(publishSettings, info, metaData) return CallStatus end @@ -515,8 +1465,7 @@ function PublishTask.reparentPublishedCollection(publishSettings, info) local publishService = info.publishService if not publishService then - log:info('reparentPublishedCollection - publishSettings:\n' .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info('reparentPublishedCollection - publishSettings:\n' .. utils.serialiseVar(publishSettings)) LrErrors.throwUserError('reparentPublishedCollection - cannot connect find publishService') return nil end @@ -568,8 +1517,7 @@ function PublishTask.renamePublishedCollection(publishSettings, info) -- called for both collections and collectionsets local publishService = info.publishService if not publishService then - log:info('renamePublishedCollection - publishSettings:\n' .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info('renamePublishedCollection - publishSettings:\n' .. utils.serialiseVar(publishSettings)) LrErrors.throwUserError('renamePublishedCollection - cannot connect find publishService') return nil end @@ -585,6 +1533,13 @@ function PublishTask.renamePublishedCollection(publishSettings, info) else collectionSettings = collection:getCollectionInfoSummary() end + -- Reconcile metadata with Piwigo before renaming + if not utils.nilOrEmpty(remoteId) then + local thisCat = PiwigoAPI.pwCategoriesGetThis(publishSettings, remoteId) + if not reconcileAlbumMeta(publishSettings, collectionSettings, thisCat) then + return { status = false, statusMsg = "Cancelled by user" } + end + end local metaData = {} metaData.name = newName metaData.remoteId = remoteId @@ -651,8 +1606,7 @@ function PublishTask.deletePublishedCollection(publishSettings, info) local publishService = info.publishService local publishCollection = info.publishedCollection if not publishService then - log:info('deletePublishedCollection - publishSettings:\n' .. - utils.serialiseVar(utils.anonymisePropertyTable(publishSettings))) + log:info('deletePublishedCollection - publishSettings:\n' .. utils.serialiseVar(publishSettings)) LrErrors.throwUserError('deletePublishedCollection - cannot connect find publishService') return nil end diff --git a/piwigoPublish.lrplugin/Tagset.lua b/piwigoPublish.lrplugin/Tagset.lua index fbe90ae..db51dc5 100644 --- a/piwigoPublish.lrplugin/Tagset.lua +++ b/piwigoPublish.lrplugin/Tagset.lua @@ -20,6 +20,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + return { title = LOC "$$$/FionaBoston/Title=Piwigo Publisher Metadata", id = 'PWPTagset', diff --git a/piwigoPublish.lrplugin/UIHelpers.lua b/piwigoPublish.lrplugin/UIHelpers.lua index 9479076..1521e17 100644 --- a/piwigoPublish.lrplugin/UIHelpers.lua +++ b/piwigoPublish.lrplugin/UIHelpers.lua @@ -24,48 +24,30 @@ UIHelpers = {} --- Functions for UI Management --- ************************************************* -local function valueEqual(a, b) - -- Define a value_equal function for the popup_menu - return a == b -end - -- ************************************************* -- Create plugin header with icon and version information -- Returns a row containing icon + plugin name + version -- ************************************************* function UIHelpers.createPluginHeader(f, share, iconPath, pluginVersion) - local INDENT_PIXELS = 14 - return f:row { + spacing = f:control_spacing(), f:picture { alignment = 'left', value = iconPath, }, f:column { - spacing = f:control_spacing(), - f:spacer { height = 1 }, - f:row { - f:spacer { width = INDENT_PIXELS }, - f:static_text { - title = "Piwigo Publisher Plugin", - font = "", - alignment = 'left', - width = share 'labelWidth', - }, + spacing = f:label_spacing(), + f:static_text { + title = "Piwigo Publisher", + font = "", + alignment = 'left', + width = share 'labelWidth', }, - f:row { - f:spacer { width = INDENT_PIXELS }, - f:static_text { - title = "Plugin Version", - alignment = 'left', - }, - f:static_text { - title = pluginVersion, - alignment = 'left', - width = share 'labelWidth', - }, + f:static_text { + title = pluginVersion, + font = "", + text_color = LrColor(0.5, 0.5, 0.5), + alignment = 'left', }, }, } @@ -75,7 +57,34 @@ end -- Create Piwigo Album Settings UI section -- Returns a group_box with album description and private checkbox -- ************************************************* -function UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings, publishSettings) +function UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSettings) + local rows = { + f:separator { fill_horizontal = 1 }, + f:row { + fill_horizontal = 1, + f:static_text { title = "Album Description:", font = "", alignment = 'right', width = share 'label_width' }, + f:edit_field { + enabled = true, + value = bind 'albumDescription', + fill_horizontal = 1, + width_in_chars = 70, + font = "", + alignment = 'left', + height_in_lines = 4, + }, + }, + f:row { + fill_horizontal = 1, + f:static_text { title = "", alignment = 'right', width = share 'label_width' }, + f:checkbox { + title = "", + tooltip = "If checked, this album will be private on Piwigo", + value = bind 'albumPrivate', + }, + f:static_text { title = "Album is Private", font = "" }, + }, + } + return f:group_box { title = "Piwigo Album Settings", font = "", @@ -84,74 +93,8 @@ function UIHelpers.createPiwigoAlbumSettingsUI(f, share, bind, collectionSetting bind_to_object = assert(collectionSettings), f:column { spacing = f:control_spacing(), - - f:separator { fill_horizontal = 1 }, - - f:row { - fill_horizontal = 1, - f:static_text { title = "Album Description:", font = "", alignment = 'right', width = share 'label_width', }, - f:edit_field { - enabled = bind { - key = 'syncAlbumDescriptions', - bind_to_object = publishSettings, - }, - font = "", - tooltip = "If synchronising album descriptions, description entered here will be sent to Piwigo. If not synchronising album descriptions, description entered here will be ignored.", - value = bind 'albumDescription', - fill_horizontal = 1, - width_in_chars = 70, - - alignment = 'left', - height_in_lines = 4, - }, - }, - - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width = share 'label_width', - }, - f:checkbox { - title = "", - tooltip = "If checked, this album will be private on Piwigo", - value = bind 'albumPrivate', - }, - f:static_text { - title = "Album is Private", - font = "", - } - }, - - f:separator { fill_horizontal = 1 }, - f:row { - fill_horizontal = 1, - --[[ - f:static_text { - title = "", - alignment = 'right', - width = share 'label_width', - },]] - f:checkbox { - title = "Enable Custom Export Settings for this Album", - font = "", - tooltip = "If checked, settings entered below will override the defaults set in Publish Settings for this album", - value = bind 'enableCustom', - enabled = bind { - key = 'PWP_customAlbumSettings', - bind_to_object = publishSettings, - transform = function(value, fromModel) - if fromModel then - return value == true - end - return value - end, - }, - - }, - }, - }, + unpack(rows), + } } end @@ -165,14 +108,14 @@ end -- fillColumns (bool) : apply fill_horizontal on columns -- ************************************************* function UIHelpers.createKeywordFilteringFields(f, bind, options) - options = options or {} - local widthInChars = options.widthInChars or 30 - local heightInLines = options.heightInLines or 8 - local fillColumns = options.fillColumns or false + options = options or {} + local widthInChars = options.widthInChars or 30 + local heightInLines = options.heightInLines or 8 + local fillColumns = options.fillColumns or false local exclusionColDef = { f:static_text { - title = "Exclusion Rules\n(keywords matching these rules will not be sent to Piwigo)", + title = "Exclusion Rules", font = "", }, f:edit_field { @@ -182,16 +125,14 @@ function UIHelpers.createKeywordFilteringFields(f, bind, options) width_in_chars = widthInChars, height_in_lines = heightInLines, fill_horizontal = fillColumns and 1 or nil, - multiline = true, - --tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", - tooltip = "Keywords matching these rules will not sent to Piwigo. One rule per line. Overrides inclusion rules - if a keyword matches both exclusion and inclusion rules, it will be excluded.", + tooltip = "Photos with any keyword matching these rules will not be published. One rule per line.", }, } if fillColumns then exclusionColDef.fill_horizontal = 1 end local inclusionColDef = { f:static_text { - title = "Inclusion Rules\n(only keywords matching these rules will be sent to Piwigo)", + title = "Inclusion Rules", font = "", }, f:edit_field { @@ -201,59 +142,33 @@ function UIHelpers.createKeywordFilteringFields(f, bind, options) width_in_chars = widthInChars, height_in_lines = heightInLines, fill_horizontal = fillColumns and 1 or nil, - multiline = true, - --tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", - tooltip = "Only keywords matching these rules will be sent to Piwigo. One rule per line. Exclusion rules take precedence over inclusion rules - if a keyword matches both exclusion and inclusion rules, it will be excluded.", + tooltip = "Photos must have at least one keyword matching these rules to be published. Leave empty to allow all. One rule per line.", }, } if fillColumns then inclusionColDef.fill_horizontal = 1 end - local elements = {} - if not options.showOverrideHint then - -- options.showOverrideHint is set to false for the PublishDialogSections - elements[#elements + 1] = f:static_text { - title = "Keyword Filtering Settings", - font = "", - } - end - elements[#elements + 1] = f:static_text { - --title = "Use these rules to filter photos based on their keywords when publishing.", - title = "Use these rules remove keywords from photos in Piwigo when published. ", - font = "", - } - - elements[#elements + 1] = f:static_text { - title = "One rule per line. Use Option+Enter (Mac) or Alt+Enter (Windows) to add a new line.", - font = "", - } - - elements[#elements + 1] = f:static_text { - title = "Wildcards: * matches any number of characters, ? matches exactly one character.", - font = "", - } - elements[#elements + 1] = f:static_text { - title = "Examples: nature* (nature, natureza, etc.), *photo* (photograph, photoshop, etc.), ?at (bat, cat, hat, etc.)", - font = "", + local elements = { + f:static_text { + title = "Use these rules to filter photos based on their keywords when publishing.", + font = "", + }, + f:static_text { + title = "One rule per line. Wildcards: * matches any number of characters, ? matches exactly one character.", + font = "", + }, + f:static_text { + title = "Examples: nature* (nature, natureza, etc.), *photo* (photograph, photoshop, etc.), ?at (bat, cat, hat, etc.)", + font = "", + }, } - elements[#elements + 1] = f:static_text { - title = "All levels of hierarchy are considered. If a keyword matches both exclusion and inclusion rules, it will be excluded.", - font = "", - } - if options.allowCustomAlbumSettings then - if not options.showOverrideHint then - elements[#elements + 1] = f:static_text { - title = "Rules can also be set for individual albums, overriding these global settings.", - font = "", - } - end - if options.showOverrideHint then - elements[#elements + 1] = f:static_text { - title = "Leave empty to use global settings from Publish Settings.", - font = "", - } - end + if options.showOverrideHint then + elements[#elements + 1] = f:static_text { + title = "Leave empty to use global settings from Publish Settings.", + font = "", + } end + elements[#elements + 1] = f:spacer { height = 2 } elements[#elements + 1] = f:row { fill_horizontal = 1, @@ -270,7 +185,7 @@ end -- Returns a group_box with exclusion and inclusion rules -- Used in PublishTask for collection settings dialogs -- ************************************************* -function UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings, propertyTable) +function UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings) local fields = UIHelpers.createKeywordFilteringFields(f, bind, { showOverrideHint = true, widthInChars = 35, @@ -289,10 +204,6 @@ function UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings, propert return f:group_box { title = "Keyword Filtering (Overrides defaults set in Publish Settings)", - visible = bind { - key = 'enableCustom', - bind_to_object = collectionSettings, - }, font = "", size = 'regular', fill_horizontal = 1, @@ -301,215 +212,6 @@ function UIHelpers.createKeywordFilteringUI(f, bind, collectionSettings, propert } end --- ************************************************* --- Create "Metadata Settings" group_box for PublishDialogSections --- Contains fields for metadata templates for title and description --- ************************************************* -function UIHelpers.createMetaDataGroupBox(f, bind) - local metadataGroupDef = { - title = "Metadata Settings", - font = "", - fill_horizontal = 1, - - f:spacer { height = 2 }, - - f:row { - f:static_text { - title = "Title: ", - font = "", - alignment = 'right', - width_in_chars = 8, - }, - f:edit_field { - value = bind 'mdTitle', - font = "", - alignment = 'left', - width_in_chars = 60, - height_in_lines = 3, - }, - }, - f:row { - f:static_text { - title = "Description: ", - font = "", - alignment = 'right', - width_in_chars = 8, - }, - f:edit_field { - value = bind 'mdDescription', - font = "", - alignment = 'left', - width_in_chars = 60, - height_in_lines = 3, - }, - }, - } - - return f:group_box(metadataGroupDef) -end - --- ************************************************* --- Create "Album Customisation Settings" group_box for PublishDialogSections --- Contains checkbox for album association and custom album settings --- ************************************************* -function UIHelpers.createAlbumSettingsGroupBox(f, bind, propertyTable) - local albumSettingsDef = { - title = "Album Association and Per Album Export Settings", - font = "", - fill_horizontal = 1, - - f:spacer { height = 1 }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - title = "Use Album Association to share a single image between multiple Piwigo Albums", - enabled = bind { - key = 'PWP_customAlbumSettings', - bind_to_object = propertyTable, - transform = function(value, fromModel) - if fromModel then - return not value -- invert for display - end - return value - end, - }, - font = "", - tooltip = "When checked, if the same image is uploaded to multiple albums, it will be uploaded once and associated with the other albums in Piwigo, rather than being uploaded multiple times.", - value = bind 'PWP_albumAssociation', - }, - }, - - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:static_text { - title = "When enabled, if the same image is uploaded to multiple albums, a single copy will be uploaded and associated with the other albums in Piwigo." .. - "\nWhen disabled (album association not used), if the same image is uploaded to multiple albums, a separate copy will be uploaded in each album." .. - "\nAlbum association is not compatible with per-album custom settings - if album association is enabled, per-album custom settings will be disabled.", - - font = "", - wrap = true, - - }, - }, - - f:spacer { height = 1 }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - title = "Per-album custom export settings", - enabled = bind { - key = 'PWP_albumAssociation', - bind_to_object = propertyTable, - transform = function(value, fromModel) - if fromModel then - return not value -- invert for display - end - return value - end, - }, - font = "", - tooltip = "When checked, per-album custom export settings will be enabled, allowing different metadata, keyword filtering rules and export rules to be set for each album. Disables album association - if the same image is uploaded to multiple albums, a separate copy will be uploaded in each album.", - value = bind 'PWP_customAlbumSettings', - }, - }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:static_text { - title = "When enabled, custom settings for each album can be set. If disabled, the global settings will be used for all albums." .. - "\nThese settings include metadata templates for title and description, keyword filtering rules and export settings (resizing, metadata stripping etc.)." .. - "\nPer-album custom settings are not compatible with album association - if per-album custom settings are enabled, album association will be disabled.", - font = "", - wrap = true, - - }, - }, - - } - return f:group_box(albumSettingsDef) -end - --- ************************************************* --- Create "Other Settings" group_box for PublishDialogSections --- --- ************************************************* - -function UIHelpers.createOtherSettingsGroupBox(f, bind, propertyTable) - local otherSettingsDef = { - title = "Other Settings", - font = "", - fill_horizontal = 1, - f:spacer { height = 1 }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - title = "Synchronise Album Descriptions", - font = "", - tooltip = "If checked, Album descriptions will be maintainable in Lightroom and sent to Piwigo", - value = bind 'syncAlbumDescriptions', - }, - }, - - - f:spacer { height = 1 }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - title = "Synchronise comments as part of a Publish Process", - font = "", - tooltip = "When checked, comments will be synchronised for all photos in a collection during a publish operation", - value = bind 'syncCommentsPublish', - }, - }, - f:row { - fill_horizontal = 1, - f:static_text { - title = "", - alignment = 'right', - width_in_chars = 7, - }, - f:checkbox { - title = "Only include Published Photos", - enabled = bind('syncCommentsPublish', propertyTable), - font = "", - tooltip = "When checked, only photos being published will have comments synchronised", - value = bind 'syncCommentsPubOnly', - }, - }, - } - - return f:group_box(otherSettingsDef) -end - -- ************************************************* -- Create "Keyword Settings" group_box for PublishDialogSections -- Combines checkboxes (Hierarchy/Synonyms) + filtering fields @@ -571,250 +273,4 @@ function UIHelpers.createKeywordSettingsGroupBox(f, bind) return f:group_box(groupBoxDef) end --- ************************************************* --- Create "Export Settings" group_box for PublishDialogSections --- Combines checkboxes (Hierarchy/Synonyms) + filtering fields --- Built dynamically to allow merging fixed elements with shared filtering fields --- ************************************************* -function UIHelpers.createExportSettingsGroupBox(f, bind, collectionSettings, propertyTable) - local reSizeOptions = { - { title = "Long Edge", value = "Long Edge" }, - { title = "Short Edge", value = "Short Edge" }, - { title = "Dimensions", value = "Dimensions" }, - { title = "Megapixels", value = "MegaPixels" }, - { title = "Percent", value = "Percent" }, - } - - local function visibleWhenResizeMode(mode) - return bind { - key = 'reSizeParam', - transform = function(value, fromModel) - if fromModel then - return value == mode - end - return value - end, - } - end - - local function enabledWhenResize() - return bind { - key = 'reSize', - bind_to_object = collectionSettings, - transform = function(value, fromModel) - if fromModel then - return value and true or false - end - return value - end, - } - end - local metaDataOpts = { - { title = "All Metadata", value = "All Metadata" }, - { title = "Copyright only", value = "Copyright Only" }, - { title = "Copyright & Contact Info Only", value = "Copyright & Contact Info Only" }, - { title = "All Except Camera Raw Info", value = "All Except Camera Raw Info" }, - { title = "All Except Camera & Camera Raw Info", value = "All Except Camera & Camera Raw Info" }, - } - - local pubSettingsUI = f:group_box { - title = "Custom Publish Settings (Overrides defaults set in Publish Settings)", - font = "", - size = 'regular', - visible = bind { - key = 'enableCustom', - bind_to_object = collectionSettings, - transform = function(value, fromModel) - if fromModel then - return (value and true or false) and - (propertyTable and propertyTable.PWP_customAlbumSettings and true or false) - end - return value - end, - }, - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:column { - spacing = f:control_spacing(), - fill_horizontal = 1, - f:separator { fill_horizontal = 1 }, - - f:row { - f:group_box { -- group for export parameters - title = "Export Settings", - - font = "", - fill_horizontal = 1, - bind_to_object = assert(collectionSettings), - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - - f:checkbox { - title = "Resize Image", - tooltip = "If checked, published image will be resized using the settings below", - value = bind 'reSize', - }, - }, - - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - enabled = bind { - key = 'reSize', - bind_to_object = collectionSettings, - }, - f:static_text { - title = "Resize Method:", - alignment = 'right', - width_in_chars = 14, - }, - f:popup_menu { - value = bind 'reSizeParam', - items = reSizeOptions, - value_equal = valueEqual, - enabled = enabledWhenResize(), - }, - f:checkbox { - title = "Allow Enlarge Image", - tooltip = "If checked, published image will be enlarged if necessary", - enabled = enabledWhenResize(), - value = bind { - key = 'reSizeNoEnlarge', - transform = function(value) - return not value - end, - }, - }, - }, - - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - enabled = bind { - key = 'reSize', - bind_to_object = collectionSettings, - }, - visible = visibleWhenResizeMode("Long Edge"), - f:static_text { - title = "Long Edge (px):", - alignment = 'right', - width_in_chars = 14, - }, - f:edit_field { - value = bind 'reSizeLongEdge', - enabled = enabledWhenResize(), - width_in_chars = 8, - tooltip = "Maximum length of the longest edge in pixels", - }, - }, - - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - enabled = bind { - key = 'reSize', - bind_to_object = collectionSettings, - }, - visible = visibleWhenResizeMode("Short Edge"), - f:static_text { - title = "Short Edge (px):", - alignment = 'right', - width_in_chars = 14, - }, - f:edit_field { - value = bind 'reSizeShortEdge', - enabled = enabledWhenResize(), - width_in_chars = 8, - tooltip = "Maximum length of the shortest edge in pixels", - }, - }, - - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - enabled = bind { - key = 'reSize', - bind_to_object = collectionSettings, - }, - visible = visibleWhenResizeMode("Dimensions"), - f:static_text { - title = "Dimensions (px):", - alignment = 'right', - width_in_chars = 14, - }, - f:edit_field { - value = bind 'reSizeW', - enabled = enabledWhenResize(), - width_in_chars = 8, - tooltip = "Maximum width in pixels", - }, - f:static_text { - title = "x", - alignment = 'center', - width_in_chars = 2, - }, - f:edit_field { - value = bind 'reSizeH', - enabled = enabledWhenResize(), - width_in_chars = 8, - tooltip = "Maximum height in pixels", - }, - }, - - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - enabled = bind { - key = 'reSize', - bind_to_object = collectionSettings, - }, - visible = visibleWhenResizeMode("MegaPixels"), - f:static_text { - title = "Megapixels:", - alignment = 'right', - width_in_chars = 14, - }, - f:edit_field { - value = bind 'reSizeMP', - enabled = enabledWhenResize(), - width_in_chars = 8, - tooltip = "Target image size in megapixels", - }, - }, - - f:row { - fill_horizontal = 1, - spacing = f:label_spacing(), - enabled = bind { - key = 'reSize', - bind_to_object = collectionSettings, - }, - visible = visibleWhenResizeMode("Percent"), - f:static_text { - title = "Scale Percent:", - alignment = 'right', - width_in_chars = 14, - }, - f:edit_field { - value = bind 'reSizePC', - enabled = enabledWhenResize(), - width_in_chars = 8, - tooltip = "Scale image by percentage", - }, - f:static_text { - title = "%", - alignment = 'left', - }, - - }, - }, - }, - }, - } - - return pubSettingsUI -end - --- ************************************************* -return UIHelpers +return UIHelpers \ No newline at end of file diff --git a/piwigoPublish.lrplugin/UpdateChecker.lua b/piwigoPublish.lrplugin/UpdateChecker.lua index 34860ae..9930e32 100644 --- a/piwigoPublish.lrplugin/UpdateChecker.lua +++ b/piwigoPublish.lrplugin/UpdateChecker.lua @@ -23,6 +23,8 @@ along with this program. If not, see . ]] +---@diagnostic disable: undefined-global + local LrHttp = import 'LrHttp' local LrTasks = import 'LrTasks' local LrDialogs = import 'LrDialogs' diff --git a/piwigoPublish.lrplugin/icons/email_32.png b/piwigoPublish.lrplugin/icons/email_32.png new file mode 100644 index 0000000..8228904 Binary files /dev/null and b/piwigoPublish.lrplugin/icons/email_32.png differ diff --git a/piwigoPublish.lrplugin/icons/github_32.png b/piwigoPublish.lrplugin/icons/github_32.png new file mode 100644 index 0000000..fd55374 Binary files /dev/null and b/piwigoPublish.lrplugin/icons/github_32.png differ diff --git a/piwigoPublish.lrplugin/utils.lua b/piwigoPublish.lrplugin/utils.lua index 352637a..1c37c39 100644 --- a/piwigoPublish.lrplugin/utils.lua +++ b/piwigoPublish.lrplugin/utils.lua @@ -23,23 +23,42 @@ local utils = {} - -- ************************************************* -function utils.getFileMd5(fName) - -- return md5 sum of file fName - local f = io.open(fName, "rb") - if not f then - return nil, "Unable to open file: " .. fName - end - local content = f:read("*all") - f:close() - if not content then - return nil, "Unable to read file: " .. fName +function utils.serialiseVar(value, indent) + -- serialises an unknown variable + indent = indent or "" + local t = type(value) + + if t == "table" then + local parts = {} + table.insert(parts, "{\n") + local nextIndent = indent .. " " + for k, v in pairs(value) do + local key + if type(k) == "string" then + key = string.format("%q", k) + else + key = tostring(k) + end + table.insert(parts, nextIndent .. "[" .. key .. "] = " .. utils.serialiseVar(v, nextIndent) .. ",\n") + end + table.insert(parts, indent .. "}") + return table.concat(parts) + elseif t == "string" then + return string.format("%q", value) + else + return tostring(value) end - log:info("DEBUG getFileMd5 - file: " .. fName .. ", size: " .. tostring(#content) .. " bytes") - local md5sum = LrMD5.digest(content) +end - return md5sum, "MD5 calculated successfully" +-- ************************************************* +function utils.uuid() + -- create uuid in form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + return string.gsub(template, '[xy]', function(c) + local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb) + return string.format('%x', v) + end) end -- ************************************************* @@ -95,100 +114,6 @@ function utils.anonymisePropertyTable(propertyTable) return anonymiseValue(propertyTable) end --- ************************************************* -function utils.anonymiseRenditionParams(renditionParams) - -- return copy of renditionParams with sensitive data removed (for logging etc) - if type(renditionParams) ~= "table" then - return renditionParams - end - - local visited = {} - - local function deepCopy(value) - if type(value) ~= "table" then - return value - end - if visited[value] then - return visited[value] - end - - local copy = {} - visited[value] = copy - for k, v in pairs(value) do - copy[k] = deepCopy(v) - end - return copy - end - - local rpCopy = deepCopy(renditionParams) - - if type(rpCopy.exportContext) == "table" and type(rpCopy.exportContext.propertyTable) == "table" then - rpCopy.exportContext.propertyTable = utils.anonymisePropertyTable(rpCopy.exportContext.propertyTable) - end - - if type(rpCopy.propertyTable) == "table" then - rpCopy.propertyTable = utils.anonymisePropertyTable(rpCopy.propertyTable) - end - - return rpCopy -end - --- ************************************************* -function utils.stripHtml(s) - -- Strip HTML tags from string - if not s or s == "" then return s end - -- Strip all HTML tags - s = s:gsub("<[^>]+>", "") - -- Decode common HTML entities - s = s:gsub(" ", " "):gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub(""", '"'):gsub( - "'", "'") - -- Linearize: replace newlines/carriage returns with a single space - s = s:gsub("[\r\n]+", " ") - -- Collapse multiple spaces - s = s:gsub(" +", " ") - -- Trim - s = s:gsub("^%s+", ""):gsub("%s+$", "") - return s -end - --- ************************************************* -function utils.serialiseVar(value, indent) - -- serialises an unknown variable - indent = indent or "" - local t = type(value) - - if t == "table" then - local parts = {} - table.insert(parts, "{\n") - local nextIndent = indent .. " " - for k, v in pairs(value) do - local key - if type(k) == "string" then - key = string.format("%q", k) - else - key = tostring(k) - end - table.insert(parts, nextIndent .. "[" .. key .. "] = " .. utils.serialiseVar(v, nextIndent) .. ",\n") - end - table.insert(parts, indent .. "}") - return table.concat(parts) - elseif t == "string" then - return string.format("%q", value) - else - return tostring(value) - end -end - --- ************************************************* -function utils.uuid() - -- create uuid in form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' - return string.gsub(template, '[xy]', function(c) - local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb) - return string.format('%x', v) - end) -end - -- ************************************************* function utils.extractNumber(inStr) -- Extract first number (integer or decimal, optional sign) from a string @@ -210,99 +135,6 @@ function utils.toPositiveNumber(value) return nil end --- ************************************************* --- Compare effective Lightroom resize-related export settings and report first difference. -function utils.resizeSettingsDiffer(originalSettings, newSettings) - local keyDefs = { - { name = 'LR_size_doConstrain', type = 'bool' }, - { name = 'LR_size_userWantsConstrain', type = 'bool' }, - { name = 'LR_size_doNotEnlarge', type = 'bool', aliases = { 'LR_size_dontEnlarge' } }, - { name = 'LR_size_maxWidth', type = 'number', aliases = { 'LR_size_maxW' } }, - { name = 'LR_size_maxHeight', type = 'number', aliases = { 'LR_size_maxH' } }, - { name = 'LR_size_percentage', type = 'number' }, - { name = 'LR_size_resizeType', type = 'string' }, - { name = 'LR_size_units', type = 'string' }, - } - - local function getValue(settings, keyDef) - local value = settings[keyDef.name] - if value == nil and keyDef.aliases then - for _, alias in ipairs(keyDef.aliases) do - value = settings[alias] - if value ~= nil then - break - end - end - end - return value - end - - for _, keyDef in ipairs(keyDefs) do - local originalRaw = getValue(originalSettings, keyDef) - local newRaw = getValue(newSettings, keyDef) - local originalValue = originalRaw - local newValue = newRaw - - if keyDef.type == 'bool' then - originalValue = originalRaw and true or false - newValue = newRaw and true or false - elseif keyDef.type == 'number' then - originalValue = tonumber(originalRaw) - newValue = tonumber(newRaw) - else - originalValue = originalRaw ~= nil and tostring(originalRaw) or nil - newValue = newRaw ~= nil and tostring(newRaw) or nil - end - - if originalValue ~= newValue then - return true, keyDef.name, originalRaw, newRaw - end - end - - return false -end - --- ************************************************* -function utils.dumpPropertyTableToDesktop(tableToDump, collectionSettings, context) - local desktopDir = LrPathUtils.getStandardFilePath('desktop') - local debugDir = LrPathUtils.child(desktopDir, "PiwigoPublishDebug") - - if not debugDir or debugDir == "" then - log:warn("Unable to resolve Desktop path for propertyTable dump") - return - end - - LrFileUtils.createAllDirectories(debugDir) - - local timestamp = os.date("%Y%m%d-%H%M%S") - local dumpPath = LrPathUtils.child(debugDir, "PiwigoPublish-propertyTable-" .. timestamp .. ".lua") - - local dumpText = "-- PiwigoPublish propertyTable debug dump\n" .. - "-- Generated: " .. os.date("%Y-%m-%d %H:%M:%S") .. "\n" .. - "-- Context: " .. (context or "runCustomRenderForCollection") .. "\n\n" .. - "collectionSettings = " .. utils.serialiseVar(collectionSettings) .. "\n\n" .. - "propertyTable = " .. utils.serialiseVar(tableToDump) .. "\n" - - local file, err = io.open(dumpPath, "w") - if not file then - log:warn("Failed to write propertyTable dump file: " .. tostring(err)) - return - end - - file:write(dumpText) - file:close() - log:info("propertyTable dump written to: " .. dumpPath) -end - --- ************************************************* -function utils.reverseTable(t) - local reversed = {} - for i = #t, 1, -1 do - reversed[#reversed + 1] = t[i] - end - return reversed -end - -- ************************************************* function utils.dmsToDecimal(deg, min, sec, hemi) -- convert DMS (degrees, minutes, seconds + direction) to decimal degrees @@ -524,7 +356,6 @@ end ---- ************************************************* function utils.GetKWHierarchy(kwHierarchy, thisKeyword, pos) -- build hierarchical list of parent keywords - -- returns table of keywords with leaf node at pos 1 and each parent in subsequent positions kwHierarchy[pos] = thisKeyword if thisKeyword:getParent() == nil then return kwHierarchy @@ -651,7 +482,7 @@ end -- ************************************************* function utils.tagsToIds(propertyTable, tagString) - -- convert tagString to list of assoiciated tag ids + -- convert tagString to list of assoiciated tag ids -- use _tagIndex which is a reverse lookup table created in utils.buildTagIndex(propertyTable) when tagTable is refreshed via PiwigoAPI.getTagList(propertyTable) -- tagString = comma delimted list of tags for which we want the associated Piwigo Tag ID @@ -680,101 +511,48 @@ function utils.tagsToIds(propertyTable, tagString) end -- ************************************************* -function utils.BuildTagString(propertyTable, lrPhoto, collectionSettings) +function utils.BuildTagString(propertyTable, lrPhoto) -- build text string of keywords on lrPhoto - to be sent to Piwigo -- respect LrC includeOnExport flag set in keyword tag editor -- respect KwFullHierarchy and KwSynonyms set in publish manager settings - -- respect keyword filters set at publish service or collections level - - -- set up custom export filters if set - local KwFullHierarchy = propertyTable.KwFullHierarchy - local KwSynonyms = propertyTable.KwSynonyms - local excludePatterns = utils.parseFilterPatterns(propertyTable.KwFilterExclude) - local includePatterns = utils.parseFilterPatterns(propertyTable.KwFilterInclude) - if propertyTable.PWP_customAlbumSettings and collectionSettings and collectionSettings.enableCustom then - if collectionSettings then - if collectionSettings.KwFullHierarchy ~= nil then - KwFullHierarchy = collectionSettings.KwFullHierarchy - end - if collectionSettings.KwSynonyms ~= nil then - KwSynonyms = collectionSettings.KwSynonyms - end - if collectionSettings.KwFilterExclude then - excludePatterns = utils.parseFilterPatterns(collectionSettings.KwFilterExclude) - end - if collectionSettings.KwFilterInclude then - includePatterns = utils.parseFilterPatterns(collectionSettings.KwFilterInclude) - end - end - end - - local hasIncludeRules = includePatterns and #includePatterns > 0 - local hasExcludeRules = excludePatterns and #excludePatterns > 0 - -- build tagTable which will contain the list of unique keywords to be sent to Piwigo + local tagString = "" local tagTable = {} - for _, thisKeyword in ipairs(lrPhoto:getRawMetadata("keywords")) do - local kwHierarchy = utils.GetKWHierarchy({}, thisKeyword, 1) -- leaf at [1], parents upwards - local thisKwHierarchy = {} -- will contain list of keywords to be sent to Piwigo for this kwHierarchy - local kwLevels = #kwHierarchy - - -- Hierarchy-level filter result: - -- include if ANY level matches include rule (when include rules exist) - -- exclude if ANY level matches exclude rule - local hierarchyIncludeMatch = not hasIncludeRules - local hierarchyExcludeMatch = false - - for kk = kwLevels, 1, -1 do - -- traverse the hierarchy from root down to leaf - this way we can check each level against filter rules and stop processing if any level fails filter criteria - local kwLevel = kwHierarchy[kk] + for ii, thisKeyword in ipairs(lrPhoto:getRawMetadata("keywords")) do + local kwHierarchy = {} + kwHierarchy = utils.GetKWHierarchy(kwHierarchy, thisKeyword, 1) + for kk, kwLevel in ipairs(kwHierarchy) do local kwAtts = kwLevel:getAttributes() if kwAtts.includeOnExport then - -- this keyword is marked for export - so get name and synonyms and add to tagTable if they satisfy filter rules local kwLevelName = kwLevel:getName() - -- evaluate filters against every hierarchy element - if hasIncludeRules and not hierarchyIncludeMatch then - hierarchyIncludeMatch = utils.checkIncludeKeywordFilter(kwLevelName, includePatterns) - end - if hasExcludeRules and not hierarchyExcludeMatch then - hierarchyExcludeMatch = utils.checkExcludeKeywordFilter(kwLevelName, excludePatterns) - end local kwLevelSyn = kwLevel:getSynonyms() if kk > 1 then - if KwFullHierarchy then - -- only process beyond leaf node if full hierarchy option is set - table.insert(thisKwHierarchy, kwLevelName) - if KwSynonyms then - -- include Synonyms if option is set - for _, syn in pairs(kwLevelSyn) do - table.insert(thisKwHierarchy, syn) + if propertyTable.KwFullHierarchy then + if utils.checkTagUnique(kwLevelName, tagTable) then + table.insert(tagTable, kwLevelName) + end + if propertyTable.KwSynonyms then + for ss, syn in pairs(kwLevelSyn) do + if utils.checkTagUnique(syn, tagTable) then + table.insert(tagTable, syn) + end end end end else - -- leaf node - always include if export flag is set and it satisfies filter criteria - table.insert(thisKwHierarchy, kwLevelName) + if utils.checkTagUnique(kwLevelName, tagTable) then + table.insert(tagTable, kwLevelName) + end if propertyTable.KwSynonyms then - for _, syn in pairs(kwLevelSyn) do - table.insert(thisKwHierarchy, syn) + for ss, syn in pairs(kwLevelSyn) do + if utils.checkTagUnique(syn, tagTable) then + table.insert(tagTable, syn) + end end end end end end - - local includeThisHierarchy = hierarchyIncludeMatch and (not hierarchyExcludeMatch) - -- add thisKwHierarchy to tagTable - if includeThisHierarchy then - for _, kw in ipairs(thisKwHierarchy) do - if utils.checkTagUnique(kw, tagTable) then - table.insert(tagTable, kw) - end - end - end end - - - -- tagString is comma delimited list of keywords to be sent to Piwigo - convert tagTable to string - local tagString = "" tagString = utils.tabletoString(tagTable, ",") return tagString end @@ -814,120 +592,70 @@ function utils.parseFilterPatterns(filterString) end -- ************************************************* -function utils.buildFilteredKeywordList(keywords, includePatterns, excludePatterns) - -- build list of keywords to send to Piwigo based on include/exclude patterns - -- keywords is a table of Class LrKeyword - local includeKeywords = {} - - for _, kw in ipairs(keywords) do - local isAllowed = true - local kwName = kw:getName() - if excludePatterns and #excludePatterns > 0 then - for _, pat in ipairs(excludePatterns) do - if utils.wildcardMatch(pat, kwName) then - isAllowed = false - break - end - end - end - if isAllowed and includePatterns and #includePatterns > 0 then - local found = false - for _, pat in ipairs(includePatterns) do - if utils.wildcardMatch(pat, kwName) then - found = true - break - end - end - if not found then - isAllowed = false - end - end - - if isAllowed then - table.insert(includeKeywords, kw) +function utils.getPhotoDirectKeywords(lrPhoto) + -- return table of direct keyword names on lrPhoto (no hierarchy, no synonyms) + -- respects includeOnExport flag + local keywords = {} + for _, kw in ipairs(lrPhoto:getRawMetadata("keywords")) do + local attrs = kw:getAttributes() + if attrs.includeOnExport then + table.insert(keywords, kw:getName()) end end - - return includeKeywords + return keywords end -- ************************************************* -function utils.checkIncludeKeywordFilter(kwName, includePatterns) - -- check if a keyword satisfies include/exclude filter rules - -- returns: doInclude (bool), doExclude (bool) - - local doInclude = false +function utils.checkKeywordFilter(keywords, includePatterns, excludePatterns) + -- check if a list of keywords satisfies include/exclude filter rules + -- returns: isAllowed (bool), failReason (string or nil) - --log:info("utils.checkIncludeKeywordFilter - kwName: " .. kwName) - -- check include rules first - if includePatterns and #includePatterns > 0 then - for _, pat in ipairs(includePatterns) do - if utils.wildcardMatch(pat, kwName) then - doInclude = true - break + -- check exclude rules first + if excludePatterns and #excludePatterns > 0 then + for _, kw in ipairs(keywords) do + for _, pat in ipairs(excludePatterns) do + if utils.wildcardMatch(pat, kw) then + return false, "keyword '" .. kw .. "' matches exclude rule '" .. pat .. "'" + end end end - else - -- if no include patterns set, then we want to include all keywords which are not excluded by the exclude patterns - doInclude = true end - - - return doInclude -end - --- ************************************************* -function utils.checkExcludeKeywordFilter(kwName, excludePatterns) - -- check if a keyword satisfies include/exclude filter rules - -- returns: doInclude (bool), doExclude (bool) - - - local doExclude = false - --log:info("utils.checkExcludeKeywordFilter - kwName: " .. kwName) - - - -- check exclude rules - if excludePatterns and #excludePatterns > 0 then - for _, pat in ipairs(excludePatterns) do - if utils.wildcardMatch(pat, kwName) then - doExclude = true - break + -- check include rules + if includePatterns and #includePatterns > 0 then + local found = false + for _, kw in ipairs(keywords) do + for _, pat in ipairs(includePatterns) do + if utils.wildcardMatch(pat, kw) then + found = true + break + end end + if found then break end + end + if not found then + local patList = table.concat(includePatterns, ", ") + return false, "no keyword matches include rule(s) '" .. patList .. "'" end end - return doExclude + return true, nil end -- ************************************************* -function utils.getPhotoMetadata(publishSettings, lrPhoto, collectionSettings) +function utils.getPhotoMetadata(publishSettings, lrPhoto) -- build set of metadata to be send to Piwigo local metaData = {} - - local useTitleFormat = publishSettings.mdTitle - local useCaptionFormat = publishSettings.mdDescription - if publishSettings.PWP_customAlbumSettings and collectionSettings then - if collectionSettings.mdTitle and collectionSettings.mdTitle ~= "" then - useTitleFormat = collectionSettings.mdTitle - end - if collectionSettings.mdDescription and collectionSettings.mdDescription ~= "" then - useCaptionFormat = collectionSettings.mdDescription - end - end - - if useTitleFormat and useTitleFormat ~= "" then - metaData.Title = utils.setCustomMetadata(lrPhoto, useTitleFormat) + if publishSettings.mdTitle and publishSettings.mdTitle ~= "" then + metaData.Title = utils.setCustomMetadata(lrPhoto, publishSettings.mdTitle) else metaData.Title = lrPhoto:getFormattedMetadata("title") or "" end - - if useCaptionFormat and useCaptionFormat ~= "" then - metaData.Caption = utils.setCustomMetadata(lrPhoto, useCaptionFormat) + if publishSettings.mdDescription and publishSettings.mdDescription ~= "" then + metaData.Caption = utils.setCustomMetadata(lrPhoto, publishSettings.mdDescription) else metaData.Caption = lrPhoto:getFormattedMetadata("caption") or "" end - metaData.Creator = lrPhoto:getFormattedMetadata("creator") or "" metaData.fileName = lrPhoto:getFormattedMetadata("fileName") or "" @@ -953,8 +681,7 @@ function utils.getPhotoMetadata(publishSettings, lrPhoto, collectionSettings) local useDate = LrDate.timeToUserFormat(rawDate, "%Y-%m-%d %H:%M:%S") metaData.dateCreated = useDate or "" - - metaData.tagString = utils.BuildTagString(publishSettings, lrPhoto, collectionSettings) + metaData.tagString = utils.BuildTagString(publishSettings, lrPhoto) -- GPS coordinates local gps = lrPhoto:getRawMetadata("gps") @@ -962,6 +689,7 @@ function utils.getPhotoMetadata(publishSettings, lrPhoto, collectionSettings) metaData.latitude = gps.latitude metaData.longitude = gps.longitude end + return metaData end @@ -1490,7 +1218,7 @@ function utils.findPublishNodeByName(service, name) end -- ************************************************* --- http utils +-- http utiils -- ************************************************* function utils.urlEncode(str) -- urlencode a string @@ -1626,6 +1354,25 @@ function utils.getLogfilePath() end end +-- ************************************************* +-- Returns the path to the VTK result file (JSON, written to temp by VTK). +function utils.getVtkResultPath() + return LrPathUtils.child(LrPathUtils.getStandardFilePath("temp"), "piwigoPublish_vtk_result.json") +end + +-- ************************************************* +-- Truncate the plugin log file to zero without deleting it. +-- Returns: true on success, false on failure. +function utils.clearLogFiles() + local path = utils.getLogfilePath() + if LrFileUtils.exists(path) then + local fh = io.open(path, "w") + if fh then fh:close(); return true end + return false + end + return true -- no file = already empty +end + -- ************************************************* function utils.pwBusyMessage(callingFunction, displayFunction) -- display Piwigo Busy message @@ -1645,34 +1392,20 @@ function utils.extractPwImageIdFromUrl(url, expectedHost) end -- ************************************************* -function utils.findExistingPwImageId(publishService, lrPhoto, excludeCollection) +function utils.findExistingPwImageId(publishService, lrPhoto) -- Searches if this LR photo is already published in another collection of the same service -- Returns the Piwigo remoteId if found, nil otherwise - -- excludeCollection is an optional parameter - - -- if provided, this collection will be skipped in the search to avoid finding the same photo in the current collection when checking for duplicates - - local foundPubPhoto = nil - local foundPubCollection = nil - local pubPhotoExists = false - local excludeId = excludeCollection and excludeCollection.localIdentifier - + local foundRemoteId = nil local function searchInCollection(collection) - if foundPubPhoto then return end - if excludeId and collection.localIdentifier == excludeId then - -- skip excludeCollection - return - end - + if foundRemoteId then return end local pubPhotos = collection:getPublishedPhotos() for _, pubPhoto in ipairs(pubPhotos) do if pubPhoto:getPhoto().localIdentifier == lrPhoto.localIdentifier then local rid = pubPhoto:getRemoteId() if rid and rid ~= "" then - foundPubPhoto = pubPhoto - foundPubCollection = collection - pubPhotoExists = true + foundRemoteId = rid return end end @@ -1680,13 +1413,13 @@ function utils.findExistingPwImageId(publishService, lrPhoto, excludeCollection) end local function searchInSet(collectionSet) - if foundPubPhoto then return end + if foundRemoteId then return end -- Search in child collections local childColls = collectionSet:getChildCollections() if childColls then for _, coll in ipairs(childColls) do searchInCollection(coll) - if foundPubPhoto then return end + if foundRemoteId then return end end end -- Search in child sets (recursive) @@ -1694,7 +1427,7 @@ function utils.findExistingPwImageId(publishService, lrPhoto, excludeCollection) if childSets then for _, childSet in ipairs(childSets) do searchInSet(childSet) - if foundPubPhoto then return end + if foundRemoteId then return end end end end @@ -1702,7 +1435,7 @@ function utils.findExistingPwImageId(publishService, lrPhoto, excludeCollection) -- Start search from service root searchInSet(publishService) - return pubPhotoExists, foundPubPhoto, foundPubCollection + return foundRemoteId end -- ************************************************* @@ -1749,9 +1482,7 @@ function utils.buildAlbumSummary(publishService) name = coll:getName(), path = fullPath .. " / " .. coll:getName(), depth = depth + 1, - published = p, - modified = m, - new = n, + published = p, modified = m, new = n, }) setPub = setPub + p setMod = setMod + m @@ -1783,9 +1514,7 @@ function utils.buildAlbumSummary(publishService) name = name, path = fullPath, depth = depth, - published = setPub, - modified = setMod, - new = setNew, + published = setPub, modified = setMod, new = setNew, }) -- Then all children for _, node in ipairs(childNodes) do @@ -1807,9 +1536,7 @@ function utils.buildAlbumSummary(publishService) name = coll:getName(), path = coll:getName(), depth = 0, - published = p, - modified = m, - new = n, + published = p, modified = m, new = n, }) totals.published = totals.published + p totals.modified = totals.modified + m @@ -1835,4 +1562,131 @@ function utils.buildAlbumSummary(publishService) return { nodes = nodes, totals = totals } end +-- ************************************************* +function utils.parsePhpSize(sizeStr) + -- Convert PHP size strings like "128M", "2G", "512K" to bytes + if not sizeStr or sizeStr == "" then return nil end + sizeStr = tostring(sizeStr):upper():gsub("%s+", "") + local num, unit = sizeStr:match("^(%d+%.?%d*)([KMGT]?)$") + if not num then return nil end + num = tonumber(num) + if not num then return nil end + local multipliers = { K = 1024, M = 1024*1024, G = 1024*1024*1024, T = 1024*1024*1024*1024 } + if unit and unit ~= "" then + num = num * (multipliers[unit] or 1) + end + return math.floor(num) +end + +-- ************************************************* +function utils.findTool(toolName) + -- Auto-detect a CLI tool (python, ffmpeg, exiftool) in PATH and common locations. + -- Returns the resolved executable path, or nil if not found. + + local isWindows = (LrSystemInfo.osVersion():lower():find("win") ~= nil) + + -- 1. Try PATH lookup first (fast, covers most cases) + local whichCmd = isWindows and ("where " .. toolName .. " 2>nul") or ("which " .. toolName .. " 2>/dev/null") + local pHandle = io.popen(whichCmd) + if pHandle then + local found = pHandle:read("*l") + pHandle:close() + if found and found ~= "" then + found = found:match("^(.-)%s*$") -- trim trailing whitespace + found = found:gsub("\r", "") -- strip CR (Windows CRLF via io.popen) + if found ~= "" then + return found + end + end + end + + -- 2. Check well-known locations per OS + local candidates = {} + + if toolName == "python" or toolName == "python3" then + if isWindows then + local user = os.getenv("USERNAME") or "" + candidates = { + "C:/Windows/py.exe", + "C:/Program Files/Python313/python.exe", + "C:/Program Files/Python312/python.exe", + "C:/Program Files/Python311/python.exe", + "C:/Program Files/Python310/python.exe", + "C:/Program Files/Python39/python.exe", + "C:/Users/" .. user .. "/AppData/Local/Programs/Python/Python313/python.exe", + "C:/Users/" .. user .. "/AppData/Local/Programs/Python/Python312/python.exe", + "C:/Users/" .. user .. "/AppData/Local/Programs/Python/Python311/python.exe", + } + else + candidates = { + "/usr/bin/python3", "/usr/local/bin/python3", + "/opt/homebrew/bin/python3", + "/usr/bin/python", "/usr/local/bin/python", + } + end + + elseif toolName == "ffmpeg" then + if isWindows then + local user = os.getenv("USERNAME") or "" + candidates = { + "C:/ffmpeg/bin/ffmpeg.exe", + "C:/Program Files/ffmpeg/bin/ffmpeg.exe", + "C:/Program Files (x86)/ffmpeg/bin/ffmpeg.exe", + "C:/Users/" .. user .. "/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-7.1-full_build/bin/ffmpeg.exe", + } + else + candidates = { + "/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", + "/opt/homebrew/bin/ffmpeg", "/opt/local/bin/ffmpeg", + } + end + + elseif toolName == "exiftool" then + if isWindows then + candidates = { + "C:/Windows/exiftool.exe", + "C:/Program Files/exiftool/exiftool.exe", + "C:/Program Files (x86)/exiftool/exiftool.exe", + } + else + candidates = { + "/usr/bin/exiftool", "/usr/local/bin/exiftool", + "/opt/homebrew/bin/exiftool", "/opt/local/bin/exiftool", + } + end + end + + for _, path in ipairs(candidates) do + if utils.fileExists(path) then + return path + end + end + + return nil +end + +-- ************************************************* +function utils.resolveTool(configuredPath, toolName) + -- Return configuredPath if set, else auto-detect, else return toolName bare (PATH fallback at runtime). + if configuredPath and configuredPath ~= "" then + return configuredPath + end + return utils.findTool(toolName) or toolName +end + +-- Resolve the path to video_toolkit.py. +-- Priority : 1) configured vtkToolkitPath +-- 2) /video-toolkit/video_toolkit.py (toolkit bundled inside plugin) +-- 3) /video-toolkit/video_toolkit.py (toolkit beside plugin) +function utils.resolveToolkitPath(configuredPath, pluginPath) + if configuredPath and configuredPath ~= "" then + return configuredPath + end + local inside = LrPathUtils.child(pluginPath, "video-toolkit/video_toolkit.py") + if utils.fileExists(inside) then + return inside + end + return LrPathUtils.child(LrPathUtils.parent(pluginPath), "video-toolkit/video_toolkit.py") +end + return utils diff --git a/piwigoPublish.lrplugin/vtk_core.lua b/piwigoPublish.lrplugin/vtk_core.lua new file mode 100644 index 0000000..1929ff5 --- /dev/null +++ b/piwigoPublish.lrplugin/vtk_core.lua @@ -0,0 +1,679 @@ +--[[ + vtk_core.lua — Video Toolkit core orchestration + + Extracted from PublishTask.lua. Contains all VTK logic: + - preScan : detect videos in batch before rendering + - checkServerSupport : verify Piwigo server is ready for video + - runBatch : launch VTK Python process, parse results + - uploadVariants : upload VTK-produced variants to Piwigo + - updateMetadataOnly : update metadata for already-published videos + + All globals (log, JSON, utils, PiwigoAPI, LrTasks, LrFileUtils, + LrPathUtils, LrDialogs, LrFunctionContext) are provided by Init.lua. + + Copyright (C) 2024 Fiona Boston . + This file is part of PiwigoPublish (GPLv3). +]] + +---@diagnostic disable: undefined-global + +local vtk_core = {} + +-- --------------------------------------------------------------------------- +-- vtk_core.preScan +-- Scan the export session for video files BEFORE rendering starts. +-- Returns videoPhotos table and batchVideoCount. +-- +-- videoPhotos[i] = { +-- photo, existingImageId, appliedPreset, republishMode +-- } +-- republishMode : "new" | "re_upload" +-- --------------------------------------------------------------------------- +function vtk_core.preScan(exportSession, propertyTable, collectionSettings) + local batchVideoCount = 0 + local videoPhotos = {} + + for photo in exportSession:photosToExport() do + local fmt = photo:getRawMetadata("fileFormat") + if fmt == "VIDEO" then + batchVideoCount = batchVideoCount + 1 + local existingImageId = nil + local appliedPreset = nil + local republishMode = "new" + + local storedHost = photo:getPropertyForPlugin(_PLUGIN, "pwHostURL") + local storedUrl = photo:getPropertyForPlugin(_PLUGIN, "pwImageURL") + if storedHost == propertyTable.host and storedUrl then + existingImageId = utils.extractPwImageIdFromUrl(storedUrl, propertyTable.host) + end + if existingImageId then + local checkStatus = PiwigoAPI.checkPhoto(propertyTable, existingImageId) + if not checkStatus.status then + log:info("vtk_core.preScan - video image_id=" .. existingImageId .. " no longer exists on Piwigo, treating as new") + existingImageId = nil + republishMode = "new" + else + appliedPreset = photo:getPropertyForPlugin(_PLUGIN, "pwVideoPreset") or "" + local currentPreset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") + and collectionSettings.vtkPresetOverride + or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium") + if appliedPreset == "" then + republishMode = "re_upload" + elseif appliedPreset ~= currentPreset then + republishMode = "re_upload" + else + -- Same preset — still re_upload (VTK cache decides whether to re-encode) + republishMode = "re_upload" + end + end + end + + table.insert(videoPhotos, { + photo = photo, + existingImageId = existingImageId, + appliedPreset = appliedPreset, + republishMode = republishMode, + }) + end + end + + return videoPhotos, batchVideoCount +end + +-- --------------------------------------------------------------------------- +-- vtk_core.checkServerSupport +-- Check Piwigo server video capabilities. +-- Also removes blocked videos from the export session. +-- Returns: videoUploadBlocked, serverMaxBytes, companionAvailable +-- +-- NOTE: exportSession, batchVideoCount, batchTotalCount, videoPhotos, +-- publishService, and PWStatusManager are needed for side-effects +-- (removePhoto, return-early dialogs). They are passed explicitly. +-- --------------------------------------------------------------------------- +function vtk_core.checkServerSupport(propertyTable, videoPhotos, batchVideoCount, batchTotalCount, + exportSession, publishService) + local videoUploadBlocked = false + local serverMaxBytes = nil + local companionAvailable = false + + -- Check if user disabled video inclusion + if propertyTable.vtkIncludeVideo == false then + log:info("vtk_core.checkServerSupport - video inclusion disabled by user") + videoUploadBlocked = true + for _, vEntry in ipairs(videoPhotos) do + local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" + log:info("vtk_core.checkServerSupport - removing video (disabled): " .. vName) + exportSession:removePhoto(vEntry.photo) + end + if batchVideoCount >= batchTotalCount then + log:info("vtk_core.checkServerSupport - batch contained only videos, all disabled") + LrDialogs.message("Video Publishing Disabled", + "Video inclusion is disabled in this publish service settings.\n\n" + .. "Enable 'Include video files' in the Video section to publish videos.\n\n" + .. "No photos to publish in this batch.", + "info") + PWStatusManager.setPiwigoBusy(publishService, false) + PWStatusManager.setRenderPhotos(publishService, false) + return videoUploadBlocked, serverMaxBytes, companionAvailable, true -- true = abort + end + return videoUploadBlocked, serverMaxBytes, companionAvailable, false + end + + -- Check server video support + local warnings = {} + local videoSupport = PiwigoAPI.getServerVideoSupport(propertyTable) + + if not videoSupport.status then + videoUploadBlocked = true + table.insert(warnings, "- Cannot verify server video support (connection issue).") + elseif not videoSupport.companionAvailable then + companionAvailable = false + videoUploadBlocked = true + table.insert(warnings, "- The 'Lightroom Companion' plugin is not installed on your Piwigo server.") + table.insert(warnings, " Without it, video upload cannot be authorized.") + table.insert(warnings, "\nInstall and activate the 'Lightroom Companion' plugin in Piwigo,") + table.insert(warnings, "then use 'Server Info' > 'Enable Video Support' to configure the server.") + else + companionAvailable = true + local cfg = videoSupport.serverConfig + if cfg and cfg.piwigo then + if cfg.piwigo.video_ready then + log:info("vtk_core.checkServerSupport - server video_ready = true") + if cfg.php and cfg.php.upload_max_filesize then + serverMaxBytes = utils.parsePhpSize(cfg.php.upload_max_filesize) + local postMax = utils.parsePhpSize(cfg.php.post_max_size or "0") + if postMax and postMax > 0 and (not serverMaxBytes or postMax < serverMaxBytes) then + serverMaxBytes = postMax + end + if serverMaxBytes then + log:info("vtk_core.checkServerSupport - server max upload = " .. serverMaxBytes .. " bytes") + end + end + else + videoUploadBlocked = true + if not cfg.piwigo.upload_form_all_types then + table.insert(warnings, "- Server does NOT accept all file types (upload_form_all_types = false)") + end + local vExts = cfg.piwigo.video_ext_configured or {} + if type(vExts) ~= "table" or #vExts == 0 then + table.insert(warnings, "- No video extensions configured on the server.") + end + table.insert(warnings, "\nUse 'Server Info' > 'Enable Video Support' to fix this automatically.") + end + + if not videoSupport.videoJsInstalled then + table.insert(warnings, "- VideoJS plugin is NOT installed (videos won't play in gallery)") + elseif not videoSupport.videoJsActive then + table.insert(warnings, "- VideoJS plugin is installed but INACTIVE") + end + + if cfg.ffmpeg and not cfg.ffmpeg.installed then + log:info("vtk_core.checkServerSupport - FFmpeg not installed (non-blocking)") + end + else + videoUploadBlocked = true + table.insert(warnings, "- Companion plugin responded but returned no configuration data.") + end + end + + if videoUploadBlocked then + for _, vEntry in ipairs(videoPhotos) do + local vName = vEntry.photo:getFormattedMetadata("fileName") or "unknown" + log:info("vtk_core.checkServerSupport - removing blocked video: " .. vName) + exportSession:removePhoto(vEntry.photo) + end + if batchVideoCount >= batchTotalCount then + local reason = (#warnings > 0) + and ("Video upload is not authorized:\n\n" .. table.concat(warnings, "\n") .. "\n\nNo photos to publish in this batch.") + or "Video inclusion is disabled in this publish service settings.\n\nNo photos to publish in this batch." + log:info("vtk_core.checkServerSupport - batch contained only videos, all blocked") + LrDialogs.message("Video Upload Blocked", reason, "critical") + PWStatusManager.setPiwigoBusy(publishService, false) + PWStatusManager.setRenderPhotos(publishService, false) + return videoUploadBlocked, serverMaxBytes, companionAvailable, true -- abort + else + local reason = (#warnings > 0) + and ("Video upload is not authorized:\n\n" .. table.concat(warnings, "\n") .. "\n\n" .. batchVideoCount .. " video(s) skipped.\nPhotos will still be published.") + or ("Video inclusion is disabled.\n\n" .. batchVideoCount .. " video(s) skipped.\nPhotos will still be published.") + LrDialogs.message("Video Upload Blocked", reason, "critical") + end + else + -- Per-file size check (only when VTK is disabled) + if serverMaxBytes and not propertyTable.vtkEnabled then + local oversizedVideos = {} + for idx = #videoPhotos, 1, -1 do + local vEntry = videoPhotos[idx] + local vPhoto = vEntry.photo + if vEntry.republishMode ~= "metadata_only" then + local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + local filePath = vPhoto:getRawMetadata("path") + if filePath then + local attrs = LrFileUtils.fileAttributes(filePath) + if attrs and attrs.fileSize and attrs.fileSize > serverMaxBytes then + local sizeMB = string.format("%.1f", attrs.fileSize / (1024*1024)) + local limitMB = string.format("%.1f", serverMaxBytes / (1024*1024)) + log:info("vtk_core.checkServerSupport - removing oversized video: " .. vName + .. " (" .. sizeMB .. " MB > " .. limitMB .. " MB)") + table.remove(videoPhotos, idx) + table.insert(oversizedVideos, vName .. " (" .. sizeMB .. " MB)") + end + end + end + end + if #oversizedVideos > 0 then + local limitMB = string.format("%.1f", serverMaxBytes / (1024*1024)) + LrDialogs.message("Video Too Large", + "The following video(s) exceed the server upload limit (" .. limitMB .. " MB):\n\n" + .. "- " .. table.concat(oversizedVideos, "\n- ") + .. "\n\nThese videos will be skipped. Other files will still be published.", + "warning") + end + end + + if #warnings > 0 then + local warningText = table.concat(warnings, "\n") + LrDialogs.message("Video Support Warning", + "Issues detected on your Piwigo server:\n\n" .. warningText .. + "\n\nVideo upload will proceed.", + "warning") + end + end + + return videoUploadBlocked, serverMaxBytes, companionAvailable, false +end + +-- --------------------------------------------------------------------------- +-- vtk_core.runBatch +-- Launch VTK Python process in batch mode and parse results. +-- Returns: vtkResults, metadataOnlyVideos +-- --------------------------------------------------------------------------- +function vtk_core.runBatch(videoPhotos, batchVideoCount, propertyTable, collectionSettings, progressScope) + local vtkResults = {} + local metadataOnlyVideos = {} + + if batchVideoCount == 0 or not propertyTable.vtkEnabled then + return vtkResults, metadataOnlyVideos + end + + log:info("vtk_core.runBatch - processing " .. batchVideoCount .. " video(s)") + + -- Remove ALL videos from export session to prevent LrC "This file is a video" dialog + for _, vEntry in ipairs(videoPhotos) do + -- (already removed by checkServerSupport if blocked; safe to call again) + end + + local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") + log:info("vtk_core.runBatch - python resolved to: " .. python) + local toolkitScript = utils.resolveToolkitPath(propertyTable.vtkToolkitPath, _PLUGIN.path) + log:info("vtk_core.runBatch - toolkitScript resolved to: " .. toolkitScript) + + local preset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") + and collectionSettings.vtkPresetOverride + or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium") + log:info("vtk_core.runBatch - video preset effective: " .. preset + .. (collectionSettings.vtkPresetOverride ~= "" and " (collection override)" or " (service default)")) + + local statusFilePath = LrPathUtils.child( + LrPathUtils.getStandardFilePath("temp"), + "piwigoPublish_vtk_status.json" + ) + local batchFilePath = LrPathUtils.child( + LrPathUtils.getStandardFilePath("temp"), + "piwigoPublish_vtk_batch.json" + ) + + local batchVideos = {} + for _, vEntry in ipairs(videoPhotos) do + local filePath = vEntry.photo:getRawMetadata("path") + if filePath then + if vEntry.republishMode == "metadata_only" then + table.insert(metadataOnlyVideos, vEntry) + else + table.insert(batchVideos, { + input = filePath, + preset = preset, + force = (vEntry.republishMode == "re_upload"), + }) + end + end + end + + if #batchVideos == 0 then + log:info("vtk_core.runBatch - all videos are metadata-only, skipping Video Toolkit") + return vtkResults, metadataOnlyVideos + end + + local batchData = { + videos = batchVideos, + status_file = statusFilePath, + } + local batchFile = io.open(batchFilePath, "w") + if batchFile then + batchFile:write(JSON:encode(batchData)) + batchFile:close() + end + + local ffmpegArg = (propertyTable.vtkFFmpegPath and propertyTable.vtkFFmpegPath ~= "") + and (' --ffmpeg-path "' .. propertyTable.vtkFFmpegPath .. '"') or "" + local exiftoolArg = (propertyTable.vtkExifToolPath and propertyTable.vtkExifToolPath ~= "") + and (' --exiftool-path "' .. propertyTable.vtkExifToolPath .. '"') or "" + local presetsArg = (propertyTable.vtkPresetsFile and propertyTable.vtkPresetsFile ~= "") + and (' --config "' .. propertyTable.vtkPresetsFile .. '"') or "" + local hwaccelArg = (propertyTable.vtkHardwareAccel and propertyTable.vtkHardwareAccel ~= "" + and propertyTable.vtkHardwareAccel ~= "auto") + and (' --hwaccel "' .. propertyTable.vtkHardwareAccel .. '"') or "" + + local vtkResultPath = utils.getVtkResultPath() + local cmd = '"' .. python .. '" "' .. toolkitScript .. '"' + .. ' --mode batch' + .. ' --batch-file "' .. batchFilePath .. '"' + .. ' --status-file "' .. statusFilePath .. '"' + .. ' --log-file "' .. vtkResultPath .. '"' + .. ffmpegArg .. exiftoolArg .. presetsArg .. hwaccelArg + + -- Delete old result file to avoid reading stale results if VTK crashes + if LrFileUtils.exists(vtkResultPath) then + LrFileUtils.delete(vtkResultPath) + end + + -- Write .bat wrapper to work around nested-quotes bug with LrTasks.execute on Windows + local batPath = LrPathUtils.child(LrPathUtils.getStandardFilePath("temp"), "piwigoPublish_vtk_run.bat") + local batFh = io.open(batPath, "w") + if batFh then + batFh:write("@echo off\r\n") + batFh:write(cmd .. "\r\n") + batFh:close() + end + + log:info("vtk_core.runBatch - VTK command: " .. cmd) + log:info("vtk_core.runBatch - VTK bat file: " .. batPath) + log:info("vtk_core.runBatch - VTK result file: " .. vtkResultPath) + local bfDiag = io.open(batchFilePath, "r") + if bfDiag then + log:info("vtk_core.runBatch - VTK batch content: " .. (bfDiag:read("*all") or "")) + bfDiag:close() + end + + progressScope:setCaption("Video Toolkit — Processing " .. batchVideoCount .. " video(s)...") + + LrDialogs.message( + "Video Toolkit — Processing", + "Video Toolkit is about to process " .. batchVideoCount .. " video(s).\n\n" + .. "HDR videos will be transcoded to SDR — this can take several minutes per video.\n\n" + .. "Lightroom will appear frozen during processing. Please wait.", + "info") + + local vtkExitCode = LrTasks.execute('"' .. batPath .. '"') + log:info("vtk_core.runBatch - LrTasks.execute returned: " .. tostring(vtkExitCode) .. " (type=" .. type(vtkExitCode) .. ")") + + if not LrFileUtils.exists(vtkResultPath) then + log:info("vtk_core.runBatch - waiting for VTK result file...") + for _ = 1, 20 do + LrTasks.sleep(0.5) + if LrFileUtils.exists(vtkResultPath) then break end + end + end + + if vtkExitCode ~= 0 and vtkExitCode ~= nil then + log:warn("vtk_core.runBatch - VTK exit code: " .. tostring(vtkExitCode) .. " — checking result file for actual status") + end + + local vtkOutput = nil + local lf = io.open(vtkResultPath, "r") + if lf then + local raw = lf:read("*all") or "" + lf:close() + -- Forward VTK result into the plugin log for unified diagnostics + log:info("vtk_core.runBatch - VTK result: " .. raw) + local ok, parsed = pcall(function() return JSON:decode(raw) end) + if ok and parsed then + vtkOutput = parsed + else + log:warn("vtk_core.runBatch - VTK result parse failed: " .. raw:sub(1, 500)) + end + else + log:warn("vtk_core.runBatch - VTK result file not found: " .. vtkResultPath) + end + + if vtkOutput and vtkOutput.status == "ok" and vtkOutput.results then + local resultsByPath = {} + for _, r in ipairs(vtkOutput.results) do + if r.input then resultsByPath[r.input] = r end + end + for _, vEntry in ipairs(videoPhotos) do + if vEntry.republishMode ~= "metadata_only" then + local filePath = vEntry.photo:getRawMetadata("path") + local r = filePath and resultsByPath[filePath] + if r then + table.insert(vtkResults, { + photo = vEntry.photo, + existingImageId = vEntry.existingImageId, + republishMode = vEntry.republishMode, + variantPath = r.variant or "", + thumbnailPath = r.thumbnail or "", + videoWidth = r.width or 0, + videoHeight = r.height or 0, + videoDuration = r.duration or 0, + videoSize = r.size or 0, + origData = r.orig or nil, + convData = r.conv or nil, + status = r.status or "error", + error = r.error or "", + }) + else + table.insert(vtkResults, { + photo = vEntry.photo, + existingImageId = vEntry.existingImageId, + republishMode = vEntry.republishMode, + status = "error", + error = "No result from Video Toolkit for " .. (filePath or "?"), + }) + end + end + end + else + local reason = (vtkOutput and vtkOutput.status) or "no output" + log:warn("vtk_core.runBatch - VTK failed: " .. reason) + LrDialogs.message("Video Toolkit Error", + "Video Toolkit failed.\n\nCheck the plugin log for details.\n\nVideos will be skipped.", + "critical") + end + + progressScope:setCaption("Publishing to Piwigo...") + progressScope:setPortionComplete(0, 100) + + return vtkResults, metadataOnlyVideos +end + +-- --------------------------------------------------------------------------- +-- vtk_core.uploadVariants +-- Upload VTK-produced video variants to Piwigo and mark them Published in LrC. +-- --------------------------------------------------------------------------- +function vtk_core.uploadVariants(vtkResults, propertyTable, collectionSettings, + albumId, albumName, albumUrl, + catalog, publishedCollection, + companionAvailable, serverMaxBytes, progressScope) + if #vtkResults == 0 then return end + + log:info("vtk_core.uploadVariants - uploading " .. #vtkResults .. " video variant(s)") + progressScope:setCaption("Uploading video variants...") + + local preset = (collectionSettings.vtkPresetOverride and collectionSettings.vtkPresetOverride ~= "") + and collectionSettings.vtkPresetOverride + or ((propertyTable.vtkDefaultPreset and propertyTable.vtkDefaultPreset ~= "") + and propertyTable.vtkDefaultPreset or "medium") + + local vtkFailedVideos = {} + + for idx, vr in ipairs(vtkResults) do + local vPhoto = vr.photo + local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + + if vr.status ~= "ok" or vr.variantPath == "" then + local errMsg = vr.error or "Unknown toolkit error" + log:warn("vtk_core.uploadVariants - skipping (toolkit error): " .. vName .. " — " .. errMsg) + table.insert(vtkFailedVideos, "• " .. vName .. "\n " .. errMsg) + else + log:info("vtk_core.uploadVariants - uploading variant: " .. vr.variantPath) + progressScope:setCaption("Uploading video: " .. vName) + progressScope:setPortionComplete(idx - 1, #vtkResults) + + local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) + metaData.Albumid = albumId + metaData.Remoteid = vr.existingImageId or "" + + local uploadStatus + local variantAttrs = LrFileUtils.fileAttributes(vr.variantPath) + local variantSize = variantAttrs and variantAttrs.fileSize or 0 + local useChunked = serverMaxBytes and (variantSize > serverMaxBytes) + + if useChunked then + log:info(string.format( + "vtk_core.uploadVariants - %s (%d bytes) > server limit (%d) → chunked upload", + vName, variantSize, serverMaxBytes)) + progressScope:setCaption("Uploading (chunked): " .. vName) + uploadStatus = PiwigoAPI.uploadVideoChunked(propertyTable, vr.variantPath, metaData) + else + log:info("vtk_core.uploadVariants - " .. vName .. " → addSimple upload") + uploadStatus = PiwigoAPI.updateGallery(propertyTable, vr.variantPath, metaData) + end + + if uploadStatus.status then + local imageId = uploadStatus.remoteid or "" + log:info("vtk_core.uploadVariants - uploaded, image_id=" .. imageId) + + -- Upload poster + if vr.thumbnailPath and vr.thumbnailPath ~= "" + and LrFileUtils.exists(vr.thumbnailPath) then + if companionAvailable then + log:info("vtk_core.uploadVariants - uploading poster: " .. vr.thumbnailPath) + progressScope:setCaption("Uploading poster: " .. vName) + local posterStatus = PiwigoAPI.setRepresentative( + propertyTable, imageId, vr.thumbnailPath) + if posterStatus.status then + log:info("vtk_core.uploadVariants - poster set for image_id=" .. imageId) + else + log:warn("vtk_core.uploadVariants - poster upload failed: " + .. (posterStatus.statusMsg or "")) + end + end + end + + -- Set video dimensions via Companion + if companionAvailable and vr.videoWidth > 0 and vr.videoHeight > 0 then + log:info("vtk_core.uploadVariants - setting video info: " + .. vr.videoWidth .. "x" .. vr.videoHeight + .. " size=" .. vr.videoSize) + PiwigoAPI.setVideoInfo( + propertyTable, imageId, + vr.videoWidth, vr.videoHeight, vr.videoSize) + end + + -- Extended video metadata (codec, fps, bitrate, format) + if companionAvailable and vr.origData then + PiwigoAPI.setVideoMeta( + propertyTable, imageId, + vr.origData, vr.convData) + end + + -- Update Piwigo metadata + metaData.Remoteid = imageId + PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) + + vr.uploadedImageId = imageId + vr.uploadedRemoteUrl = uploadStatus.remoteurl or "" + + -- Store plugin-side metadata + local pluginData = { + pwHostURL = propertyTable.host, + albumName = albumName, + albumUrl = albumUrl, + imageUrl = uploadStatus.remoteurl or "", + pwUploadDate = os.date("%Y-%m-%d"), + pwUploadTime = os.date("%H:%M:%S"), + pwCommentSync = "", + pwVideoPreset = preset, + } + PiwigoAPI.storeMetaData(catalog, vPhoto, pluginData) + + -- Cleanup VTK temp files (variant + poster) after successful upload. + -- Never delete the variant if it IS the source file (preset=origin, same path). + local sourcePath = vPhoto:getRawMetadata("path") or "" + if vr.variantPath ~= "" and vr.variantPath ~= sourcePath + and LrFileUtils.exists(vr.variantPath) then + LrFileUtils.delete(vr.variantPath) + log:info("vtk_core.uploadVariants - deleted temp variant: " .. vr.variantPath) + end + if vr.thumbnailPath ~= "" and LrFileUtils.exists(vr.thumbnailPath) then + LrFileUtils.delete(vr.thumbnailPath) + log:info("vtk_core.uploadVariants - deleted temp poster: " .. vr.thumbnailPath) + end + + -- Mark video as Published in LrC + catalog:withWriteAccessDo("Mark video published", function() + publishedCollection:addPhotoByRemoteId( + vPhoto, tostring(imageId), + uploadStatus.remoteurl or "", true) + log:info("vtk_core.uploadVariants - marked published: " .. vName .. " (image_id=" .. imageId .. ")") + end, { timeout = 5 }) + else + log:warn("vtk_core.uploadVariants - upload failed: " .. vName + .. " — " .. (uploadStatus.statusMsg or "")) + table.insert(vtkFailedVideos, "• " .. vName .. "\n " .. (uploadStatus.statusMsg or "")) + end + end + end + + if #vtkFailedVideos > 0 then + LrDialogs.message("Video Toolkit — Processing Errors", + #vtkFailedVideos .. " video(s) could not be processed by the Video Toolkit " + .. "and were skipped:\n\n" + .. table.concat(vtkFailedVideos, "\n\n") + .. "\n\nCheck the Video Toolkit log for details.", + "warning") + end +end + +-- --------------------------------------------------------------------------- +-- vtk_core.updateMetadataOnly +-- Update metadata on Piwigo for videos that are already published (same preset). +-- --------------------------------------------------------------------------- +function vtk_core.updateMetadataOnly(metadataOnlyVideos, propertyTable, albumId, + catalog, publishedCollection, + companionAvailable, progressScope) + if #metadataOnlyVideos == 0 then return end + + log:info("vtk_core.updateMetadataOnly - updating metadata for " .. #metadataOnlyVideos .. " video(s)") + progressScope:setCaption("Updating video metadata...") + + for _, vEntry in ipairs(metadataOnlyVideos) do + local vPhoto = vEntry.photo + local imageId = vEntry.existingImageId or "" + local vName = vPhoto:getFormattedMetadata("fileName") or "unknown" + + if imageId ~= "" then + log:info("vtk_core.updateMetadataOnly - image_id=" .. imageId .. " (" .. vName .. ")") + local metaData = utils.getPhotoMetadata(propertyTable, vPhoto) + metaData.Albumid = albumId + metaData.Remoteid = imageId + PiwigoAPI.updateMetadata(propertyTable, vPhoto, metaData) + log:info("vtk_core.updateMetadataOnly - metadata updated for image_id=" .. imageId) + + -- setVideoInfo from .vtk cache file + if companionAvailable then + local srcPath = vPhoto:getRawMetadata("path") or "" + local preset = vEntry.appliedPreset or "" + if srcPath ~= "" and preset ~= "" then + local stem = LrPathUtils.removeExtension(LrPathUtils.leafName(srcPath)) + local vtkFile = LrPathUtils.child(LrPathUtils.parent(srcPath), ".vtk") + vtkFile = LrPathUtils.child(vtkFile, stem .. ".json") + local fh = io.open(vtkFile, "r") + if fh then + local raw = fh:read("*all"); fh:close() + local ok, vtk = pcall(function() return JSON:decode(raw) end) + if ok and vtk and vtk.variants and vtk.variants[preset] then + local v = vtk.variants[preset] + local vw = v.width or 0 + local vh = v.height or 0 + local vs = v.size or 0 + if (vw == 0 or vh == 0) and v.resolution then + vw, vh = v.resolution:match("^(%d+)x(%d+)$") + vw = tonumber(vw) or 0 + vh = tonumber(vh) or 0 + end + if vw > 0 and vh > 0 then + log:info("vtk_core.updateMetadataOnly - setVideoInfo image_id=" + .. imageId .. " " .. vw .. "x" .. vh .. " size=" .. vs) + PiwigoAPI.setVideoInfo(propertyTable, imageId, vw, vh, vs) + end + else + log:info("vtk_core.updateMetadataOnly - no .vtk variant data for preset=" .. preset .. " (" .. vName .. ")") + end + else + log:info("vtk_core.updateMetadataOnly - .vtk file not found for " .. vName) + end + end + end + + -- Mark video as Published in LrC + catalog:withWriteAccessDo("Mark video published", function() + local publishedPhotos = publishedCollection:getPublishedPhotos() + for _, pubPhoto in ipairs(publishedPhotos) do + if pubPhoto:getPhoto().localIdentifier == vPhoto.localIdentifier then + pubPhoto:setRemoteId(tostring(imageId)) + pubPhoto:setRemoteUrl("") + pubPhoto:setEditedFlag(false) + log:info("vtk_core.updateMetadataOnly - marked published: " .. vName) + break + end + end + end, { timeout = 5 }) + else + log:warn("vtk_core.updateMetadataOnly - no image_id for " .. vName .. ", skipping") + end + end +end + +return vtk_core diff --git a/piwigoPublish.lrplugin/vtk_ui.lua b/piwigoPublish.lrplugin/vtk_ui.lua new file mode 100644 index 0000000..c872304 --- /dev/null +++ b/piwigoPublish.lrplugin/vtk_ui.lua @@ -0,0 +1,365 @@ +--[[ + vtk_ui.lua - Video Toolkit UI section + + Extracted from PublishDialogSections.lua. Provides: + - vtk_ui.videoDialog(f, propertyTable) : LrView section for Video Settings + + All globals (LrView, LrTasks, LrDialogs, LrPathUtils, LrSystemInfo, + utils, JSON) are provided by Init.lua. + + Copyright (C) 2024 Fiona Boston . + This file is part of PiwigoPublish (GPLv3). +]] + +---@diagnostic disable: undefined-global + +local vtk_ui = {} + +local VTK_PRESETS = { "small", "medium", "large", "xlarge", "xxl", "origin" } +local VTK_PRESET_LABELS = { "Small (480p)", "Medium (720p)", "Large (1080p)", "XLarge (1440p)", "XXL (2160p)", "Origin (no transcode)" } + +-- Download URLs for external tools +local DL_PYTHON = "https://www.python.org/downloads/" +local DL_FFMPEG = "https://ffmpeg.org/download.html" +local DL_EXIFTOOL = "https://exiftool.org/" + +function vtk_ui.videoDialog(f, propertyTable) + local bind = LrView.bind + local share = LrView.share + + -- Build preset items list for popup_menu + local presetItems = {} + for i, key in ipairs(VTK_PRESETS) do + presetItems[#presetItems + 1] = { title = VTK_PRESET_LABELS[i], value = key } + end + + -- Helper: section header row + local function sectionHeader(title) + return f:row { + f:static_text { + title = title, + fill_horizontal = 1, + font = "", + text_color = LrColor(0.4, 0.4, 0.4), + }, + } + end + + -- Helper: download button (small, opens URL) + local function dlButton(url, tip) + return f:push_button { + title = "Download", + tooltip = tip, + width_in_chars = 9, + action = function() LrHttp.openUrlInBrowser(url) end, + } + end + + return { + title = "Video Settings", + bind_to_object = propertyTable, + + f:group_box { + title = "Video Toolkit", + fill_horizontal = 1, + + f:spacer { height = 2 }, + + -- "Include" first (intent), then "Enable" (mechanism) + f:row { + fill_horizontal = 1, + f:checkbox { + title = "Include video files in publications", + fill_horizontal = 1, + value = bind "vtkIncludeVideo", + tooltip = "Include video files in publications.", + }, + }, + + f:row { + fill_horizontal = 1, + f:checkbox { + title = "Enable Video Toolkit (local transcoding)", + fill_horizontal = 1, + value = bind "vtkEnabled", + tooltip = "When enabled, videos are transcoded locally by the Video Toolkit before upload.", + enabled = bind "vtkIncludeVideo", + }, + }, + + f:spacer { height = 4 }, + + -- Encoding Settings + f:separator { fill_horizontal = 1 }, + sectionHeader("Encoding Settings"), + f:column { + fill_horizontal = 1, + enabled = bind "vtkEnabled", + + f:spacer { height = 2 }, + + -- Default preset + Hardware accel on same row + f:row { + f:static_text { + title = "Default preset:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:popup_menu { + value = bind "vtkDefaultPreset", + items = presetItems, + tooltip = "Preset applied to all videos unless overridden per collection.", + }, + f:spacer { width = 16 }, + f:static_text { + title = "Hardware accel:", + alignment = 'right', + }, + f:popup_menu { + value = bind "vtkHardwareAccel", + items = { + { title = "Auto (detect GPU)", value = "auto" }, + { title = "CPU only (libx264)", value = "cpu" }, + { title = "GPU (force)", value = "gpu" }, + }, + tooltip = "GPU hardware acceleration. Auto detects the best available encoder. HDR sources always use CPU (tonemap).", + }, + }, + + f:spacer { height = 2 }, + + -- Poster thumbnail + Poster at on same row + f:row { + f:static_text { + title = "Poster thumbnail:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:checkbox { + title = "Generate poster (JPG)", + value = bind "vtkGeneratePoster", + tooltip = "Extract a JPG thumbnail from the video and upload as representative image.", + }, + f:spacer { width = 16 }, + f:static_text { + title = "Poster at:", + alignment = 'right', + }, + f:edit_field { + value = bind "vtkPosterTimestamp", + width_in_chars = 4, + tooltip = "Percentage of video duration for the thumbnail frame (0-95).", + enabled = bind "vtkGeneratePoster", + }, + f:static_text { + title = "% of duration", + alignment = 'left', + }, + }, + }, + + f:spacer { height = 4 }, + + -- Status (before Advanced) + f:separator { fill_horizontal = 1 }, + sectionHeader("Status"), + f:column { + fill_horizontal = 1, + enabled = bind "vtkEnabled", + + f:spacer { height = 2 }, + + f:row { + f:static_text { + title = LrView.bind { + keys = { "vtkEnabled", "vtkIncludeVideo" }, + operation = function(_, values, _) + if not values.vtkIncludeVideo then + return "Video files not included." + end + if not values.vtkEnabled then + return "Video Toolkit disabled - videos uploaded as-is." + end + return "Use 'Check Tools' to verify installation." + end, + }, + fill_horizontal = 1, + alignment = 'left', + font = "", + }, + }, + + f:spacer { height = 2 }, + + f:row { + f:push_button { + title = "Check Tools...", + width = share 'buttonwidth', + enabled = bind "vtkEnabled", + tooltip = "Run Video Toolkit to verify Python, FFmpeg and ExifTool installations.", + action = function(_) + LrTasks.startAsyncTask(function() + local python = utils.resolveTool(propertyTable.vtkPythonPath, "python") + local plugin = rawget(_G, "_PLUGIN") + local toolkitPath = utils.resolveToolkitPath(propertyTable.vtkToolkitPath, plugin.path) + local isWindows = (LrSystemInfo.osVersion():lower():find("win") ~= nil) + local installCmd = isWindows and "winget install --id Gyan.FFmpeg" or "brew install ffmpeg" + + local tools = { + { key = "vtkFFmpegPath", name = "FFmpeg", val = propertyTable.vtkFFmpegPath }, + { key = "vtkFFprobePath", name = "FFprobe", val = propertyTable.vtkFFprobePath }, + { key = "vtkExifToolPath", name = "ExifTool", val = propertyTable.vtkExifToolPath }, + } + for _, t in ipairs(tools) do + if t.val and t.val ~= "" and not utils.fileExists(t.val) then + LrDialogs.message("Video Toolkit - Invalid Path", + "The configured path for " .. t.name .. " is invalid:\n" .. t.val .. "\n\nFix the path in Advanced settings, or clear the field to let the toolkit detect it automatically.", "critical") + return + end + end + + if not utils.fileExists(python) then + LrDialogs.message("Video Toolkit - Python Not Found", + "Python was not found at:\n" .. python .. "\n\nFix the path in Advanced settings, or clear the field for auto-detect.", "critical") + return + end + if not utils.fileExists(toolkitPath) then + LrDialogs.message("Video Toolkit - Script Not Found", + "video_toolkit.py not found at:\n" .. toolkitPath .. "\n\nFix the path in Advanced settings, or clear the field for auto-detect.", "critical") + return + end + + local outFile = LrPathUtils.child(LrPathUtils.getStandardFilePath("temp"), "vtk_check.json") + local innerCmd = '"' .. python .. '" "' .. toolkitPath .. '" --mode check > "' .. outFile .. '" 2>&1' + local cmd = isWindows and ('cmd /c "' .. innerCmd .. '"') or innerCmd + local result = LrTasks.execute(cmd) + local checkOutput = "" + local fh = io.open(outFile, "r") + if fh then checkOutput = fh:read("*a"); fh:close() end + + if result == 0 then + local ok, parsed = pcall(JSON.decode, JSON, checkOutput) + if ok and parsed then + local function fillIfEmpty(key, val) + if val and val ~= "not found" and (not propertyTable[key] or propertyTable[key] == "") then + propertyTable[key] = val + end + end + fillIfEmpty("vtkFFmpegPath", parsed.ffmpeg) + fillIfEmpty("vtkFFprobePath", parsed.ffprobe) + fillIfEmpty("vtkExifToolPath", parsed.exiftool) + end + if not (propertyTable.vtkPythonPath and propertyTable.vtkPythonPath ~= "") then + propertyTable.vtkPythonPath = python + end + if not (propertyTable.vtkToolkitPath and propertyTable.vtkToolkitPath ~= "") then + propertyTable.vtkToolkitPath = toolkitPath + end + LrDialogs.message("Video Toolkit - OK", + "All tools verified and working.\n\nDetected paths have been filled in Advanced settings.", "info") + else + LrDialogs.message("Video Toolkit - FFprobe Not Found", + "Python and the toolkit are working, but ffprobe was not found.\n\n" + .. "Install FFmpeg (includes ffprobe):\n " .. installCmd .. "\n\n" + .. "Or set the FFprobe path in Advanced settings.", "critical") + end + end) + end, + }, + }, + + f:spacer { height = 2 }, + }, + + f:spacer { height = 4 }, + + -- Advanced paths (after Status) + f:separator { fill_horizontal = 1 }, + sectionHeader("Advanced - Tool Paths"), + f:column { + fill_horizontal = 1, + enabled = bind "vtkEnabled", + + f:spacer { height = 2 }, + + f:row { + f:static_text { + title = "Python:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:edit_field { + value = bind "vtkPythonPath", + fill_horizontal = 1, + tooltip = "Full path to python.exe (leave blank for auto-detect).", + placeholder_string = "(auto-detect)", + }, + dlButton(DL_PYTHON, "Download Python from python.org"), + }, + + f:row { + f:static_text { + title = "FFmpeg:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:edit_field { + value = bind "vtkFFmpegPath", + fill_horizontal = 1, + tooltip = "Full path to ffmpeg.exe (leave blank for auto-detect).", + placeholder_string = "(auto-detect)", + }, + dlButton(DL_FFMPEG, "Download FFmpeg from ffmpeg.org (includes ffprobe)"), + }, + + f:row { + f:static_text { + title = "FFprobe:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:edit_field { + value = bind "vtkFFprobePath", + fill_horizontal = 1, + tooltip = "Full path to ffprobe.exe (leave blank for auto-detect). Included in the FFmpeg package.", + placeholder_string = "(auto-detect)", + }, + dlButton(DL_FFMPEG, "Download FFmpeg from ffmpeg.org (includes ffprobe)"), + }, + + f:row { + f:static_text { + title = "ExifTool:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:edit_field { + value = bind "vtkExifToolPath", + fill_horizontal = 1, + tooltip = "Full path to exiftool.exe (leave blank for auto-detect, optional).", + placeholder_string = "(auto-detect, optional)", + }, + dlButton(DL_EXIFTOOL, "Download ExifTool from exiftool.org"), + }, + + f:row { + f:static_text { + title = "Presets file:", + alignment = 'right', + width = share 'vtk_label_w', + }, + f:edit_field { + value = bind "vtkPresetsFile", + fill_horizontal = 1, + tooltip = "Path to a custom presets.json file (leave blank for built-in presets).", + placeholder_string = "(built-in presets)", + }, + }, + + f:spacer { height = 2 }, + }, + }, + } +end + +return vtk_ui diff --git a/video-toolkit/INSTALL.md b/video-toolkit/INSTALL.md new file mode 100644 index 0000000..d1cb77f --- /dev/null +++ b/video-toolkit/INSTALL.md @@ -0,0 +1,107 @@ +# Video Toolkit — Installation + +## Dependencies + +### Required + +- **Python** 3.8+ +- **FFmpeg** 5.0+ (video transcoding + analysis via ffprobe) + +### Optional + +- **ExifTool** 12+ (metadata copying — without it, GPS, date and keywords are not copied to the compressed file) + +## Installation by Platform + +### Windows + +#### Via winget (recommended — built into Windows 11) +```cmd +winget install Python.Python.3 +winget install Gyan.FFmpeg +winget install OliverBetz.ExifTool +``` + +#### Via Chocolatey +```cmd +choco install python ffmpeg exiftool +``` + +#### Manual +1. Download FFmpeg: https://ffmpeg.org/download.html + - Extract the ZIP to `C:\ffmpeg\` + - Add `C:\ffmpeg\bin` to the PATH environment variable (or configure the path in Lightroom's Advanced settings) + +2. Download ExifTool: https://exiftool.org/ + - Place `exiftool.exe` in `C:\exiftool\` (or any folder that is in PATH) + +### macOS + +```bash +brew install python@3.11 +brew install ffmpeg +brew install exiftool +``` + +### Linux (Debian / Ubuntu) + +```bash +sudo apt update +sudo apt install python3 ffmpeg libimage-exiftool-perl +``` + +### Linux (Fedora / RHEL) + +```bash +sudo dnf install python3 ffmpeg perl-Image-ExifTool +``` + +### Linux (Arch) + +```bash +sudo pacman -S python ffmpeg perl-image-exiftool +``` + +## Configuring the Toolkit + +### Option 1: Auto-detection (recommended) + +Tools are detected automatically if they are: +- In the system PATH +- Or at common installation locations (Windows: `C:\ffmpeg\bin\ffmpeg.exe`, etc.) + +**From Lightroom**: open the publish service settings → Video Settings → click **"Check Tools…"**. This validates all tools, fills in the path fields automatically, and shows a clear result. + +**From the command line**, run the interactive menu to check tool status: +```bash +cd video-toolkit +python video_toolkit.py +# Tools menu shows the status of each dependency +``` + +### Option 2: Configure manually + +If auto-detection fails, set the paths directly in Lightroom under **Video Settings → Advanced — Tool Paths**. + +Alternatively, edit `~/.piwigoPublish/video-toolkit.json`: + +```json +{ + "ffmpeg_path": "C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe", + "ffprobe_path": "C:\\Program Files\\ffmpeg\\bin\\ffprobe.exe", + "exiftool_path": "C:\\exiftool\\exiftool.exe" +} +``` + +## Verification + +```bash +python video_toolkit.py --mode probe --input sample_video.mp4 +``` + +Should return a JSON object with resolution, duration, codecs, etc. + +```bash +python video_toolkit.py +# Interactive menu → Tools (option 3) to check dependency status +``` diff --git a/video-toolkit/VIDEO-TOOLKIT-USER-GUIDE.md b/video-toolkit/VIDEO-TOOLKIT-USER-GUIDE.md new file mode 100644 index 0000000..e655b5b --- /dev/null +++ b/video-toolkit/VIDEO-TOOLKIT-USER-GUIDE.md @@ -0,0 +1,241 @@ +# Video Publishing with Piwigo Publisher + +> **Who this is for**: Lightroom Classic users who want to publish videos to their Piwigo gallery alongside photos. + +--- + +## What You Get + +With the standard Piwigo Publisher plugin, **photos publish fine — but videos don't**. To enable video publishing, two optional components work together: + +``` +Your Lightroom ──────────────────────────────► Your Piwigo Gallery + ▲ + [Piwigo Publisher plugin] │ + │ │ + ├── Photos ──────────────────────────── upload directly + │ + └── Videos ──► [Video Toolkit] ──► compress ──► upload + ▲ + Python + FFmpeg on your computer +``` + +| Component | Where it lives | What it does | +|-----------|---------------|-------------| +| **Lightroom Companion** | On your Piwigo server | Tells Lightroom "video uploads are allowed here" | +| **Video Toolkit (VTK)** | On your computer | Compresses videos before upload | + +Both are **optional but recommended**. Without the Companion, videos are blocked entirely. Without VTK, videos are uploaded as Lightroom renders them — usually fine for small files, risky for large ones. + +--- + +## Before You Start + +### On your Piwigo server + +1. Install the **Lightroom Companion** plugin (copy the `lightroom_companion/` folder into Piwigo's `plugins/` directory) +2. In Piwigo admin → Plugins → activate **Lightroom Companion** +3. Go to **Plugins → Lightroom Companion** → click **"Enable Video Support"** +4. Install the **VideoJS** plugin from the Piwigo plugin browser — without it, videos won't play in the gallery + +### On your computer + +Install these three tools (all free): + +| Tool | Why you need it | Download | +|------|----------------|---------| +| **Python 3.8+** | Runs the Video Toolkit | [python.org/downloads](https://www.python.org/downloads/) | +| **FFmpeg** | Compresses the video | [ffmpeg.org/download](https://ffmpeg.org/download.html) | +| **ExifTool** *(optional)* | Copies GPS, date, keywords to the compressed file | [exiftool.org](https://exiftool.org/) | + +> For platform-specific installation instructions (winget, brew, apt, manual), see [`INSTALL.md`](INSTALL.md). + +**Windows shortcut** — open a terminal and run: +```cmd +winget install Python.Python.3 +winget install Gyan.FFmpeg +winget install OliverBetz.ExifTool +``` + +> **Shortcut from Lightroom**: in the publish service settings → Video Settings → Advanced, each tool path has a **Download** button that opens the official page directly. + +--- + +## Setting Up in Lightroom + +Open your Piwigo publish service settings (right-click → Edit Settings). Scroll down to **Video Settings**. + +``` +┌─ Video Settings ──────────────────────────────────┐ +│ Video Toolkit │ +│ ☑ Include video files in publications │ +│ ☑ Enable Video Toolkit (local transcoding) │ +│ │ +│ Encoding Settings │ +│ Default preset: [ Medium (720p) ▼ ] │ +│ Hardware accel: [ Auto (detect GPU) ▼ ] │ +│ Poster thumbnail: ☑ Generate poster (JPG) │ +│ Poster at: [ 10 ] % of duration │ +│ │ +│ Status │ +│ Use 'Check Tools' to verify installation. │ +│ [ Check Tools... ] │ +│ │ +│ Advanced — Tool Paths │ +│ Python: C:\Python3\python.exe [ Download ] │ +│ FFmpeg: C:\ffmpeg\ffmpeg.exe [ Download ] │ +│ FFprobe: C:\ffmpeg\ffprobe.exe [ Download ] │ +│ ExifTool: C:\exiftool.exe [ Download ] │ +│ Presets file: (built-in presets) │ +└────────────────────────────────────────────────────┘ +``` + +**Step by step:** + +1. Check **"Include video files in publications"** +2. Check **"Enable Video Toolkit"** +3. Click **"Check Tools…"** — it verifies Python, FFmpeg and ExifTool, and fills in the paths automatically +4. If the check passes, you're ready + +--- + +## Choosing a Quality Preset + +The preset controls how much the video is compressed before upload. Pick based on your typical source material and your server's storage capacity. + +| Preset | Max size | Typical file | Best for | +|--------|----------|-------------|---------| +| **Small (480p)** | 854×480 | ~30 MB/min | Shared hosting, mobile viewing | +| **Medium (720p)** | 1280×720 | ~90 MB/min | **Good default** for most galleries | +| **Large (1080p)** | 1920×1080 | ~200 MB/min | HD quality, VPS or dedicated server | +| **XLarge (1440p)** | 2560×1440 | ~400 MB/min | 2K sources | +| **XXL (2160p)** | 3840×2160 | ~750 MB/min | 4K archival | +| **Origin** | unchanged | varies | Already web-ready files | + +> **Good to know**: if your source is smaller than the preset's maximum, it stays at its original size. A 720p video processed with "Large (1080p)" stays at 720p — it is never upscaled. + +> **Origin preset**: the file is uploaded as-is, without any compression. Use it only if the video is already in a web-friendly format (H.264 MP4) and is not too large. + +--- + +## What Happens When You Click "Publish" + +``` +You click Publish + │ + ▼ +Plugin scans the batch + │ + ├─ No videos? ──► publish photos normally (done) + │ + └─ Videos found? + │ + ▼ + Ask the Piwigo server: "Are video uploads allowed?" + │ + ├─ No (Companion not installed, or video not enabled) + │ └──► Videos removed from batch, photos continue + │ A message tells you which videos were skipped and why + │ + └─ Yes + │ + ▼ + Video Toolkit compresses each video + │ + ├─ Already compressed recently? ──► skip (uses cache) + │ + └─ New or changed? ──► compress with FFmpeg + │ + ▼ + Generate poster image (JPG cover) + │ + ▼ + Upload compressed video + poster to Piwigo + │ + ▼ + Delete local compressed copy (the original is untouched) +``` + +**Your original video file is never modified or deleted.** + +--- + +## GPU Acceleration + +If you have a compatible GPU (NVIDIA, AMD, Intel), VTK can use it to speed up compression. + +| Mode | What it does | +|------|-------------| +| **Auto** *(recommended)* | Detects your GPU and uses it if available; falls back to CPU silently | +| **CPU only** | Always uses the processor — slower but works everywhere | +| **GPU (force)** | Forces GPU; retries with CPU automatically if the GPU fails | + +> **Note**: most videos from a camera (SDR H.264) are simply **remuxed** — the video stream is copied as-is with no re-encoding. GPU acceleration only applies when actual encoding is needed (typically HDR footage that must be converted to SDR). For day-to-day use, the GPU setting rarely makes a visible difference. + +--- + +## The Poster Image + +The poster is the cover image displayed in your Piwigo gallery before the video plays. + +- VTK extracts a frame from the video at a configurable position (default: 10% into the video) +- The Lightroom Companion plugin processes it server-side: resize, optional film-strip border, optional play-button overlay +- You can adjust the frame position with **"Poster at: N % of duration"** + +--- + +## Points to Watch + +### Server storage +Videos are large. 10 videos at 100 MB each = 1 GB consumed on your server. Check your hosting plan's disk quota before bulk publishing. + +### Shared hosting +If your Piwigo is on shared hosting (OVH, o2switch, etc.): +- Use **Small (480p)** or **Medium (720p)** presets to keep files manageable +- Publish **a few videos at a time** rather than 50 at once +- The plugin handles large files automatically via chunked upload — no manual workaround needed + +### VideoJS is required for playback +Without the [VideoJS plugin](https://fr.piwigo.org/ext/index.php?eid=610) on Piwigo, uploaded videos will show as broken or not play. Install it from the Piwigo plugin browser before publishing your first video. + +### ExifTool is optional but useful +Without ExifTool, the compressed video will lose its GPS location, capture date, title and keywords. If these matter to you (especially GPS for travel photos/videos), install ExifTool. + +### First-time setup +Always click **"Check Tools…"** after installation. It validates everything, fills in the paths, and shows a clear OK or error message. Do not skip this step. + +--- + +## Troubleshooting + +| Problem | Likely cause | Fix | +|---------|-------------|-----| +| Videos skipped silently | Companion not installed or video not enabled | Install Companion, click "Enable Video Support" in its admin page | +| "Check Tools" fails | Python or FFmpeg not found | Install the missing tool, then click Check Tools again — paths are filled automatically | +| Video uploads but won't play | VideoJS not installed | Install VideoJS from Piwigo plugin browser | +| Poster image missing or wrong frame | Poster at % too early/late | Adjust "Poster at" (try 5–20%) | +| Upload very slow | Large file, slow connection | Use a smaller preset; the plugin uses chunked upload automatically for large files | +| GPS / date missing on video | ExifTool not installed | Install ExifTool and click Check Tools again | + +--- + +## Quick Reference Card + +``` +FIRST TIME: + Server side → Install Lightroom Companion → Enable Video Support → Install VideoJS + Your machine → Install Python + FFmpeg (+ ExifTool) → Check Tools in Lightroom + +DAILY USE: + Include videos in collection → click Publish → done + (VTK compresses automatically, cache avoids re-compressing unchanged videos) + +PRESET CHOICE: + Shared hosting → Small (480p) + Standard use → Medium (720p) ← recommended default + HD gallery → Large (1080p) + Already encoded → Origin + +IF SOMETHING GOES WRONG: + Check Tools... → read the message → fix the indicated issue +``` diff --git a/video-toolkit/presets/default.json b/video-toolkit/presets/default.json new file mode 100644 index 0000000..0962041 --- /dev/null +++ b/video-toolkit/presets/default.json @@ -0,0 +1,115 @@ +{ + "version": 1, + "presets": { + "small": { + "name": "Small", + "suffix": "_small", + "max_width": 854, + "max_height": 480, + "video_bitrate": 800, + "audio_bitrate": 96, + "video_codec": "libx264", + "audio_codec": "aac", + "h264_profile": "baseline", + "pixel_format": "yuv420p", + "crf": 28, + "two_pass": false, + "container": "mp4", + "custom_ffmpeg_args": {} + }, + "medium": { + "name": "Medium", + "suffix": "_medium", + "max_width": 1280, + "max_height": 720, + "video_bitrate": 2500, + "audio_bitrate": 128, + "video_codec": "libx264", + "audio_codec": "aac", + "h264_profile": "main", + "pixel_format": "yuv420p", + "crf": 23, + "two_pass": false, + "container": "mp4", + "custom_ffmpeg_args": {} + }, + "large": { + "name": "Large", + "suffix": "_large", + "max_width": 1920, + "max_height": 1080, + "video_bitrate": 5000, + "audio_bitrate": 192, + "video_codec": "libx264", + "audio_codec": "aac", + "h264_profile": "high", + "pixel_format": "yuv420p", + "crf": 21, + "two_pass": false, + "container": "mp4", + "custom_ffmpeg_args": {} + }, + "xlarge": { + "name": "XLarge", + "suffix": "_xlarge", + "max_width": 2560, + "max_height": 1440, + "video_bitrate": 10000, + "audio_bitrate": 256, + "video_codec": "libx264", + "audio_codec": "aac", + "h264_profile": "high", + "pixel_format": "yuv420p", + "crf": 20, + "two_pass": false, + "container": "mp4", + "custom_ffmpeg_args": {} + }, + "xxl": { + "name": "XXL", + "suffix": "_xxl", + "max_width": 3840, + "max_height": 2160, + "video_bitrate": 20000, + "audio_bitrate": 320, + "video_codec": "libx264", + "audio_codec": "aac", + "h264_profile": "high", + "pixel_format": "yuv420p", + "crf": 18, + "two_pass": false, + "container": "mp4", + "custom_ffmpeg_args": {} + }, + "origin": { + "name": "Origin", + "suffix": "", + "max_width": 99999, + "max_height": 99999, + "video_bitrate": 0, + "audio_bitrate": 0, + "video_codec": "copy", + "audio_codec": "copy", + "h264_profile": "main", + "pixel_format": "yuv420p", + "crf": 0, + "two_pass": false, + "container": "mp4", + "custom_ffmpeg_args": {} + } + }, + "thumbnail": { + "width": 1280, + "height": 720, + "format": "jpg", + "quality": 85, + "timestamp_pct": 10 + }, + "metadata": { + "copy_exif": true, + "embed_title": true, + "embed_description": true, + "embed_keywords": true, + "embed_gps": true + } +} diff --git a/video-toolkit/src/__init__.py b/video-toolkit/src/__init__.py new file mode 100644 index 0000000..55852a2 --- /dev/null +++ b/video-toolkit/src/__init__.py @@ -0,0 +1,9 @@ +# Video Toolkit — src package + +import subprocess +import sys + +# Évite le flash d'une fenêtre console sur Windows quand lancé depuis un process GUI +SUBPROCESS_FLAGS: dict = ( + {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} +) diff --git a/video-toolkit/src/cli.py b/video-toolkit/src/cli.py new file mode 100644 index 0000000..b9e6e1b --- /dev/null +++ b/video-toolkit/src/cli.py @@ -0,0 +1,1022 @@ +""" +CLI — Interface en ligne de commande du Video Toolkit. + +Deux modes : + 1. Mode non-interactif (appelé depuis Lightroom via args) + python video_toolkit.py --mode probe --input video.mp4 + python video_toolkit.py --mode batch --batch-file batch.json + + 2. Mode interactif (menu terminal, lancé sans args) + python video_toolkit.py + +L'interface interactive utilise les patterns Menu Generator : + - Menus compacts par sections + - Annulation systématique (0 / x / ENTRÉE) + - Rapport structuré après chaque opération +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from .config import Config +from .ffprobe import FFprobe, ProbeError +from .hasher import partial_hash +from .presets import PresetManager, PRESET_ORDER +from .processor import VideoProcessor +from .status import StatusManager, GlobalStatusFile, STATE_PROCESSING, STATE_COMPLETE, STATE_ERROR +from .ui import Colors, OutputFormatter, clear_screen, pause + +# --------------------------------------------------------------------------- +# Parser non-interactif +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="video_toolkit.py", + description="Video Toolkit — Local video processing for PiwigoPublish", + ) + p.add_argument( + "--mode", + choices=["check", "probe", "process", "batch", "status", "clean"], + help="Mode d'exécution (sans --mode : mode interactif)", + ) + p.add_argument("--input", help="Fichier vidéo source") + p.add_argument("--preset", help="Preset : small/medium/large/xlarge/xxl/origin") + p.add_argument("--config", help="Chemin vers le fichier de configuration JSON") + p.add_argument("--output-dir", help="Dossier de sortie (défaut : même que la source)") + p.add_argument("--batch-file", help="Fichier JSON liste de vidéos (mode batch)") + p.add_argument("--status-file", help="Fichier statut global pour le polling Lightroom") + p.add_argument("--force", action="store_true", help="Forcer le re-traitement même si cache valide") + p.add_argument("--thumbnail-only", action="store_true", dest="thumbnail_only", help="Générer uniquement la miniature") + p.add_argument("--keep", help="Preset à conserver lors du nettoyage (mode clean)") + p.add_argument("--check-config", dest="check_config", help="Fichier JSON avec les chemins à valider (mode check)") + p.add_argument("--ffmpeg-path", dest="ffmpeg_path", help="Chemin explicite vers ffmpeg (override config)") + p.add_argument("--exiftool-path", dest="exiftool_path", help="Chemin explicite vers exiftool (override config)") + p.add_argument("--log-file", dest="log_file", help="Fichier log pour capturer stdout+stderr (diagnostic)") + p.add_argument("--verbose", action="store_true", help="Sortie détaillée") + p.add_argument("--dry-run", action="store_true", dest="dry_run", help="Simuler sans écrire") + p.add_argument("--hwaccel", choices=["auto", "cpu", "gpu"], default=None, + help="Hardware acceleration: auto/cpu/gpu (default: from config)") + return p + + +# --------------------------------------------------------------------------- +# Mode probe (non-interactif — appelé par Lightroom) +# --------------------------------------------------------------------------- + +def run_probe(args: argparse.Namespace, cfg: Config) -> int: + """Analyse une vidéo et écrit le résultat JSON sur stdout. Exit 0 = OK.""" + if not args.input: + _json_error("--input requis pour le mode probe") + return 1 + + ffprobe_bin = cfg.resolve_tool("ffprobe") or "ffprobe" + prober = FFprobe(ffprobe_bin) + + try: + info = prober.probe(args.input) + except ProbeError as e: + _json_error(str(e)) + return 1 + + # Enrichir avec le hash partiel + try: + h = partial_hash(args.input) + except OSError as e: + _json_error(f"Impossible de lire le fichier : {e}") + return 1 + + result = info.to_dict() + result["hash"] = h + + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + +# --------------------------------------------------------------------------- +# Mode process (non-interactif — appelé par Lightroom) +# --------------------------------------------------------------------------- + +def run_process(args: argparse.Namespace, cfg: Config) -> int: + """Traite une vidéo unique selon le preset donné. Écrit le résultat JSON sur stdout.""" + if not args.input: + _json_error("--input requis pour le mode process") + return 1 + + preset_key: str = str(args.preset or cfg.get("default_preset", "medium")) + dry_run = getattr(args, "dry_run", False) + force = getattr(args, "force", False) + thumbnail_only = getattr(args, "thumbnail_only", False) + + processor = _build_processor(cfg, getattr(args, "hwaccel", None)) + + global_sf = None + if args.status_file: + global_sf = GlobalStatusFile(args.status_file) + global_sf.update(STATE_PROCESSING, progress=0, current_file=args.input, total=1, done=0) + + def _progress(pct: int) -> None: + if global_sf: + global_sf.update(STATE_PROCESSING, progress=pct, current_file=args.input, total=1, done=0) + + result = processor.process( + input_path=args.input, + preset_key=preset_key, + output_dir=args.output_dir or None, + force=force, + thumbnail_only=thumbnail_only, + dry_run=dry_run, + progress_callback=_progress, + ) + + if result.error: + if global_sf: + global_sf.mark_error(result.error) + _json_error(result.error) + return 1 + + output = { + "status": "ok", + "skipped": result.skipped, + "input": result.input_path, + "variant": result.variant_path, + "thumbnail": result.thumbnail_path, + "preset": result.preset_key, + "width": result.width, + "height": result.height, + "duration": result.duration, + "size": result.size, + "thumbnail_size": result.thumbnail_size, + } + if result.orig: + output["orig"] = result.orig + if result.conv: + output["conv"] = result.conv + + if global_sf: + global_sf.mark_complete(files=[result.variant_path, result.thumbnail_path]) + + print(json.dumps(output, indent=2, ensure_ascii=False)) + return 0 + + +# --------------------------------------------------------------------------- +# Mode batch (non-interactif — appelé par Lightroom) +# --------------------------------------------------------------------------- + +def run_batch(args: argparse.Namespace, cfg: Config) -> int: + """Traite un batch de vidéos depuis un fichier JSON.""" + if not args.batch_file: + _json_error("--batch-file requis pour le mode batch") + return 1 + + batch_path = Path(args.batch_file) + if not batch_path.exists(): + _json_error(f"Fichier batch introuvable : {args.batch_file}") + return 1 + + try: + with batch_path.open("r", encoding="utf-8") as f: + batch = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _json_error(f"Erreur lecture batch : {e}") + return 1 + + videos = batch.get("videos", []) + total = len(videos) + dry_run = getattr(args, "dry_run", False) + + global_sf = None + status_file = args.status_file or batch.get("status_file") + if status_file: + global_sf = GlobalStatusFile(status_file) + global_sf.update(STATE_PROCESSING, progress=0, total=total, done=0) + + processor = _build_processor(cfg, getattr(args, "hwaccel", None)) + + # Construire les jobs depuis le batch JSON + jobs = [] + for item in videos: + jobs.append({ + "input": item.get("input", ""), + "preset": item.get("preset") or cfg.get("default_preset", "medium"), + "output_dir": item.get("output_dir"), + "force": item.get("force", False), + "thumbnail_only": item.get("thumbnail_only", False), + "dry_run": dry_run, + }) + + def _batch_progress(done: int, total_: int, current: str) -> None: + if global_sf and total_ > 0: + pct = int(done * 100 / total_) + global_sf.update(STATE_PROCESSING, progress=pct, current_file=current, + total=total_, done=done) + + results_out = [] + for idx, job in enumerate(jobs): + _batch_progress(idx, total, job["input"]) + r = processor.process( + input_path=job["input"], + preset_key=job["preset"], + output_dir=job.get("output_dir"), + force=job.get("force", False), + thumbnail_only=job.get("thumbnail_only", False), + dry_run=job.get("dry_run", False), + ) + entry = { + "input": r.input_path, + "variant": r.variant_path, + "thumbnail": r.thumbnail_path, + "preset": r.preset_key, + "width": r.width, + "height": r.height, + "duration": r.duration, + "size": r.size, + "skipped": r.skipped, + "status": "error" if r.error else "ok", + } + if r.error: + entry["error"] = r.error + if r.orig: + entry["orig"] = r.orig + if r.conv: + entry["conv"] = r.conv + results_out.append(entry) + + _batch_progress(total, total, "") + + has_errors = any(r["status"] == "error" for r in results_out) + output = { + "status": "error" if has_errors else "ok", + "total": total, + "results": results_out, + } + + if global_sf: + if has_errors: + errors = [r["error"] for r in results_out if r.get("error")] + global_sf.mark_error("; ".join(errors[:3])) + else: + global_sf.mark_complete(files=[r["variant"] for r in results_out if r.get("variant")]) + + print(json.dumps(output, indent=2, ensure_ascii=False)) + return 1 if has_errors else 0 + + +# --------------------------------------------------------------------------- +# Mode clean (non-interactif) +# --------------------------------------------------------------------------- + +def run_clean(args: argparse.Namespace, cfg: Config) -> int: + """Supprime les variantes d'une vidéo (garde éventuellement un preset).""" + if not args.input: + _json_error("--input requis pour le mode clean") + return 1 + + input_path = Path(args.input) + if not input_path.exists(): + _json_error(f"Fichier introuvable : {args.input}") + return 1 + + keep_preset = getattr(args, "keep", None) + dry_run = getattr(args, "dry_run", False) + + from .presets import BUILTIN_PRESETS + stem = input_path.stem + parent = input_path.parent + + deleted: list[str] = [] + skipped: list[str] = [] + + for key, preset in BUILTIN_PRESETS.items(): + if not preset.suffix: + continue # Origin = pas de variante fichier + if keep_preset and key == keep_preset.lower(): + continue + + variant = parent / f"{stem}{preset.suffix}.mp4" + if variant.exists(): + if not dry_run: + variant.unlink() + deleted.append(str(variant)) + + # Poster + poster = parent / f"{stem}_poster.jpg" + if poster.exists() and not keep_preset: + if not dry_run: + poster.unlink() + deleted.append(str(poster)) + + # Fichier statut .vtk/ + from .status import VTK_DIR + status_file = parent / VTK_DIR / f"{stem}.json" + if status_file.exists() and not keep_preset: + if not dry_run: + status_file.unlink() + deleted.append(str(status_file)) + + output = { + "status": "ok", + "dry_run": dry_run, + "deleted": deleted, + "skipped": skipped, + } + print(json.dumps(output, indent=2, ensure_ascii=False)) + return 0 + + +# --------------------------------------------------------------------------- +# Mode status (non-interactif) +# --------------------------------------------------------------------------- + +def run_status(args: argparse.Namespace, cfg: Config) -> int: + """Vérifie le statut d'une vidéo déjà traitée.""" + if not args.input: + _json_error("--input requis pour le mode status") + return 1 + + sm = StatusManager(args.input, str(cfg.get("vtk_dir_name") or ".vtk")) + state = sm.get_state() + source = sm.get_source() + variants = { + k: v for k, v in sm._data.get("variants", {}).items() + } + + result = { + "state": state, + "source": source, + "variants": variants, + "thumbnail": sm.get_thumbnail(), + } + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + +# --------------------------------------------------------------------------- +# Mode interactif — Menu principal +# --------------------------------------------------------------------------- + +class InteractiveCLI: + """Menu interactif pour tester et configurer le Video Toolkit.""" + + def __init__(self, cfg: Config): + self.cfg = cfg + self.c = Colors() + self.fmt = OutputFormatter(self.c) + self.presets = PresetManager(cfg.get_presets_file()) + + def run(self) -> None: + while True: + clear_screen() + self._print_header() + self._print_main_menu() + + choice = input(self.c.prompt("Votre choix (0-5): ")).strip() + + if choice == "0": + print(f"\n{self.c.DIM}Au revoir.{self.c.RESET}\n") + break + elif choice == "1": + self._menu_probe() + elif choice == "2": + self._menu_process() + elif choice == "3": + self._menu_presets() + elif choice == "4": + self._menu_tools() + elif choice == "5": + self._menu_config() + else: + print(self.c.error(f'Choix invalide : "{choice}"')) + pause(self.c) + + # --- Header --- + + def _print_header(self) -> None: + print() + print(self.c.box_header("VIDEO TOOLKIT — PiwigoPublish", width=70)) + print() + + # Ligne de statut outils + tools = self.cfg.tool_status() + parts = [] + for name, path in tools.items(): + if path: + parts.append(f"{self.c.KEY}{name}{self.c.RESET}: {self.c.OK}OK{self.c.RESET}") + else: + parts.append(f"{self.c.KEY}{name}{self.c.RESET}: {self.c.ERROR}absent{self.c.RESET}") + print(" " + " | ".join(parts)) + print() + + # --- Menu principal --- + + def _print_main_menu(self) -> None: + c = self.c + + print(c.title("ANALYSE & TRAITEMENT")) + print(c.separator()) + print(c.menu_option("1", "Probe - Analyser une vidéo (résolution, codecs, durée...)")) + print(c.menu_option("2", "Traiter - Transcoder une vidéo selon un preset")) + print() + print(c.title("CONFIGURATION")) + print(c.separator()) + print(c.menu_option("3", "Presets - Voir et gérer les presets vidéo")) + print(c.menu_option("4", "Outils - Vérifier FFmpeg / FFprobe / ExifTool")) + print(c.menu_option("5", "Paramètres - Configuration générale")) + print() + print(c.menu_option("0", f"{c.DIM}Quitter{c.RESET}")) + print() + + # --- 1. Menu Probe --- + + def _menu_probe(self) -> None: + while True: + clear_screen() + print() + print(self.c.box_header("PROBE — Analyse d'une vidéo", width=70)) + print() + + ffprobe_path = self.cfg.resolve_tool("ffprobe") + if ffprobe_path: + print(f" ffprobe : {self.c.OK}OK{self.c.RESET} ({self.c.VALUE}{ffprobe_path}{self.c.RESET})") + else: + print(f" ffprobe : {self.c.ERROR}non trouvé{self.c.RESET}") + print(f" {self.c.DIM}Configurez le chemin dans Outils (option 3).{self.c.RESET}") + pause(self.c) + return + print() + + print(f" {self.c.DIM}Entrez le chemin vers un fichier vidéo (x pour annuler) :{self.c.RESET}") + path = input(self.c.prompt(" > ")).strip() + + if path.lower() == "x" or not path: + return + + video_path = Path(path.strip('"').strip("'")) + if not video_path.exists(): + print(self.c.error(f"Fichier introuvable : {video_path}")) + pause(self.c) + continue + + # Lancer probe + print(f"\n{self.c.DIM}Analyse en cours...{self.c.RESET}") + prober = FFprobe(ffprobe_path) + try: + info = prober.probe(str(video_path)) + h = partial_hash(str(video_path)) + except ProbeError as e: + print(self.c.error(str(e))) + pause(self.c) + continue + except OSError as e: + print(self.c.error(f"Erreur lecture : {e}")) + pause(self.c) + continue + + # Rapport + self.fmt.print_section_header("RÉSULTAT PROBE") + + rows = [ + ("Fichier", video_path.name), + ("Résolution", info.resolution), + ("Durée", info.duration_str), + ("FPS", f"{info.fps:.3f}"), + ("Codec vidéo", info.video_codec), + ("Codec audio", info.audio_codec), + ("Bitrate vidéo", f"{info.video_bitrate} kbps" if info.video_bitrate else "inconnu"), + ("Bitrate audio", f"{info.audio_bitrate} kbps" if info.audio_bitrate else "inconnu"), + ("Taille", _format_size(info.size)), + ("Container", info.container), + ("Hash (partiel)", h), + ] + + # HDR detection + if info.is_hdr: + hdr_label = "HDR (PQ)" if info.color_transfer == "smpte2084" else "HDR (HLG)" + rows.append(("Colorimétrie", f"{self.c.YELLOW}{hdr_label}{self.c.RESET} → tonemap auto SDR")) + elif info.color_transfer: + rows.append(("Colorimétrie", f"SDR ({info.color_transfer})")) + + self.fmt.aligned_output(rows) + + # Suggestion de preset adapté + print() + suggested = _suggest_preset(info.width, info.height) + print(f" {self.c.DIM}Preset suggéré :{self.c.RESET} {self.c.VALUE}{suggested}{self.c.RESET}") + + self.fmt.print_section_divider() + pause(self.c) + return + + # --- 2. Menu Process --- + + def _menu_process(self) -> None: + """Menu interactif de traitement vidéo (probe + transcode + miniature).""" + while True: + clear_screen() + print() + print(self.c.box_header("TRAITEMENT — Transcoder une vidéo", width=70)) + print() + + # Vérifier FFmpeg + ffmpeg_path = self.cfg.resolve_tool("ffmpeg") + if not ffmpeg_path: + print(f" {self.c.error_marker()} FFmpeg non trouvé — requis pour le transcodage.") + print(f" {self.c.DIM}Configurez le chemin dans Outils (option 4).{self.c.RESET}") + pause(self.c) + return + + default_preset: str = str(self.cfg.get("default_preset", "medium")) + + print(f" {self.c.DIM}Entrez le chemin vers un fichier vidéo (x pour annuler) :{self.c.RESET}") + path = input(self.c.prompt(" > ")).strip() + + if path.lower() == "x" or not path: + return + + video_path = Path(path.strip('"').strip("'")) + if not video_path.exists(): + print(self.c.error(f"Fichier introuvable : {video_path}")) + pause(self.c) + continue + + # Choix du preset + print() + print(self.c.title("Preset de transcodage")) + print(self.c.separator()) + for i, key in enumerate(PRESET_ORDER, 1): + p = self.presets.get_preset(key) + marker = f" {self.c.OK}← défaut{self.c.RESET}" if key == default_preset else "" + print(f" {self.c.YELLOW}{i}{self.c.RESET}. {p.name:<8} {self.c.DIM}{p.max_width}×{p.max_height} {p.video_bitrate} kbps{self.c.RESET}{marker}") + print(f" {self.c.YELLOW}0{self.c.RESET}. Annuler") + print() + + preset_choice = input(self.c.prompt(f"Preset (1-{len(PRESET_ORDER)}, ENTRÉE={default_preset}): ")).strip() + if preset_choice == "0": + continue + if preset_choice == "": + preset_key = default_preset + else: + try: + idx = int(preset_choice) - 1 + if 0 <= idx < len(PRESET_ORDER): + preset_key = PRESET_ORDER[idx] + else: + print(self.c.error(f'Choix invalide : "{preset_choice}"')) + pause(self.c) + continue + except ValueError: + print(self.c.error(f'Choix invalide : "{preset_choice}"')) + pause(self.c) + continue + + print() + print(f" {self.c.DIM}Traitement en cours : {video_path.name} → preset {preset_key}...{self.c.RESET}") + print() + + processor = _build_processor(self.cfg) + last_pct = [0] + + def _show_progress(pct: int) -> None: + if pct != last_pct[0]: + last_pct[0] = pct + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + print(f"\r [{bar}] {pct:3d}%", end="", flush=True) + + result = processor.process( + input_path=video_path, + preset_key=preset_key, + progress_callback=_show_progress, + ) + print() # après la barre de progression + + # Rapport + self.fmt.print_section_header("RÉSULTAT TRAITEMENT") + + if result.error: + print(self.c.error(f"Erreur : {result.error}")) + elif result.skipped: + print(self.c.success("Variante déjà à jour — aucun traitement nécessaire.")) + self.fmt.aligned_output([ + ("Variante", result.variant_path), + ("Miniature", result.thumbnail_path), + ]) + else: + self.fmt.aligned_output([ + ("Variante", result.variant_path), + ("Résolution", f"{result.width}×{result.height}"), + ("Taille", _format_size(result.size)), + ("Miniature", result.thumbnail_path), + ("Taille poster", _format_size(result.thumbnail_size)), + ]) + print(self.c.success("Traitement terminé.")) + + self.fmt.print_section_divider() + pause(self.c) + return + + # --- 3. Menu Presets --- + + def _menu_presets(self) -> None: + clear_screen() + print() + print(self.c.box_header("PRESETS — Configuration des presets vidéo", width=70)) + print() + + all_presets = self.presets.list_presets() + default_key = self.cfg.get("default_preset", "medium") + + print(self.c.title("Presets disponibles")) + print(self.c.separator()) + print() + + for key, preset in all_presets: + marker = f" {self.c.OK}← défaut{self.c.RESET}" if key == default_key else "" + print( + f" {self.c.BOLD}{preset.name:<8}{self.c.RESET}" + f" {self.c.VALUE}{preset.max_width}×{preset.max_height}{self.c.RESET:<6}" + f" {self.c.DIM}{preset.video_bitrate:>6} kbps vidéo" + f" {preset.audio_bitrate:>4} kbps audio" + f" {preset.h264_profile:<8}{self.c.RESET}" + f"{marker}" + ) + print() + + presets_file = self.cfg.get_presets_file() + if presets_file.exists(): + print(f" {self.c.DIM}Fichier :{self.c.RESET} {self.c.VALUE}{presets_file}{self.c.RESET}") + else: + print(f" {self.c.DIM}Fichier presets non configuré (presets builtin utilisés){self.c.RESET}") + + print() + pause(self.c) + + # --- 3. Menu Outils --- + + def _menu_tools(self) -> None: + while True: + clear_screen() + print() + print(self.c.box_header("OUTILS — Vérification des dépendances", width=70)) + print() + + tools = { + "ffmpeg": ("FFmpeg", "Transcodage vidéo", True), + "ffprobe": ("FFprobe", "Analyse vidéo (inclus avec FFmpeg)", True), + "exiftool": ("ExifTool", "Copie de métadonnées (optionnel)", False), + } + + all_ok = True + missing = [] + + for tool_key, (tool_name, description, required) in tools.items(): + path = self.cfg.resolve_tool(tool_key) + if path: + version = _get_tool_version(tool_key, path) + ver_str = f" {self.c.DIM}v{version}{self.c.RESET}" if version else "" + print(f" {self.c.ok_marker()} {self.c.KEY}{tool_name:<12}{self.c.RESET} {self.c.VALUE}{path}{self.c.RESET}{ver_str}") + print(f" {self.c.DIM}{description}{self.c.RESET}\n") + else: + all_ok = False + severity = "REQUIS" if required else "OPTIONNEL" + marker = self.c.error_marker() if required else self.c.warn_marker() + print(f" {marker} {self.c.KEY}{tool_name:<12}{self.c.RESET} non trouvé ({severity})") + print(f" {self.c.DIM}{description}{self.c.RESET}\n") + missing.append((tool_key, tool_name, required)) + + # Afficher les instructions d'installation pour les outils manquants + if missing: + print() + print(self.c.title("Installation des outils manquants")) + print(self.c.separator()) + print() + + for tool_key, tool_name, _ in missing: + _print_install_instructions(self.c, tool_key, tool_name) + + else: + print(self.c.success("Tous les outils requis sont disponibles.")) + + # Section encodeurs GPU + print() + print(self.c.title("Encodeurs GPU détectés")) + print(self.c.separator()) + print() + ffmpeg_path = self.cfg.resolve_tool("ffmpeg") + if ffmpeg_path: + from .hwaccel import HWAccelDetector, _ENCODER_REGISTRY + detector = HWAccelDetector(ffmpeg_path) + available = detector.list_available() + available_codecs = {e.codec for e in available} + os_key = sys.platform if sys.platform in ("win32", "darwin") else "linux" + all_candidates = _ENCODER_REGISTRY.get(os_key, []) + if all_candidates: + for enc in all_candidates: + if enc.codec in available_codecs: + print(f" {self.c.ok_marker()} {self.c.VALUE}{enc.codec:<22}{self.c.RESET} {enc.name}") + else: + print(f" {self.c.error_marker()} {self.c.DIM}{enc.codec:<22}{self.c.RESET} {enc.name} — non disponible") + else: + print(f" {self.c.DIM}Aucun encodeur GPU connu pour cette plateforme{self.c.RESET}") + if not available: + print(f"\n {self.c.DIM}Aucun encodeur GPU détecté — le CPU sera utilisé{self.c.RESET}") + else: + hw_mode = self.cfg.get("hardware_accel", "auto") + print(f"\n {self.c.DIM}Mode actuel :{self.c.RESET} {self.c.VALUE}{hw_mode}{self.c.RESET}") + else: + print(f" {self.c.DIM}FFmpeg non trouvé — impossible de détecter les encodeurs GPU{self.c.RESET}") + + print() + if missing: + print(f" {self.c.YELLOW}1{self.c.RESET}. Configurer les chemins manuellement") + print(f" {self.c.YELLOW}0{self.c.RESET}. Retour\n") + choice = input(self.c.prompt("Votre choix (0-1): ")).strip() + if choice == "0": + return + elif choice == "1": + self._menu_config() # option 5 du menu principal + else: + print(self.c.error(f'Choix invalide : "{choice}"')) + pause(self.c) + else: + pause(self.c) + return + + # --- 4. Menu Config --- + + def _menu_config(self) -> None: + while True: + clear_screen() + print() + print(self.c.box_header("PARAMÈTRES — Configuration générale", width=70)) + print() + + default_preset: str = str(self.cfg.get("default_preset", "medium")) + generate_poster = self.cfg.get("generate_poster", True) + poster_ts = self.cfg.get("poster_timestamp_pct", 10) + ffmpeg_path = self.cfg.get("ffmpeg_path", "") or self.c.DIM + "(auto)" + self.c.RESET + ffprobe_path = self.cfg.get("ffprobe_path", "") or self.c.DIM + "(auto)" + self.c.RESET + exiftool_path = self.cfg.get("exiftool_path", "") or self.c.DIM + "(auto)" + self.c.RESET + presets_file = str(self.cfg.get_presets_file()) + hw_accel: str = str(self.cfg.get("hardware_accel") or "auto") + + poster_status = f"{self.c.OK}activé{self.c.RESET}" if generate_poster else f"{self.c.DIM}désactivé{self.c.RESET}" + + print(self.c.title("Paramètres actuels")) + print() + print(self.c.config_line("1. Preset par défaut", default_preset)) + print(self.c.config_line("2. Génération poster", poster_status)) + print(self.c.config_line("3. Timestamp poster", f"{poster_ts}% de la durée")) + print(self.c.config_line("4. FFmpeg path", ffmpeg_path)) + print(self.c.config_line("5. FFprobe path", ffprobe_path)) + print(self.c.config_line("6. ExifTool path", exiftool_path)) + print(self.c.config_line("7. Fichier presets", presets_file)) + print(self.c.config_line("8. Accélération GPU", hw_accel)) + print() + print(self.c.separator()) + print(f" {self.c.DIM}Numéro pour modifier, 0 pour revenir{self.c.RESET}") + print() + + choice = input(self.c.prompt("Votre choix (0-8): ")).strip() + + if choice == "0": + return + elif choice == "1": + self._edit_default_preset() + elif choice == "2": + current = self.cfg.get("generate_poster", True) + self.cfg.set("generate_poster", not current) + self.cfg.save() + elif choice == "3": + self._edit_poster_timestamp() + elif choice in ("4", "5", "6"): + key_map = {"4": "ffmpeg", "5": "ffprobe", "6": "exiftool"} + self._edit_tool_path(key_map[choice]) + elif choice == "7": + self._edit_presets_file() + elif choice == "8": + self._edit_hardware_accel() + else: + print(self.c.error(f'Choix invalide : "{choice}"')) + pause(self.c) + + def _edit_default_preset(self) -> None: + print() + print(self.c.title("Preset par défaut")) + print(self.c.separator()) + for i, key in enumerate(PRESET_ORDER, 1): + preset = self.presets.get_preset(key) + print(f" {self.c.YELLOW}{i}{self.c.RESET}. {preset.name} ({preset.max_width}×{preset.max_height})") + print() + choice = input(self.c.prompt("Choix (1-6, x pour annuler): ")).strip() + if choice.lower() == "x": + return + try: + idx = int(choice) - 1 + if 0 <= idx < len(PRESET_ORDER): + self.cfg.set("default_preset", PRESET_ORDER[idx]) + self.cfg.save() + print(self.c.success(f"Preset par défaut : {PRESET_ORDER[idx]}")) + else: + print(self.c.error(f'Choix invalide : "{choice}"')) + except ValueError: + print(self.c.error(f'Choix invalide : "{choice}"')) + pause(self.c) + + def _edit_poster_timestamp(self) -> None: + print() + current = self.cfg.get("poster_timestamp_pct", 10) + val = input(self.c.prompt(f"Timestamp poster en % (actuel: {current}%, x pour annuler): ")).strip() + if val.lower() == "x" or not val: + return + try: + pct = int(val) + if 0 <= pct <= 95: + self.cfg.set("poster_timestamp_pct", pct) + self.cfg.save() + print(self.c.success(f"Timestamp poster : {pct}%")) + else: + print(self.c.error("Valeur entre 0 et 95")) + except ValueError: + print(self.c.error(f'Valeur invalide : "{val}"')) + pause(self.c) + + def _edit_tool_path(self, tool: str) -> None: + print() + current = self.cfg.get(f"{tool}_path", "") + if current: + print(f" Actuel : {self.c.VALUE}{current}{self.c.RESET}") + print(f" {self.c.DIM}Laissez vide pour auto-détection (ENTRÉE pour garder, x pour annuler){self.c.RESET}") + val = input(self.c.prompt(f"Chemin {tool}: ")).strip() + if val.lower() == "x": + return + if val == "": + print(self.c.success("Chemin inchangé")) + return + self.cfg.set(f"{tool}_path", val) + self.cfg.save() + print(self.c.success(f"{tool} configuré : {val}")) + pause(self.c) + + def _edit_presets_file(self) -> None: + print() + current = str(self.cfg.get_presets_file()) + print(f" Actuel : {self.c.VALUE}{current}{self.c.RESET}") + val = input(self.c.prompt("Chemin fichier presets (ENTRÉE pour garder, x pour annuler): ")).strip() + if val.lower() == "x": + return + if val == "": + print(self.c.success("Chemin inchangé")) + return + self.cfg.set("presets_file", val) + self.cfg.save() + print(self.c.success(f"Fichier presets : {val}")) + pause(self.c) + + def _edit_hardware_accel(self) -> None: + print() + print(self.c.title("Accélération GPU")) + print(self.c.separator()) + modes = [("auto", "Auto (détecte le GPU disponible)"), + ("cpu", "CPU uniquement (libx264)"), + ("gpu", "GPU (force, fallback CPU si échec)")] + for i, (key, desc) in enumerate(modes, 1): + print(f" {self.c.YELLOW}{i}{self.c.RESET}. {desc}") + print() + choice = input(self.c.prompt("Choix (1-3, x pour annuler): ")).strip() + if choice.lower() == "x": + return + try: + idx = int(choice) - 1 + if 0 <= idx < len(modes): + self.cfg.set("hardware_accel", modes[idx][0]) + self.cfg.save() + print(self.c.success(f"Accélération GPU : {modes[idx][0]}")) + else: + print(self.c.error(f'Choix invalide : "{choice}"')) + except ValueError: + print(self.c.error(f'Choix invalide : "{choice}"')) + pause(self.c) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _build_processor(cfg: Config, hwaccel_mode: str | None = None) -> VideoProcessor: + """Construit un VideoProcessor avec les chemins d'outils depuis la config.""" + from .presets import PresetManager as PM + effective_hwaccel: str = str(hwaccel_mode or cfg.get("hardware_accel", "auto")) + return VideoProcessor( + ffmpeg_path=cfg.resolve_tool("ffmpeg") or "ffmpeg", + ffprobe_path=cfg.resolve_tool("ffprobe") or "ffprobe", + exiftool_path=cfg.resolve_tool("exiftool") or "exiftool", + preset_manager=PM(cfg.get_presets_file()), + thumbnail_timestamp_pct=int(cfg.get("poster_timestamp_pct") or 10), + thumbnail_max_width=int(cfg.get("thumbnail_width") or 1280), + copy_metadata=bool(cfg.get("copy_metadata", True)), + hwaccel_mode=effective_hwaccel, + ) + + +def _json_error(msg: str) -> None: + """Écrit une erreur JSON sur stderr (pour les appels Lightroom).""" + import json as _json + print(_json.dumps({"status": "error", "error": msg}), file=sys.stderr) + + +def _format_size(size: float) -> str: + for unit in ("o", "Ko", "Mo", "Go"): + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} To" + + +def _suggest_preset(width: int, height: int) -> str: + """Suggère le preset le plus adapté à la résolution source.""" + if width >= 3840 or height >= 2160: + return "xxl" + if width >= 2560 or height >= 1440: + return "xlarge" + if width >= 1920 or height >= 1080: + return "large" + if width >= 1280 or height >= 720: + return "medium" + return "small" + + +def _get_tool_version(tool: str, path: str) -> str | None: + """Extrait la version d'un outil (ffmpeg, ffprobe, exiftool).""" + import subprocess + from . import SUBPROCESS_FLAGS + try: + r = subprocess.run([path, "-version"], capture_output=True, text=True, timeout=5, **SUBPROCESS_FLAGS) + first = r.stdout.splitlines()[0] if r.stdout else "" + parts = first.split() + if tool in ("ffmpeg", "ffprobe") and len(parts) >= 3: + return parts[2] + if tool == "exiftool" and len(parts) >= 2: + return parts[-1] + except Exception: + pass + return None + + +INSTALL_INSTRUCTIONS = { + "ffmpeg": { + "Windows": [ + ("Méthode 1 (recommandée):", "winget install ffmpeg"), + ("Méthode 2 (Chocolatey):", "choco install ffmpeg"), + ("Méthode 3 (manuel):", "Télécharger sur https://ffmpeg.org/download.html"), + ], + "macOS": [ + ("Avec Homebrew:", "brew install ffmpeg"), + ("Voir aussi:", "https://ffmpeg.org/download.html"), + ], + "Linux": [ + ("Debian/Ubuntu:", "sudo apt install ffmpeg"), + ("Fedora/RHEL:", "sudo dnf install ffmpeg"), + ("Arch:", "sudo pacman -S ffmpeg"), + ], + }, + "exiftool": { + "Windows": [ + ("Méthode 1 (winget):", "winget install exiftool"), + ("Méthode 2 (Chocolatey):", "choco install exiftool"), + ("Méthode 3 (manuel):", "Télécharger sur https://exiftool.org/"), + ], + "macOS": [ + ("Avec Homebrew:", "brew install exiftool"), + ("Voir aussi:", "https://exiftool.org/"), + ], + "Linux": [ + ("Debian/Ubuntu:", "sudo apt install libimage-exiftool-perl"), + ("Fedora/RHEL:", "sudo dnf install perl-Image-ExifTool"), + ("Arch:", "sudo pacman -S perl-image-exiftool"), + ], + }, +} + + +def _print_install_instructions(c: Colors, tool: str, tool_name: str) -> None: + """Affiche les instructions d'installation pour un outil.""" + import sys + + instructions = INSTALL_INSTRUCTIONS.get(tool, {}) + if not instructions: + print(f" {c.DIM}Aucune instruction d'installation pour {tool_name}{c.RESET}\n") + return + + os_label = "Windows" if sys.platform == "win32" else ("macOS" if sys.platform == "darwin" else "Linux") + methods = instructions.get(os_label, instructions.get("Linux", [])) + + print(f" {c.BOLD}{tool_name}{c.RESET}") + for label, cmd in methods: + print(f" {c.DIM}{label}{c.RESET}") + print(f" {c.VALUE}{cmd}{c.RESET}") + print() diff --git a/video-toolkit/src/config.py b/video-toolkit/src/config.py new file mode 100644 index 0000000..a15b771 --- /dev/null +++ b/video-toolkit/src/config.py @@ -0,0 +1,207 @@ +""" +Config — Chargement et sauvegarde de la configuration globale du toolkit. + +Séparé des presets : gère les chemins d'outils, les préférences globales, +et le fichier de presets à utiliser. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Chemins par défaut +# --------------------------------------------------------------------------- + +DEFAULT_CONFIG_DIR = Path.home() / ".piwigoPublish" +DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "video-toolkit.json" +DEFAULT_PRESETS_FILE = DEFAULT_CONFIG_DIR / "presets.json" +DEFAULT_STATUS_FILE = DEFAULT_CONFIG_DIR / ".vtk-status.json" + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +class Config: + """Configuration globale du Video Toolkit (chemins + préférences).""" + + DEFAULTS = { + "python_path": "", + "ffmpeg_path": "", + "ffprobe_path": "", + "exiftool_path": "", + "presets_file": "", + "default_preset": "medium", + "generate_poster": True, + "poster_timestamp_pct": 10, + "thumbnail_width": 1280, + "thumbnail_height": 720, + "thumbnail_quality": 85, + "copy_metadata": True, + "hardware_accel": "auto", + "vtk_dir_name": ".vtk", + } + + def __init__(self, config_path: Path | str | None = None): + self._path = Path(config_path) if config_path else DEFAULT_CONFIG_FILE + self._data: dict = dict(self.DEFAULTS) + self._load() + + # --- Persistence --- + + def _load(self) -> None: + if self._path.exists(): + try: + with self._path.open("r", encoding="utf-8") as f: + stored = json.load(f) + self._data.update(stored) + except (json.JSONDecodeError, OSError): + pass # Garder les defaults si le fichier est corrompu + + def save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + + # --- Accès --- + + def get(self, key: str, default=None): + return self._data.get(key, default) + + def set(self, key: str, value) -> None: + self._data[key] = value + + def get_presets_file(self) -> Path: + p = self._data.get("presets_file", "") + return Path(p) if p else DEFAULT_PRESETS_FILE + + # --- Résolution des outils --- + + def resolve_tool(self, tool: str) -> str | None: + """ + Résout le chemin d'un outil (ffmpeg, ffprobe, python, exiftool). + Ordre : config → PATH → emplacements courants (Windows/macOS/Linux). + Retourne le chemin trouvé ou None. + """ + configured = self._data.get(f"{tool}_path", "").strip() + if configured and Path(configured).is_file(): + return configured + + # PATH système + found = shutil.which(tool) + if found: + return found + + # Emplacements courants par plateforme + if sys.platform == "win32": + candidates = _windows_candidates(tool) + elif sys.platform == "darwin": + candidates = _macos_candidates(tool) + else: + candidates = _linux_candidates(tool) + + for candidate in candidates: + if Path(candidate).is_file(): + return candidate + + return None + + def tool_status(self) -> dict[str, str | None]: + """Retourne un dict outil → chemin résolu (None = non trouvé).""" + tools = ["ffmpeg", "ffprobe", "exiftool"] + return {t: self.resolve_tool(t) for t in tools} + + +# --------------------------------------------------------------------------- +# Détection Windows +# --------------------------------------------------------------------------- + +def _windows_candidates(tool: str) -> list[str]: + """Emplacements courants sur Windows pour ffmpeg, ffprobe, exiftool.""" + home = str(Path.home()) + program_files = os.environ.get("ProgramFiles", "C:\\Program Files") + local_app = os.environ.get("LOCALAPPDATA", home + "\\AppData\\Local") + + # LOCALAPPDATA peut être absent si lancé depuis un process isolé (ex: Lightroom) + # Fallback sur Path.home() / AppData / Local + if not local_app: + local_app = str(Path.home() / "AppData" / "Local") + winget_links = f"{local_app}\\Microsoft\\WinGet\\Links" + winget_pkgs = f"{local_app}\\Microsoft\\WinGet\\Packages" + candidates_map: dict[str, list[str]] = { + "ffmpeg": [ + f"{winget_links}\\ffmpeg.exe", + f"{winget_pkgs}\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-7.1-full_build\\bin\\ffmpeg.exe", + "C:\\ffmpeg\\bin\\ffmpeg.exe", + f"{program_files}\\ffmpeg\\bin\\ffmpeg.exe", + f"{local_app}\\ffmpeg\\bin\\ffmpeg.exe", + ], + "ffprobe": [ + f"{winget_links}\\ffprobe.exe", + f"{winget_pkgs}\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-7.1-full_build\\bin\\ffprobe.exe", + "C:\\ffmpeg\\bin\\ffprobe.exe", + f"{program_files}\\ffmpeg\\bin\\ffprobe.exe", + f"{local_app}\\ffmpeg\\bin\\ffprobe.exe", + ], + "exiftool": [ + "C:\\exiftool\\exiftool.exe", + f"{program_files}\\ExifTool\\exiftool.exe", + f"{home}\\exiftool.exe", + "C:\\Windows\\exiftool.exe", + ], + } + return candidates_map.get(tool, []) + + +def _macos_candidates(tool: str) -> list[str]: + """Emplacements courants sur macOS pour ffmpeg, ffprobe, exiftool.""" + home = str(Path.home()) + candidates_map: dict[str, list[str]] = { + "ffmpeg": [ + "/opt/homebrew/bin/ffmpeg", # Apple Silicon Homebrew + "/usr/local/bin/ffmpeg", # Intel Homebrew / manual + f"{home}/.local/bin/ffmpeg", + ], + "ffprobe": [ + "/opt/homebrew/bin/ffprobe", + "/usr/local/bin/ffprobe", + f"{home}/.local/bin/ffprobe", + ], + "exiftool": [ + "/opt/homebrew/bin/exiftool", + "/usr/local/bin/exiftool", + f"{home}/.local/bin/exiftool", + ], + } + return candidates_map.get(tool, []) + + +def _linux_candidates(tool: str) -> list[str]: + """Emplacements courants sur Linux pour ffmpeg, ffprobe, exiftool.""" + home = str(Path.home()) + candidates_map: dict[str, list[str]] = { + "ffmpeg": [ + "/usr/bin/ffmpeg", + "/usr/local/bin/ffmpeg", + f"{home}/.local/bin/ffmpeg", + "/snap/bin/ffmpeg", + ], + "ffprobe": [ + "/usr/bin/ffprobe", + "/usr/local/bin/ffprobe", + f"{home}/.local/bin/ffprobe", + "/snap/bin/ffprobe", + ], + "exiftool": [ + "/usr/bin/exiftool", + "/usr/local/bin/exiftool", + f"{home}/.local/bin/exiftool", + ], + } + return candidates_map.get(tool, []) diff --git a/video-toolkit/src/ffmpeg.py b/video-toolkit/src/ffmpeg.py new file mode 100644 index 0000000..632e898 --- /dev/null +++ b/video-toolkit/src/ffmpeg.py @@ -0,0 +1,442 @@ +""" +FFmpeg — Wrapper transcode vidéo + génération miniature. + +Deux opérations principales : + - transcode() : convertit une vidéo selon un VideoPreset + - thumbnail() : extrait une image JPG à un timestamp donné + +Signale la progression via un callback optionnel (pour GlobalStatusFile). +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from . import SUBPROCESS_FLAGS +from .ffprobe import VideoInfo +from .hwaccel import HWAccelDetector, HWAccelConfig +from .presets import VideoPreset, PresetManager + + +# --------------------------------------------------------------------------- +# Dataclass TranscodeResult +# --------------------------------------------------------------------------- + +@dataclass +class TranscodeResult: + input_path: str + output_path: str + width: int + height: int + duration: float # secondes + size: int # octets + + +@dataclass +class ThumbnailResult: + input_path: str + output_path: str + timestamp: str # "HH:MM:SS" ou "N%" selon la demande + size: int # octets + + +# --------------------------------------------------------------------------- +# Classe FFmpeg +# --------------------------------------------------------------------------- + +class FFmpeg: + """Wrapper autour de l'exécutable ffmpeg pour transcode + miniature.""" + + def __init__(self, ffmpeg_path: str = "ffmpeg", hwaccel_mode: str = "auto"): + self.binary = ffmpeg_path + self._hwaccel_mode = hwaccel_mode + self._detector = HWAccelDetector(ffmpeg_path) + + # ------------------------------------------------------------------- + # Public API + # ------------------------------------------------------------------- + + def transcode( + self, + input_path: str | Path, + output_path: str | Path, + preset: VideoPreset, + preset_manager: PresetManager, + src_width: int, + src_height: int, + src_duration: float, + progress_callback: Callable[[int], None] | None = None, + dry_run: bool = False, + video_info: VideoInfo | None = None, + ) -> TranscodeResult: + """ + Transcode une vidéo selon le preset donné. + + - preset Origin → remux sans réencodage + - autres presets → réencodage H.264/AAC avec downscale si nécessaire + - HDR sources → automatic tonemap to SDR (when not Origin) + - progress_callback(pct: int) est appelé pendant le traitement (0..100) + """ + input_path = str(input_path) + output_path = str(output_path) + + is_hdr = video_info.is_hdr if video_info else False + + scale_filter = "" + if preset.is_origin: + cmd = self._build_remux_cmd(input_path, output_path) + hw_config = None + else: + out_w, out_h = preset_manager.compute_output_resolution( + src_width, src_height, preset + ) + scale_filter = preset_manager.build_ffmpeg_scale_filter( + src_width, src_height, preset + ) + hw_config = self._detector.resolve( + self._hwaccel_mode, preset.crf, preset.h264_profile, is_hdr + ) + cmd = self._build_transcode_cmd( + input_path, output_path, preset, scale_filter, + is_hdr=is_hdr, hw_config=hw_config, + ) + + if dry_run: + return TranscodeResult( + input_path=input_path, + output_path=output_path, + width=src_width, + height=src_height, + duration=src_duration, + size=0, + ) + + if hw_config is not None: + try: + self._run(cmd, src_duration, progress_callback) + except FFmpegError: + # GPU failed — fallback to CPU silently + cpu_cmd = self._build_transcode_cmd( + input_path, output_path, preset, scale_filter, + is_hdr=is_hdr, hw_config=None, + ) + self._run(cpu_cmd, src_duration, progress_callback) + else: + self._run(cmd, src_duration, progress_callback) + + out_file = Path(output_path) + size = out_file.stat().st_size if out_file.exists() else 0 + + # Déduire dimensions réelles depuis le preset (approximation) + if preset.is_origin: + out_w, out_h = src_width, src_height + else: + out_w, out_h = preset_manager.compute_output_resolution( + src_width, src_height, preset + ) + + return TranscodeResult( + input_path=input_path, + output_path=output_path, + width=out_w, + height=out_h, + duration=src_duration, + size=size, + ) + + def thumbnail( + self, + input_path: str | Path, + output_path: str | Path, + duration: float, + timestamp_pct: int = 10, + max_width: int = 1280, + dry_run: bool = False, + ) -> ThumbnailResult: + """ + Extrait une image JPG depuis la vidéo. + + timestamp_pct : pourcentage de la durée (0–100), défaut 10 % + max_width : largeur max du poster (hauteur calculée pour garder le ratio) + """ + input_path = str(input_path) + output_path = str(output_path) + + offset = max(0.0, duration * timestamp_pct / 100.0) + # Jamais plus loin que duration - 1s pour éviter les fins noires + offset = min(offset, max(0.0, duration - 1.0)) + timestamp_str = _seconds_to_hhmmss(offset) + + cmd = self._build_thumbnail_cmd( + input_path, output_path, timestamp_str, max_width + ) + + if dry_run: + return ThumbnailResult( + input_path=input_path, + output_path=output_path, + timestamp=timestamp_str, + size=0, + ) + + self._run_simple(cmd) + + size = Path(output_path).stat().st_size if Path(output_path).exists() else 0 + return ThumbnailResult( + input_path=input_path, + output_path=output_path, + timestamp=timestamp_str, + size=size, + ) + + def check_available(self) -> bool: + """Vérifie que ffmpeg est disponible.""" + try: + r = subprocess.run( + [self.binary, "-version"], + capture_output=True, + timeout=5, + **SUBPROCESS_FLAGS, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + def get_version(self) -> str | None: + """Retourne la version de ffmpeg ou None.""" + try: + r = subprocess.run( + [self.binary, "-version"], + capture_output=True, + text=True, + timeout=5, + **SUBPROCESS_FLAGS, + ) + if r.returncode == 0: + first_line = r.stdout.splitlines()[0] + parts = first_line.split() + if len(parts) >= 3: + return parts[2] + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + # ------------------------------------------------------------------- + # Construction des commandes + # ------------------------------------------------------------------- + + def _build_transcode_cmd( + self, + input_path: str, + output_path: str, + preset: VideoPreset, + scale_filter: str, + is_hdr: bool = False, + hw_config: HWAccelConfig | None = None, + ) -> list[str]: + vb = preset.video_bitrate + ab = preset.audio_bitrate + + # Build video filter chain + if is_hdr: + # HDR → SDR tonemap pipeline (always CPU — hw_config is None here) + vf = ( + "zscale=t=linear:npl=100," + "format=gbrpf32le," + "zscale=p=bt709," + "tonemap=tonemap=hable:desat=0," + "zscale=t=bt709:m=bt709:r=tv," + "format=yuv420p," + f"{scale_filter}" + ) + else: + vf_prefix = hw_config.extra_vf_prefix if hw_config else "" + vf = f"{vf_prefix}{scale_filter}" + + # Codec effectif + video_codec = hw_config.effective_codec if hw_config else preset.video_codec + + # Paramètre qualité : GPU override ou CRF standard + if hw_config and hw_config.quality_override: + quality_args = hw_config.quality_override + elif hw_config: + # VideoToolbox : pas de param qualité → bitrate seul + quality_args = [] + else: + quality_args = ["-crf", str(preset.crf)] + + # Pre-input args (hwaccel flags avant -i) + pre_input = hw_config.pre_input_args if hw_config else [] + + cmd = [self.binary] + cmd += pre_input + cmd += ["-i", input_path] + cmd += [ + # Vidéo + "-c:v", video_codec, + "-profile:v", preset.h264_profile, + "-level:v", "4.0", + ] + cmd += quality_args + cmd += [ + "-b:v", f"{vb}k", + "-maxrate", f"{int(vb * 1.2)}k", + "-bufsize", f"{vb * 2}k", + "-vf", vf, + "-pix_fmt", preset.pixel_format, + # Audio + "-c:a", preset.audio_codec, + "-b:a", f"{ab}k", + "-ac", "2", + # MP4 streaming-friendly + "-movflags", "+faststart", + # Écraser sans confirmation + "-y", + ] + + # Arguments custom du preset + if preset.custom_ffmpeg_args: + for k, v in preset.custom_ffmpeg_args.items(): + cmd.extend([k, str(v)]) + + cmd.append(output_path) + return cmd + + def _build_remux_cmd(self, input_path: str, output_path: str) -> list[str]: + """Remux sans réencodage (preset Origin).""" + return [ + self.binary, + "-i", input_path, + "-c", "copy", + "-movflags", "+faststart", + "-y", + output_path, + ] + + def _build_thumbnail_cmd( + self, + input_path: str, + output_path: str, + timestamp: str, + max_width: int, + ) -> list[str]: + return [ + self.binary, + "-ss", timestamp, + "-i", input_path, + "-frames:v", "1", + "-vf", f"scale='min({max_width},iw)':-2", + "-q:v", "3", + "-y", + output_path, + ] + + # ------------------------------------------------------------------- + # Exécution + # ------------------------------------------------------------------- + + def _run( + self, + cmd: list[str], + duration: float, + progress_callback: Callable[[int], None] | None, + ) -> None: + """ + Lance ffmpeg avec capture de la progression via stderr. + Appelle progress_callback(pct) à chaque mise à jour si fourni. + """ + try: + proc = subprocess.Popen( + cmd, + stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + **SUBPROCESS_FLAGS, + ) + except FileNotFoundError: + raise FFmpegError(f"ffmpeg introuvable : '{self.binary}'") + + stderr_lines: list[str] = [] + + for line in proc.stderr: # type: ignore[union-attr] + stderr_lines.append(line) + if progress_callback and duration > 0: + pct = _parse_progress(line, duration) + if pct is not None: + progress_callback(min(pct, 99)) + + proc.wait() + + if proc.returncode != 0: + last_lines = "".join(stderr_lines[-10:]).strip() + raise FFmpegError( + f"ffmpeg a échoué (code {proc.returncode}) :\n{last_lines}" + ) + + if progress_callback: + progress_callback(100) + + def _run_simple(self, cmd: list[str]) -> None: + """Lance ffmpeg sans suivi de progression (pour thumbnail).""" + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + **SUBPROCESS_FLAGS, + ) + except FileNotFoundError: + raise FFmpegError(f"ffmpeg introuvable : '{self.binary}'") + except subprocess.TimeoutExpired: + raise FFmpegError("ffmpeg timeout lors de la génération de la miniature") + + if result.returncode != 0: + raise FFmpegError( + f"ffmpeg a échoué (miniature, code {result.returncode}) :\n" + f"{result.stderr.strip()[-500:]}" + ) + + +# --------------------------------------------------------------------------- +# Parsing de la progression ffmpeg +# --------------------------------------------------------------------------- + +# ffmpeg écrit dans stderr : "frame= 120 fps= 60 ... time=00:00:04.00 ..." +_RE_TIME = re.compile(r"time=(\d+):(\d+):(\d+\.\d+)") + + +def _parse_progress(line: str, total_duration: float) -> int | None: + """Retourne le pourcentage [0..100] depuis une ligne stderr ffmpeg.""" + m = _RE_TIME.search(line) + if not m: + return None + h, mn, s = int(m.group(1)), int(m.group(2)), float(m.group(3)) + elapsed = h * 3600 + mn * 60 + s + if total_duration <= 0: + return None + return int(elapsed * 100 / total_duration) + + +def _seconds_to_hhmmss(seconds: float) -> str: + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = seconds % 60 + return f"{h:02d}:{m:02d}:{s:06.3f}" + + +# --------------------------------------------------------------------------- +# Exception +# --------------------------------------------------------------------------- + +class FFmpegError(Exception): + """Erreur lors de l'exécution de ffmpeg.""" + pass diff --git a/video-toolkit/src/ffprobe.py b/video-toolkit/src/ffprobe.py new file mode 100644 index 0000000..b66302c --- /dev/null +++ b/video-toolkit/src/ffprobe.py @@ -0,0 +1,274 @@ +""" +FFprobe — Analyse de la source vidéo (résolution, durée, codecs, bitrate, fps). + +Retourne un dict structuré compatible avec le fichier statut .vtk/ +et utilisable par processor.py pour calculer la résolution de sortie. +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from . import SUBPROCESS_FLAGS + + +# --------------------------------------------------------------------------- +# Dataclass VideoInfo +# --------------------------------------------------------------------------- + +@dataclass +class VideoInfo: + path: str + width: int + height: int + duration: float # secondes + video_codec: str + audio_codec: str + video_bitrate: int # kbps (0 si inconnu) + audio_bitrate: int # kbps (0 si inconnu) + fps: float + size: int # octets + container: str # mp4, mov, mkv... + color_transfer: str = "" # e.g. "smpte2084" (PQ), "arib-std-b67" (HLG) + color_primaries: str = "" # e.g. "bt2020" + color_space: str = "" # e.g. "bt2020nc" + + @property + def is_hdr(self) -> bool: + """True if the source uses HDR transfer (PQ or HLG).""" + hdr_transfers = {"smpte2084", "arib-std-b67"} + return self.color_transfer.lower() in hdr_transfers + + @property + def resolution(self) -> str: + return f"{self.width}x{self.height}" + + @property + def duration_str(self) -> str: + h = int(self.duration // 3600) + m = int((self.duration % 3600) // 60) + s = self.duration % 60 + if h: + return f"{h:02d}:{m:02d}:{s:05.2f}" + return f"{m:02d}:{s:05.2f}" + + def to_dict(self) -> dict: + return { + "path": self.path, + "width": self.width, + "height": self.height, + "resolution": self.resolution, + "duration": self.duration, + "video_codec": self.video_codec, + "audio_codec": self.audio_codec, + "video_bitrate": self.video_bitrate, + "audio_bitrate": self.audio_bitrate, + "fps": self.fps, + "size": self.size, + "container": self.container, + "color_transfer": self.color_transfer, + "color_primaries": self.color_primaries, + "color_space": self.color_space, + "is_hdr": self.is_hdr, + } + + +# --------------------------------------------------------------------------- +# Probe +# --------------------------------------------------------------------------- + +class FFprobe: + """Wrapper autour de ffprobe pour analyser un fichier vidéo.""" + + def __init__(self, ffprobe_path: str = "ffprobe"): + self.binary = ffprobe_path + + def probe(self, input_path: str | Path) -> VideoInfo: + """ + Analyse un fichier vidéo. Lève ProbeError si ffprobe échoue + ou si le fichier n'est pas une vidéo valide. + """ + input_path = str(input_path) + + cmd = [ + self.binary, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + input_path, + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + **SUBPROCESS_FLAGS, + ) + except FileNotFoundError: + raise ProbeError(f"ffprobe introuvable : '{self.binary}'") + except subprocess.TimeoutExpired: + raise ProbeError(f"ffprobe timeout sur '{input_path}'") + + if result.returncode != 0: + raise ProbeError( + f"ffprobe a échoué (code {result.returncode}) : {result.stderr.strip()}" + ) + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as e: + raise ProbeError(f"Sortie ffprobe invalide : {e}") + + return _parse_probe_data(input_path, data) + + def check_available(self) -> bool: + """Vérifie que ffprobe est disponible.""" + try: + r = subprocess.run( + [self.binary, "-version"], + capture_output=True, + timeout=5, + **SUBPROCESS_FLAGS, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + def get_version(self) -> str | None: + """Retourne la version de ffprobe ou None.""" + try: + r = subprocess.run( + [self.binary, "-version"], + capture_output=True, + text=True, + timeout=5, + **SUBPROCESS_FLAGS, + ) + if r.returncode == 0: + first_line = r.stdout.splitlines()[0] + # "ffprobe version 7.1 ..." + parts = first_line.split() + if len(parts) >= 3: + return parts[2] + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + +# --------------------------------------------------------------------------- +# Parsing interne +# --------------------------------------------------------------------------- + +def _parse_probe_data(path: str, data: dict) -> VideoInfo: + """Parse la sortie JSON de ffprobe en VideoInfo.""" + fmt = data.get("format", {}) + streams = data.get("streams", []) + + video_stream = next((s for s in streams if s.get("codec_type") == "video"), None) + audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None) + + if not video_stream: + raise ProbeError(f"Aucun flux vidéo trouvé dans '{path}'") + + # Dimensions + width = int(video_stream.get("width", 0)) + height = int(video_stream.get("height", 0)) + if width == 0 or height == 0: + raise ProbeError(f"Résolution invalide (0x0) pour '{path}'") + + # Durée : préférer format > stream + duration = float(fmt.get("duration") or video_stream.get("duration") or 0) + + # Codecs + video_codec = video_stream.get("codec_name", "unknown") + audio_codec = audio_stream.get("codec_name", "unknown") if audio_stream else "none" + + # Bitrates + fmt_bitrate = int(fmt.get("bit_rate", 0)) + vid_bitrate = int(video_stream.get("bit_rate", 0)) + aud_bitrate = int(audio_stream.get("bit_rate", 0)) if audio_stream else 0 + + video_kbps = (vid_bitrate or fmt_bitrate) // 1000 + audio_kbps = aud_bitrate // 1000 + + # FPS + fps = _parse_fps(video_stream.get("r_frame_rate", "0/1")) + + # Taille + size = int(fmt.get("size", 0)) + if not size: + try: + size = Path(path).stat().st_size + except OSError: + size = 0 + + # Container (format_name peut être "mov,mp4,m4a,3gp,3g2,mj2") + fmt_name = fmt.get("format_name", "") + container = _normalize_container(fmt_name, path) + + # Colorimetry (HDR detection) + color_transfer = video_stream.get("color_transfer", "") + color_primaries = video_stream.get("color_primaries", "") + color_space = video_stream.get("color_space", "") + + return VideoInfo( + path=path, + width=width, + height=height, + duration=duration, + video_codec=video_codec, + audio_codec=audio_codec, + video_bitrate=video_kbps, + audio_bitrate=audio_kbps, + fps=fps, + size=size, + container=container, + color_transfer=color_transfer, + color_primaries=color_primaries, + color_space=color_space, + ) + + +def _parse_fps(fps_str: str) -> float: + """Parse '30000/1001' → 29.97, '25/1' → 25.0, etc.""" + try: + if "/" in fps_str: + num, den = fps_str.split("/") + den = int(den) + if den == 0: + return 0.0 + return round(int(num) / den, 3) + return float(fps_str) + except (ValueError, ZeroDivisionError): + return 0.0 + + +def _normalize_container(fmt_name: str, path: str) -> str: + """Normalise le nom de container FFprobe vers mp4/mov/mkv/etc.""" + ext = Path(path).suffix.lower().lstrip(".") + if "mp4" in fmt_name or ext in ("mp4", "m4v"): + return "mp4" + if "mov" in fmt_name or ext == "mov": + return "mov" + if "matroska" in fmt_name or ext == "mkv": + return "mkv" + if "avi" in fmt_name or ext == "avi": + return "avi" + if ext: + return ext + return fmt_name.split(",")[0] + + +# --------------------------------------------------------------------------- +# Exception +# --------------------------------------------------------------------------- + +class ProbeError(Exception): + """Erreur lors de l'analyse ffprobe.""" + pass diff --git a/video-toolkit/src/hasher.py b/video-toolkit/src/hasher.py new file mode 100644 index 0000000..154da82 --- /dev/null +++ b/video-toolkit/src/hasher.py @@ -0,0 +1,36 @@ +""" +Hasher — Hash partiel SHA-256 pour la détection de changement de fichier source. + +Stratégie : premiers 64 KB + derniers 64 KB + taille → rapide même sur gros fichiers. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +CHUNK_SIZE = 65536 # 64 KB + + +def partial_hash(path: str | Path) -> str: + """ + Calcule un hash partiel SHA-256 d'un fichier. + Lit les CHUNK_SIZE premiers octets + les CHUNK_SIZE derniers octets + la taille. + Suffisant pour détecter un changement de contenu sans lire tout le fichier. + """ + path = Path(path) + size = path.stat().st_size + + h = hashlib.sha256() + h.update(str(size).encode()) # Inclure la taille + + with path.open("rb") as f: + # Début + h.update(f.read(CHUNK_SIZE)) + + # Fin (si le fichier est assez grand) + if size > CHUNK_SIZE * 2: + f.seek(-CHUNK_SIZE, 2) + h.update(f.read(CHUNK_SIZE)) + + return h.hexdigest()[:32] diff --git a/video-toolkit/src/hwaccel.py b/video-toolkit/src/hwaccel.py new file mode 100644 index 0000000..0773456 --- /dev/null +++ b/video-toolkit/src/hwaccel.py @@ -0,0 +1,192 @@ +""" +HWAccel — Détection et configuration de l'accélération matérielle GPU. + +Encodeurs supportés : + - NVIDIA NVENC (h264_nvenc) + - AMD AMF (h264_amf) + - Intel QSV (h264_qsv) + - Apple VT (h264_videotoolbox) + - Linux VA-API (h264_vaapi) + +Usage : + detector = HWAccelDetector(ffmpeg_binary) + config = detector.resolve("auto", crf=23, profile="high", is_hdr=False) + # config is None → CPU, else → GPU encoder ready +""" + +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass, field +from typing import List, Optional + +from . import SUBPROCESS_FLAGS + + +# --------------------------------------------------------------------------- +# HWEncoder — description d'un encodeur matériel +# --------------------------------------------------------------------------- + +@dataclass +class HWEncoder: + name: str # label humain ("NVIDIA NVENC") + codec: str # codec ffmpeg ("h264_nvenc") + hwaccel: str # valeur -hwaccel ("cuda", "qsv", "") + quality_param: str # param qualité ("-cq", "-qp", "-global_quality", "") + supported_profiles: List[str] # profils H264 supportés + needs_vaapi_device: bool = False + vaapi_device: str = "/dev/dri/renderD128" + + def supports_profile(self, profile: str) -> bool: + return not self.supported_profiles or profile in self.supported_profiles + + +# --------------------------------------------------------------------------- +# HWAccelConfig — décision résolue pour la commande ffmpeg +# --------------------------------------------------------------------------- + +@dataclass +class HWAccelConfig: + encoder: HWEncoder + effective_codec: str # = encoder.codec + pre_input_args: List[str] # args avant -i (["-hwaccel", "cuda"]) + quality_override: List[str] # remplace [-crf N] (["-cq", "23"]) + extra_vf_prefix: str # préfixe -vf ("hwupload," pour vaapi, "" sinon) + + +# --------------------------------------------------------------------------- +# Registre des encodeurs par plateforme (ordre = priorité) +# --------------------------------------------------------------------------- + +_PROFILES_STANDARD = ["baseline", "main", "high"] + +_ENCODER_REGISTRY: dict = { + "win32": [ + HWEncoder("NVIDIA NVENC", "h264_nvenc", "cuda", "-cq", _PROFILES_STANDARD), + HWEncoder("AMD AMF", "h264_amf", "", "-qp", _PROFILES_STANDARD), + HWEncoder("Intel QSV", "h264_qsv", "qsv", "-global_quality", _PROFILES_STANDARD), + ], + "darwin": [ + HWEncoder("Apple VideoToolbox", "h264_videotoolbox", "videotoolbox", "", _PROFILES_STANDARD), + ], + "linux": [ + HWEncoder("NVIDIA NVENC", "h264_nvenc", "cuda", "-cq", _PROFILES_STANDARD), + HWEncoder("Intel QSV", "h264_qsv", "qsv", "-global_quality", _PROFILES_STANDARD), + HWEncoder("VA-API", "h264_vaapi", "vaapi", "-qp", + ["constrained_baseline", "main", "high"], + needs_vaapi_device=True), + ], +} + + +# --------------------------------------------------------------------------- +# HWAccelDetector +# --------------------------------------------------------------------------- + +class HWAccelDetector: + """Détecte les encodeurs GPU disponibles via `ffmpeg -encoders`.""" + + def __init__(self, ffmpeg_binary: str = "ffmpeg"): + self._binary = ffmpeg_binary + self._available: Optional[set] = None # cache + + def _query_encoders(self) -> set: + """Parse `ffmpeg -encoders` et retourne les noms d'encodeurs vidéo.""" + if self._available is not None: + return self._available + try: + r = subprocess.run( + [self._binary, "-encoders"], + capture_output=True, text=True, timeout=10, + **SUBPROCESS_FLAGS, + ) + names: set = set() + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[0].startswith("V"): + names.add(parts[1]) + self._available = names + except Exception: + self._available = set() + return self._available + + def list_available(self) -> List[HWEncoder]: + """Retourne les encodeurs GPU disponibles sur ce système.""" + os_key = sys.platform if sys.platform in ("win32", "darwin") else "linux" + candidates = _ENCODER_REGISTRY.get(os_key, []) + available = self._query_encoders() + return [enc for enc in candidates if enc.codec in available] + + def pick_best(self) -> Optional[HWEncoder]: + """Premier encodeur disponible selon la priorité plateforme.""" + avail = self.list_available() + return avail[0] if avail else None + + def resolve( + self, + mode: str, + preset_crf: int, + preset_profile: str, + is_hdr: bool, + ) -> Optional[HWAccelConfig]: + """ + Résout la configuration hwaccel. + + Retourne None → utiliser libx264 (CPU). + Retourne HWAccelConfig → utiliser le GPU. + + is_hdr=True → toujours None (tonemap software-only). + """ + if mode == "cpu": + return None + if is_hdr: + return None + + if mode in ("auto", "gpu"): + encoder = self.pick_best() + else: + # mode = nom d'encodeur explicite (ex: "h264_nvenc") + available = self.list_available() + encoder = next((e for e in available if e.codec == mode), None) + + if encoder is None: + if mode == "gpu": + return None # pas d'encodeur GPU trouvé + return None + + # Vérifier compatibilité profil H264 + if not encoder.supports_profile(preset_profile): + if encoder.supports_profile("high"): + pass # on laisse ffmpeg gérer + else: + return None + + return _build_config(encoder, preset_crf) + + +def _build_config(encoder: HWEncoder, crf: int) -> HWAccelConfig: + """Construit HWAccelConfig depuis un HWEncoder sélectionné.""" + pre_input: List[str] = [] + if encoder.hwaccel: + pre_input = ["-hwaccel", encoder.hwaccel] + if encoder.needs_vaapi_device: + pre_input += ["-vaapi_device", encoder.vaapi_device] + + # Paramètre qualité + if encoder.quality_param: + quality_override = [encoder.quality_param, str(crf)] + else: + # VideoToolbox : pas de param qualité, bitrate seul + quality_override = [] + + # VA-API nécessite hwupload dans -vf + extra_vf = "hwupload," if encoder.codec == "h264_vaapi" else "" + + return HWAccelConfig( + encoder=encoder, + effective_codec=encoder.codec, + pre_input_args=pre_input, + quality_override=quality_override, + extra_vf_prefix=extra_vf, + ) diff --git a/video-toolkit/src/metadata.py b/video-toolkit/src/metadata.py new file mode 100644 index 0000000..67a1f43 --- /dev/null +++ b/video-toolkit/src/metadata.py @@ -0,0 +1,396 @@ +""" +Metadata — Wrapper ExifTool pour copier les métadonnées source vers la variante. + +Deux opérations : + - copy() : copie les tags d'un fichier source vers la variante (exiftool -TagsFromFile) + - extract() : lit les tags d'un fichier source et les retourne en dict Python + +Tags copiés : titre, description, mots-clés, GPS, date de création, artiste, copyright. + +ExifTool est optionnel : si absent, copy() retourne ExifToolResult(skipped=True) +sans lever d'exception. L'appelant choisit si c'est bloquant. +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +from . import SUBPROCESS_FLAGS + + +# --------------------------------------------------------------------------- +# Tags copiés par défaut +# --------------------------------------------------------------------------- + +DEFAULT_TAGS = [ + "Title", + "Description", + "Comment", + "Keywords", + "Subject", + "GPSLatitude", + "GPSLongitude", + "GPSAltitude", + "GPSLatitudeRef", + "GPSLongitudeRef", + "CreateDate", + "DateTimeOriginal", + "Artist", + "Author", + "Copyright", + "Make", + "Model", +] + + +# --------------------------------------------------------------------------- +# Dataclasses résultat +# --------------------------------------------------------------------------- + +@dataclass +class ExifToolResult: + source_path: str + target_path: str + tags_copied: int = 0 + skipped: bool = False # True si exiftool absent ou skip demandé + error: str = "" + + +@dataclass +class VideoMetadata: + """Métadonnées extraites d'un fichier vidéo.""" + title: str = "" + description: str = "" + keywords: list[str] = field(default_factory=list) + gps_lat: float | None = None + gps_lon: float | None = None + gps_alt: float | None = None + date_created: str = "" + artist: str = "" + copyright: str = "" + make: str = "" + model: str = "" + raw: dict = field(default_factory=dict) # données brutes exiftool + + def to_dict(self) -> dict: + return { + "title": self.title, + "description": self.description, + "keywords": self.keywords, + "gps": { + "lat": self.gps_lat, + "lon": self.gps_lon, + "alt": self.gps_alt, + } if self.gps_lat is not None else None, + "date_created": self.date_created, + "artist": self.artist, + "copyright": self.copyright, + "make": self.make, + "model": self.model, + } + + +# --------------------------------------------------------------------------- +# ExifTool wrapper +# --------------------------------------------------------------------------- + +class ExifTool: + """Wrapper autour de l'exécutable exiftool.""" + + def __init__(self, exiftool_path: str = "exiftool"): + self.binary = exiftool_path + + # ------------------------------------------------------------------- + # Public API + # ------------------------------------------------------------------- + + def copy( + self, + source_path: str | Path, + target_path: str | Path, + tags: list[str] | None = None, + overwrite: bool = True, + ) -> ExifToolResult: + """ + Copie les métadonnées de source_path vers target_path. + + Si exiftool n'est pas disponible, retourne un résultat avec skipped=True + sans lever d'exception. + """ + source_path = str(source_path) + target_path = str(target_path) + + if not self.check_available(): + return ExifToolResult( + source_path=source_path, + target_path=target_path, + skipped=True, + error="exiftool introuvable", + ) + + tags_to_copy = tags or DEFAULT_TAGS + cmd = self._build_copy_cmd(source_path, target_path, tags_to_copy, overwrite) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + **SUBPROCESS_FLAGS, + ) + except FileNotFoundError: + return ExifToolResult( + source_path=source_path, + target_path=target_path, + skipped=True, + error=f"exiftool introuvable : '{self.binary}'", + ) + except subprocess.TimeoutExpired: + return ExifToolResult( + source_path=source_path, + target_path=target_path, + skipped=False, + error="exiftool timeout lors de la copie des métadonnées", + ) + + if result.returncode != 0: + return ExifToolResult( + source_path=source_path, + target_path=target_path, + skipped=False, + error=f"exiftool a échoué (code {result.returncode}) : {result.stderr.strip()[-300:]}", + ) + + # Compter les tags copiés depuis stdout : "1 image files updated" + tags_copied = _parse_updated_count(result.stdout) + + return ExifToolResult( + source_path=source_path, + target_path=target_path, + tags_copied=tags_copied, + skipped=False, + ) + + def extract(self, source_path: str | Path) -> VideoMetadata | None: + """ + Extrait les métadonnées d'une vidéo en JSON via exiftool. + Retourne None si exiftool est absent ou si le fichier n'est pas lisible. + """ + source_path = str(source_path) + + if not self.check_available(): + return None + + cmd = [ + self.binary, + "-json", + "-charset", "utf8", + source_path, + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + **SUBPROCESS_FLAGS, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + + if result.returncode != 0: + return None + + try: + data_list = json.loads(result.stdout) + if not data_list: + return None + raw = data_list[0] + except (json.JSONDecodeError, IndexError): + return None + + return _parse_exiftool_json(raw) + + def check_available(self) -> bool: + """Vérifie que exiftool est disponible.""" + try: + r = subprocess.run( + [self.binary, "-ver"], + capture_output=True, + timeout=5, + **SUBPROCESS_FLAGS, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + def get_version(self) -> str | None: + """Retourne la version de exiftool ou None.""" + try: + r = subprocess.run( + [self.binary, "-ver"], + capture_output=True, + text=True, + timeout=5, + **SUBPROCESS_FLAGS, + ) + if r.returncode == 0: + return r.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + # ------------------------------------------------------------------- + # Construction de la commande copy + # ------------------------------------------------------------------- + + def _build_copy_cmd( + self, + source: str, + target: str, + tags: list[str], + overwrite: bool, + ) -> list[str]: + cmd = [self.binary] + + # -TagsFromFile source -Tag1 -Tag2 ... + cmd += ["-TagsFromFile", source] + for tag in tags: + cmd.append(f"-{tag}") + + cmd += ["-charset", "utf8"] + + if overwrite: + cmd.append("-overwrite_original") + + cmd.append(target) + return cmd + + +# --------------------------------------------------------------------------- +# Parsing interne +# --------------------------------------------------------------------------- + +def _parse_updated_count(stdout: str) -> int: + """Parse '1 image files updated' → 1.""" + import re + m = re.search(r"(\d+)\s+image files? updated", stdout) + if m: + return int(m.group(1)) + return 0 + + +def _parse_exiftool_json(raw: dict) -> VideoMetadata: + """Construit un VideoMetadata depuis la sortie JSON d'exiftool.""" + + def _str(key: str) -> str: + v = raw.get(key, "") + return str(v).strip() if v else "" + + def _float_gps(key: str, ref_key: str | None = None) -> float | None: + v = raw.get(key) + if v is None: + return None + # exiftool peut retourner "48 deg 51' 23.40\" N" ou un float + val = _parse_gps_value(v) + if val is None: + return None + # Appliquer la référence (S/W → négatif) + if ref_key: + ref = str(raw.get(ref_key, "")).strip().upper() + if ref in ("S", "W"): + val = -abs(val) + return val + + # Mots-clés : peut être str ou liste + kw_raw = raw.get("Keywords") or raw.get("Subject") or [] + if isinstance(kw_raw, str): + keywords = [k.strip() for k in kw_raw.split(",") if k.strip()] + elif isinstance(kw_raw, list): + keywords = [str(k).strip() for k in kw_raw if k] + else: + keywords = [] + + # Titre : Title > Description + title = ( + _str("Title") + or _str("DisplayName") + ) + description = ( + _str("Description") + or _str("Comment") + or _str("UserComment") + ) + + # Date : DateTimeOriginal > CreateDate > MediaCreateDate + date_created = ( + _str("DateTimeOriginal") + or _str("CreateDate") + or _str("MediaCreateDate") + ) + + return VideoMetadata( + title=title, + description=description, + keywords=keywords, + gps_lat=_float_gps("GPSLatitude", "GPSLatitudeRef"), + gps_lon=_float_gps("GPSLongitude", "GPSLongitudeRef"), + gps_alt=_float_gps("GPSAltitude"), + date_created=date_created, + artist=_str("Artist") or _str("Author"), + copyright=_str("Copyright"), + make=_str("Make"), + model=_str("Model"), + raw=raw, + ) + + +def _parse_gps_value(value) -> float | None: + """ + Parse une valeur GPS exiftool : + - float brut : retourné tel quel + - str "48 deg 51' 23.40\" N" : converti en degrés décimaux + - str "48.8567" : converti en float + """ + if isinstance(value, (int, float)): + return float(value) + + s = str(value).strip() + # Essai direct + try: + return float(s) + except ValueError: + pass + + # Format DMS : "48 deg 51' 23.40\" N" (on ignore la direction ici) + import re + m = re.match( + r"(\d+)\s*deg\s*(\d+)['′]\s*([\d.]+)[\"″]", + s, + re.IGNORECASE, + ) + if m: + degrees = int(m.group(1)) + minutes = int(m.group(2)) + seconds = float(m.group(3)) + return degrees + minutes / 60.0 + seconds / 3600.0 + + return None + + +# --------------------------------------------------------------------------- +# Exception +# --------------------------------------------------------------------------- + +class MetadataError(Exception): + """Erreur lors du traitement des métadonnées.""" + pass diff --git a/video-toolkit/src/presets.py b/video-toolkit/src/presets.py new file mode 100644 index 0000000..18b5e55 --- /dev/null +++ b/video-toolkit/src/presets.py @@ -0,0 +1,274 @@ +""" +Presets vidéo — Définition et gestion des presets de transcodage. + +Presets prédéfinis : Small (480p) → Medium (720p) → Large (1080p) + → XLarge (1440p) → XXL (2160p) → Origin (pas de transcodage) +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field, asdict +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Dataclass VideoPreset +# --------------------------------------------------------------------------- + +@dataclass +class VideoPreset: + name: str + suffix: str + max_width: int + max_height: int + video_bitrate: int # kbps + audio_bitrate: int # kbps + video_codec: str = "libx264" + audio_codec: str = "aac" + h264_profile: str = "main" + pixel_format: str = "yuv420p" + crf: int = 23 + two_pass: bool = False + container: str = "mp4" + custom_ffmpeg_args: dict = field(default_factory=dict) + + @property + def is_origin(self) -> bool: + return self.suffix == "" + + def hash(self) -> str: + """Hash stable pour détection de changement de preset.""" + data = json.dumps(asdict(self), sort_keys=True) + return hashlib.sha256(data.encode()).hexdigest()[:16] + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, name: str, d: dict) -> "VideoPreset": + d = {k: v for k, v in d.items() if k in cls.__dataclass_fields__ and k != "name"} + return cls(name=name, **d) + + +# --------------------------------------------------------------------------- +# Presets prédéfinis (builtin) +# --------------------------------------------------------------------------- + +BUILTIN_PRESETS: dict[str, VideoPreset] = { + "small": VideoPreset( + name="Small", + suffix="_small", + max_width=854, + max_height=480, + video_bitrate=800, + audio_bitrate=96, + h264_profile="baseline", + crf=28, + ), + "medium": VideoPreset( + name="Medium", + suffix="_medium", + max_width=1280, + max_height=720, + video_bitrate=2500, + audio_bitrate=128, + h264_profile="main", + crf=23, + ), + "large": VideoPreset( + name="Large", + suffix="_large", + max_width=1920, + max_height=1080, + video_bitrate=5000, + audio_bitrate=192, + h264_profile="high", + crf=21, + ), + "xlarge": VideoPreset( + name="XLarge", + suffix="_xlarge", + max_width=2560, + max_height=1440, + video_bitrate=10000, + audio_bitrate=256, + h264_profile="high", + crf=20, + ), + "xxl": VideoPreset( + name="XXL", + suffix="_xxl", + max_width=3840, + max_height=2160, + video_bitrate=20000, + audio_bitrate=320, + h264_profile="high", + crf=18, + ), + "origin": VideoPreset( + name="Origin", + suffix="", + max_width=99999, + max_height=99999, + video_bitrate=0, + audio_bitrate=0, + video_codec="copy", + audio_codec="copy", + ), +} + +PRESET_ORDER = ["small", "medium", "large", "xlarge", "xxl", "origin"] + + +# --------------------------------------------------------------------------- +# PresetManager +# --------------------------------------------------------------------------- + +class PresetManager: + """Gestion des presets builtin + presets utilisateur (JSON).""" + + def __init__(self, config_path: Path | str | None = None): + self._builtin: dict[str, VideoPreset] = dict(BUILTIN_PRESETS) + self._user: dict[str, VideoPreset] = {} + self._config_path: Path | None = None + + if config_path: + self.load_presets(config_path) + + # --- Chargement / sauvegarde --- + + def load_presets(self, path: Path | str) -> None: + """Charge les presets utilisateur depuis un fichier JSON.""" + path = Path(path) + self._config_path = path + + if not path.exists(): + return + + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + + for key, d in data.get("presets", {}).items(): + key_lower = key.lower() + self._user[key_lower] = VideoPreset.from_dict( + name=d.get("name", key), + d=d, + ) + + def save_presets(self, path: Path | str | None = None) -> None: + """Sauvegarde les presets utilisateur dans un fichier JSON.""" + resolved = path or self._config_path + if resolved is None: + raise ValueError("Aucun chemin de configuration défini") + path = Path(resolved) + + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "version": 1, + "presets": { + key: preset.to_dict() + for key, preset in self._user.items() + } + } + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # --- Accès aux presets --- + + def get_preset(self, name: str) -> VideoPreset: + """Retourne un preset (user > builtin). Lève KeyError si introuvable.""" + key = name.lower() + if key in self._user: + return self._user[key] + if key in self._builtin: + return self._builtin[key] + raise KeyError(f"Preset inconnu : '{name}'") + + def list_presets(self) -> list[tuple[str, VideoPreset]]: + """Liste tous les presets dans l'ordre logique (builtin + user).""" + result = [] + seen = set() + for key in PRESET_ORDER: + if key in self._user: + result.append((key, self._user[key])) + seen.add(key) + elif key in self._builtin: + result.append((key, self._builtin[key])) + seen.add(key) + # Presets user hors ordre prédéfini + for key, preset in self._user.items(): + if key not in seen: + result.append((key, preset)) + return result + + def validate_preset(self, preset: VideoPreset) -> list[str]: + """Valide un preset. Retourne liste d'erreurs (vide = OK).""" + errors = [] + if preset.max_width <= 0 or preset.max_height <= 0: + errors.append("Résolution invalide (max_width/max_height doivent être > 0)") + if not preset.is_origin: + if preset.video_bitrate <= 0: + errors.append("video_bitrate doit être > 0") + if preset.audio_bitrate <= 0: + errors.append("audio_bitrate doit être > 0") + if preset.crf not in range(0, 52): + errors.append("crf doit être entre 0 et 51") + if preset.container not in ("mp4", "mkv", "mov"): + errors.append(f"Container '{preset.container}' non supporté (mp4/mkv/mov)") + return errors + + def compute_output_resolution( + self, + src_width: int, + src_height: int, + preset: VideoPreset, + ) -> tuple[int, int]: + """ + Calcule la résolution de sortie sans upscale. + Respecte le ratio d'aspect source, ne dépasse pas le max du preset. + """ + if preset.is_origin: + return src_width, src_height + + # Pas d'upscale : si la source est plus petite, on garde la résolution source + if src_width <= preset.max_width and src_height <= preset.max_height: + # Aligner sur 2 (requis par yuv420p) + return _align2(src_width), _align2(src_height) + + # Downscale : conserver le ratio + scale_w = preset.max_width / src_width + scale_h = preset.max_height / src_height + scale = min(scale_w, scale_h) + + out_w = _align2(int(src_width * scale)) + out_h = _align2(int(src_height * scale)) + return out_w, out_h + + def build_ffmpeg_scale_filter( + self, + src_width: int, + src_height: int, + preset: VideoPreset, + ) -> str: + """ + Retourne le filtre FFmpeg scale correspondant à la résolution cible. + Utilise force_original_aspect_ratio=decrease pour éviter l'upscale. + """ + mw = preset.max_width + mh = preset.max_height + return ( + f"scale='min({mw},iw)':'min({mh},ih)'" + f":force_original_aspect_ratio=decrease," + f"pad=ceil(iw/2)*2:ceil(ih/2)*2" + ) + + +# --------------------------------------------------------------------------- +# Helpers internes +# --------------------------------------------------------------------------- + +def _align2(value: int) -> int: + """Aligne sur le multiple de 2 inférieur le plus proche.""" + return value if value % 2 == 0 else value - 1 diff --git a/video-toolkit/src/processor.py b/video-toolkit/src/processor.py new file mode 100644 index 0000000..4443560 --- /dev/null +++ b/video-toolkit/src/processor.py @@ -0,0 +1,411 @@ +""" +Processor — Orchestration du traitement complet d'une vidéo. + +Flux : + 1. Probe → FFprobe analyse la source (VideoInfo) + 2. Hash → Hasher calcule l'empreinte partielle de la source + 3. Cache check → StatusManager vérifie si la variante est à jour + 4. Transcode → FFmpeg encode selon le preset (skip si Origin + cache OK) + 5. Thumbnail → FFmpeg extrait le poster JPG + 6. Status → StatusManager met à jour le fichier .vtk/.json + +Utilisé par : + - video_toolkit.py --mode process (fichier unique) + - video_toolkit.py --mode batch (plusieurs fichiers) +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .ffmpeg import FFmpeg, FFmpegError +from .ffprobe import FFprobe, ProbeError, VideoInfo +from .hasher import partial_hash +from .metadata import ExifTool +from .presets import PresetManager, VideoPreset +from .status import StatusManager, STATE_PROCESSING, STATE_COMPLETE, STATE_ERROR + + +# --------------------------------------------------------------------------- +# Résultat de traitement +# --------------------------------------------------------------------------- + +@dataclass +class ProcessResult: + """Résultat retourné après le traitement d'une vidéo.""" + input_path: str + variant_path: str + thumbnail_path: str + preset_key: str + width: int + height: int + duration: float + size: int # octets de la variante + thumbnail_size: int + skipped: bool # True si la variante était déjà à jour + error: str = "" + orig: dict | None = None # métadonnées source (width, height, fps, bitrate, codec, format, filesize) + conv: dict | None = None # métadonnées variante (idem) + + +# --------------------------------------------------------------------------- +# Processeur principal +# --------------------------------------------------------------------------- + +class VideoProcessor: + """ + Orchestre le traitement complet d'une vidéo : + probe → hash → cache → transcode → thumbnail → status. + """ + + def __init__( + self, + ffmpeg_path: str = "ffmpeg", + ffprobe_path: str = "ffprobe", + exiftool_path: str = "exiftool", + preset_manager: PresetManager | None = None, + thumbnail_timestamp_pct: int = 10, + thumbnail_max_width: int = 1280, + copy_metadata: bool = True, + hwaccel_mode: str = "auto", + ): + self._ffmpeg = FFmpeg(ffmpeg_path, hwaccel_mode=hwaccel_mode) + self._ffprobe = FFprobe(ffprobe_path) + self._exiftool = ExifTool(exiftool_path) + + self._presets = preset_manager or PresetManager() + self._thumb_pct = thumbnail_timestamp_pct + self._thumb_max_w = thumbnail_max_width + self._copy_metadata = copy_metadata + + # ------------------------------------------------------------------- + # Public API + # ------------------------------------------------------------------- + + def process( + self, + input_path: str | Path, + preset_key: str, + output_dir: str | Path | None = None, + force: bool = False, + thumbnail_only: bool = False, + dry_run: bool = False, + progress_callback: Callable[[int], None] | None = None, + ) -> ProcessResult: + """ + Traite une vidéo selon le preset donné. + + input_path : chemin du fichier source + preset_key : nom du preset ("small", "medium", etc.) + output_dir : dossier de sortie (défaut : même dossier que la source) + force : ignore le cache, retraite même si à jour + thumbnail_only : ne fait que la miniature, pas de transcode + dry_run : simule le traitement sans rien écrire + progress_callback(pct) : appelé pendant le transcode (0..100) + """ + input_path = Path(input_path) + out_dir = Path(output_dir) if output_dir else input_path.parent + + # --- 1. Preset --- + preset = self._presets.get_preset(preset_key) + preset_hash = preset.hash() + + # --- 2. Probe --- + try: + info: VideoInfo = self._ffprobe.probe(input_path) + except ProbeError as e: + return self._error_result(str(input_path), preset_key, str(e)) + + # --- 2b. Auto-downgrade preset pour SDR : origin = remux sans réencodage --- + # Si la source n'est pas HDR et que le preset n'est pas déjà "origin", + # on bascule sur "origin" (copie directe, sans transcode inutile). + # Le suffix du preset demandé est conservé pour nommer la variante + # (ex: preset "medium" SDR → remux → fichier nommé "..._medium.mp4"). + # Le preset_key d'origine est conservé dans le résultat pour Lightroom. + output_suffix = preset.suffix # suffix du preset demandé, avant downgrade éventuel + if not info.is_hdr and not preset.is_origin: + preset = self._presets.get_preset("origin") + preset_hash = preset.hash() + + # --- 3. Hash source --- + src_hash = partial_hash(input_path) + + # --- 4. StatusManager --- + status = StatusManager(input_path) + status.set_source( + hash_val=src_hash, + size=info.size, + width=info.width, + height=info.height, + duration=info.duration, + video_codec=info.video_codec, + audio_codec=info.audio_codec, + fps=info.fps, + is_hdr=info.is_hdr, + color_transfer=info.color_transfer, + ) + + # --- 5. Chemins de sortie --- + stem = input_path.stem + # output_suffix : suffix du preset demandé (conservé même si downgrade SDR→origin) + # preset "origin" explicitement demandé → suffix "" (pas de suffixe, nom source) + variant_name = f"{stem}{output_suffix}.mp4" if output_suffix else f"{stem}.mp4" + variant_path = out_dir / variant_name + thumbnail_path = out_dir / f"{stem}_poster.jpg" + + # --- 6. Cache check variante --- + if not force and not thumbnail_only: + if status.has_valid_variant(preset_key, src_hash, preset_hash): + # Variante à jour → vérifier quand même la miniature + thumb_skipped = status.has_thumbnail() + if thumb_skipped and not dry_run: + t_info = status.get_thumbnail() + t_size = t_info.get("size", 0) + else: + t_size = 0 + + # Métadonnées étendues orig/conv (même en cache hit) + orig_meta = self._video_meta(info) + conv_meta = None + if variant_path.exists(): + try: + conv_info = self._ffprobe.probe(variant_path) + conv_meta = self._video_meta(conv_info) + except ProbeError: + pass + + return ProcessResult( + input_path=str(input_path), + variant_path=str(variant_path), + thumbnail_path=str(thumbnail_path), + preset_key=preset_key, + width=info.width, + height=info.height, + duration=info.duration, + size=Path(variant_path).stat().st_size if variant_path.exists() else 0, + thumbnail_size=t_size, + skipped=True, + orig=orig_meta, + conv=conv_meta, + ) + + # --- 7. Transcode --- + if not thumbnail_only: + status.set_state( + STATE_PROCESSING, progress=5, + current_file=str(input_path), + pid=os.getpid(), + ) + + # Wrapper progress : transcode = 10%→85% du total + def _transcode_progress(pct: int) -> None: + global_pct = 10 + int(pct * 75 / 100) + status.set_state( + STATE_PROCESSING, progress=global_pct, + current_file=str(input_path), + pid=os.getpid(), + ) + if progress_callback: + progress_callback(global_pct) + + try: + t_result = self._ffmpeg.transcode( + input_path=input_path, + output_path=variant_path, + preset=preset, + preset_manager=self._presets, + src_width=info.width, + src_height=info.height, + src_duration=info.duration, + progress_callback=_transcode_progress, + dry_run=dry_run, + video_info=info, + ) + except FFmpegError as e: + status.set_state(STATE_ERROR, error=str(e)) + return self._error_result(str(input_path), preset_key, str(e)) + + # Enregistrer la variante dans le statut + if not dry_run: + status.set_variant( + preset_key=preset_key, + preset_hash=preset_hash, + variant_path=variant_path, + size=t_result.size, + width=t_result.width, + height=t_result.height, + duration=t_result.duration, + ) + else: + # thumbnail_only : utiliser les infos source + t_result = None + + # --- 8. Miniature --- + status.set_state( + STATE_PROCESSING, progress=88, + current_file=str(input_path), + pid=os.getpid(), + ) + + thumb_size = 0 + if not status.has_thumbnail() or force: + try: + # Extract thumbnail from the transcoded variant (SDR colors) when available, + # not from the HDR source which would produce washed-out colors. + thumb_source = ( + variant_path + if t_result and not preset.is_origin and variant_path.exists() + else input_path + ) + thumb_duration = t_result.duration if t_result else info.duration + th_result = self._ffmpeg.thumbnail( + input_path=thumb_source, + output_path=thumbnail_path, + duration=thumb_duration, + timestamp_pct=self._thumb_pct, + max_width=self._thumb_max_w, + dry_run=dry_run, + ) + thumb_size = th_result.size + if not dry_run: + status.set_thumbnail( + path=thumbnail_path, + size=thumb_size, + timestamp=th_result.timestamp, + ) + except FFmpegError as e: + # Miniature non critique : on log mais on ne bloque pas + thumb_size = 0 + else: + t_info = status.get_thumbnail() + thumb_size = t_info.get("size", 0) + + # --- 9. Copie des métadonnées source → variante --- + if self._copy_metadata and not thumbnail_only and not dry_run and not preset.is_origin: + # origin = copie directe, métadonnées déjà présentes + meta_copy = self._exiftool.copy( + source_path=input_path, + target_path=variant_path, + ) + # Extraction pour le fichier statut (optionnelle, ne bloque pas) + if not meta_copy.skipped and not meta_copy.error: + meta_obj = self._exiftool.extract(input_path) + if meta_obj: + status.set_metadata(meta_obj.to_dict()) + + # --- 10. Finalisation --- + status.set_state(STATE_COMPLETE, progress=100) + if not dry_run: + status.save() + + if progress_callback: + progress_callback(100) + + variant_size = ( + t_result.size if t_result and not dry_run + else (variant_path.stat().st_size if variant_path.exists() else 0) + ) + + # --- 11. Métadonnées étendues orig/conv --- + orig_meta = self._video_meta(info) + conv_meta = None + if not dry_run and not thumbnail_only and variant_path.exists(): + try: + conv_info = self._ffprobe.probe(variant_path) + conv_meta = self._video_meta(conv_info) + except ProbeError: + pass # non critique + + return ProcessResult( + input_path=str(input_path), + variant_path=str(variant_path), + thumbnail_path=str(thumbnail_path), + preset_key=preset_key, + width=info.width if thumbnail_only or not t_result else t_result.width, + height=info.height if thumbnail_only or not t_result else t_result.height, + duration=info.duration, + size=variant_size, + thumbnail_size=thumb_size, + skipped=False, + orig=orig_meta, + conv=conv_meta, + ) + + # ------------------------------------------------------------------- + # Batch + # ------------------------------------------------------------------- + + def process_batch( + self, + jobs: list[dict], + global_status_callback: Callable[[int, int, str], None] | None = None, + ) -> list[ProcessResult]: + """ + Traite plusieurs vidéos. + + jobs : liste de dicts avec les clés : + - input (str) + - preset (str) + - output_dir (str, optionnel) + - force (bool, optionnel) + + global_status_callback(done, total, current_file) est appelé entre les fichiers. + """ + results: list[ProcessResult] = [] + total = len(jobs) + + for idx, job in enumerate(jobs): + current = job.get("input", "") + if global_status_callback: + global_status_callback(idx, total, current) + + result = self.process( + input_path=job["input"], + preset_key=job.get("preset", "medium"), + output_dir=job.get("output_dir"), + force=job.get("force", False), + thumbnail_only=job.get("thumbnail_only", False), + dry_run=job.get("dry_run", False), + ) + results.append(result) + + if global_status_callback: + global_status_callback(total, total, "") + + return results + + # ------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------- + + @staticmethod + def _video_meta(info: VideoInfo) -> dict: + """Extrait les métadonnées vidéo étendues pour orig/conv.""" + return { + "width": info.width, + "height": info.height, + "fps": info.fps, + "bitrate": info.video_bitrate, # kbps + "codec": info.video_codec, + "format": info.container, + "filesize": info.size, + } + + @staticmethod + def _error_result(input_path: str, preset_key: str, error: str) -> ProcessResult: + return ProcessResult( + input_path=input_path, + variant_path="", + thumbnail_path="", + preset_key=preset_key, + width=0, + height=0, + duration=0.0, + size=0, + thumbnail_size=0, + skipped=False, + error=error, + ) diff --git a/video-toolkit/src/status.py b/video-toolkit/src/status.py new file mode 100644 index 0000000..39fd945 --- /dev/null +++ b/video-toolkit/src/status.py @@ -0,0 +1,265 @@ +""" +Status — Gestion du fichier statut .vtk/.json + +Le statut stocke : + - info source (hash, taille, résolution, codecs, durée, fps) + - variantes générées par preset + - miniature + - métadonnées extraites + - état en cours (processing/complete/error) pour le polling Lightroom +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +VTK_DIR = ".vtk" + +STATE_PROCESSING = "processing" +STATE_COMPLETE = "complete" +STATE_ERROR = "error" +STATE_IDLE = "idle" + + +# --------------------------------------------------------------------------- +# StatusManager +# --------------------------------------------------------------------------- + +class StatusManager: + """ + Gère le fichier statut d'une vidéo dans le sous-dossier .vtk/. + + Chemin : /.vtk/.json + """ + + def __init__(self, video_path: str | Path, vtk_dir_name: str = VTK_DIR): + self._video = Path(video_path) + self._vtk_dir = self._video.parent / vtk_dir_name + self._status_file = self._vtk_dir / (self._video.stem + ".json") + self._data: dict = {} + self._load() + + # --- Persistence --- + + def _load(self) -> None: + if self._status_file.exists(): + try: + with self._status_file.open("r", encoding="utf-8") as f: + self._data = json.load(f) + except (json.JSONDecodeError, OSError): + self._data = {} + + def save(self) -> None: + self._vtk_dir.mkdir(parents=True, exist_ok=True) + with self._status_file.open("w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + + def delete(self) -> None: + if self._status_file.exists(): + self._status_file.unlink() + + # --- Infos source --- + + def set_source( + self, + hash_val: str, + size: int, + width: int, + height: int, + duration: float, + video_codec: str, + audio_codec: str, + fps: float, + is_hdr: bool = False, + color_transfer: str = "", + ) -> None: + self._data["source"] = { + "path": str(self._video), + "hash": hash_val, + "size": size, + "resolution": f"{width}x{height}", + "width": width, + "height": height, + "duration": duration, + "video_codec": video_codec, + "audio_codec": audio_codec, + "fps": fps, + "is_hdr": is_hdr, + "color_transfer": color_transfer, + } + + def get_source(self) -> dict: + return self._data.get("source", {}) + + def get_source_hash(self) -> str: + return self.get_source().get("hash", "") + + # --- Variantes --- + + def set_variant( + self, + preset_key: str, + preset_hash: str, + variant_path: str | Path, + size: int, + width: int, + height: int, + duration: float, + ) -> None: + if "variants" not in self._data: + self._data["variants"] = {} + self._data["variants"][preset_key] = { + "path": str(variant_path), + "size": size, + "resolution": f"{width}x{height}", + "duration": duration, + "preset_hash": preset_hash, + "created": _now_iso(), + } + + def get_variant(self, preset_key: str) -> dict: + return self._data.get("variants", {}).get(preset_key, {}) + + def has_valid_variant(self, preset_key: str, source_hash: str, preset_hash: str) -> bool: + """ + Vérifie si une variante est à jour : + - Fichier variante existe + - Hash source identique (source n'a pas changé) + - Hash preset identique (preset n'a pas changé) + """ + variant = self.get_variant(preset_key) + if not variant: + return False + if variant.get("preset_hash") != preset_hash: + return False + if self.get_source_hash() != source_hash: + return False + # Vérifier que le fichier existe toujours + variant_path = variant.get("path", "") + return bool(variant_path) and Path(variant_path).exists() + + # --- Miniature --- + + def set_thumbnail(self, path: str | Path, size: int, timestamp: str) -> None: + self._data["thumbnail"] = { + "path": str(path), + "size": size, + "timestamp": timestamp, + } + + def get_thumbnail(self) -> dict: + return self._data.get("thumbnail", {}) + + def has_thumbnail(self) -> bool: + t = self.get_thumbnail() + if not t: + return False + return Path(t.get("path", "")).exists() + + # --- Métadonnées --- + + def set_metadata(self, meta: dict) -> None: + self._data["metadata"] = meta + + def get_metadata(self) -> dict: + return self._data.get("metadata", {}) + + # --- État (polling Lightroom) --- + + def set_state( + self, + state: str, + progress: int = 0, + current_file: str = "", + error: str = "", + pid: int = 0, + ) -> None: + self._data["state"] = { + "status": state, + "progress": progress, + "current_file": current_file, + "error": error, + "pid": pid, + "updated": _now_iso(), + } + self.save() # Sauvegarde immédiate pour le polling + + def get_state(self) -> dict: + return self._data.get("state", {}) + + def is_complete(self) -> bool: + return self.get_state().get("status") == STATE_COMPLETE + + def is_error(self) -> bool: + return self.get_state().get("status") == STATE_ERROR + + # --- Chemin du fichier statut --- + + @property + def path(self) -> Path: + return self._status_file + + +# --------------------------------------------------------------------------- +# Fichier statut global (pour le batch / polling Lightroom) +# --------------------------------------------------------------------------- + +class GlobalStatusFile: + """ + Fichier statut global utilisé lors des traitements batch. + Lightroom lit ce fichier pour mettre à jour la barre de progression. + """ + + def __init__(self, path: str | Path): + self._path = Path(path) + self._data: dict = {} + + def update( + self, + state: str, + progress: int = 0, + current_file: str = "", + total: int = 0, + done: int = 0, + error: str = "", + pid: int = 0, + ) -> None: + self._data = { + "state": state, + "progress": progress, + "current_file": current_file, + "total": total, + "done": done, + "error": error, + "pid": pid or os.getpid(), + "updated": _now_iso(), + } + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._path.open("w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2) + + def mark_complete(self, files: list[str] | None = None) -> None: + self.update(state=STATE_COMPLETE, progress=100) + if files is not None: + self._data["files"] = files + with self._path.open("w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2) + + def mark_error(self, error: str) -> None: + self.update(state=STATE_ERROR, error=error) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") diff --git a/video-toolkit/src/ui.py b/video-toolkit/src/ui.py new file mode 100644 index 0000000..3469806 --- /dev/null +++ b/video-toolkit/src/ui.py @@ -0,0 +1,247 @@ +""" +UI — Couleurs ANSI, menus et rapports pour le Video Toolkit CLI. + +Patterns inspirés du Menu Generator skill : menus compacts, sections +catégorisées, annulation systématique, rapports structurés. +""" + +import os +import sys +import ctypes +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Détection et activation ANSI +# --------------------------------------------------------------------------- + +def _activate_windows_ansi() -> bool: + """Active le mode ANSI sur Windows via kernel32. Retourne True si OK.""" + if sys.platform != "win32": + return True + try: + kernel32 = ctypes.windll.kernel32 + # ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + return True + except Exception: + return False + + +def _detect_color_support() -> bool: + """Détecte si le terminal supporte les couleurs ANSI.""" + # NO_COLOR standard + if os.environ.get("NO_COLOR"): + return False + # Force couleurs + if os.environ.get("FORCE_COLOR"): + return True + # Terminaux Windows modernes + if os.environ.get("WT_SESSION"): + return True + if os.environ.get("ConEmuANSI") == "ON": + return True + # Vérifier isatty (toutes plateformes) + if not (hasattr(sys.stdout, "isatty") and sys.stdout.isatty()): + return False + # Activation Windows + if sys.platform == "win32": + return _activate_windows_ansi() + # Unix : isatty déjà vérifié + return True + + +# --------------------------------------------------------------------------- +# Classe Colors +# --------------------------------------------------------------------------- + +class Colors: + """Gestion des couleurs ANSI avec détection automatique du terminal.""" + + def __init__(self, force_color: bool | None = None): + if force_color is None: + self._enabled = _detect_color_support() + else: + self._enabled = force_color + + self._setup_codes() + + def _setup_codes(self): + e = self._enabled + + def c(code: str) -> str: + return code if e else "" + + # Couleurs de base + self.RED = c("\033[31m") + self.GREEN = c("\033[32m") + self.YELLOW = c("\033[33m") + self.BLUE = c("\033[34m") + self.MAGENTA = c("\033[35m") + self.CYAN = c("\033[36m") + self.WHITE = c("\033[37m") + + self.LIGHT_RED = c("\033[91m") + self.LIGHT_GREEN = c("\033[92m") + self.LIGHT_YELLOW = c("\033[93m") + self.LIGHT_BLUE = c("\033[94m") + self.LIGHT_MAGENTA = c("\033[95m") + self.LIGHT_CYAN = c("\033[96m") + + # Styles + self.BOLD = c("\033[1m") + self.DIM = c("\033[2m") + self.UNDERLINE = c("\033[4m") + self.RESET = c("\033[0m") + + # Alias sémantiques + self.OK = c("\033[32m") + self.SUCCESS = c("\033[32m") + self.ERROR = c("\033[31m") + self.WARNING = c("\033[33m") + self.INFO = c("\033[34m") + self.VALUE = c("\033[36m") + self.KEY = c("\033[37m") + self.PROMPT = c("\033[33m") + self.HEADER = c("\033[1;36m") + self.TITLE = c("\033[1;37m") + + # --- Formatage avec préfixe --- + + def success(self, text: str) -> str: + return f"{self.GREEN}[OK]{self.RESET} {text}" + + def error(self, text: str) -> str: + return f"{self.RED}[ERREUR]{self.RESET} {text}" + + def warning(self, text: str) -> str: + return f"{self.YELLOW}[ATTENTION]{self.RESET} {text}" + + def info(self, text: str) -> str: + return f"{self.BLUE}[INFO]{self.RESET} {text}" + + # --- Formatage inline --- + + def header(self, text: str) -> str: + return f"{self.HEADER}{text}{self.RESET}" + + def title(self, text: str) -> str: + return f"{self.TITLE}{text}{self.RESET}" + + def value(self, text: str) -> str: + return f"{self.VALUE}{text}{self.RESET}" + + def key(self, text: str) -> str: + return f"{self.KEY}{text}{self.RESET}" + + def prompt(self, text: str) -> str: + return f"{self.PROMPT}{text}{self.RESET}" + + # --- Marqueurs isolés --- + + def ok_marker(self) -> str: + return f"{self.GREEN}[OK]{self.RESET}" + + def error_marker(self) -> str: + return f"{self.RED}[ERREUR]{self.RESET}" + + def warn_marker(self) -> str: + return f"{self.YELLOW}[ATTENTION]{self.RESET}" + + # --- Éléments d'interface --- + + def separator(self, char: str = "-", width: int = 60) -> str: + return f"{self.DIM}{char * width}{self.RESET}" + + def box_header(self, text: str, width: int = 70) -> str: + """Boîte avec titre centré entre == (style section_header).""" + border = "=" * width + padding = width - 4 + centered = text.center(padding) + return ( + f"{self.HEADER}{border}\n" + f" {centered}\n" + f"{border}{self.RESET}" + ) + + def menu_option(self, number: str, text: str) -> str: + return f" {self.YELLOW}{number}{self.RESET}. {text}" + + def config_line(self, key: str, value: str, key_width: int = 25) -> str: + return f" {self.KEY}{key:<{key_width}}{self.RESET}: {self.VALUE}{value}{self.RESET}" + + +# --------------------------------------------------------------------------- +# Classe OutputFormatter +# --------------------------------------------------------------------------- + +class OutputFormatter: + """Rapports structurés : headers, stats alignées, fichiers générés.""" + + def __init__(self, c: Colors | None = None): + self.c = c or Colors() + + def print_section_header(self, title: str, width: int = 80): + border = "=" * width + print(f"\n{self.c.HEADER}{border}") + print(title) + print(f"{border}{self.c.RESET}\n") + + def print_section_divider(self, width: int = 80): + print(f"{self.c.DIM}{'=' * width}{self.c.RESET}\n") + + def aligned_output(self, items: list[tuple[str, str]], indent: int = 2): + if not items: + return + key_w = max(len(k) for k, _ in items) + 1 + for key, val in items: + print(f"{' ' * indent}{self.c.KEY}{key:<{key_w}}{self.c.RESET}: {self.c.VALUE}{val}{self.c.RESET}") + + def print_summary_stats(self, stats: dict, title: str = "Statistiques"): + print(f"\n{self.c.TITLE}{title}{self.c.RESET}") + key_w = max(len(k) for k in stats) + 1 + for key, val in stats.items(): + print(f" {self.c.KEY}{key:<{key_w}}{self.c.RESET}: {self.c.BOLD}{val}{self.c.RESET}") + + def print_summary_details(self, details: dict, title: str = "Détails"): + print(f"\n{self.c.DIM}{title}{self.c.RESET}") + key_w = max(len(k) for k in details) + 1 + for key, val in details.items(): + print(f" {self.c.DIM}{key:<{key_w}}: {val}{self.c.RESET}") + + def print_files_generated( + self, + files: list[tuple[str, str, str]], + output_dir: str = "", + plugin_path: str = "", + ): + self.print_section_header("FICHIERS GÉNÉRÉS") + if output_dir: + rel = output_dir + if plugin_path: + try: + rel = str(Path(output_dir).relative_to(Path(plugin_path))) + except ValueError: + pass # output_dir n'est pas sous plugin_path + print(f" {self.c.DIM}Sortie :{self.c.RESET} {self.c.VALUE}{rel}{self.c.RESET}\n") + for filename, detail, description in files: + print(f" {self.c.ok_marker()} {self.c.KEY}{filename:<35}{self.c.RESET} ({detail})") + print(f" {self.c.DIM}{description}{self.c.RESET}\n") + + +# --------------------------------------------------------------------------- +# Fonctions utilitaires +# --------------------------------------------------------------------------- + +def clear_screen(): + if not (hasattr(sys.stdout, "isatty") and sys.stdout.isatty()): + return + if sys.platform == "win32": + os.system("cls") + else: + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + +def pause(c: Colors, message: str = "Appuyez sur ENTRÉE pour continuer..."): + input(f"\n{c.DIM}{message}{c.RESET}") diff --git a/video-toolkit/tests/test_hasher.py b/video-toolkit/tests/test_hasher.py new file mode 100644 index 0000000..4d1b104 --- /dev/null +++ b/video-toolkit/tests/test_hasher.py @@ -0,0 +1,106 @@ +""" +Tests unitaires — hasher.py +Couvre : partial_hash (stabilité, sensibilité aux changements, fichiers petits/grands) +""" + +import hashlib +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.hasher import CHUNK_SIZE, partial_hash + + +def _write_tmp(content: bytes) -> Path: + """Écrit content dans un fichier temporaire et retourne le Path.""" + f = tempfile.NamedTemporaryFile(delete=False, suffix=".bin") + f.write(content) + f.close() + return Path(f.name) + + +class TestPartialHash: + def test_returns_string(self): + p = _write_tmp(b"hello world") + try: + h = partial_hash(p) + assert isinstance(h, str) + finally: + p.unlink() + + def test_hash_is_32_chars(self): + p = _write_tmp(b"hello world") + try: + assert len(partial_hash(p)) == 32 + finally: + p.unlink() + + def test_stable_same_content(self): + content = b"A" * 1000 + p = _write_tmp(content) + try: + assert partial_hash(p) == partial_hash(p) + finally: + p.unlink() + + def test_different_content_different_hash(self): + p1 = _write_tmp(b"content A" * 100) + p2 = _write_tmp(b"content B" * 100) + try: + assert partial_hash(p1) != partial_hash(p2) + finally: + p1.unlink() + p2.unlink() + + def test_size_change_detected(self): + """Un octet supplémentaire doit changer le hash (taille incluse).""" + p1 = _write_tmp(b"A" * 500) + p2 = _write_tmp(b"A" * 501) + try: + assert partial_hash(p1) != partial_hash(p2) + finally: + p1.unlink() + p2.unlink() + + def test_small_file_below_two_chunks(self): + """Fichier < 2*CHUNK_SIZE : seul le début est lu.""" + content = b"X" * (CHUNK_SIZE - 1) + p = _write_tmp(content) + try: + h = partial_hash(p) + assert isinstance(h, str) and len(h) == 32 + finally: + p.unlink() + + def test_large_file_reads_head_and_tail(self): + """Fichier > 2*CHUNK_SIZE : début ET fin sont lus. + Un changement au milieu peut ne pas être détecté (par conception), + mais un changement en fin de fichier DOIT l'être. + """ + base = b"A" * (CHUNK_SIZE * 3) + modified = base[:-10] + b"Z" * 10 # Modification en fin de fichier + + p1 = _write_tmp(base) + p2 = _write_tmp(modified) + try: + assert partial_hash(p1) != partial_hash(p2) + finally: + p1.unlink() + p2.unlink() + + def test_empty_file(self): + """Fichier vide ne doit pas lever d'exception.""" + p = _write_tmp(b"") + try: + h = partial_hash(p) + assert isinstance(h, str) + finally: + p.unlink() + + def test_nonexistent_file_raises(self, tmp_path): + with pytest.raises((FileNotFoundError, OSError)): + partial_hash(tmp_path / "__nonexistent__.mp4") diff --git a/video-toolkit/tests/test_presets.py b/video-toolkit/tests/test_presets.py new file mode 100644 index 0000000..a0c5b24 --- /dev/null +++ b/video-toolkit/tests/test_presets.py @@ -0,0 +1,300 @@ +""" +Tests unitaires — presets.py +Couvre : VideoPreset, PresetManager, compute_output_resolution, _align2 +""" + +import json +import sys +import tempfile +from pathlib import Path + +import pytest + +# Ajout du répertoire parent pour l'import +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.presets import ( + BUILTIN_PRESETS, + PRESET_ORDER, + PresetManager, + VideoPreset, + _align2, +) + + +# --------------------------------------------------------------------------- +# _align2 +# --------------------------------------------------------------------------- + +class TestAlign2: + def test_even_unchanged(self): + assert _align2(100) == 100 + + def test_odd_decremented(self): + assert _align2(101) == 100 + + def test_one(self): + assert _align2(1) == 0 + + def test_zero(self): + assert _align2(0) == 0 + + +# --------------------------------------------------------------------------- +# VideoPreset +# --------------------------------------------------------------------------- + +class TestVideoPreset: + def test_is_origin_true_when_suffix_empty(self): + p = BUILTIN_PRESETS["origin"] + assert p.is_origin is True + + def test_is_origin_false_for_medium(self): + p = BUILTIN_PRESETS["medium"] + assert p.is_origin is False + + def test_hash_is_16_chars(self): + p = BUILTIN_PRESETS["medium"] + h = p.hash() + assert isinstance(h, str) + assert len(h) == 16 + + def test_hash_stable(self): + p = BUILTIN_PRESETS["medium"] + assert p.hash() == p.hash() + + def test_hash_differs_between_presets(self): + assert BUILTIN_PRESETS["medium"].hash() != BUILTIN_PRESETS["large"].hash() + + def test_to_dict_roundtrip(self): + p = BUILTIN_PRESETS["medium"] + d = p.to_dict() + p2 = VideoPreset.from_dict(p.name, d) + assert p2.name == p.name + assert p2.max_width == p.max_width + assert p2.crf == p.crf + + def test_from_dict_ignores_unknown_keys(self): + src = BUILTIN_PRESETS["small"] + d = src.to_dict() + d["unknown_field"] = "should_be_ignored" + # Should not raise + p = VideoPreset.from_dict(src.name, d) + assert p.name == src.name + + +# --------------------------------------------------------------------------- +# PresetManager — builtin access +# --------------------------------------------------------------------------- + +class TestPresetManagerBuiltin: + def setup_method(self): + self.pm = PresetManager() + + def test_get_builtin_preset(self): + p = self.pm.get_preset("medium") + assert p.name == "Medium" + assert p.max_height == 720 + + def test_get_preset_case_insensitive(self): + p = self.pm.get_preset("MEDIUM") + assert p.name == "Medium" + + def test_get_unknown_preset_raises(self): + with pytest.raises(KeyError): + self.pm.get_preset("nonexistent") + + def test_list_presets_order(self): + presets = self.pm.list_presets() + keys = [k for k, _ in presets] + for expected_key in PRESET_ORDER: + assert expected_key in keys + + def test_all_builtin_presets_present(self): + listed = {k for k, _ in self.pm.list_presets()} + for key in BUILTIN_PRESETS: + assert key in listed + + +# --------------------------------------------------------------------------- +# PresetManager — user presets (load / save / override) +# --------------------------------------------------------------------------- + +class TestPresetManagerUserPresets: + def test_load_user_preset_overrides_builtin(self): + data = { + "version": 1, + "presets": { + "medium": { + "name": "Medium Custom", + "suffix": "_medium", + "max_width": 1280, + "max_height": 720, + "video_bitrate": 9999, + "audio_bitrate": 128, + } + } + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(data, f) + tmp = Path(f.name) + + try: + pm = PresetManager(tmp) + p = pm.get_preset("medium") + assert p.video_bitrate == 9999 + assert p.name == "Medium Custom" + finally: + tmp.unlink() + + def test_load_nonexistent_file_no_error(self): + pm = PresetManager(Path("/nonexistent/path/presets.json")) + # Should still have builtin presets + p = pm.get_preset("small") + assert p.name == "Small" + + def test_save_and_reload(self): + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "presets.json" + pm = PresetManager() + # Add a user preset by direct manipulation + pm._user["custom"] = VideoPreset( + name="Custom", + suffix="_custom", + max_width=640, + max_height=360, + video_bitrate=500, + audio_bitrate=64, + ) + pm.save_presets(config_path) + + pm2 = PresetManager(config_path) + p = pm2.get_preset("custom") + assert p.max_width == 640 + assert p.max_height == 360 + + +# --------------------------------------------------------------------------- +# PresetManager — validate_preset +# --------------------------------------------------------------------------- + +class TestValidatePreset: + def setup_method(self): + self.pm = PresetManager() + + def test_valid_medium_no_errors(self): + p = BUILTIN_PRESETS["medium"] + errors = self.pm.validate_preset(p) + assert errors == [] + + def test_invalid_resolution(self): + p = VideoPreset( + name="Bad", suffix="_bad", + max_width=0, max_height=720, + video_bitrate=1000, audio_bitrate=128, + ) + errors = self.pm.validate_preset(p) + assert any("Résolution" in e for e in errors) + + def test_invalid_bitrate_non_origin(self): + p = VideoPreset( + name="Bad", suffix="_bad", + max_width=1280, max_height=720, + video_bitrate=0, audio_bitrate=128, + ) + errors = self.pm.validate_preset(p) + assert any("video_bitrate" in e for e in errors) + + def test_invalid_crf(self): + p = VideoPreset( + name="Bad", suffix="_bad", + max_width=1280, max_height=720, + video_bitrate=1000, audio_bitrate=128, + crf=99, + ) + errors = self.pm.validate_preset(p) + assert any("crf" in e for e in errors) + + def test_invalid_container(self): + p = VideoPreset( + name="Bad", suffix="_bad", + max_width=1280, max_height=720, + video_bitrate=1000, audio_bitrate=128, + container="avi", + ) + errors = self.pm.validate_preset(p) + assert any("Container" in e for e in errors) + + def test_origin_skips_bitrate_check(self): + p = BUILTIN_PRESETS["origin"] # video_bitrate=0, audio_bitrate=0 + errors = self.pm.validate_preset(p) + assert errors == [] + + +# --------------------------------------------------------------------------- +# PresetManager — compute_output_resolution +# --------------------------------------------------------------------------- + +class TestComputeOutputResolution: + def setup_method(self): + self.pm = PresetManager() + + def _preset(self, key): + return BUILTIN_PRESETS[key] + + # --- Cas : source plus grande que le preset → downscale --- + + def test_landscape_1080p_to_720p(self): + w, h = self.pm.compute_output_resolution(1920, 1080, self._preset("medium")) + assert w == 1280 + assert h == 720 + + def test_landscape_2160p_to_1080p(self): + w, h = self.pm.compute_output_resolution(3840, 2160, self._preset("large")) + assert w == 1920 + assert h == 1080 + + def test_portrait_1080_to_720(self): + # Source 1080x1920 (portrait) → preset medium 1280x720 + # Contrainte hauteur : 720/1920 = 0.375 → 1080*0.375=405, 1920*0.375=720 + w, h = self.pm.compute_output_resolution(1080, 1920, self._preset("medium")) + assert h == 720 + assert w == 404 # 405 → align2 → 404 + + # --- Cas : source plus petite que le preset → pas d'upscale --- + + def test_no_upscale_small_source(self): + # Source 640x360 → preset medium 1280x720 : pas d'upscale + w, h = self.pm.compute_output_resolution(640, 360, self._preset("medium")) + assert w == 640 + assert h == 360 + + def test_no_upscale_odd_dimensions_aligned(self): + # Source 641x361 → align2 → 640x360 + w, h = self.pm.compute_output_resolution(641, 361, self._preset("medium")) + assert w == 640 + assert h == 360 + + # --- Cas : origin → résolution source inchangée --- + + def test_origin_passthrough(self): + w, h = self.pm.compute_output_resolution(3840, 2160, self._preset("origin")) + assert w == 3840 + assert h == 2160 + + # --- Résolution toujours multiple de 2 --- + + def test_output_always_even_width(self): + w, _ = self.pm.compute_output_resolution(1921, 1081, self._preset("medium")) + assert w % 2 == 0 + + def test_output_always_even_height(self): + _, h = self.pm.compute_output_resolution(1921, 1081, self._preset("medium")) + assert h % 2 == 0 + + # --- Ratio d'aspect conservé --- + + def test_aspect_ratio_preserved_landscape(self): + w, h = self.pm.compute_output_resolution(1920, 1080, self._preset("medium")) + # Ratio source : 16/9 → ratio sortie doit être ≈ 16/9 + assert abs(w / h - 16 / 9) < 0.02 diff --git a/video-toolkit/tests/test_status.py b/video-toolkit/tests/test_status.py new file mode 100644 index 0000000..8bce90e --- /dev/null +++ b/video-toolkit/tests/test_status.py @@ -0,0 +1,280 @@ +""" +Tests unitaires — status.py +Couvre : StatusManager, GlobalStatusFile +""" + +import json +import sys +import tempfile +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.status import ( + STATE_COMPLETE, + STATE_ERROR, + STATE_PROCESSING, + GlobalStatusFile, + StatusManager, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def tmp_video(tmp_path): + """Crée un fichier vidéo fictif pour StatusManager.""" + p = tmp_path / "test_video.mp4" + p.write_bytes(b"fake video content") + return p + + +@pytest.fixture +def sm(tmp_video): + return StatusManager(tmp_video) + + +# --------------------------------------------------------------------------- +# StatusManager — persistence +# --------------------------------------------------------------------------- + +class TestStatusManagerPersistence: + def test_status_file_path(self, tmp_video): + sm = StatusManager(tmp_video) + assert sm.path.parent.name == ".vtk" + assert sm.path.name == "test_video.json" + + def test_save_creates_vtk_dir(self, sm, tmp_video): + sm.save() + vtk_dir = tmp_video.parent / ".vtk" + assert vtk_dir.is_dir() + assert sm.path.exists() + + def test_save_and_reload(self, tmp_video): + sm1 = StatusManager(tmp_video) + sm1.set_source( + hash_val="abc123", + size=1000, + width=1920, height=1080, + duration=60.0, + video_codec="h264", + audio_codec="aac", + fps=25.0, + ) + sm1.save() + + sm2 = StatusManager(tmp_video) + src = sm2.get_source() + assert src["hash"] == "abc123" + assert src["width"] == 1920 + + def test_delete_removes_file(self, sm): + sm.save() + assert sm.path.exists() + sm.delete() + assert not sm.path.exists() + + def test_corrupted_json_loads_empty(self, tmp_video): + vtk_dir = tmp_video.parent / ".vtk" + vtk_dir.mkdir() + status_file = vtk_dir / "test_video.json" + status_file.write_text("{invalid json", encoding="utf-8") + + sm = StatusManager(tmp_video) + assert sm.get_source() == {} + + +# --------------------------------------------------------------------------- +# StatusManager — source info +# --------------------------------------------------------------------------- + +class TestStatusManagerSource: + def test_set_get_source(self, sm): + sm.set_source( + hash_val="deadbeef", + size=2048, + width=640, height=360, + duration=30.5, + video_codec="h264", + audio_codec="aac", + fps=24.0, + ) + src = sm.get_source() + assert src["hash"] == "deadbeef" + assert src["size"] == 2048 + assert src["resolution"] == "640x360" + assert src["duration"] == 30.5 + assert src["fps"] == 24.0 + + def test_get_source_hash_empty_when_unset(self, sm): + assert sm.get_source_hash() == "" + + def test_get_source_hash_returns_value(self, sm): + sm.set_source("myhash", 100, 1280, 720, 10.0, "h264", "aac", 30.0) + assert sm.get_source_hash() == "myhash" + + +# --------------------------------------------------------------------------- +# StatusManager — variants +# --------------------------------------------------------------------------- + +class TestStatusManagerVariants: + def test_set_get_variant(self, sm, tmp_video): + variant_path = tmp_video.parent / "test_video_medium.mp4" + variant_path.write_bytes(b"variant content") + + sm.set_variant("medium", "presethash", variant_path, 512, 1280, 720, 10.0) + v = sm.get_variant("medium") + assert v["size"] == 512 + assert v["preset_hash"] == "presethash" + assert v["resolution"] == "1280x720" + + def test_get_variant_unknown_returns_empty(self, sm): + assert sm.get_variant("nonexistent") == {} + + def test_has_valid_variant_true(self, sm, tmp_video): + variant_path = tmp_video.parent / "test_video_medium.mp4" + variant_path.write_bytes(b"variant") + + sm.set_source("srchash", 1000, 1920, 1080, 60.0, "h264", "aac", 25.0) + sm.set_variant("medium", "phash", variant_path, 512, 1280, 720, 60.0) + + assert sm.has_valid_variant("medium", "srchash", "phash") is True + + def test_has_valid_variant_false_wrong_source_hash(self, sm, tmp_video): + variant_path = tmp_video.parent / "test_video_medium.mp4" + variant_path.write_bytes(b"variant") + + sm.set_source("srchash", 1000, 1920, 1080, 60.0, "h264", "aac", 25.0) + sm.set_variant("medium", "phash", variant_path, 512, 1280, 720, 60.0) + + assert sm.has_valid_variant("medium", "DIFFERENT_HASH", "phash") is False + + def test_has_valid_variant_false_wrong_preset_hash(self, sm, tmp_video): + variant_path = tmp_video.parent / "test_video_medium.mp4" + variant_path.write_bytes(b"variant") + + sm.set_source("srchash", 1000, 1920, 1080, 60.0, "h264", "aac", 25.0) + sm.set_variant("medium", "phash", variant_path, 512, 1280, 720, 60.0) + + assert sm.has_valid_variant("medium", "srchash", "DIFFERENT_PRESET_HASH") is False + + def test_has_valid_variant_false_file_deleted(self, sm, tmp_video): + variant_path = tmp_video.parent / "test_video_medium.mp4" + variant_path.write_bytes(b"variant") + + sm.set_source("srchash", 1000, 1920, 1080, 60.0, "h264", "aac", 25.0) + sm.set_variant("medium", "phash", variant_path, 512, 1280, 720, 60.0) + + variant_path.unlink() # Supprimer le fichier + + assert sm.has_valid_variant("medium", "srchash", "phash") is False + + +# --------------------------------------------------------------------------- +# StatusManager — thumbnail +# --------------------------------------------------------------------------- + +class TestStatusManagerThumbnail: + def test_set_get_thumbnail(self, sm, tmp_video): + thumb = tmp_video.parent / "test_video.jpg" + thumb.write_bytes(b"jpg data") + + sm.set_thumbnail(thumb, 10240, "00:00:01") + t = sm.get_thumbnail() + assert t["size"] == 10240 + assert t["timestamp"] == "00:00:01" + + def test_has_thumbnail_true(self, sm, tmp_video): + thumb = tmp_video.parent / "test_video.jpg" + thumb.write_bytes(b"jpg") + sm.set_thumbnail(thumb, 100, "00:00:01") + assert sm.has_thumbnail() is True + + def test_has_thumbnail_false_when_unset(self, sm): + assert sm.has_thumbnail() is False + + def test_has_thumbnail_false_when_file_missing(self, sm, tmp_video): + thumb = tmp_video.parent / "missing.jpg" + sm.set_thumbnail(thumb, 100, "00:00:01") + assert sm.has_thumbnail() is False + + +# --------------------------------------------------------------------------- +# StatusManager — state +# --------------------------------------------------------------------------- + +class TestStatusManagerState: + def test_set_state_saves_immediately(self, sm): + sm.set_state(STATE_PROCESSING, progress=50, current_file="test.mp4") + assert sm.path.exists() + + def test_get_state(self, sm): + sm.set_state(STATE_PROCESSING, progress=30) + s = sm.get_state() + assert s["status"] == STATE_PROCESSING + assert s["progress"] == 30 + + def test_is_complete(self, sm): + sm.set_state(STATE_COMPLETE) + assert sm.is_complete() is True + assert sm.is_error() is False + + def test_is_error(self, sm): + sm.set_state(STATE_ERROR, error="ffmpeg failed") + assert sm.is_error() is True + assert sm.is_complete() is False + + def test_state_not_complete_by_default(self, sm): + assert sm.is_complete() is False + + +# --------------------------------------------------------------------------- +# GlobalStatusFile +# --------------------------------------------------------------------------- + +class TestGlobalStatusFile: + def test_update_creates_file(self, tmp_path): + gsf = GlobalStatusFile(tmp_path / "status.json") + gsf.update(STATE_PROCESSING, progress=10, total=5, done=1) + assert (tmp_path / "status.json").exists() + + def test_update_content(self, tmp_path): + p = tmp_path / "status.json" + gsf = GlobalStatusFile(p) + gsf.update(STATE_PROCESSING, progress=50, current_file="vid.mp4", total=3, done=1) + + data = json.loads(p.read_text()) + assert data["state"] == STATE_PROCESSING + assert data["progress"] == 50 + assert data["current_file"] == "vid.mp4" + assert data["total"] == 3 + + def test_mark_complete(self, tmp_path): + p = tmp_path / "status.json" + gsf = GlobalStatusFile(p) + gsf.mark_complete(files=["a.mp4", "b.mp4"]) + + data = json.loads(p.read_text()) + assert data["state"] == STATE_COMPLETE + assert data["progress"] == 100 + assert "a.mp4" in data["files"] + + def test_mark_error(self, tmp_path): + p = tmp_path / "status.json" + gsf = GlobalStatusFile(p) + gsf.mark_error("Something went wrong") + + data = json.loads(p.read_text()) + assert data["state"] == STATE_ERROR + assert data["error"] == "Something went wrong" + + def test_creates_parent_dir(self, tmp_path): + p = tmp_path / "subdir" / "deep" / "status.json" + gsf = GlobalStatusFile(p) + gsf.update(STATE_PROCESSING) + assert p.exists() diff --git a/video-toolkit/video_toolkit.py b/video-toolkit/video_toolkit.py new file mode 100644 index 0000000..5e0ba83 --- /dev/null +++ b/video-toolkit/video_toolkit.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Video Toolkit — Point d'entrée principal. + +Usage non-interactif (depuis Lightroom) : + python video_toolkit.py --mode probe --input video.mp4 + python video_toolkit.py --mode batch --batch-file batch.json --status-file status.json + python video_toolkit.py --mode status --input video.mp4 + +Usage interactif (terminal) : + python video_toolkit.py + +Codes de sortie : + 0 = succès + 1 = erreur (message JSON sur stderr) +""" + +import sys +import os + +# Ajouter le répertoire du script au path +sys.path.insert(0, os.path.dirname(__file__)) + +from src.cli import build_parser, run_probe, run_process, run_batch, run_status, run_clean, InteractiveCLI +from src.config import Config + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + # Charger la configuration + cfg = Config(args.config if hasattr(args, "config") and args.config else None) + + # Propager les overrides CLI → config (priorité sur le fichier de config) + if getattr(args, "ffmpeg_path", None): + cfg.set("ffmpeg_path", args.ffmpeg_path) + if getattr(args, "exiftool_path", None): + cfg.set("exiftool_path", args.exiftool_path) + if getattr(args, "hwaccel", None): + cfg.set("hardware_accel", args.hwaccel) + + # Rediriger stdout+stderr vers un fichier log si --log-file fourni + _log_fh = None + if getattr(args, "log_file", None): + try: + _log_fh = open(args.log_file, "w", encoding="utf-8", buffering=1) + sys.stdout = _log_fh + sys.stderr = _log_fh + except OSError as e: + # Pas bloquant — on continue sans log + print(f"[VTK] Impossible d'ouvrir le log file: {e}", file=sys.__stderr__) + + # Mode non-interactif (avec --mode) + if args.mode: + if args.mode == "check": + import json + from pathlib import Path + # Lire les chemins configurés depuis le fichier JSON passé par Lightroom + explicit = {} + check_cfg = getattr(args, "check_config", None) + if check_cfg: + try: + with open(check_cfg, "r", encoding="utf-8") as fh: + explicit = json.load(fh) + except Exception: + pass + + tools_out = {} + for tool in ["ffmpeg", "ffprobe", "exiftool"]: + configured = explicit.get(tool) + if configured: + # Chemin fourni explicitement : vérifier qu'il existe + if Path(configured).is_file(): + tools_out[tool] = configured + else: + tools_out[tool] = f"not found at: {configured}" + else: + # Auto-détection + found = cfg.resolve_tool(tool) + tools_out[tool] = found or "not found" + + # Detect GPU encoders + from src.hwaccel import HWAccelDetector + ffmpeg_resolved = tools_out.get("ffmpeg", "ffmpeg") + if ffmpeg_resolved and not ffmpeg_resolved.startswith("not found"): + detector = HWAccelDetector(ffmpeg_resolved) + gpu_list = [{"name": e.name, "codec": e.codec} for e in detector.list_available()] + else: + gpu_list = [] + + any_invalid = any(v.startswith("not found at:") for v in tools_out.values() if v) + ok = (tools_out["ffprobe"] not in (None, "not found") + and not tools_out["ffprobe"].startswith("not found at:") + and not any_invalid) + print(json.dumps({ + "status": "ok" if ok else "error", + "python_version": sys.version.split()[0], + "ffmpeg": tools_out["ffmpeg"], + "ffprobe": tools_out["ffprobe"], + "exiftool": tools_out["exiftool"], + "gpu_encoders": gpu_list, + })) + return 0 if ok else 1 + if args.mode == "probe": + return run_probe(args, cfg) + if args.mode == "process": + return run_process(args, cfg) + if args.mode == "batch": + return run_batch(args, cfg) + if args.mode == "status": + return run_status(args, cfg) + if args.mode == "clean": + return run_clean(args, cfg) + + # Mode interactif + cli = InteractiveCLI(cfg) + cli.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main())