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())